injeca 0.2.0 → 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 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,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` implements singleton / cached dependencies by default**
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 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.
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
- > 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:
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
- Avoid recreating the reusable function inside the returned function, otherwise
97
- each call starts from a fresh cache and can create duplicate resources.
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
- ### Manual containers
203
+ ### Atomic replacement
100
204
 
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`.
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
- import { createContainer, invoke, provide, start, stop } from 'injeca'
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
- // eslint-disable-next-line no-console
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