@zudojs/core 1.0.0 → 1.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (41) hide show
  1. package/README.md +15 -5
  2. package/dist/application/applicationContext.context.d.ts +12 -1
  3. package/dist/application/applicationContext.context.js +10 -2
  4. package/dist/application/createApplication.js +6 -1
  5. package/dist/configuration/configurationManager.manager.d.ts +10 -0
  6. package/dist/configuration/configurationManager.manager.js +19 -1
  7. package/dist/container/container.d.ts +4 -3
  8. package/dist/container/container.js +15 -10
  9. package/dist/container/container.lifetime.d.ts +21 -0
  10. package/dist/container/container.lifetime.js +39 -0
  11. package/dist/container/scope.d.ts +3 -1
  12. package/dist/context/provider/contextStorage.storage.d.ts +8 -0
  13. package/dist/context/provider/contextStorage.storage.js +12 -1
  14. package/dist/lifecycle/core/lifecycle.d.ts +8 -3
  15. package/dist/lifecycle/core/lifecycle.js +18 -5
  16. package/dist/lifecycle/core/lifecycle.rollback.d.ts +18 -0
  17. package/dist/lifecycle/core/lifecycle.rollback.js +34 -0
  18. package/dist/logging/core/logEntry.entry.js +12 -1
  19. package/dist/modules/moduleLifecycle/index.d.ts +1 -1
  20. package/dist/modules/moduleLifecycle/moduleLifecycle.lifecycle.d.ts +30 -5
  21. package/dist/modules/moduleLifecycle/moduleLifecycle.lifecycle.js +58 -17
  22. package/dist/modules/moduleLifecycle/moduleLifecycle.stateMachine.d.ts +1 -1
  23. package/dist/modules/moduleLifecycle/moduleLifecycle.stateMachine.js +3 -1
  24. package/dist/modules/moduleLifecycle/moduleLifecycle.type.d.ts +20 -0
  25. package/dist/modules/moduleLoader/moduleLoader.loader.js +61 -25
  26. package/dist/runtime/runtime.d.ts +2 -0
  27. package/dist/runtime/runtime.js +15 -4
  28. package/dist/runtime/runtimeBootstrap/pipeline/runtimeBootstrap.pipeline.d.ts +3 -1
  29. package/dist/runtime/runtimeBootstrap/pipeline/runtimeBootstrap.pipeline.js +29 -5
  30. package/dist/runtime/runtimeOptions/runtimeOptions.defaults.js +3 -1
  31. package/dist/runtime/runtimeOptions/runtimeOptions.mode.d.ts +14 -0
  32. package/dist/runtime/runtimeOptions/runtimeOptions.mode.js +16 -0
  33. package/dist/runtime/runtimeOptions/runtimeOptions.resolver.js +17 -1
  34. package/dist/runtime/runtimeOptions/runtimeOptions.type.d.ts +18 -2
  35. package/dist/runtime/runtimeOptions/runtimeOptions.validation.d.ts +1 -1
  36. package/dist/runtime/runtimeShutdown/pipeline/runtimeShutdown.pipeline.js +10 -2
  37. package/dist/runtime/runtimeSignals/runtimeSignals.d.ts +14 -3
  38. package/dist/runtime/runtimeSignals/runtimeSignals.fatal.d.ts +29 -0
  39. package/dist/runtime/runtimeSignals/runtimeSignals.fatal.js +51 -0
  40. package/dist/runtime/runtimeSignals/runtimeSignals.js +15 -4
  41. package/package.json +7 -3
package/README.md CHANGED
@@ -2,6 +2,12 @@
2
2
 
3
3
  Application lifecycle management, execution context propagation, and runtime orchestration for Zudojs applications.
4
4
 
5
+ <!-- zudo-docs:start -->
6
+
7
+ **Documentation:** [zudojs.oyinlola.site/docs/packages-core](https://zudojs.oyinlola.site/docs/packages-core) · **For AI agents:** [Markdown version](https://zudojs.oyinlola.site/docs/packages-core.md), [llms.txt](https://zudojs.oyinlola.site/llms.txt)
8
+
9
+ <!-- zudo-docs:end -->
10
+
5
11
  ## Installation
6
12
 
7
13
  ```bash
@@ -43,6 +49,8 @@ await app.start(); // restart: a fresh runtime is created
43
49
  await app.shutdown(); // stop + dispose; cannot be restarted afterwards
44
50
  ```
45
51
 
52
+ When `runtime.mode` is omitted, it is derived from `NODE_ENV` with `resolveEnvironment()` from `@zudojs/constants` (`prod` / `Production` → `"production"`, `staging` → `"production"`, `test` → `"test"`, unset → `"development"`).
53
+
46
54
  `createApplication` wires the standard graph (container, configuration manager, logger, module registry/loader/lifecycle, runtime, application lifecycle). Every piece can also be assembled by hand:
47
55
 
48
56
  ```typescript
@@ -66,6 +74,8 @@ const app = await Application.create({
66
74
  });
67
75
  ```
68
76
 
77
+ A failed `lifecycle.start()` stops, in reverse order, every participant whose `start()` completed, so a retry starts from the first one again. `dispose()` only reaches participants whose `initialize()` ran.
78
+
69
79
  ## Runtime
70
80
 
71
81
  `createRuntime(dependencies, options)` returns a single-use runtime:
@@ -77,14 +87,14 @@ FAILED is reachable from every non-terminal state via fail() or a failure.
77
87
 
78
88
  - `start()` loads, initializes, and starts modules through the bootstrap pipeline; `getStatus().bootstrap` reports counts, errors, and duration.
79
89
  - `stop()` stops and destroys modules through the shutdown pipeline; `getStatus().shutdown` reports the same.
80
- - `startup.timeoutMs` / `shutdown.timeoutMs` abort the pipeline with a `RuntimeTimeoutError`; the abandoned pipeline can never surface an unhandled rejection.
90
+ - `startup.timeoutMs` / `shutdown.timeoutMs` abort the pipeline with a `RuntimeTimeoutError`; the abandoned pipeline can never surface an unhandled rejection. A timed-out bootstrap starts no further phase or module hook; the hook still running finishes, and the runtime then stops and destroys whatever it brought up. `stop()` waits for that teardown.
81
91
  - `continueOnInitializeError` / `continueOnStartError` (and the stop/destroy equivalents) make the runtime finish with `success: false` and the failures listed instead of throwing.
82
92
  - Every error the runtime throws extends `RuntimeError` (which itself extends `RuntimeError` from `@zudojs/errors`), so `isRuntimeError()` from either package recognises it.
83
93
  - `runtime.context` is the runtime's immutable `RuntimeExecutionContext` (`executionId` = runtime id, `service` = runtime name, `metadata.runtimeId/runtimeName/runtimeMode/runtimeRole` plus `RuntimeOptions.metadata`); `runtime.timing` holds the state-transition timestamps; `runtime.contextStorage` is the `ContextStorage` the context is established in (`RuntimeDependencies.contextStorage`, default `getDefaultContextStorage()`).
84
94
 
85
95
  ### Signals
86
96
 
87
- When `signals.handleSigint` / `handleSigterm` / `handleSighup` are on (SIGINT and SIGTERM default to on), the runtime registers handlers on start and removes them on stop, failure, or dispose. The first signal triggers a graceful `stop()`. A second signal during shutdown is logged and ignored unless `signals.forceExitOnSecondSignal` is explicitly enabled, in which case the process exits with `signals.forceExitCode`. `handleUncaughtException` / `handleUnhandledRejection` mark the runtime failed and stop it. The runtime never calls `process.exit()` otherwise.
97
+ When `signals.handleSigint` / `handleSigterm` / `handleSighup` are on (SIGINT and SIGTERM default to on), the runtime registers handlers on start and removes them on stop, failure, or dispose. The first signal triggers a graceful `stop()`. A second signal during shutdown exits the process with `signals.forceExitCode` (default 1); set `signals.forceExitOnSecondSignal: false` to log and ignore it instead. `handleUncaughtException` / `handleUnhandledRejection` mark the runtime failed, stop it, and then exit the process with code 1, because the installed handler suppresses Node's own crash and the process would otherwise end with code 0. `signals.fatalExitTimeout` (default 10000 ms) bounds a shutdown that hangs; set `signals.exitOnFatalError: false` to stop the runtime and keep the process running. The runtime never calls `process.exit()` otherwise.
88
98
 
89
99
  ## Modules
90
100
 
@@ -103,7 +113,7 @@ onInitialize → onReady → onShutdown → onDestroy
103
113
 
104
114
  `createConfigurationManager({ registry, schemas, loaderOptions, validationOptions, redactorOptions })` loads registered sources by priority, deep-merges them, applies schema defaults, validates, and exposes an immutable `Configuration`.
105
115
 
106
- - `manager.on(event, listener)` / `manager.off(...)` receive `initializing`, `loaded`, `validated`, `ready`, `failed`, `reloading`, and `reloaded` events.
116
+ - `manager.on(type, listener)` / `manager.off(...)` receive the `configuration.initializing`, `configuration.loaded`, `configuration.validated`, `configuration.ready`, `configuration.failed`, `configuration.reloading`, and `configuration.reloaded` events (`"*"` subscribes to all of them).
107
117
  - `manager.getSection(name)` returns a scoped configuration for a registered section; `manager.reload()` keeps programmatic defaults and serialises overlapping reloads.
108
118
  - Values are deep-frozen; `toObject()` returns an independent deep copy. Path lookups never walk the prototype chain, and `getNumber` accepts only plain decimal numbers.
109
119
  - Secrets never reach errors or logs: validation, load, and source errors pass through the redactor, which matches whole key words (`password`, `secret`, `token`, `pwd`, `passphrase`, `auth`, `dsn`, `credential`, ...).
@@ -120,14 +130,14 @@ onInitialize → onReady → onShutdown → onDestroy
120
130
 
121
131
  ## Execution context
122
132
 
123
- There is one execution-context model: the immutable `ExecutionContext` (`createExecutionContext`, `deriveExecutionContext`, `withExecutionMetadata`) propagated by `ContextStorage`, which wraps `AsyncLocalStorage`. `run(context, fn)` and `runDerived(overrides, fn)` establish a context for the callback and everything it awaits, `runWithValues(values, fn)` adds request-scoped values, and `capture()` / `runSnapshot(snapshot, fn)` carry a context across queue or timer boundaries. `getDefaultContextStorage()` is the process-wide instance every component uses unless another is injected.
133
+ There is one execution-context model: the immutable `ExecutionContext` (`createExecutionContext`, `deriveExecutionContext`, `withExecutionMetadata`) propagated by `ContextStorage`, which wraps `AsyncLocalStorage`. `run(context, fn)` and `runDerived(overrides, fn)` establish a context for the callback and everything it awaits, `runWithValues(context, values, fn)` additionally binds a `ContextValues` collection (read back with `getValues()`; `run()` starts a new execution without the enclosing execution's values, `runDerived()` keeps them), and `capture()` / `runSnapshot(snapshot, fn)` carry a context across queue or timer boundaries. `getDefaultContextStorage()` is the process-wide instance every component uses unless another is injected.
124
134
 
125
135
  Propagation is real at runtime:
126
136
 
127
137
  - `runtime.start()` and `runtime.stop()` run the bootstrap and shutdown pipelines inside `runtime.contextStorage.run(runtime.context, ...)`.
128
138
  - Each module hook (`onInitialize`, `onReady`, `onShutdown`, `onDestroy`) runs in a context derived from it: `module` is the module id, `operation` is the hook name, and `{ moduleId, phase }` is merged into `metadata`. `ModuleLifecycleOptions.contextStorage` selects the storage (default: the shared one).
129
139
  - `Application.start()` / `stop()` / `shutdown()` run lifecycle participants inside the runtime's context as well.
130
- - `createApplication` threads one storage (`CreateApplicationOptions.contextStorage`) through the `ApplicationContext` (`getContextStorage()`), the container's `currentScope` (so `"scoped"` providers resolve once per execution context), the module lifecycle, the runtime, and the logger.
140
+ - `createApplication` threads one storage (`CreateApplicationOptions.contextStorage`) through the `ApplicationContext` (`getContextStorage()`), the container's `currentScope` (so `"scoped"` providers resolve once per execution context; resolving one outside any execution context, or from a singleton's factory, throws a `DependencyResolutionError`), the module lifecycle, the runtime, and the logger.
131
141
 
132
142
  ```typescript
133
143
  import { defineModule, getDefaultContextStorage } from "@zudojs/core";
@@ -5,7 +5,14 @@ import type { Logger } from "../logging/core/logger.js";
5
5
  import type { ContextStorage } from "../context/provider/contextStorage.storage.js";
6
6
  export interface ApplicationContextOptions {
7
7
  readonly container: Container;
8
- readonly configuration: Configuration;
8
+ /**
9
+ * The application configuration, or a function returning the
10
+ * current one. Pass a function (for example
11
+ * `() => manager.getConfiguration()`) so that
12
+ * `getConfiguration()` reflects configuration reloads instead of
13
+ * the snapshot taken when the context was created.
14
+ */
15
+ readonly configuration: Configuration | (() => Configuration);
9
16
  readonly modules: ModuleRegistry;
10
17
  readonly logger: Logger;
11
18
  /**
@@ -34,6 +41,10 @@ export declare class ApplicationContext {
34
41
  getContainer(): Container;
35
42
  /**
36
43
  * Application configuration.
44
+ *
45
+ * When the context was created with a configuration accessor
46
+ * (as `createApplication` does), this returns the configuration
47
+ * manager's current configuration, including reloads.
37
48
  */
38
49
  getConfiguration(): Configuration;
39
50
  /**
@@ -13,7 +13,11 @@ export class ApplicationContext {
13
13
  contextStorage;
14
14
  constructor(options) {
15
15
  this.container = options.container;
16
- this.configuration = options.configuration;
16
+ const configuration = options.configuration;
17
+ this.configuration =
18
+ typeof configuration === "function"
19
+ ? configuration
20
+ : () => configuration;
17
21
  this.modules = options.modules;
18
22
  this.logger = options.logger;
19
23
  this.contextStorage = options.contextStorage ?? getDefaultContextStorage();
@@ -26,9 +30,13 @@ export class ApplicationContext {
26
30
  }
27
31
  /**
28
32
  * Application configuration.
33
+ *
34
+ * When the context was created with a configuration accessor
35
+ * (as `createApplication` does), this returns the configuration
36
+ * manager's current configuration, including reloads.
29
37
  */
30
38
  getConfiguration() {
31
- return this.configuration;
39
+ return this.configuration();
32
40
  }
33
41
  /**
34
42
  * Registered application modules.
@@ -44,7 +44,12 @@ export async function createApplication(options = {}) {
44
44
  }
45
45
  const context = new ApplicationContext({
46
46
  container,
47
- configuration: configuration.getConfiguration(),
47
+ /*
48
+ * An accessor rather than a snapshot: after
49
+ * `configuration.reload()` the application context must hand
50
+ * out the reloaded configuration, not the one captured here.
51
+ */
52
+ configuration: () => configuration.getConfiguration(),
48
53
  modules: moduleRegistry,
49
54
  logger,
50
55
  contextStorage,
@@ -53,6 +53,7 @@ export declare class ConfigurationManager {
53
53
  private loadResult;
54
54
  private validationReport;
55
55
  private missingSetConfigurationWarned;
56
+ private inFlightReload;
56
57
  private stateValue;
57
58
  constructor(options?: ConfigurationManagerOptions);
58
59
  /**
@@ -72,7 +73,16 @@ export declare class ConfigurationManager {
72
73
  */
73
74
  getListenerErrors(): readonly unknown[];
74
75
  initialize(): Promise<ConfigurationManagerResult>;
76
+ /**
77
+ * Reloads configuration from the registered sources.
78
+ *
79
+ * Overlapping calls are serialised: a reload requested while one
80
+ * is already in progress shares that in-flight reload instead of
81
+ * failing with InvalidStateError. A manager that is not ready yet
82
+ * is initialized instead.
83
+ */
75
84
  reload(): Promise<ConfigurationManagerResult>;
85
+ private performReload;
76
86
  getConfiguration(): Configuration;
77
87
  getProvider(): ConfigurationProvider;
78
88
  getRegistry(): ConfigurationRegistry;
@@ -31,6 +31,7 @@ export class ConfigurationManager {
31
31
  loadResult;
32
32
  validationReport;
33
33
  missingSetConfigurationWarned = false;
34
+ inFlightReload;
34
35
  stateValue = ConfigurationManagerState.CREATED;
35
36
  constructor(options = {}) {
36
37
  this.loader =
@@ -104,9 +105,26 @@ export class ConfigurationManager {
104
105
  throw error;
105
106
  }
106
107
  }
108
+ /**
109
+ * Reloads configuration from the registered sources.
110
+ *
111
+ * Overlapping calls are serialised: a reload requested while one
112
+ * is already in progress shares that in-flight reload instead of
113
+ * failing with InvalidStateError. A manager that is not ready yet
114
+ * is initialized instead.
115
+ */
107
116
  async reload() {
117
+ if (this.inFlightReload)
118
+ return this.inFlightReload;
108
119
  if (this.stateValue !== ConfigurationManagerState.READY)
109
120
  return this.initialize();
121
+ const reload = this.performReload().finally(() => {
122
+ this.inFlightReload = undefined;
123
+ });
124
+ this.inFlightReload = reload;
125
+ return reload;
126
+ }
127
+ async performReload() {
110
128
  const previousConfiguration = this.configuration;
111
129
  const previousLoadResult = this.loadResult;
112
130
  const previousValidation = this.validationReport;
@@ -201,7 +219,7 @@ export class ConfigurationManager {
201
219
  }
202
220
  if (!this.missingSetConfigurationWarned) {
203
221
  this.missingSetConfigurationWarned = true;
204
- console.warn("ConfigurationManager: the configured ConfigurationProvider does not implement setConfiguration(); loaded configuration will not be pushed into the provider.");
222
+ process.emitWarning("ConfigurationManager: the configured ConfigurationProvider does not implement setConfiguration(); loaded configuration will not be pushed into the provider.", { type: "ZudojsCoreWarning", code: "ZUDOJS_CONFIG_PROVIDER_NO_SET" });
205
223
  }
206
224
  }
207
225
  emit(event) {
@@ -12,8 +12,8 @@ export interface ContainerOptions {
12
12
  * new Container({ currentScope: () => storage.get() })
13
13
  *
14
14
  * "scoped" providers resolve to one instance per returned object.
15
- * When it returns undefined and no explicit scope is active, scoped
16
- * providers behave as transient.
15
+ * When it returns undefined and no explicit scope is active, resolving
16
+ * a scoped provider throws a DependencyResolutionError.
17
17
  */
18
18
  readonly currentScope?: () => object | undefined;
19
19
  }
@@ -26,10 +26,11 @@ export interface ContainerOptions {
26
26
  */
27
27
  export declare class Container {
28
28
  private readonly providers;
29
- private readonly scopedInstances;
30
29
  private readonly currentScope;
31
30
  private readonly resolving;
32
31
  private activeScope;
32
+ /** The singleton under construction, if any; see assertScopedResolvable. */
33
+ private captor;
33
34
  constructor(options?: ContainerOptions);
34
35
  /**
35
36
  * Registers a provider in the container.
@@ -1,4 +1,5 @@
1
1
  import { ProviderNotFoundError, ProviderAlreadyRegisteredError, InvalidProviderError, DependencyResolutionError, } from "../errors/exceptions.js";
2
+ import { assertScopedResolvable } from "./container.lifetime.js";
2
3
  /**
3
4
  * Dependency Injection container for Zudojs applications.
4
5
  *
@@ -8,10 +9,11 @@ import { ProviderNotFoundError, ProviderAlreadyRegisteredError, InvalidProviderE
8
9
  */
9
10
  export class Container {
10
11
  providers = new Map();
11
- scopedInstances = new WeakMap();
12
12
  currentScope;
13
13
  resolving = [];
14
14
  activeScope;
15
+ /** The singleton under construction, if any; see assertScopedResolvable. */
16
+ captor;
15
17
  constructor(options = {}) {
16
18
  this.currentScope = options.currentScope;
17
19
  }
@@ -29,6 +31,7 @@ export class Container {
29
31
  provider: provider,
30
32
  scope,
31
33
  resolved: false,
34
+ scopedInstances: new WeakMap(),
32
35
  });
33
36
  }
34
37
  /**
@@ -80,10 +83,12 @@ export class Container {
80
83
  if (registration.scope === "singleton" && registration.resolved) {
81
84
  return registration.instance;
82
85
  }
86
+ if (registration.scope === "scoped") {
87
+ assertScopedResolvable(token, this.captor, scopeKey, this.resolving);
88
+ }
83
89
  if (registration.scope === "scoped" && scopeKey) {
84
- const cache = this.scopedInstances.get(scopeKey);
85
- if (cache?.has(token)) {
86
- return cache.get(token);
90
+ if (registration.scopedInstances.has(scopeKey)) {
91
+ return registration.scopedInstances.get(scopeKey);
87
92
  }
88
93
  }
89
94
  const instance = this.createInstance(token, registration, scopeKey);
@@ -92,12 +97,7 @@ export class Container {
92
97
  registration.resolved = true;
93
98
  }
94
99
  else if (registration.scope === "scoped" && scopeKey) {
95
- let cache = this.scopedInstances.get(scopeKey);
96
- if (!cache) {
97
- cache = new Map();
98
- this.scopedInstances.set(scopeKey, cache);
99
- }
100
- cache.set(token, instance);
100
+ registration.scopedInstances.set(scopeKey, instance);
101
101
  }
102
102
  return instance;
103
103
  }
@@ -115,8 +115,12 @@ export class Container {
115
115
  return provider.useValue;
116
116
  }
117
117
  const previousScope = this.activeScope;
118
+ const previousCaptor = this.captor;
118
119
  this.resolving.push(token);
119
120
  this.activeScope = scopeKey;
121
+ if (registration.scope === "singleton") {
122
+ this.captor = token;
123
+ }
120
124
  try {
121
125
  if ("useFactory" in provider) {
122
126
  return provider.useFactory(this);
@@ -131,6 +135,7 @@ export class Container {
131
135
  finally {
132
136
  this.resolving.pop();
133
137
  this.activeScope = previousScope;
138
+ this.captor = previousCaptor;
134
139
  }
135
140
  }
136
141
  }
@@ -0,0 +1,21 @@
1
+ import type { Token } from "./token.js";
2
+ /**
3
+ * Rejects a "scoped" resolution that would outlive or escape its scope.
4
+ *
5
+ * - A scoped provider resolved (directly or transitively) while a
6
+ * singleton is being constructed would be captured by that singleton
7
+ * and served to every later scope: one request's user, tenant or
8
+ * transaction leaking into all the others. This is the captive
9
+ * dependency error mature DI containers raise.
10
+ * - A scoped provider resolved with no active scope has nothing to be
11
+ * scoped to. It used to behave as transient, which hid the missing
12
+ * scope; it now fails like `@zudojs/container`'s ScopedResolutionError.
13
+ *
14
+ * @param token - The scoped token being resolved.
15
+ * @param captor - The singleton currently under construction, if any.
16
+ * @param scopeKey - The active scope key, if any.
17
+ * @param chain - The current resolution chain, for the diagnostic.
18
+ * @throws DependencyResolutionError when either rule is broken.
19
+ */
20
+ export declare function assertScopedResolvable(token: Token<unknown>, captor: Token<unknown> | undefined, scopeKey: object | undefined, chain: readonly Token<unknown>[]): void;
21
+ //# sourceMappingURL=container.lifetime.d.ts.map
@@ -0,0 +1,39 @@
1
+ import { DependencyResolutionError } from "../errors/exceptions.js";
2
+ /**
3
+ * Rejects a "scoped" resolution that would outlive or escape its scope.
4
+ *
5
+ * - A scoped provider resolved (directly or transitively) while a
6
+ * singleton is being constructed would be captured by that singleton
7
+ * and served to every later scope: one request's user, tenant or
8
+ * transaction leaking into all the others. This is the captive
9
+ * dependency error mature DI containers raise.
10
+ * - A scoped provider resolved with no active scope has nothing to be
11
+ * scoped to. It used to behave as transient, which hid the missing
12
+ * scope; it now fails like `@zudojs/container`'s ScopedResolutionError.
13
+ *
14
+ * @param token - The scoped token being resolved.
15
+ * @param captor - The singleton currently under construction, if any.
16
+ * @param scopeKey - The active scope key, if any.
17
+ * @param chain - The current resolution chain, for the diagnostic.
18
+ * @throws DependencyResolutionError when either rule is broken.
19
+ */
20
+ export function assertScopedResolvable(token, captor, scopeKey, chain) {
21
+ if (captor !== undefined) {
22
+ throw new DependencyResolutionError(`Captive dependency: singleton "${describe(captor)}" cannot depend on ` +
23
+ `scoped "${describe(token)}". Register the consumer as "scoped" or ` +
24
+ `"transient", or resolve the scoped dependency lazily per call.`, [...chain, token]);
25
+ }
26
+ if (scopeKey === undefined) {
27
+ throw new DependencyResolutionError(`Scoped provider "${describe(token)}" was resolved outside any scope. ` +
28
+ `Resolve it through container.createScope() or inside an execution ` +
29
+ `context.`, [...chain, token]);
30
+ }
31
+ }
32
+ function describe(token) {
33
+ if (typeof token === "function")
34
+ return token.name || "anonymous class";
35
+ if (typeof token === "symbol")
36
+ return token.description ?? token.toString();
37
+ return String(token);
38
+ }
39
+ //# sourceMappingURL=container.lifetime.js.map
@@ -15,7 +15,9 @@ export type Scope =
15
15
  * example the current ExecutionContext of an HTTP request,
16
16
  * background job, message consumption, or RPC call).
17
17
  *
18
- * When no scope is active, scoped providers behave as transient.
18
+ * Resolving a scoped provider with no active scope, or from inside a
19
+ * singleton's construction (a captive dependency), throws a
20
+ * DependencyResolutionError.
19
21
  */
20
22
  | "scoped"
21
23
  /**
@@ -30,6 +30,14 @@ export declare class ContextStorage {
30
30
  *
31
31
  * The context is automatically available to all asynchronous
32
32
  * operations created within the callback.
33
+ *
34
+ * A new execution starts without ContextValues: values bound by an
35
+ * enclosing `runWithValues` belong to that execution (its tenant,
36
+ * user, transaction) and must not leak into an unrelated one started
37
+ * from inside its async scope, such as a listener or consumer created
38
+ * during a request. Re-entering the execution that is already current
39
+ * keeps its values. Use `runDerived` or `runWithValues` to carry
40
+ * values on purpose.
33
41
  */
34
42
  run<T>(context: ExecutionContext, callback: () => T): T;
35
43
  /**
@@ -24,9 +24,20 @@ export class ContextStorage {
24
24
  *
25
25
  * The context is automatically available to all asynchronous
26
26
  * operations created within the callback.
27
+ *
28
+ * A new execution starts without ContextValues: values bound by an
29
+ * enclosing `runWithValues` belong to that execution (its tenant,
30
+ * user, transaction) and must not leak into an unrelated one started
31
+ * from inside its async scope, such as a listener or consumer created
32
+ * during a request. Re-entering the execution that is already current
33
+ * keeps its values. Use `runDerived` or `runWithValues` to carry
34
+ * values on purpose.
27
35
  */
28
36
  run(context, callback) {
29
- return this.storage.run(context, callback);
37
+ if (context === this.storage.getStore()) {
38
+ return this.storage.run(context, callback);
39
+ }
40
+ return this.storage.run(context, () => this.valuesStorage.exit(callback));
30
41
  }
31
42
  /**
32
43
  * Returns the current execution context.
@@ -39,9 +39,12 @@ export interface LifecycleOptions {
39
39
  *
40
40
  * CREATED → INITIALIZING → INITIALIZED → STARTING → RUNNING
41
41
  * RUNNING → STOPPING → STOPPED → (STARTING → RUNNING) restart
42
- * any phase failure → FAILED; initialize()/start() retry from FAILED
43
- * resume with the participant that failed; stop() from FAILED unwinds
44
- * whatever started.
42
+ * any phase failure → FAILED; initialize() retries from FAILED resume
43
+ * with the participant that failed. A failed start() is rolled back:
44
+ * every participant whose start() completed is stopped in reverse
45
+ * order, so a retry starts again from the first one. dispose() only
46
+ * reaches participants whose initialize() ran (including one that
47
+ * threw, which may have acquired resources before failing).
45
48
  *
46
49
  * Concurrent callers of the same phase share the in-flight promise.
47
50
  */
@@ -52,6 +55,8 @@ export declare class Lifecycle {
52
55
  private readonly continueOnShutdownError;
53
56
  private initializedCount;
54
57
  private startedCount;
58
+ /** Participants whose initialize() was invoked, whether or not it threw. */
59
+ private initializeAttempted;
55
60
  private disposed;
56
61
  private failedPhase;
57
62
  private initializePromise;
@@ -1,4 +1,5 @@
1
1
  import { InvalidStateError } from "../../errors/exceptions.js";
2
+ import { rollbackStartedParticipants } from "./lifecycle.rollback.js";
2
3
  /** Lifecycle states supported by the Zudojs application runtime. */
3
4
  export const LifecycleState = {
4
5
  CREATED: "created",
@@ -18,9 +19,12 @@ export const LifecycleState = {
18
19
  *
19
20
  * CREATED → INITIALIZING → INITIALIZED → STARTING → RUNNING
20
21
  * RUNNING → STOPPING → STOPPED → (STARTING → RUNNING) restart
21
- * any phase failure → FAILED; initialize()/start() retry from FAILED
22
- * resume with the participant that failed; stop() from FAILED unwinds
23
- * whatever started.
22
+ * any phase failure → FAILED; initialize() retries from FAILED resume
23
+ * with the participant that failed. A failed start() is rolled back:
24
+ * every participant whose start() completed is stopped in reverse
25
+ * order, so a retry starts again from the first one. dispose() only
26
+ * reaches participants whose initialize() ran (including one that
27
+ * threw, which may have acquired resources before failing).
24
28
  *
25
29
  * Concurrent callers of the same phase share the in-flight promise.
26
30
  */
@@ -31,6 +35,8 @@ export class Lifecycle {
31
35
  continueOnShutdownError;
32
36
  initializedCount = 0;
33
37
  startedCount = 0;
38
+ /** Participants whose initialize() was invoked, whether or not it threw. */
39
+ initializeAttempted = 0;
34
40
  disposed = false;
35
41
  failedPhase;
36
42
  initializePromise;
@@ -194,7 +200,8 @@ export class Lifecycle {
194
200
  }
195
201
  this.disposed = true;
196
202
  const errors = [];
197
- for (let i = this.participants.length - 1; i >= 0; i--) {
203
+ // A participant whose initialize() never ran has nothing to release.
204
+ for (let i = this.initializeAttempted - 1; i >= 0; i--) {
198
205
  const participant = this.participants[i];
199
206
  try {
200
207
  this.logger?.debug("Disposing lifecycle participant", {
@@ -213,6 +220,7 @@ export class Lifecycle {
213
220
  }
214
221
  this.initializedCount = 0;
215
222
  this.startedCount = 0;
223
+ this.initializeAttempted = 0;
216
224
  if (errors.length > 0 && !this.continueOnShutdownError) {
217
225
  this.state = LifecycleState.FAILED;
218
226
  this.failedPhase = "dispose";
@@ -255,6 +263,7 @@ export class Lifecycle {
255
263
  this.logger?.debug("Initializing lifecycle participant", {
256
264
  participant: participant.name,
257
265
  });
266
+ this.initializeAttempted = Math.max(this.initializeAttempted, i + 1);
258
267
  await participant.initialize?.();
259
268
  this.initializedCount = i + 1;
260
269
  }
@@ -285,9 +294,13 @@ export class Lifecycle {
285
294
  this.logger?.info("Application startup completed");
286
295
  }
287
296
  catch (error) {
297
+ this.logger?.error("Application startup failed", error);
298
+ // Roll back rather than leave earlier participants running until
299
+ // someone remembers to call shutdown().
300
+ await rollbackStartedParticipants(this.participants, this.startedCount, this.logger);
301
+ this.startedCount = 0;
288
302
  this.state = LifecycleState.FAILED;
289
303
  this.failedPhase = "start";
290
- this.logger?.error("Application startup failed", error);
291
304
  throw error;
292
305
  }
293
306
  }
@@ -0,0 +1,18 @@
1
+ import type { Logger } from "../../logging/core/logger.js";
2
+ import type { LifecycleParticipant } from "./lifecycle.js";
3
+ /**
4
+ * Stops, in reverse order, the first `count` participants after a
5
+ * failed start.
6
+ *
7
+ * Only participants whose `start()` completed are stopped; the one
8
+ * that threw is left to its own error handling, as `stop()` has always
9
+ * done. Stop failures are logged and returned; they never mask the
10
+ * start error.
11
+ *
12
+ * @param participants - Registered participants, in start order.
13
+ * @param count - How many leading participants started successfully.
14
+ * @param logger - Optional logger for rollback diagnostics.
15
+ * @returns The errors raised by `stop()` hooks during the rollback.
16
+ */
17
+ export declare function rollbackStartedParticipants(participants: readonly LifecycleParticipant[], count: number, logger: Logger | undefined): Promise<readonly unknown[]>;
18
+ //# sourceMappingURL=lifecycle.rollback.d.ts.map
@@ -0,0 +1,34 @@
1
+ /**
2
+ * Stops, in reverse order, the first `count` participants after a
3
+ * failed start.
4
+ *
5
+ * Only participants whose `start()` completed are stopped; the one
6
+ * that threw is left to its own error handling, as `stop()` has always
7
+ * done. Stop failures are logged and returned; they never mask the
8
+ * start error.
9
+ *
10
+ * @param participants - Registered participants, in start order.
11
+ * @param count - How many leading participants started successfully.
12
+ * @param logger - Optional logger for rollback diagnostics.
13
+ * @returns The errors raised by `stop()` hooks during the rollback.
14
+ */
15
+ export async function rollbackStartedParticipants(participants, count, logger) {
16
+ const errors = [];
17
+ for (let i = count - 1; i >= 0; i--) {
18
+ const participant = participants[i];
19
+ try {
20
+ logger?.debug("Rolling back lifecycle participant", {
21
+ participant: participant.name,
22
+ });
23
+ await participant.stop?.();
24
+ }
25
+ catch (error) {
26
+ errors.push(error);
27
+ logger?.error("Failed to roll back lifecycle participant", error, {
28
+ participant: participant.name,
29
+ });
30
+ }
31
+ }
32
+ return errors;
33
+ }
34
+ //# sourceMappingURL=lifecycle.rollback.js.map
@@ -123,7 +123,18 @@ export function sanitizeLogValue(value, seen = new WeakSet(), depth = 0) {
123
123
  }
124
124
  const result = {};
125
125
  for (const [key, item] of Object.entries(objectValue)) {
126
- result[key] = sanitizeLogValue(item, seen, depth + 1);
126
+ /*
127
+ * Assigning result["__proto__"] would replace the prototype
128
+ * (and drop the value from serialized output) instead of
129
+ * storing the key, so own "__proto__" keys — as produced by
130
+ * JSON.parse on untrusted input — are defined explicitly.
131
+ */
132
+ Object.defineProperty(result, key, {
133
+ value: sanitizeLogValue(item, seen, depth + 1),
134
+ enumerable: true,
135
+ writable: true,
136
+ configurable: true,
137
+ });
127
138
  }
128
139
  return result;
129
140
  }
@@ -8,6 +8,6 @@
8
8
  * state maps) is internal to this subsystem and intentionally
9
9
  * not exported from this barrel.
10
10
  */
11
- export { type ModuleLifecyclePhase, type ModuleLifecycleState, type ModuleLifecycleHookName, type ModuleLifecycleStep, type ModuleLifecycleHooks, type ModuleLifecycleOptions, ModuleLifecycleError, type ModuleLifecycleResult, type ModuleLifecycleSkip, } from "./moduleLifecycle.type.js";
11
+ export { type ModuleLifecyclePhase, type ModuleLifecycleState, type ModuleLifecycleHookName, type ModuleLifecycleStep, type ModuleLifecycleHooks, type ModuleLifecycleOptions, type ModuleLifecyclePhaseOptions, ModuleLifecycleError, type ModuleLifecycleResult, type ModuleLifecycleSkip, } from "./moduleLifecycle.type.js";
12
12
  export { ModuleLifecycleManager, createModuleLifecycleManager, } from "./moduleLifecycle.lifecycle.js";
13
13
  //# sourceMappingURL=index.d.ts.map