injeca 0.3.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 +251 -14
- package/dist/decision-BXZu3N1O.mjs +1356 -0
- package/dist/decision-BXZu3N1O.mjs.map +1 -0
- package/dist/{global-C0Nk0pU8.mjs → global-B5N-d-TO.mjs} +1024 -1214
- package/dist/global-B5N-d-TO.mjs.map +1 -0
- package/dist/{global-B9Bk2EG2.d.mts → global-DPfe26g5.d.mts} +81 -17
- package/dist/global.d.mts +1 -1
- package/dist/global.mjs +1 -1
- package/dist/{index-DXguNx2M.d.mts → index-DTYp044J.d.mts} +139 -19
- package/dist/index.d.mts +40 -15
- package/dist/index.mjs +55 -23
- package/dist/index.mjs.map +1 -1
- package/dist/testing.d.mts +31 -1
- package/dist/testing.mjs +60 -2
- package/dist/testing.mjs.map +1 -1
- package/package.json +5 -5
- package/dist/decision-odiPb2Au.mjs +0 -194
- package/dist/decision-odiPb2Au.mjs.map +0 -1
- package/dist/global-C0Nk0pU8.mjs.map +0 -1
package/README.md
CHANGED
|
@@ -71,6 +71,7 @@ process.once('SIGINT', async () => {
|
|
|
71
71
|
- `provide` returns a typed key. Use it in `dependsOn`. injeca infers the type of each dependency.
|
|
72
72
|
- `start()` builds each service when its dependencies are running, then runs its `start` hooks. It resolves when everything is running.
|
|
73
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).
|
|
74
75
|
|
|
75
76
|
> [!WARNING]
|
|
76
77
|
>
|
|
@@ -163,16 +164,110 @@ provide(container, 'modules:camera', {
|
|
|
163
164
|
onTimeout: (context) => {
|
|
164
165
|
if (context.hook === 'start')
|
|
165
166
|
return context.decide.requeue({ after: 1_000, maxAttempts: 3 })
|
|
166
|
-
return
|
|
167
|
+
return undefined // a stop timeout counts as handled
|
|
167
168
|
},
|
|
168
169
|
build: () => createCamera(),
|
|
169
170
|
})
|
|
170
171
|
```
|
|
171
172
|
|
|
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.
|
|
173
|
-
- When a timeout passes, `observe` gets a `timeout` record. Then `onTimeout` of the service, or of the container, decides.
|
|
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).
|
|
174
178
|
- `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.
|
|
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.
|
|
176
271
|
|
|
177
272
|
## Changes at run time
|
|
178
273
|
|
|
@@ -200,6 +295,14 @@ on('dependency-change', ({ dependsOn, decide }) => {
|
|
|
200
295
|
})
|
|
201
296
|
```
|
|
202
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
|
+
|
|
203
306
|
### Atomic replacement
|
|
204
307
|
|
|
205
308
|
A withdrawal followed by a new `provide` leaves a gap: all dependents stop. `replace()` has no gap. A hot reload tool uses it.
|
|
@@ -212,9 +315,12 @@ await tts.replace(() => createTtsV2())
|
|
|
212
315
|
```
|
|
213
316
|
|
|
214
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.
|
|
215
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.
|
|
216
|
-
- The old instance stops after each dependent releases it. A dependent with a `dependency-change` handler moves in place. Other dependents rebuild.
|
|
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.
|
|
217
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).
|
|
218
324
|
|
|
219
325
|
### Transactions
|
|
220
326
|
|
|
@@ -236,6 +342,9 @@ await tx.commit()
|
|
|
236
342
|
- A service whose dependencies are not available goes to `pending`. That is not a failure.
|
|
237
343
|
- A declared service without `provide` fails the commit with `TransactionIncompleteError`, unless it is declared with `optional: true`.
|
|
238
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).
|
|
239
348
|
|
|
240
349
|
## Keys without a provider
|
|
241
350
|
|
|
@@ -268,6 +377,8 @@ const whiteboardKey = defineKey<Whiteboard>('dev.airi.whiteboard', { exclusive:
|
|
|
268
377
|
|
|
269
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.
|
|
270
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
|
+
|
|
271
382
|
### Resolution conditions
|
|
272
383
|
|
|
273
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.
|
|
@@ -290,6 +401,29 @@ injeca.provide('whiteboard:view', {
|
|
|
290
401
|
- A rejected required dependency keeps the dependent `pending`. A rejected optional dependency is `undefined`.
|
|
291
402
|
- The condition must be pure and synchronous. A thrown error counts as `false` and goes to the observe stream.
|
|
292
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
|
+
|
|
293
427
|
## Status
|
|
294
428
|
|
|
295
429
|
A running service is `ready`, `degraded`, or `unavailable`.
|
|
@@ -339,20 +473,69 @@ const plugin = createContainer({
|
|
|
339
473
|
provide(plugin, 'agent-activity:reaction', { dependsOn: { llm: llmKey }, build: () => createReaction() })
|
|
340
474
|
effect(plugin, () => () => console.info('plugin cleaned up'))
|
|
341
475
|
|
|
342
|
-
await start(plugin, {
|
|
476
|
+
await start(plugin, { signal: timeout(plugin, 10_000) })
|
|
343
477
|
await dispose(plugin)
|
|
344
478
|
```
|
|
345
479
|
|
|
346
480
|
- `stop()` stops. `dispose()` also withdraws the registrations and runs the container effects. A disposed container cannot be used again.
|
|
347
|
-
- `
|
|
348
|
-
-
|
|
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.
|
|
349
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.
|
|
350
|
-
- `createContainer({ isolate: [key] })` stops a key from resolving from the ancestors.
|
|
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.
|
|
351
528
|
|
|
352
529
|
### Per-consumer services
|
|
353
530
|
|
|
354
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.
|
|
355
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
|
+
|
|
356
539
|
```ts
|
|
357
540
|
const hostClient = injeca.provide('host:client', {
|
|
358
541
|
perConsumer: true,
|
|
@@ -366,17 +549,33 @@ const hostClient = injeca.provide('host:client', {
|
|
|
366
549
|
## Reading state
|
|
367
550
|
|
|
368
551
|
```ts
|
|
369
|
-
import { observe, waitFor, watch } from 'injeca'
|
|
552
|
+
import { observe, timeout, waitFor, watch } from 'injeca'
|
|
370
553
|
|
|
371
554
|
const container = injeca.getContainer()
|
|
372
555
|
|
|
373
556
|
const stopWatching = watch(container, server, ({ phase, status }) => {})
|
|
374
|
-
await waitFor(container, server, { status: 'ready',
|
|
557
|
+
await waitFor(container, server, { status: 'ready', signal: timeout(container, 5_000) })
|
|
375
558
|
const stopObserving = observe(container, (record) => {})
|
|
376
559
|
```
|
|
377
560
|
|
|
378
561
|
- `watch` sends the current state at once, then each change, in order.
|
|
379
|
-
- `
|
|
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
|
+
```
|
|
380
579
|
|
|
381
580
|
### Graph queries
|
|
382
581
|
|
|
@@ -392,7 +591,7 @@ queries.dependencies(server) // the direct edges of a service
|
|
|
392
591
|
queries.dependents(config) // the services that use a service now
|
|
393
592
|
queries.order() // providers before their dependents
|
|
394
593
|
queries.cycles() // each dependency cycle
|
|
395
|
-
queries.explain(server) // why a service is not running
|
|
594
|
+
queries.explain(server) // why a service is not running, also for a key without a provider, with a pending replacement
|
|
396
595
|
queries.impact({ kind: 'withdraw', key: llmKey }) // { stopped, rebuilt, notified }
|
|
397
596
|
```
|
|
398
597
|
|
|
@@ -442,9 +641,47 @@ const server = injeca.provide({
|
|
|
442
641
|
|
|
443
642
|
Other differences:
|
|
444
643
|
|
|
445
|
-
- `start()` waits for a missing provider. 0.2 threw an error. Use `start({
|
|
644
|
+
- `start()` waits for a missing provider. 0.2 threw an error. Use `start(container, { signal: timeout(container, ms) })` to fail after a time.
|
|
446
645
|
- `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.
|
|
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
|
+
```
|
|
448
685
|
|
|
449
686
|
## License
|
|
450
687
|
|