injeca 0.2.0 → 0.4.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 CHANGED
@@ -1,8 +1,8 @@
1
1
  # Injeca
2
2
 
3
- Pure functional programming (means no `class` and extra wrappers and containers) based dependency injection with application lifecycle management.
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 Golang ecosystem.
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 singleton container exposed as `injeca`. You can register
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, lifecycle } from 'injeca'
40
+ import { injeca } from 'injeca'
40
41
 
41
- // Providers return a value once and are cached inside the container.
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, lifecycle },
46
- async build({ dependsOn }) {
47
- const { config, lifecycle } = dependsOn
46
+ dependsOn: { config },
47
+ build({ dependsOn, on }) {
48
48
  const app = createServer()
49
49
 
50
- lifecycle.appHooks.onStart(() => app.listen(config.port))
51
- lifecycle.appHooks.onStop(() => app.close())
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
- async callback({ server }) {
60
- // eslint-disable-next-line no-console
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,18 @@ 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
+ - injeca writes no lifecycle logs by default. It writes warnings with `console.warn`. See [Logging](#logging).
75
+
72
76
  > [!WARNING]
73
77
  >
74
- > **`injeca` implements singleton / cached dependencies by default**
78
+ > **`injeca` caches each value that `build` returns.**
75
79
  >
76
- > This means every value returned by `build` will be stored and `build` will only
77
- > got called once.
80
+ > `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.
78
81
  >
79
- > If `build` returns a function, that function is cached (not the result of calling
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.
82
- >
83
- > If you want the returned function to be reusable (e.g. a single window), create
84
- > the reusable closure once inside `build`, and return a wrapper that calls it:
82
+ > To reuse one resource, for example a single window, create it once inside `build` and return a wrapper:
85
83
  >
86
84
  > ```ts
87
85
  > const settingsWindow = injeca.provide('windows:settings', {
@@ -93,17 +91,363 @@ process.once('SIGINT', async () => {
93
91
  > })
94
92
  > ```
95
93
 
96
- Avoid recreating the reusable function inside the returned function, otherwise
97
- each call starts from a fresh cache and can create duplicate resources.
94
+ ## Lifecycle in `build`
95
+
96
+ The build context gives each service its own lifecycle:
98
97
 
99
- ### Manual containers
98
+ | Field | Meaning |
99
+ | --- | --- |
100
+ | `dependsOn` | The resolved dependencies |
101
+ | `on('start', handler)` | Runs after the service is published and its dependencies run |
102
+ | `on('stop', handler)` | Runs before the service stops. Its dependencies are still available. |
103
+ | `effect(setup)` | Runs `setup` at once. The disposer that it returns runs when the instance is disposed, after the `stop` hooks. |
104
+ | `signal` | An `AbortSignal` that aborts when a newer target replaces this build, or when the container is disposed |
105
+ | `setStatus(status, reason?)` | Sets the status of the instance. See [Status](#status). |
100
106
 
101
- If you need multiple containers (for tests or per-request scopes), create them
102
- explicitly and pass the container reference into `provide`, `invoke`, `start`, and
103
- `stop`.
107
+ 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:
104
108
 
105
109
  ```ts
106
- import { createContainer, invoke, provide, start, stop } from 'injeca'
110
+ on('start', async ({ decide, attempt }) => {
111
+ try {
112
+ await app.listen(port)
113
+ }
114
+ catch (error) {
115
+ return decide.fail(error)
116
+ }
117
+ })
118
+ ```
119
+
120
+ ### Retries
121
+
122
+ A `start` hook can return `decide.requeue()` to try again later. The service keeps its instance, so `build` does not run again.
123
+
124
+ ```ts
125
+ on('start', async ({ decide }) => {
126
+ try {
127
+ await device.connect()
128
+ }
129
+ catch (error) {
130
+ return decide.requeue({
131
+ backoff: { kind: 'exponential', initial: 500, max: 30_000, jitter: 0.2 },
132
+ maxAttempts: 5,
133
+ reason: `connect failed: ${String(error)}`,
134
+ })
135
+ }
136
+ })
137
+ ```
138
+
139
+ - During the wait, the service is `retrying`. It is not available, so its dependents stay `pending`. It still holds its dependencies.
140
+ - `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.
141
+ - The next attempt runs the `start` handler that requeued, then the handlers after it.
142
+ - Changes during the wait merge into one attempt. A withdrawn required dependency, or `stop()`, cancels the wait and stops the service at once.
143
+ - A dependency hook can requeue too. The service stays `running` with its current dependencies during the wait.
144
+ - injeca has no built-in delay or limit. Without `maxAttempts`, the retries continue.
145
+
146
+ `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.
147
+
148
+ ```ts
149
+ const container = createContainer({ maxAttempts: 10, random: () => 0.5 }) // a fixed random source for deterministic tests
150
+ injeca.provide('modules:device', { maxAttempts: 3, build: () => createDevice() })
151
+ ```
152
+
153
+ 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`.
154
+
155
+ ### Timeouts
156
+
157
+ `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.
158
+
159
+ ```ts
160
+ const container = createContainer({ timeouts: { stop: 5_000 } })
161
+
162
+ provide(container, 'modules:camera', {
163
+ timeouts: { start: 10_000 },
164
+ onTimeout: (context) => {
165
+ if (context.hook === 'start')
166
+ return context.decide.requeue({ after: 1_000, maxAttempts: 3 })
167
+ return undefined // a stop timeout counts as handled
168
+ },
169
+ build: () => createCamera(),
170
+ })
171
+ ```
172
+
173
+ - 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.
174
+ - When a timeout passes, `observe` gets a `timeout` record. Then `onTimeout` of the service, or of the container, decides.
175
+ - Without `onTimeout`, a `start` timeout gives `fail` with a `HookTimeoutError` (`hook-timeout`).
176
+ - Without `onTimeout`, a `stop` timeout counts as `handled`. injeca writes a warning with `logger.warn`, and `stop()`, `dispose()`, and `withdraw()` resolve as usual. Only `decide.fail()` from `onTimeout` fails them.
177
+ - During a stop or a disposal of the container, the warning tells that a cleanup can be waiting for its own container. Then that cleanup must pass its signal. See [Calling dispose from a cleanup](#calling-dispose-from-a-cleanup).
178
+ - `onTimeout` runs synchronously. `context.decide` has the methods of the hook that timed out.
179
+ - 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 without its signal cannot block that disposal.
180
+ - A cleanup or a `start` hook that injeca stopped waiting for can still fail later. injeca writes that failure with `logger.warn`, and a late `stop` decision gets a `decision` record with `ignored: true`. A rejection with the abort reason of the callback signal has no warning, because the callback only followed its abort.
181
+ - `stop()` and `dispose()` abort the signal of a `start` hook in progress and do not wait for it. The `stop` hooks run at once. A decision that the hook returns later is ignored.
182
+ - At a `start` timeout, the `signal` of the hook aborts with the `HookTimeoutError`. A `stop` handler sees the `stop` timeout on its `signal` in the same way.
183
+
184
+ ## Cancellation
185
+
186
+ Each call that can wait takes `{ signal }`, a standard `AbortSignal`. injeca has no built-in timeout for these calls. Use `timeout(container, ms)` to make a signal on the container clock:
187
+
188
+ ```ts
189
+ import { resolve, start, stop, timeout, withTimeout } from 'injeca'
190
+
191
+ await start(container, { signal: timeout(container, 10_000) })
192
+ const { llm } = await resolve(container, { llm: llmKey }, { signal: withTimeout(request.signal, 5_000, container) })
193
+ await stop(container, { signal: timeout(container, 5_000) })
194
+ ```
195
+
196
+ - `timeout(container, ms)` aborts after `ms` on the clock of the container, with a `DOMException` named `TimeoutError`. Tests with a manual clock control it. `AbortSignal.timeout()` does not use the container clock.
197
+ - `withTimeout(signal, ms, container)` aborts when `signal` aborts, or after `ms`. `signal` can be `undefined`.
198
+ - An aborted signal ends the call at once. A call removes its listener from the signal when it settles.
199
+ - A timeout reason is a reason with `name === 'TimeoutError'`, from `timeout()` or from `AbortSignal.timeout()`.
200
+
201
+ | Call | When the signal aborts |
202
+ | --- | --- |
203
+ | `resolve(container, deps, { signal })` | Rejects. A timeout reason gives `WaitTimeoutError` (`wait-timeout`) with the reason as `cause`. Another reason rejects as it is. The hidden resolver goes. |
204
+ | `waitFor(container, key, { status, signal })` | The same as `resolve`. |
205
+ | `start(container, { signal })` | Stops the wait only. The services keep converging. A timeout reason gives `StartTimeoutError` (`start-timeout`) with the explanations. Another reason rejects as it is. |
206
+ | `stop(container, { signal })` | Rejects with the reason. The container is stopped, and a later `start()` builds again. The services that did not stop stay in `stopping` until their cleanups settle. |
207
+ | `dispose(container, { signal })` | Rejects with the reason. The disposal continues in the background, and the container stays in `disposing`. A later `dispose(container)` waits for the same disposal and gets its result. |
208
+ | `registration.withdraw({ signal })` | Rejects with the reason. The withdrawal continues. The service gets a `wait-aborted` record when it did not finish. `invoke()` handles take the same option. |
209
+ | `registration.replace(option, { signal })` | Cancels a pending replacement, as a newer `replace()` does. The old instance stays, and `replace()` resolves `false`. After the swap, the abort has no effect. |
210
+ | `tx.commit({ signal })` | Before the publish, the transaction aborts and rolls back, and `commit()` rejects at once with the reason. After the publish, the abort has no effect. |
211
+
212
+ - For `stop`, `dispose`, and `withdraw`, each service that did not stop gets a `wait-aborted` record in `observe`, with the phase at the abort and the reason.
213
+ - For `start`, `stop`, `dispose`, and `withdraw`, an aborted signal still starts the action. Only the wait ends.
214
+
215
+ ### Calling dispose from a cleanup
216
+
217
+ A cleanup that awaits `stop()` or `dispose()` of its own container, or of an ancestor, waits for itself. A cleanup that awaits `withdraw()` of its own registration, or of a provider that it holds, waits for itself too. So does a cleanup that awaits `stop()` or `dispose()` of a container whose service it holds. Pass the `signal` of the cleanup, so that injeca knows the caller:
218
+
219
+ ```ts
220
+ provide(plugin, 'plugin:window', ({ on }) => {
221
+ on('stop', async ({ signal }) => {
222
+ await window.close()
223
+ await dispose(plugin, { signal }) // resolves at once, and the disposal continues
224
+ })
225
+ return createWindow()
226
+ })
227
+ ```
228
+
229
+ - injeca knows the instance of each signal that it gives to a build, a hook, and an invocation. A signal from `withTimeout(signal, ms, container)` keeps that origin. A signal from `AbortSignal.any()` does not.
230
+ - A call with such a signal while the teardown of its instance runs does not wait. The check uses the instance, so it also works during a rebuild or a replacement, when the old and the new instance live together. A signal of the new running instance still waits.
231
+ - The cleanup can also be outside the container. A service of the parent that holds an export of a plugin container, directly or through other services, stops before that export. So its `stop` hook can call `dispose(plugin, { signal })` or `stop(plugin, { signal })`, and the call does not wait. A plugin host that stops a plugin from its own cleanup needs this.
232
+ - A call from the synchronous part of a cleanup does not wait either.
233
+ - Such a call writes a warning with `logger.warn`, sends a `reentrant-call` record to `observe`, and resolves at once. A re-entrant `withdraw()` resolves `true`. The stop, the disposal, or the withdrawal continues. Other callers get a promise that settles when it ends.
234
+ - A call without a signal after an `await` is not found. It waits for itself until a configured `stop` timeout ends the cleanup. The warning of that timeout tells about this cause.
235
+
236
+ ```ts
237
+ provide(plugin, 'plugin:host', ({ on }) => {
238
+ on('stop', async ({ signal }) => {
239
+ await exportedTool.withdraw({ signal }) // waits for the export, or resolves at once when it is re-entrant
240
+ await dispose(plugin, { signal })
241
+ })
242
+ return createHost()
243
+ })
244
+ ```
245
+
246
+ ### Testing with a manual clock
247
+
248
+ `injeca/testing` has a manual clock, so that a test controls `timeout()`, `withTimeout()`, requeue delays, and policy timeouts without real time:
249
+
250
+ ```ts
251
+ import { createContainer, provide, start, stop } from 'injeca'
252
+ import { drained, manualClock } from 'injeca/testing'
253
+
254
+ const { clock, advance } = manualClock()
255
+ const container = createContainer({ clock, timeouts: { stop: 1_000 } })
256
+ provide(container, 'modules:tray', ({ on }) => {
257
+ on('stop', () => new Promise(() => {})) // never settles
258
+ return createTray()
259
+ })
260
+
261
+ await start(container)
262
+ const stopped = stop(container)
263
+ await drained(container) // the work queue stays drained
264
+ advance(1_000) // runs the stop timeout, synchronously
265
+ await stopped // resolves, with a warning
266
+ ```
267
+
268
+ - `advance(ms)` moves the time and runs each due timer in time order. `pending()` counts the timers that wait.
269
+ - `drained(container)` waits one real macrotask after each drain of the work queue. Do not use it while the fake timers of the test runner hold `setTimeout`.
270
+ - `createHookContext` and `checkDecision` test a hook handler alone.
271
+
272
+ ## Changes at run time
273
+
274
+ Services can appear and disappear after `start()`. Dependents converge automatically.
275
+
276
+ ```ts
277
+ await injeca.start()
278
+
279
+ // A provide after start() builds and starts when its dependencies run.
280
+ const vision = injeca.provide('modules:vision', {
281
+ dependsOn: { config },
282
+ build: ({ dependsOn }) => createVision(dependsOn.config),
283
+ })
284
+
285
+ // Withdraw. Dependents stop first. A stale registration returns false.
286
+ await vision.withdraw()
287
+ ```
288
+
289
+ When a dependency gets a new instance, a dependent rebuilds by default. A `dependency-change` handler can move to the new instance in place:
290
+
291
+ ```ts
292
+ on('dependency-change', ({ dependsOn, decide }) => {
293
+ speech.setTts(dependsOn.tts?.value)
294
+ return decide.handled()
295
+ })
296
+ ```
297
+
298
+ A rebuild of a running service has no gap. The old instance keeps serving while a new instance builds and starts against the new dependencies. Then one batch swaps them, as for `replace()`. The dependents of the service get `dependency-change` with the new instance. They do not lose it.
299
+
300
+ - This applies to a rebuild without a handler and to `decide.rebuild()`.
301
+ - The service stops first and builds again when a dependency that the old instance uses has no provider now. Then there is nothing to build against.
302
+ - An invocation stops first and runs again.
303
+ - If the new instance fails, the old instance stops and the service goes to `failed`.
304
+ - A `replace()` during the rebuild cancels the rebuild and replaces the service.
305
+
306
+ ### Atomic replacement
307
+
308
+ A withdrawal followed by a new `provide` leaves a gap: all dependents stop. `replace()` has no gap. A hot reload tool uses it.
309
+
310
+ ```ts
311
+ const tts = injeca.provide(ttsKey, () => createTts())
312
+
313
+ // The old instance keeps serving while the new one runs `build` and `start`.
314
+ await tts.replace(() => createTtsV2())
315
+ ```
316
+
317
+ - The swap is one batch. It occurs only after the new instance completes `start`. The new instance is `ready`.
318
+ - For a `perConsumer` registration, each running consumer gets a new instance first. The swap of all consumers is one batch, after each new instance completes `start`. If one fails, no consumer changes.
319
+ - 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.
320
+ - The old instance stops after each dependent releases it. A dependent with a `dependency-change` handler moves in place. Other dependents rebuild without a gap, so a replacement deep in the graph does not stop the services above it.
321
+ - `graph(container).explain(key)` shows the new instance as `replacement` while the swap waits, with its phase and blockers.
322
+ - A stale registration, or a call that a newer `replace()` or a withdrawal supersedes, resolves `false`. A service in `stopping` rejects with `ReplaceWhileStoppingError`.
323
+ - `replace(option, { signal })` cancels the pending replacement when the signal aborts, and resolves `false`. See [Cancellation](#cancellation).
324
+
325
+ ### Transactions
326
+
327
+ A transaction publishes many services together, for example all exports of one plugin.
328
+
329
+ ```ts
330
+ import { transaction } from 'injeca'
331
+
332
+ const tx = transaction(container)
333
+ tx.declare(statusKitKey, { dependsOn: { character: characterKey } })
334
+ tx.provide(statusKitKey, { build: ({ dependsOn }) => createStatusKit(dependsOn) })
335
+ tx.provide(characterKey, () => createCharacter())
336
+ await tx.commit()
337
+ ```
338
+
339
+ - Nothing is visible to consumers before `commit()`. Graph queries show the declared services.
340
+ - `commit()` builds in dependency order. A service can use another service of the same transaction.
341
+ - If all builds succeed, the services publish in one batch, then their `start` hooks run. If one build fails, nothing publishes and `commit()` rejects.
342
+ - A service whose dependencies are not available goes to `pending`. That is not a failure.
343
+ - A declared service without `provide` fails the commit with `TransactionIncompleteError`, unless it is declared with `optional: true`.
344
+ - `tx.replace(key, option)` replaces a registration at the commit. `tx.abort()` and the disposal of the container drop everything.
345
+ - `tx.declare` and `tx.provide` reserve the key until the commit or the abort. Another `provide` of the key in the same container fails with `DuplicateServiceError`. For an exclusive key, a provider in another container of the area goes to `conflict` until the abort.
346
+ - `withdraw()` on the result of `tx.provide` before the commit removes that provide and resolves `true`. A declared key without a provide then fails the commit.
347
+ - `tx.commit({ signal })` aborts the transaction when the signal aborts before the publish. See [Cancellation](#cancellation).
348
+
349
+ ## Keys without a provider
350
+
351
+ `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.
352
+
353
+ ```ts
354
+ import { defineKey, firstAvailable, injeca, optional } from 'injeca'
355
+
356
+ export const llmKey = defineKey<LlmClient>('airi.llm')
357
+
358
+ injeca.provide('voice:speech', {
359
+ dependsOn: {
360
+ llm: firstAvailable(remoteLlmKey, llmKey), // the first available provider
361
+ tts: optional(ttsKey), // `{ value, status } | undefined`
362
+ },
363
+ build: ({ dependsOn }) => createSpeech(dependsOn.llm, dependsOn.tts?.value),
364
+ })
365
+
366
+ // A plugin provides it later. The build result must match LlmClient.
367
+ injeca.provide(llmKey, () => createLocalLlm())
368
+ ```
369
+
370
+ ### Exclusive keys
371
+
372
+ An exclusive key can have only one provider in its resolution area: the container that has the key, with its descendants.
373
+
374
+ ```ts
375
+ const whiteboardKey = defineKey<Whiteboard>('dev.airi.whiteboard', { exclusive: true })
376
+ ```
377
+
378
+ 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.
379
+
380
+ An [exported registration](#exported-registrations) counts in the area of the parent container. So two sibling plugins that export one exclusive key conflict, and so do a host registration and a plugin export of that key.
381
+
382
+ ### Resolution conditions
383
+
384
+ `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.
385
+
386
+ ```ts
387
+ import { defineKey, optional, where } from 'injeca'
388
+
389
+ const panelKey = injeca.provide('whiteboard:panel', { metadata: { version: '2.1.0' }, build: () => createPanel() })
390
+ const themeKey = defineKey<Theme>('ui:theme')
391
+
392
+ injeca.provide('whiteboard:view', {
393
+ dependsOn: {
394
+ panel: where(panelKey, ({ metadata }) => String(metadata.version).startsWith('2.')),
395
+ theme: optional(where(themeKey, ({ owner }) => owner.kind === 'host')),
396
+ },
397
+ build: ({ dependsOn }) => createView(dependsOn.panel, dependsOn.theme?.value),
398
+ })
399
+ ```
400
+
401
+ - A rejected required dependency keeps the dependent `pending`. A rejected optional dependency is `undefined`.
402
+ - The condition must be pure and synchronous. A thrown error counts as `false` and goes to the observe stream.
403
+
404
+ A condition also filters the candidates of `firstAvailable`. The selection is the first available candidate that passes:
405
+
406
+ ```ts
407
+ import type { ProviderInfo } from 'injeca'
408
+
409
+ import { firstAvailable, optional, where } from 'injeca'
410
+
411
+ const atLeastTwo = ({ metadata }: ProviderInfo) => String(metadata.version).startsWith('2.')
412
+
413
+ injeca.provide('voice:chat', {
414
+ dependsOn: {
415
+ // The condition applies to each candidate.
416
+ llm: where(firstAvailable(remoteLlmKey, localLlmKey), atLeastTwo),
417
+ // The condition applies to the remote candidate only.
418
+ backup: optional(firstAvailable(where(remoteLlmKey, atLeastTwo), localLlmKey)),
419
+ },
420
+ build: ({ dependsOn }) => createChat(dependsOn.llm, dependsOn.backup?.value),
421
+ })
422
+ ```
423
+
424
+ - `where`, `firstAvailable`, and `optional` combine in any order. A candidate with two conditions must pass both.
425
+ - `graph(container).explain()` names each candidate that a condition rejected.
426
+
427
+ ## Status
428
+
429
+ A running service is `ready`, `degraded`, or `unavailable`.
430
+
431
+ - `degraded` means that calls still complete, at lower quality.
432
+ - `unavailable` means that the instance exists, but calls fail now.
433
+ - 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.
434
+ - A dependency that becomes `unavailable` does not stop or rebuild its dependents. They get a `dependency-status-change` call if they register a handler.
435
+
436
+ ```ts
437
+ build: ({ setStatus }) => {
438
+ const client = connect()
439
+ client.on('reconnecting', () => setStatus('unavailable', { reason: 'reconnecting' }))
440
+ client.on('ready', () => setStatus('ready'))
441
+ return client
442
+ }
443
+ ```
444
+
445
+ ## Containers
446
+
447
+ Create containers to isolate tests, or to give each plugin its own child container.
448
+
449
+ ```ts
450
+ import { createContainer, dispose, effect, invoke, provide, start, stop } from 'injeca'
107
451
 
108
452
  const container = createContainer()
109
453
 
@@ -111,14 +455,234 @@ const token = provide(container, 'token', () => crypto.randomUUID())
111
455
 
112
456
  invoke(container, {
113
457
  dependsOn: { token },
114
- // eslint-disable-next-line no-console
115
- callback: ({ token }) => console.log('token', token),
458
+ callback: ({ token }) => console.info('token', token),
116
459
  })
117
460
 
118
461
  await start(container)
119
462
  await stop(container)
120
463
  ```
121
464
 
465
+ 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.
466
+
467
+ ```ts
468
+ const plugin = createContainer({
469
+ parent: injeca.getContainer(),
470
+ owner: { kind: 'plugin', id: 'agent-activity' },
471
+ })
472
+
473
+ provide(plugin, 'agent-activity:reaction', { dependsOn: { llm: llmKey }, build: () => createReaction() })
474
+ effect(plugin, () => () => console.info('plugin cleaned up'))
475
+
476
+ await start(plugin, { signal: timeout(plugin, 10_000) })
477
+ await dispose(plugin)
478
+ ```
479
+
480
+ - `stop()` stops. `dispose()` also withdraws the registrations and runs the container effects. A disposed container cannot be used again.
481
+ - `dispose(parent)` disposes the child containers first, newest first, each one with its effects. Then the services of the parent stop, and then its effects run.
482
+ - `effect(container, setup)` after the disposal started runs the disposer at once.
483
+ - `start(container, { signal: timeout(container, ms) })` rejects with a `StartTimeoutError` that explains each service that is not running. injeca has no built-in timeout.
484
+ - A service with a missing provider, or in a dependency cycle, stays `pending`. Use a timeout signal to find it.
485
+ - 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.
486
+ - `createContainer({ isolate: [key] })` stops a key from resolving from the ancestors. It also hides the exports of the sibling containers. It does not hide the exports of its own child containers.
487
+ - A `stop` or `dispose` call from a cleanup of the same container, or of a descendant, does not wait for itself. The same is true for a cleanup of a service that holds a service of the container. It warns and resolves at once. See [Calling dispose from a cleanup](#calling-dispose-from-a-cleanup).
488
+
489
+ ### Logging
490
+
491
+ `logging: true` writes the lifecycle trace: each `provide`, `invoke`, build, `start` and `stop` hook, and `running`. It is off by default.
492
+
493
+ ```ts
494
+ import { createContainer, createLoggLogger, createNoopLogger, injeca } from 'injeca'
495
+
496
+ const app = createContainer({ logging: true }) // writes PROVIDE, RUN, HOOK OnStart, RUNNING, and the other lines
497
+ const plugin = createContainer({ parent: app, logging: false }) // a child uses the value of its parent, unless it sets its own
498
+ const quiet = createContainer({ logger: createNoopLogger() }) // also no warnings, for tests
499
+
500
+ injeca.getContainer().logging = true // the global container
501
+ injeca.setLogger(createLoggLogger(logg)) // a logger from @guiiai/logg
502
+ ```
503
+
504
+ - `logger.warn` always writes, whatever `logging` is. injeca warns for a re-entrant call, a `stop` timeout, and a late failure of a callback that it stopped waiting for.
505
+ - The logger is `options.logger`, then the logger of the parent container, then `createDefaultLogger()`. It writes with `console`.
506
+
507
+ ### Exported registrations
508
+
509
+ A registration in a child container is visible only in that container and its descendants. `export: true` also makes it a candidate for consumers in the area of the parent container: the parent and each of its descendants. So a plugin can use a Kit that another plugin provides.
510
+
511
+ ```ts
512
+ const host = createContainer({ owner: { kind: 'host' } })
513
+ const pluginA = createContainer({ parent: host, owner: { kind: 'plugin', id: 'a' } })
514
+ const pluginB = createContainer({ parent: host, owner: { kind: 'plugin', id: 'b' } })
515
+
516
+ provide(pluginA, memoryKitKey, { export: true, build: () => createMemoryKit() })
517
+ provide(pluginB, 'b:session', { dependsOn: { memory: memoryKitKey }, build: ({ dependsOn }) => startSession(dependsOn.memory) })
518
+ ```
519
+
520
+ - The registration stays in its container. That container owns it, and `dispose(pluginA)` withdraws it. Its own dependencies resolve from that container. This is also true for a `perConsumer` export.
521
+ - An export goes exactly one level up. A container that wants to reach the area of its grandparent exports from its own level.
522
+ - A consumer looks in its own container, then in each ancestor. At each level, the registration of that container comes first, then the exports of its children in creation order.
523
+ - Shadowing still has no gap. An export takes over from a provider of a farther ancestor only while it runs.
524
+ - `perConsumer` and `export` combine. Each consumer instance lives in the consumer container.
525
+ - `tx.provide(key, { export: true, ... })` reserves an exclusive key in the parent area at once, and the commit publishes the service there in one batch.
526
+ - `replace()` keeps the export. On a root container, `export` has no effect.
527
+ - `graph(parent)`, `peek`, `watch`, `waitFor`, and `observe` see the export. `graph().services()` marks it with `exported: true`, and its `owner` is the owner of the exporting container.
528
+
529
+ ### Per-consumer services
530
+
531
+ `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.
532
+
533
+ The instance lives in the consumer container, but its `dependsOn` resolves from the container of the registration, as for a plain registration. So a key of the consumer container does not change the dependencies of the registration.
534
+
535
+ A dependent demands its consumer instance through a required, an optional, or a `firstAvailable` edge. This is also true when the key registers after the dependent.
536
+
537
+ A failed consumer instance builds again on the next demand from its container: a new `resolve`, `watch`, or `waitFor` call, or a dependent that starts to wait. Each demander gets one new build for each failure.
538
+
539
+ ```ts
540
+ const hostClient = injeca.provide('host:client', {
541
+ perConsumer: true,
542
+ metadata: { api: 'v1' },
543
+ build: ({ consumer }) => createHostClient(consumer),
544
+ })
545
+ ```
546
+
547
+ `metadata` is a frozen record on any registration. Resolution conditions and graph queries read it.
548
+
549
+ ## Reading state
550
+
551
+ ```ts
552
+ import { observe, timeout, waitFor, watch } from 'injeca'
553
+
554
+ const container = injeca.getContainer()
555
+
556
+ const stopWatching = watch(container, server, ({ phase, status }) => {})
557
+ await waitFor(container, server, { status: 'ready', signal: timeout(container, 5_000) })
558
+ const stopObserving = observe(container, (record) => {})
559
+ ```
560
+
561
+ - `watch` sends the current state at once, then each change, in order.
562
+ - `waitFor` rejects with a `ServiceFailedError` (`service-failed`) when the service failed, or waits for a required provider that failed. Its `cause` is the failure.
563
+ - For a `perConsumer` key, `watch` and `waitFor` create the consumer instance for their container, as `resolve` does. The demand ends with the subscription.
564
+ - `observe` sends each phase change, status change, decision, hook timeout, aborted wait, and re-entrant call in the container and its children.
565
+
566
+ `peek` reads a value now, without a wait, and never builds anything. `resolve` takes `signal` to stop a wait:
567
+
568
+ ```ts
569
+ import { peek, resolve, timeout, where } from 'injeca'
570
+
571
+ const result = peek(container, where(llmKey, ({ metadata }) => metadata.version === '2'))
572
+ if (result.kind === 'resolved')
573
+ result.value.chat()
574
+ else
575
+ console.info(result.kind) // 'missing', 'rejected', 'conflict', 'not-running', or 'unavailable'
576
+
577
+ const { llm } = await resolve(container, { llm: llmKey }, { signal: timeout(container, 5_000) }) // rejects with WaitTimeoutError
578
+ ```
579
+
580
+ ### Graph queries
581
+
582
+ `graph(container)` reads the graph of the container and its children. It never changes state.
583
+
584
+ ```ts
585
+ import { graph } from 'injeca'
586
+
587
+ const queries = graph(container)
588
+
589
+ queries.services() // all services, with the declared services of open transactions
590
+ queries.dependencies(server) // the direct edges of a service
591
+ queries.dependents(config) // the services that use a service now
592
+ queries.order() // providers before their dependents
593
+ queries.cycles() // each dependency cycle
594
+ queries.explain(server) // why a service is not running, also for a key without a provider, with a pending replacement
595
+ queries.impact({ kind: 'withdraw', key: llmKey }) // { stopped, rebuilt, notified }
596
+ ```
597
+
598
+ `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`.
599
+
600
+ ### Listener registry
601
+
602
+ injeca does not dispatch events. It stores listeners that a container owns, so that a consumer can run them by its own rules.
603
+
604
+ ```ts
605
+ import { listen, listeners } from 'injeca'
606
+
607
+ const gate = { id: 'airi.harness.gate' } // any object with a string id, for example an eventa definition
608
+
609
+ const remove = listen(plugin, gate, handler, { priority: 10 })
610
+ const entries = listeners(container, gate) // [{ handler, owner, metadata }]
611
+ ```
612
+
613
+ `listeners()` returns the listeners of the container and its children, in registration order. `dispose(container)` removes the listeners of the container.
614
+
615
+ ## Migration from 0.2
616
+
617
+ 0.3 keeps the 0.2 call style: `provide`, `invoke`, `resolve`, `start`, `stop`, `createContainer`, and the global `injeca` object. One API changed:
618
+
619
+ ```ts
620
+ // 0.2
621
+ const server = injeca.provide({
622
+ dependsOn: { lifecycle },
623
+ build({ dependsOn }) {
624
+ const app = createServer()
625
+ dependsOn.lifecycle.appHooks.onStart(() => app.listen(3000))
626
+ dependsOn.lifecycle.appHooks.onStop(() => app.close())
627
+ return app
628
+ },
629
+ })
630
+
631
+ // 0.3
632
+ const server = injeca.provide({
633
+ build({ on }) {
634
+ const app = createServer()
635
+ on('start', () => app.listen(3000))
636
+ on('stop', () => app.close())
637
+ return app
638
+ },
639
+ })
640
+ ```
641
+
642
+ Other differences:
643
+
644
+ - `start()` waits for a missing provider. 0.2 threw an error. Use `start(container, { signal: timeout(container, ms) })` to fail after a time.
645
+ - `resolve()` waits for a missing provider too. Before `start()`, it builds the services that it needs, with their `start` hooks.
646
+ - `invoke()` returns a handle with `key` and `withdraw()`. Graph queries accept `key`. Its callback runs again when a required dependency gets a new instance.
647
+
648
+ ## Migration from 0.3 to 0.4
649
+
650
+ 0.4 removes the `timeoutMs` option of `resolve`, `waitFor`, and `start`. Pass a signal from `timeout(container, ms)`:
651
+
652
+ ```ts
653
+ // 0.3
654
+ await resolve(container, { llm: llmKey }, { timeoutMs: 5_000 })
655
+ await waitFor(container, server, { status: 'ready', timeoutMs: 5_000 })
656
+ await start(container, { timeoutMs: 10_000 })
657
+
658
+ // 0.4
659
+ await resolve(container, { llm: llmKey }, { signal: timeout(container, 5_000) })
660
+ await waitFor(container, server, { status: 'ready', signal: timeout(container, 5_000) })
661
+ await start(container, { signal: timeout(container, 10_000) })
662
+ ```
663
+
664
+ - The errors do not change. A timeout gives `WaitTimeoutError` or `StartTimeoutError`. The error message no longer has the milliseconds. The `cause` is the `TimeoutError` reason.
665
+ - A call with `timeoutMs` and `signal` becomes one signal: `withTimeout(signal, ms, container)`.
666
+ - A `TimeoutError` reason from your own signal, for example `AbortSignal.timeout()`, now also gives `WaitTimeoutError` or `StartTimeoutError`. Another reason rejects as it is, as before.
667
+ - `stop`, `dispose`, `withdraw`, `replace`, and `tx.commit` take `{ signal }` too. See [Cancellation](#cancellation).
668
+ - `timeouts: { start, stop }` and `onTimeout` keep their shape. A `stop` handler now sees the `stop` timeout on its `signal`.
669
+ - A `stop` timeout no longer fails `stop()`, `dispose()`, or `withdraw()`. Without `onTimeout`, it counts as `handled`, with a warning. In 0.3, the callers rejected with `HookTimeoutError`. To keep a failure, return `context.decide.fail(error)` from `onTimeout` for the `stop` hook.
670
+ - `observe` has two new record types, `wait-aborted` and `reentrant-call`. A `switch` on `record.type` with an exhaustive check needs a case for each. Their `call` can be `'stop'`, `'dispose'`, or `'withdraw'`.
671
+ - `ReentrantDisposeError` (`reentrant-dispose`) is removed. A `stop()`, `dispose()`, or `withdraw()` from a cleanup that its wait includes resolves at once with a warning. See [Calling dispose from a cleanup](#calling-dispose-from-a-cleanup).
672
+ - A custom `Logger` needs `warn(message)`. `createDefaultLogger`, `createLoggLogger`, and `createNoopLogger` have it. injeca also writes stop timeouts and late failures of abandoned callbacks with it.
673
+ - `injeca/testing` exports `manualClock` and `drained`. See [Testing with a manual clock](#testing-with-a-manual-clock).
674
+ - The `enabled` option of `createContainer` is removed. The lifecycle logs are off by default. `logging: true` turns them on. Warnings always go to the logger. See [Logging](#logging).
675
+
676
+ ```ts
677
+ // 0.3
678
+ createContainer({ enabled: false }) // no logs
679
+ createContainer() // lifecycle logs
680
+
681
+ // 0.4
682
+ createContainer({ logger: createNoopLogger() }) // no logs and no warnings. Or remove the option to keep the warnings.
683
+ createContainer({ logging: true }) // lifecycle logs
684
+ ```
685
+
122
686
  ## License
123
687
 
124
688
  MIT