diff --git a/.gitignore b/.gitignore index 66f5cbf..fcca843 100644 --- a/.gitignore +++ b/.gitignore @@ -3,3 +3,4 @@ node_modules dist .DS_Store .vscode/symbols.json +artifacts diff --git a/CHANGELOG.md b/CHANGELOG.md index 196c1da..00594d2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,43 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## Unreleased +### Added + +- `iNodeTokenBaseOptions.global` — opt-in token-instance deduplication by name via + a `globalThis` registry (mirrors the existing token-class dedup). Identically-named + global tokens constructed in separately-bundled modules resolve to the same instance, + so the container (which keys providers by reference) treats them as one. Enables a + dynamically-imported plugin to bind a host's seam tokens without sharing a bundle. + +### Changed + +- The three remaining raw `throw new Error` sites now throw a typed `InjectionError` + carrying a stable code, matching the rest of the library: a global-token kind conflict + (`i600`), a middleware calling `next()` more than once (`i700`), and the internal + unknown-`ProtoNode` invariant (`i800`). Messages are unchanged apart from the `[iNNN]` + prefix, and callers can now branch on `error.code` for these cases. + +### Fixed + +- Tree-node resolution no longer leaves a node stranded as "in progress" when a factory + or dependency throws. A failed resolution now resets its guard on every exit path, so a + retried `get()` (lazy `instant: false` mode) or another consumer of a shared dependency + re-runs the factory instead of reporting a bogus circular-dependency (`i401`) error. + `MultiNodeToken` resolution also resets its members per attempt so a retry cannot + accumulate duplicates. + +### Documentation + +- Completed the error reference (`TROUBLESHOOTING.md`): documented `i202`, `i304`, `i305`, + and the new `i600`/`i700`/`i800` codes across the quick-reference table, table of contents, + and detail sections; documented the `singleton` and `global` token options in `TOKENS.md` + and `API.md`. +- Corrected stale examples: plugin imports now use the `@illuma/core/plugins` subpath + (`Illuma`, `iMiddleware`, the diagnostics types, `iContextScanner`); fixed the `nodeInject` + signature and `@NodeInjectable()` usage in `TECHNICAL_OVERVIEW.md`, the `iSpectator` + name and a malformed provider array in `TESTKIT.md`, and a request-scoped `injectGroupAsync` + example in `ASYNC_INJECTION.md`. + ## 2.3.0 - 2026-05-28 ## 2.2.0 - 2026-05-10 diff --git a/artifacts/coverage/coverage-summary.json b/artifacts/coverage/coverage-summary.json deleted file mode 100644 index 3b6c618..0000000 --- a/artifacts/coverage/coverage-summary.json +++ /dev/null @@ -1,25 +0,0 @@ -{"total": {"lines":{"total":797,"covered":763,"skipped":0,"pct":95.73},"statements":{"total":924,"covered":865,"skipped":0,"pct":93.61},"functions":{"total":183,"covered":172,"skipped":0,"pct":93.98},"branches":{"total":478,"covered":424,"skipped":0,"pct":88.7},"branchesTrue":{"total":0,"covered":0,"skipped":0,"pct":100}} -,"/home/zak/dev/opensource/illuma/core/src/plugins.ts": {"lines":{"total":0,"covered":0,"skipped":0,"pct":100},"functions":{"total":0,"covered":0,"skipped":0,"pct":100},"statements":{"total":0,"covered":0,"skipped":0,"pct":100},"branches":{"total":0,"covered":0,"skipped":0,"pct":100}} -,"/home/zak/dev/opensource/illuma/core/src/testkit.ts": {"lines":{"total":0,"covered":0,"skipped":0,"pct":100},"functions":{"total":0,"covered":0,"skipped":0,"pct":100},"statements":{"total":0,"covered":0,"skipped":0,"pct":100},"branches":{"total":0,"covered":0,"skipped":0,"pct":100}} -,"/home/zak/dev/opensource/illuma/core/src/lib/api/decorator.ts": {"lines":{"total":18,"covered":18,"skipped":0,"pct":100},"functions":{"total":13,"covered":13,"skipped":0,"pct":100},"statements":{"total":19,"covered":19,"skipped":0,"pct":100},"branches":{"total":7,"covered":7,"skipped":0,"pct":100}} -,"/home/zak/dev/opensource/illuma/core/src/lib/api/injection.ts": {"lines":{"total":9,"covered":9,"skipped":0,"pct":100},"functions":{"total":1,"covered":1,"skipped":0,"pct":100},"statements":{"total":9,"covered":9,"skipped":0,"pct":100},"branches":{"total":14,"covered":14,"skipped":0,"pct":100}} -,"/home/zak/dev/opensource/illuma/core/src/lib/api/proxy.ts": {"lines":{"total":3,"covered":3,"skipped":0,"pct":100},"functions":{"total":3,"covered":2,"skipped":0,"pct":66.66},"statements":{"total":3,"covered":3,"skipped":0,"pct":100},"branches":{"total":0,"covered":0,"skipped":0,"pct":100}} -,"/home/zak/dev/opensource/illuma/core/src/lib/api/token-utils.ts": {"lines":{"total":7,"covered":7,"skipped":0,"pct":100},"functions":{"total":2,"covered":2,"skipped":0,"pct":100},"statements":{"total":10,"covered":10,"skipped":0,"pct":100},"branches":{"total":11,"covered":11,"skipped":0,"pct":100}} -,"/home/zak/dev/opensource/illuma/core/src/lib/api/token.ts": {"lines":{"total":19,"covered":19,"skipped":0,"pct":100},"functions":{"total":9,"covered":9,"skipped":0,"pct":100},"statements":{"total":19,"covered":19,"skipped":0,"pct":100},"branches":{"total":2,"covered":2,"skipped":0,"pct":100}} -,"/home/zak/dev/opensource/illuma/core/src/lib/container/container.ts": {"lines":{"total":220,"covered":205,"skipped":0,"pct":93.18},"functions":{"total":33,"covered":29,"skipped":0,"pct":87.87},"statements":{"total":266,"covered":236,"skipped":0,"pct":88.72},"branches":{"total":194,"covered":163,"skipped":0,"pct":84.02}} -,"/home/zak/dev/opensource/illuma/core/src/lib/container/lifecycle.ts": {"lines":{"total":29,"covered":27,"skipped":0,"pct":93.1},"functions":{"total":11,"covered":8,"skipped":0,"pct":72.72},"statements":{"total":38,"covered":33,"skipped":0,"pct":86.84},"branches":{"total":4,"covered":4,"skipped":0,"pct":100}} -,"/home/zak/dev/opensource/illuma/core/src/lib/context/context.ts": {"lines":{"total":49,"covered":49,"skipped":0,"pct":100},"functions":{"total":14,"covered":14,"skipped":0,"pct":100},"statements":{"total":52,"covered":52,"skipped":0,"pct":100},"branches":{"total":12,"covered":12,"skipped":0,"pct":100}} -,"/home/zak/dev/opensource/illuma/core/src/lib/global/global.ts": {"lines":{"total":32,"covered":30,"skipped":0,"pct":93.75},"functions":{"total":17,"covered":15,"skipped":0,"pct":88.23},"statements":{"total":33,"covered":31,"skipped":0,"pct":93.93},"branches":{"total":6,"covered":6,"skipped":0,"pct":100}} -,"/home/zak/dev/opensource/illuma/core/src/lib/plugins/diagnostics/built-in.ts": {"lines":{"total":6,"covered":6,"skipped":0,"pct":100},"functions":{"total":2,"covered":2,"skipped":0,"pct":100},"statements":{"total":7,"covered":6,"skipped":0,"pct":85.71},"branches":{"total":2,"covered":1,"skipped":0,"pct":50}} -,"/home/zak/dev/opensource/illuma/core/src/lib/plugins/diagnostics/default-impl.ts": {"lines":{"total":5,"covered":5,"skipped":0,"pct":100},"functions":{"total":1,"covered":1,"skipped":0,"pct":100},"statements":{"total":6,"covered":6,"skipped":0,"pct":100},"branches":{"total":0,"covered":0,"skipped":0,"pct":100}} -,"/home/zak/dev/opensource/illuma/core/src/lib/plugins/middlewares/diagnostics.middleware.ts": {"lines":{"total":9,"covered":9,"skipped":0,"pct":100},"functions":{"total":1,"covered":1,"skipped":0,"pct":100},"statements":{"total":9,"covered":9,"skipped":0,"pct":100},"branches":{"total":2,"covered":2,"skipped":0,"pct":100}} -,"/home/zak/dev/opensource/illuma/core/src/lib/plugins/middlewares/runner.ts": {"lines":{"total":7,"covered":7,"skipped":0,"pct":100},"functions":{"total":2,"covered":2,"skipped":0,"pct":100},"statements":{"total":9,"covered":8,"skipped":0,"pct":88.88},"branches":{"total":4,"covered":3,"skipped":0,"pct":75}} -,"/home/zak/dev/opensource/illuma/core/src/lib/provider/extractor.ts": {"lines":{"total":5,"covered":5,"skipped":0,"pct":100},"functions":{"total":3,"covered":3,"skipped":0,"pct":100},"statements":{"total":11,"covered":11,"skipped":0,"pct":100},"branches":{"total":8,"covered":8,"skipped":0,"pct":100}} -,"/home/zak/dev/opensource/illuma/core/src/lib/provider/proto.ts": {"lines":{"total":29,"covered":29,"skipped":0,"pct":100},"functions":{"total":10,"covered":10,"skipped":0,"pct":100},"statements":{"total":30,"covered":30,"skipped":0,"pct":100},"branches":{"total":12,"covered":11,"skipped":0,"pct":91.66}} -,"/home/zak/dev/opensource/illuma/core/src/lib/provider/resolver.ts": {"lines":{"total":88,"covered":85,"skipped":0,"pct":96.59},"functions":{"total":7,"covered":7,"skipped":0,"pct":100},"statements":{"total":100,"covered":97,"skipped":0,"pct":97},"branches":{"total":73,"covered":65,"skipped":0,"pct":89.04}} -,"/home/zak/dev/opensource/illuma/core/src/lib/provider/tree-node.ts": {"lines":{"total":176,"covered":166,"skipped":0,"pct":94.31},"functions":{"total":31,"covered":31,"skipped":0,"pct":100},"statements":{"total":207,"covered":193,"skipped":0,"pct":93.23},"branches":{"total":89,"covered":80,"skipped":0,"pct":89.88}} -,"/home/zak/dev/opensource/illuma/core/src/lib/testkit/helpers.ts": {"lines":{"total":14,"covered":14,"skipped":0,"pct":100},"functions":{"total":3,"covered":3,"skipped":0,"pct":100},"statements":{"total":15,"covered":15,"skipped":0,"pct":100},"branches":{"total":6,"covered":6,"skipped":0,"pct":100}} -,"/home/zak/dev/opensource/illuma/core/src/lib/utils/defer.ts": {"lines":{"total":24,"covered":24,"skipped":0,"pct":100},"functions":{"total":3,"covered":3,"skipped":0,"pct":100},"statements":{"total":26,"covered":26,"skipped":0,"pct":100},"branches":{"total":8,"covered":8,"skipped":0,"pct":100}} -,"/home/zak/dev/opensource/illuma/core/src/lib/utils/inheritance.ts": {"lines":{"total":33,"covered":33,"skipped":0,"pct":100},"functions":{"total":11,"covered":11,"skipped":0,"pct":100},"statements":{"total":36,"covered":36,"skipped":0,"pct":100},"branches":{"total":14,"covered":13,"skipped":0,"pct":92.85}} -,"/home/zak/dev/opensource/illuma/core/src/lib/utils/injector.ts": {"lines":{"total":15,"covered":13,"skipped":0,"pct":86.66},"functions":{"total":6,"covered":5,"skipped":0,"pct":83.33},"statements":{"total":19,"covered":16,"skipped":0,"pct":84.21},"branches":{"total":10,"covered":8,"skipped":0,"pct":80}} -} diff --git a/docs/API.md b/docs/API.md index f9bd790..74bb9d3 100644 --- a/docs/API.md +++ b/docs/API.md @@ -149,6 +149,7 @@ new NodeToken( options?: { factory?: () => T; singleton?: boolean; + global?: boolean; } ) ``` @@ -158,6 +159,7 @@ new NodeToken( | `name` | `string` | Unique identifier for the token | | `options.factory` | `() => T` | Optional factory for default value | | `options.singleton` | `boolean` | Marks token as root-scoped singleton in parent-child containers | +| `options.global` | `boolean` | Dedupe this token by name in a process-global registry, so an identically-named global token from another bundle is the same instance. Reserve for cross-bundle seam tokens; the name becomes a global identity and must be unique per kind (see [i600](./TROUBLESHOOTING.md#token-errors)) | When `singleton: true`, there's no need to call `provide` for this token. It will be automatically provided as a singleton in the root container when first requested until you want to override it in a child container. @@ -207,7 +209,14 @@ A token that can have multiple providers, returning an array. ### Constructor ```typescript -new MultiNodeToken(name: string, options?: { factory?: () => T }) +new MultiNodeToken( + name: string, + options?: { + factory?: () => T; + singleton?: boolean; + global?: boolean; + } +) ``` ### Usage diff --git a/docs/ASYNC_INJECTION.md b/docs/ASYNC_INJECTION.md index d1c7fdf..562dcd1 100644 --- a/docs/ASYNC_INJECTION.md +++ b/docs/ASYNC_INJECTION.md @@ -41,11 +41,11 @@ This guide covers advanced dependency injection patterns using `injectAsync`, `i Illuma provides three utilities for advanced async dependency injection: -| Utility | Purpose | Returns | -|---------|---------|---------| -| `injectAsync` | Lazily inject a single dependency | The dependency instance | -| `injectEntryAsync` | Create sub-container and resolve specific entrypoint | The entrypoint instance | -| `injectGroupAsync` | Create isolated sub-container with array of providers | An injector | +| Utility | Purpose | Returns | +| ------------------ | ----------------------------------------------------- | ----------------------- | +| `injectAsync` | Lazily inject a single dependency | The dependency instance | +| `injectEntryAsync` | Create sub-container and resolve specific entrypoint | The entrypoint instance | +| `injectGroupAsync` | Create isolated sub-container with array of providers | An injector | **Note:** For synchronous deferred injection (e.g. to solve circular dependencies without async/await), use [`injectDefer`](./API.md#injectDefer) instead. @@ -185,6 +185,8 @@ private readonly getAnalytics = injectAsync( ); ``` +> **Lifecycle note for `withCache: false`.** Each call creates a fresh sub-container that is tied to the parent container's lifecycle and is destroyed when the parent is destroyed. The parent therefore accumulates one sub-container per call until it is destroyed — by design, since you opted out of caching. For request-scoped work on a long-lived parent where you need to release each scope eagerly, use `injectGroupAsync` (which returns the sub-container's `Injector`) and call `injector.destroy()` when the scope ends, or create and destroy a child container explicitly. `injectAsync` / `injectEntryAsync` return the produced instance only, so they intentionally do not expose a per-call disposal handle. + ### Overriding dependencies Provide additional dependencies or override parent container values: @@ -467,11 +469,20 @@ const REQUEST_CONTEXT = new NodeToken('REQUEST_CONTEXT'); @NodeInjectable() class RequestHandler { + // Capture the injector during construction, while the injection context is + // open. `injectGroupAsync` resolves `Injector` eagerly when it is *called*, + // so invoking it later from a request handler (outside the context) would + // otherwise throw an "outside of an injection context" error ([i501]). + private readonly injector = nodeInject(Injector); + public createRequestScope(userId: string, requestId: string) { - return injectGroupAsync(async () => [ - RequestService, - { provide: REQUEST_CONTEXT, value: { userId, requestId } } - ], { withCache: false }); // New scope per request + return injectGroupAsync( + async () => [ + RequestService, + { provide: REQUEST_CONTEXT, value: { userId, requestId } }, + ], + { injector: this.injector, withCache: false }, // new scope per request + ); } public async handleRequest(userId: string, requestId: string) { diff --git a/docs/PLUGINS.md b/docs/PLUGINS.md index 299ce36..93fef38 100644 --- a/docs/PLUGINS.md +++ b/docs/PLUGINS.md @@ -47,7 +47,7 @@ Illuma provides a plugin system that allows you to extend its core functionality The `Illuma` class is the central hub for managing plugins in Illuma. It provides static methods to register plugins globally, which will then be automatically invoked at the appropriate times during the container lifecycle. ```typescript -import { Illuma } from '@illuma/core'; +import { Illuma } from '@illuma/core/plugins'; // Register a context scanner Illuma.extendContextScanner(myScanner); @@ -136,17 +136,19 @@ Diagnostics modules analyze the container state after bootstrap and provide insi A diagnostics module must implement the `iDiagnosticsModule` interface: ```typescript -import type { TreeNode } from '@illuma/core'; - -interface iDiagnosticsReport { - readonly totalNodes: number; // Total dependency nodes in container - readonly unusedNodes: TreeNode[]; // Nodes that weren't resolved - readonly bootstrapDuration: number; // Bootstrap time in milliseconds -} - -interface iDiagnosticsModule { - readonly onReport: (report: iDiagnosticsReport) => void; -} +import type { iDiagnosticsModule, iDiagnosticsReport } from '@illuma/core/plugins'; + +// Both interfaces are exported from '@illuma/core/plugins'. Their shape: +// +// interface iDiagnosticsReport { +// readonly totalNodes: number; // Total dependency nodes in container +// readonly unusedNodes: TreeNode[]; // Nodes never resolved (TreeNode is internal) +// readonly bootstrapDuration: number; // Bootstrap time in milliseconds +// } +// +// interface iDiagnosticsModule { +// readonly onReport: (report: iDiagnosticsReport) => void; +// } ``` **Report fields:** @@ -160,7 +162,7 @@ interface iDiagnosticsModule { #### Example: Custom Performance Reporter ```typescript -import type { iDiagnosticsModule, iDiagnosticsReport } from '@illuma/core'; +import type { iDiagnosticsModule, iDiagnosticsReport } from '@illuma/core/plugins'; export class PerformanceReporter implements iDiagnosticsModule { public onReport(report: iDiagnosticsReport): void { @@ -186,7 +188,7 @@ export class PerformanceReporter implements iDiagnosticsModule { Throw an error if any dependencies are unused (strict mode): ```typescript -import type { iDiagnosticsModule, iDiagnosticsReport } from '@illuma/core'; +import type { iDiagnosticsModule, iDiagnosticsReport } from '@illuma/core/plugins'; export class StrictUnusedValidator implements iDiagnosticsModule { public onReport(report: iDiagnosticsReport): void { @@ -208,7 +210,7 @@ export class StrictUnusedValidator implements iDiagnosticsModule { Send diagnostics to a logging service: ```typescript -import type { iDiagnosticsModule, iDiagnosticsReport } from '@illuma/core'; +import type { iDiagnosticsModule, iDiagnosticsReport } from '@illuma/core/plugins'; export class JsonDiagnosticsLogger implements iDiagnosticsModule { constructor(private readonly loggerService: LoggerService) {} @@ -246,9 +248,9 @@ export class JsonDiagnosticsLogger implements iDiagnosticsModule { Diagnostics modules should be registered before bootstrapping the container. To enable the diagnostics system, you must call `enableIllumaDiagnostics()` from `@illuma/core/plugins`: ```typescript -import { Illuma, NodeContainer } from '@illuma/core'; +import { NodeContainer } from '@illuma/core'; import { PerformanceReporter } from './diagnostics'; -import { enableIllumaDiagnostics } from '@illuma/core/plugins'; +import { Illuma, enableIllumaDiagnostics } from '@illuma/core/plugins'; // 1. Enable diagnostics system enableIllumaDiagnostics(); @@ -334,7 +336,7 @@ type iMiddleware = ( Log every time a dependency is instantiated: ```typescript -import type { iMiddleware } from '@illuma/core'; +import type { iMiddleware } from '@illuma/core/plugins'; export const loggerMiddleware: iMiddleware = (params, next) => { console.log(`[Middleware] Creating instance of: ${params.token.name}`); @@ -354,7 +356,7 @@ export const loggerMiddleware: iMiddleware = (params, next) => { Automatically wrap certain services in a Proxy: ```typescript -import type { iMiddleware } from '@illuma/core'; +import type { iMiddleware } from '@illuma/core/plugins'; export const proxyMiddleware: iMiddleware = (params, next) => { const instance = next(params); @@ -380,7 +382,7 @@ export const proxyMiddleware: iMiddleware = (params, next) => { Affects **all** containers created thereafter. ```typescript -import { Illuma } from '@illuma/core'; +import { Illuma } from '@illuma/core/plugins'; Illuma.registerGlobalMiddleware(loggerMiddleware); ``` @@ -446,7 +448,8 @@ export class OptimizedScanner implements iContextScanner { Support property-based injection using decorators: ```typescript -import type { iContextScanner, NodeToken, iInjectionNode } from '@illuma/core'; +import type { iContextScanner } from '@illuma/core/plugins'; +import type { NodeToken, iInjectionNode } from '@illuma/core'; const PROPERTY_INJECT_KEY = Symbol('di:properties'); @@ -496,7 +499,7 @@ Only report diagnostics in development mode: ```typescript import { enableIllumaDiagnostics } from '@illuma/core/plugins'; -import type { iDiagnosticsModule, iDiagnosticsReport } from '@illuma/core'; +import type { iDiagnosticsModule, iDiagnosticsReport } from '@illuma/core/plugins'; export class ConditionalReporter implements iDiagnosticsModule { constructor( diff --git a/docs/RESOLUTION_MODIFIERS.md b/docs/RESOLUTION_MODIFIERS.md index ad51990..e2e73a1 100644 --- a/docs/RESOLUTION_MODIFIERS.md +++ b/docs/RESOLUTION_MODIFIERS.md @@ -34,13 +34,13 @@ Sometimes you need to explicitly constrain this traversal to either: When `self: true` is passed, the container stops traversal and **only looks for the provider in the current (local) container**. -If the provider is not registered locally, a `NotFound` error (`[i101]`) will be thrown (*unless `optional: true` is also provided*). +If the provider is not registered locally, a `NotFound` error (`[i400]`) will be thrown (*unless `optional: true` is also provided*). ### `skipSelf` When `skipSelf: true` is passed, the container **ignores providers in the current container** and immediately delegates the resolution to the parent container. -If the container has no parent, or if none of the parents provide the dependency, a `NotFound` error (`[i101]`) will be thrown (*unless `optional: true` is also provided*). +If the container has no parent, or if none of the parents provide the dependency, a `NotFound` error (`[i400]`) will be thrown (*unless `optional: true` is also provided*). ### `optional` diff --git a/docs/TECHNICAL_OVERVIEW.md b/docs/TECHNICAL_OVERVIEW.md index 18557dd..e76afb7 100644 --- a/docs/TECHNICAL_OVERVIEW.md +++ b/docs/TECHNICAL_OVERVIEW.md @@ -590,7 +590,14 @@ These are collected during scanning to build the dependency graph. ### Function Signature ```typescript -function nodeInject(token: N, options?: { optional?: boolean }): ExtractInjectedType; +function nodeInject(token: N, options?: iNodeInjectorOptions): ExtractInjectedType; + +// where: +interface iNodeInjectorOptions { + optional?: boolean; // return null instead of throwing when not found + self?: boolean; // only resolve from the current container + skipSelf?: boolean; // skip the current container, resolve from a parent +} ``` ### Behavior @@ -1088,13 +1095,13 @@ const ConfigToken = new NodeToken('Config'); const UserServiceToken = new NodeToken('UserService'); const PluginToken = new MultiNodeToken('Plugin'); -@Injectable() +@NodeInjectable() class Logger { /* ... */ } -@Injectable() +@NodeInjectable() class Config { /* ... */ } -@Injectable() +@NodeInjectable() class UserService { // Dependencies are injected via nodeInject() in class body private readonly logger = nodeInject(LoggerToken); @@ -1102,10 +1109,10 @@ class UserService { private readonly plugins = nodeInject(PluginToken); } -@Injectable() +@NodeInjectable() class AuthPlugin { /* ... */ } -@Injectable() +@NodeInjectable() class CachePlugin { /* ... */ } ``` diff --git a/docs/TESTKIT.md b/docs/TESTKIT.md index d9698af..3b74865 100644 --- a/docs/TESTKIT.md +++ b/docs/TESTKIT.md @@ -21,7 +21,7 @@ The Illuma testkit provides framework-agnostic utilities for testing components - [API Reference](#api-reference) - [`createTestFactory(config)`](#createtestfactorytconfig) - [`TestFactoryFn`](#testfactoryfnt) - - [`Spectator`](#spectatort) + - [`iSpectator`](#ispectatort) - [Best practices](#best-practices) - [Related documentation](#related-documentation) @@ -134,10 +134,10 @@ class NotificationService { describe('NotificationService', () => { const createTest = createTestFactory({ target: NotificationService, - provide: [ + provide: [{ provide: EmailService, useClass: MockEmailService, - ], + }], }); it('should send notification via email', () => { @@ -366,9 +366,9 @@ Creates a test factory for the specified target. A function that creates a new test instance with a clean DI container. -**Returns:** `Spectator` +**Returns:** `iSpectator` -### `Spectator` +### `iSpectator` The object returned by test factory functions. diff --git a/docs/TOKENS.md b/docs/TOKENS.md index 6ac5c2e..a24ab76 100644 --- a/docs/TOKENS.md +++ b/docs/TOKENS.md @@ -65,6 +65,29 @@ container.bootstrap(); const logger = container.get(LOGGER); ``` +### Root-scoped singletons + +Pass `singleton: true` to mark a token as a root-scoped singleton in a hierarchical (parent–child) container tree. The token is provided automatically in the root container the first time it is requested, and the same instance is shared across the whole tree — you don't need to call `provide()` for it unless a child container should override it. + +```typescript +const CLOCK = new NodeToken('CLOCK', { + singleton: true, + factory: () => new SystemClock(), +}); +``` + +### Global (cross-bundle) tokens + +Pass `global: true` to deduplicate a token by name in a process-global registry. Containers key providers by token _reference_, so two identically-named tokens constructed in separately-bundled modules would otherwise be treated as different providers. With `global: true`, constructing the same-named token again returns the **same** instance — letting a dynamically-imported plugin bind a host's seam tokens without sharing a bundle. + +```typescript +// In the host bundle and in a separately-built plugin bundle: +const SEAM = new NodeToken('host.seam', { global: true }); +// Both constructions resolve to one and the same token instance. +``` + +Reserve `global: true` for cross-bundle seam tokens: the name becomes a global identity, so it must be unique and stable. Reusing one global name for two different token kinds (e.g. a `NodeToken` and a `MultiNodeToken`) throws [`[i600]` Global Token Conflict](./TROUBLESHOOTING.md#token-errors). + ### Provider helper methods Tokens provide convenient methods to create providers: diff --git a/docs/TROUBLESHOOTING.md b/docs/TROUBLESHOOTING.md index 70b69a6..c5c6abf 100644 --- a/docs/TROUBLESHOOTING.md +++ b/docs/TROUBLESHOOTING.md @@ -6,10 +6,13 @@ This document provides detailed information about all error codes in Illuma and - [Quick Reference](#quick-reference) - [Provider Errors (i100-i103)](#provider-errors) -- [Alias Errors (i200-i201)](#alias-errors) -- [Bootstrap Errors (i300-i302)](#bootstrap-errors) +- [Alias Errors (i200-i202)](#alias-errors) +- [Bootstrap Errors (i300-i305)](#bootstrap-errors) - [Retrieval Errors (i400-i401)](#retrieval-errors) - [Instantiation Errors (i500-i504)](#instantiation-errors) +- [Token Errors (i600)](#token-errors) +- [Middleware Errors (i700)](#middleware-errors) +- [Internal Errors (i800)](#internal-errors) - [Debugging Tips](#debugging-tips) ## Quick Reference @@ -27,6 +30,8 @@ This document provides detailed information about all error codes in Illuma and | i301 | Container Bootstrapped | Provide before `bootstrap()` | | i302 | Double Bootstrap | Only bootstrap once | | i303 | Container destroyed | Container has been destroyed | +| i304 | Parent Destroyed | Keep the parent alive while children exist | +| i305 | Parent Not Bootstrapped | Bootstrap the parent before the child | | i400 | Provider Not Found | Provide the token or use `optional` | | i401 | Circular Dependency | Refactor to break cycle | | i500 | Untracked Injection | Use in class field initializers only | @@ -34,6 +39,9 @@ This document provides detailed information about all error codes in Illuma and | i502 | Called Utils Outside | Use only during instantiation | | i503 | Instance Access Failed | Check factory/constructor logic | | i504 | Access Failed | Check provider configuration | +| i600 | Global Token Conflict | Use one token kind per global name | +| i700 | Middleware Next Reused | Call `next()` at most once per middleware | +| i800 | Unknown ProtoNode | Internal invariant — please report it | --- @@ -337,7 +345,7 @@ container.provide({ }); ``` -**Note:** You don't need to provide the alias target before creating the alias (unlike what you might expect). The target will be resolved when the container is bootstrapped. However, if the alias target is never provided, you'll get an `[i400] Provider Not Found` error when trying to retrieve it. +**Note:** You don't need to provide the alias target before creating the alias (unlike what you might expect). The target will be resolved when the container is bootstrapped. However, if the alias target is never provided, `bootstrap()` itself throws an `[i400] Provider Not Found` error (the whole container build fails, not just a later retrieval of the alias). --- @@ -385,6 +393,45 @@ container.provide({ --- +### [i202] Conflicting Strategies + +**Error Message:** + +``` +Token "TokenName" cannot use both 'self' and 'skipSelf' strategies. +``` + +**Cause:** +You passed both `self: true` and `skipSelf: true` to a single `nodeInject()` call. Their semantics are mutually exclusive — `self` restricts resolution to the current container, while `skipSelf` skips the current container and delegates to the parent — so they cannot be combined. + +**Example:** + +```typescript +@NodeInjectable() +class MyService { + // ❌ This will throw [i202] + private readonly dep = nodeInject(SomeToken, { self: true, skipSelf: true }); +} +``` + +**Solution:** +Pick the one that matches your intent: + +```typescript +@NodeInjectable() +class MyService { + // ✅ Only resolve from the current container + private readonly local = nodeInject(SomeToken, { self: true }); + + // ✅ Or skip the current container and resolve from a parent + private readonly inherited = nodeInject(OtherToken, { skipSelf: true }); +} +``` + +See [Resolution Modifiers](./RESOLUTION_MODIFIERS.md) for details on `self` and `skipSelf`. + +--- + ## Bootstrap Errors ### [i300] Not Bootstrapped @@ -534,7 +581,7 @@ const container2 = createContainer(); // ✅ New instance **Error Message:** ``` -Container has been already destroyed +Container has been already destroyed. ``` **Cause:** @@ -557,6 +604,67 @@ Make sure to only call `destroy()` when you are completely done with the contain --- +### [i304] Parent Destroyed + +**Error Message:** + +``` +Parent container has been destroyed. +``` + +**Cause:** +You tried to create a child container whose parent was already destroyed, or you called `bootstrap()` on a child whose parent was destroyed after the child was created. A destroyed container can no longer parent or bootstrap children. + +**Example:** + +```typescript +const parent = new NodeContainer(); +parent.bootstrap(); +parent.destroy(); + +// ❌ This will throw [i304] - the parent is already destroyed +const child = new NodeContainer({ parent }); +``` + +**Solution:** +Ensure the parent container outlives its children. Create and bootstrap child containers before the parent is destroyed, and when managing lifecycles manually, tear children down before (or together with) the parent. + +--- + +### [i305] Parent Not Bootstrapped + +**Error Message:** + +``` +Parent container has not been bootstrapped. +``` + +**Cause:** +You called `bootstrap()` on a child container before its parent container was bootstrapped. A child cannot complete its own bootstrap until the parent is ready. + +**Example:** + +```typescript +const parent = new NodeContainer(); +const child = new NodeContainer({ parent }); + +// ❌ This will throw [i305] - the parent hasn't been bootstrapped yet +child.bootstrap(); +``` + +**Solution:** +Bootstrap the parent first. A child created before its parent is bootstrapped is bootstrapped automatically when the parent bootstraps, so you usually only need to bootstrap the parent: + +```typescript +const parent = new NodeContainer(); +const child = new NodeContainer({ parent }); + +// ✅ Bootstrapping the parent cascades to the child +parent.bootstrap(); +``` + +--- + ## Retrieval Errors ### [i400] Provider Not Found @@ -951,6 +1059,93 @@ If the issue persists, create a minimal reproduction and [report it on GitHub](h --- +## Token Errors + +### [i600] Global Token Conflict + +**Error Message:** + +``` +Global token "name" is already registered as NodeToken; cannot redeclare it as MultiNodeToken. +``` + +**Cause:** +You constructed two tokens with `{ global: true }` that share the same name but are of different kinds (for example a `NodeToken` and a `MultiNodeToken`). Global tokens are deduplicated by name in a process-wide registry so identically-named tokens from separately-bundled modules resolve to one instance — which only works if every declaration of that name agrees on the token kind. + +**Example:** + +```typescript +// ❌ Same global name, different kinds → throws [i600] +const CONFIG = new NodeToken('seam.config', { global: true }); +const ALSO_CONFIG = new MultiNodeToken('seam.config', { global: true }); +``` + +**Solution:** +Give each global token a unique, stable name and keep its kind consistent everywhere it is declared: + +```typescript +// ✅ Distinct names, one kind each +const CONFIG = new NodeToken('seam.config', { global: true }); +const PLUGINS = new MultiNodeToken('seam.plugins', { global: true }); +``` + +Reserve `global: true` for cross-bundle seam tokens. Tokens used within a single bundle don't need it — ordinary (non-global) tokens are distinct by reference and never collide. See the [Tokens guide](./TOKENS.md) for more on the `global` option. + +--- + +## Middleware Errors + +### [i700] Middleware Next Reused + +**Error Message:** + +``` +Middleware next() was called more than once. +``` + +**Cause:** +An instantiation middleware called its `next()` callback more than once. Each middleware in the chain must call `next()` at most once; a second call is rejected explicitly rather than silently re-running downstream middlewares. + +**Example:** + +```typescript +const badMiddleware: iMiddleware = (params, next) => { + next(params); + return next(params); // ❌ second call throws [i700] +}; +``` + +**Solution:** +Call `next()` exactly once and return its result. To transform the produced instance, capture the result and modify it instead of calling `next()` again: + +```typescript +const goodMiddleware: iMiddleware = (params, next) => { + const instance = next(params); // ✅ called once + // ...inspect or wrap `instance`... + return instance; +}; +``` + +--- + +## Internal Errors + +### [i800] Unknown ProtoNode + +**Error Message:** + +``` +Unknown ProtoNode type. +``` + +**Cause:** +An internal invariant failed: the resolver encountered a provider prototype node of an unrecognized type. This should not occur through normal use of the public API and usually indicates a bug in Illuma or a mismatched/corrupted build. + +**Solution:** +This is an internal error. Please [report it on GitHub](https://github.com/git-illuma/core/issues) with a minimal reproduction, and make sure all `@illuma/*` packages are on compatible versions. + +--- + ## Debugging Tips ### Enable Performance Monitoring diff --git a/src/lib/api/injection.spec.ts b/src/lib/api/injection.spec.ts index c0b4334..5770e80 100644 --- a/src/lib/api/injection.spec.ts +++ b/src/lib/api/injection.spec.ts @@ -409,3 +409,51 @@ describe("nodeInject", () => { }); }); }); + +describe("dependency scan with primitive coercion (#14)", () => { + it("detects deps declared after a factory coerces an injected value to a primitive", () => { + const A = new NodeToken("COERCE_A"); + const B = new NodeToken("COERCE_B"); + const HOST = new NodeToken("COERCE_HOST"); + + const c = new NodeContainer(); + c.provide(A.withValue(1)); + c.provide(B.withValue(2)); + c.provide( + HOST.withFactory(() => { + const a = nodeInject(A); + // Coerce the injected value to a primitive: during the scan dry-run `a` + // is the SHAPE_SHIFTER placeholder. This must NOT abort dependency + // detection, so nodeInject(B) below is still tracked. + const prefix = `value-${a}`; + const b = nodeInject(B); + return `${prefix}-${b}`; + }), + ); + + expect(() => c.bootstrap()).not.toThrow(); + expect(c.get(HOST)).toBe("value-1-2"); + }); + + it("supports numeric and string coercion of placeholders in the scan", () => { + const N = new NodeToken("COERCE_N"); + const M = new NodeToken("COERCE_M"); + const HOST = new NodeToken("COERCE_NUM_HOST"); + + const c = new NodeContainer(); + c.provide(N.withValue(10)); + c.provide(M.withValue(5)); + c.provide( + HOST.withFactory(() => { + const n = nodeInject(N); + // Numeric coercion + comparison during the dry-run must not throw. + const flag = +n > 0 && `${n}`.length >= 0; + const m = nodeInject(M); + return flag ? n + m : m; + }), + ); + + expect(() => c.bootstrap()).not.toThrow(); + expect(c.get(HOST)).toBe(15); + }); +}); diff --git a/src/lib/api/proxy.ts b/src/lib/api/proxy.ts index 52d5735..d55c79c 100644 --- a/src/lib/api/proxy.ts +++ b/src/lib/api/proxy.ts @@ -5,6 +5,9 @@ */ // @ts-nocheck export const SHAPE_SHIFTER = new Proxy(() => {}, { - get: () => SHAPE_SHIFTER, + get: (_target, prop) => { + if (prop === Symbol.toPrimitive) return () => ""; + return SHAPE_SHIFTER; + }, apply: () => SHAPE_SHIFTER, }); diff --git a/src/lib/api/token-utils.spec.ts b/src/lib/api/token-utils.spec.ts index 879c397..08604f6 100644 --- a/src/lib/api/token-utils.spec.ts +++ b/src/lib/api/token-utils.spec.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { InjectionError } from "../errors"; +import { ERR_CODES, InjectionError } from "../errors"; import type { Token } from "../provider/types"; import { NodeInjectable } from "./decorator"; import { MultiNodeToken, NodeToken } from "./token"; @@ -43,3 +43,29 @@ describe("Token Utils", () => { }); }); }); + +describe("extractToken alias target error (#35)", () => { + it("throws invalidAlias (i200) for a non-injectable function used as an alias target", () => { + function NotInjectable() {} + + let code: number | undefined; + try { + extractToken(NotInjectable as unknown as Token, true); + } catch (e) { + code = (e as InjectionError).code; + } + expect(code).toBe(ERR_CODES.INVALID_ALIAS); + }); + + it("still throws invalidCtor (i102) for a non-injectable function NOT used as an alias", () => { + function NotInjectable() {} + + let code: number | undefined; + try { + extractToken(NotInjectable as unknown as Token); + } catch (e) { + code = (e as InjectionError).code; + } + expect(code).toBe(ERR_CODES.INVALID_CTOR); + }); +}); diff --git a/src/lib/api/token-utils.ts b/src/lib/api/token-utils.ts index 8427aac..a9b52e4 100644 --- a/src/lib/api/token-utils.ts +++ b/src/lib/api/token-utils.ts @@ -39,7 +39,11 @@ export function extractToken( if (isNodeBase(provider)) return provider; if (typeof provider === "function") { - if (!isInjectable(provider)) throw InjectionError.invalidCtor(provider); + if (!isInjectable(provider)) { + if (isAlias) throw InjectionError.invalidAlias(provider); + throw InjectionError.invalidCtor(provider); + } + return getInjectableToken(provider); } diff --git a/src/lib/api/token.spec.ts b/src/lib/api/token.spec.ts index 51a9acb..3c7c4bf 100644 --- a/src/lib/api/token.spec.ts +++ b/src/lib/api/token.spec.ts @@ -1,4 +1,5 @@ import { describe, expect, it } from "vitest"; +import { ERR_CODES, InjectionError } from "../errors"; import { MultiNodeToken, NodeBase, NodeToken } from "./token"; describe("Token", () => { @@ -206,3 +207,49 @@ describe("Token", () => { }); }); }); + +describe("global tokens", () => { + it("dedupes a single token by name so independent constructions are identical", () => { + const a = new NodeToken("seam.alpha", { global: true }); + const b = new NodeToken("seam.alpha", { global: true }); + expect(b).toBe(a); + }); + + it("dedupes multi tokens too", () => { + const a = new MultiNodeToken("seam.beta", { global: true }); + const b = new MultiNodeToken("seam.beta", { global: true }); + expect(b).toBe(a); + }); + + it("leaves non-global tokens as distinct instances", () => { + expect(new NodeToken("seam.gamma")).not.toBe(new NodeToken("seam.gamma")); + }); + + it("keeps a non-global token independent of a same-named global one", () => { + const g = new NodeToken("seam.delta", { global: true }); + expect(new NodeToken("seam.delta")).not.toBe(g); + }); + + it("throws when a name is reused for a different token kind", () => { + new NodeToken("seam.epsilon", { global: true }); + + let err: unknown; + try { + new MultiNodeToken("seam.epsilon", { global: true }); + } catch (e) { + err = e; + } + + expect(err).toBeInstanceOf(InjectionError); + expect((err as InjectionError).code).toBe(ERR_CODES.GLOBAL_TOKEN_CONFLICT); + expect((err as InjectionError).message).toMatch(/already registered/); + }); + + it("the first registration's options win for the shared instance", () => { + const factory = () => "x"; + const a = new NodeToken("seam.zeta", { global: true, factory }); + const b = new NodeToken("seam.zeta", { global: true }); + expect(b).toBe(a); + expect(b.opts?.factory).toBe(factory); + }); +}); diff --git a/src/lib/api/token.ts b/src/lib/api/token.ts index e9cb618..9447755 100644 --- a/src/lib/api/token.ts +++ b/src/lib/api/token.ts @@ -1,3 +1,4 @@ +import { InjectionError } from "../errors"; import type { Ctor, ImplementationShape, @@ -11,6 +12,7 @@ import type { } from "../provider/types"; const NODE_TOKEN_CLASSES_KEY = Symbol.for("@illuma/core/NodeTokenClasses"); +const GLOBAL_TOKENS_KEY = Symbol.for("@illuma/core/GlobalTokens"); type iNodeTokenGlobalThis = typeof globalThis & { [NODE_TOKEN_CLASSES_KEY]?: { @@ -18,6 +20,7 @@ type iNodeTokenGlobalThis = typeof globalThis & { NodeToken: typeof NodeTokenImpl; MultiNodeToken: typeof MultiNodeTokenImpl; }; + [GLOBAL_TOKENS_KEY]?: Map>; }; const nodeTokenGlobal = globalThis as iNodeTokenGlobalThis; @@ -31,7 +34,30 @@ abstract class NodeBaseImpl { constructor( public readonly name: string, public readonly opts?: iNodeTokenBaseOptions, - ) {} + ) { + if (!opts?.global) return; + + let registry = nodeTokenGlobal[GLOBAL_TOKENS_KEY]; + if (!registry) { + registry = new Map(); + nodeTokenGlobal[GLOBAL_TOKENS_KEY] = registry; + } + + const existing = registry.get(name); + if (existing) { + if (existing.constructor !== new.target) { + throw InjectionError.globalTokenConflict( + name, + existing.constructor.name, + new.target?.name, + ); + } + + return existing as NodeBaseImpl; + } + + registry.set(name, this); + } /** Provides this token with a value */ public withValue(value: T): iNodeValueProvider { @@ -103,7 +129,10 @@ abstract class NodeBaseImpl { * ``` */ class NodeTokenImpl extends NodeBaseImpl { - public readonly multi = false as const; + public get multi(): false { + return false; + } + public override toString(): string { return `NodeToken[${this.name}]`; } @@ -125,7 +154,10 @@ class NodeTokenImpl extends NodeBaseImpl { * ``` */ class MultiNodeTokenImpl extends NodeBaseImpl { - public readonly multi = true as const; + public get multi(): true { + return true; + } + public override toString(): string { return `MultiNodeToken[${this.name}]`; } diff --git a/src/lib/container/container.ts b/src/lib/container/container.ts index 7fadf30..93057a8 100644 --- a/src/lib/container/container.ts +++ b/src/lib/container/container.ts @@ -13,6 +13,7 @@ import { extractProvider, ProtoNodeMulti, ProtoNodeSingle, + readNodeInstance, resolveTreeNode, TreeRootNode, } from "../provider"; @@ -46,7 +47,7 @@ export class NodeContainer extends Illuma implements iDIContainer { private _bootstrapped = false; private _rootNode?: TreeRootNode; - private readonly _unsubParentBootstrap?: () => void; + private _unsubParentBootstrap?: () => void; private readonly _unsubParentDestroy?: () => void; private readonly _parent?: iDIContainer; @@ -116,6 +117,7 @@ export class NodeContainer extends Illuma implements iDIContainer { * ``` */ public provide(provider: Provider): void { + if (this.destroyed) throw InjectionError.destroyed(); if (this._bootstrapped) { throw InjectionError.bootstrapped(); } @@ -220,6 +222,7 @@ export class NodeContainer extends Illuma implements iDIContainer { } public bootstrap(): void { + if (this.destroyed) throw InjectionError.destroyed(); if (this._bootstrapped) throw InjectionError.doubleBootstrap(); if (this._parent) { if (this._parent.destroyed) throw InjectionError.parentDestroyed(); @@ -228,13 +231,36 @@ export class NodeContainer extends Illuma implements iDIContainer { const start = performance.now(); + // Snapshot providers and lifecycle hooks so a build that throws rolls back + // to the pre-bootstrap state instead of leaving cleared maps and stray + // hooks (which would fire on retry or on a teardown that never bootstrapped). + const protoSnapshot = new Map(this._protoNodes); + const multiSnapshot = new Map(this._multiProtoNodes); + const hookSnapshot = this._lifecycle.snapshotHooks(); + this.provide(Injector.withValue(this._injector)); this.provide(LifecycleRef.withValue(this._lifecycle)); - this._rootNode = this._buildInjectionTree(); - this._rootNode.build(); + try { + this._rootNode = this._buildInjectionTree(); + this._rootNode.build(); + } catch (error) { + this._rootNode = undefined; + this._protoNodes.clear(); + this._multiProtoNodes.clear(); + for (const [token, proto] of protoSnapshot) this._protoNodes.set(token, proto); + for (const [token, proto] of multiSnapshot) this._multiProtoNodes.set(token, proto); + this._lifecycle.restoreHooks(hookSnapshot); + throw error; + } + this._bootstrapped = true; + // Drop the parent's bootstrap-cascade hook: it has fired, and keeping it + // retains this child for the parent's lifetime and risks a re-bootstrap. + this._unsubParentBootstrap?.(); + this._unsubParentBootstrap = undefined; + const end = performance.now(); const duration = end - start; if (this._opts?.measurePerformance) { @@ -243,18 +269,22 @@ export class NodeContainer extends Illuma implements iDIContainer { this._lifecycle.runBootstrapHooks(); + // A bootstrap hook may have destroyed the container; skip diagnostics + // rather than dereference the cleared _rootNode. + if (!this._rootNode) return; + // Run diagnostics if enabled or diagnostics modules are registered if (Illuma.hasDiagnostics()) { - const allNodes = this._rootNode.dependencies.size; - const unusedNodes = Array.from(this._rootNode.dependencies) - .filter((node) => node.allocations === 0) - .filter((node) => { - if (!(node.proto instanceof ProtoNodeSingle)) return true; - return node.proto.token !== Injector; - }); + // Count totals and unused over the same population: user nodes, excluding + // the built-in Injector / LifecycleRef, so the ratio compares like sets. + const userNodes = Array.from(this._rootNode.dependencies).filter((node) => { + if (!(node.proto instanceof ProtoNodeSingle)) return true; + return node.proto.token !== Injector && node.proto.token !== LifecycleRef; + }); + const unusedNodes = userNodes.filter((node) => node.allocations === 0); Illuma.onReport({ - totalNodes: allNodes, + totalNodes: userNodes.length, unusedNodes: unusedNodes, bootstrapDuration: duration, }); @@ -299,15 +329,30 @@ export class NodeContainer extends Illuma implements iDIContainer { } const token = extractToken(provider); + return this._resolve(token, options); + } + + /** + * @internal + * Shared resolution algorithm for {@link get} and {@link produce}'s injector + * retriever, so the two cannot drift on modifiers, hierarchy walking, or + * root-singleton scoping. + */ + private _resolve( + token: NodeBase, + options?: iNodeInjectorOptions, + ): T | T[] | null { + if (!this._rootNode) throw InjectionError.notBootstrapped(); const { optional, self, skipSelf } = options ?? {}; if (self && skipSelf) { - throw InjectionError.conflictingStrategies(token as any); + throw InjectionError.conflictingStrategies(token); } if (!skipSelf) { const treeNode = this._rootNode.obtain(token); - if (treeNode) return treeNode.instance; + // self:true on a multi token yields only this container's own members. + if (treeNode) return readNodeInstance(treeNode, self ?? false); } if (!self) { @@ -318,8 +363,8 @@ export class NodeContainer extends Illuma implements iDIContainer { if (rootSingleton) return rootSingleton.instance; } + if (token instanceof MultiNodeToken) return []; if (optional) return null; - if (!skipSelf && token instanceof MultiNodeToken) return []; throw InjectionError.notFound(token); } @@ -340,36 +385,10 @@ export class NodeContainer extends Illuma implements iDIContainer { factory = isConstructor(fn) ? () => new fn() : (fn as () => T); } - const rootNode = this._rootNode; - if (!rootNode) throw InjectionError.notBootstrapped(); + if (!this._rootNode) throw InjectionError.notBootstrapped(); - const retriever: InjectorFn = ( - token, - { optional, self, skipSelf }: iNodeInjectorOptions = {}, - ) => { - if (self && skipSelf) { - throw InjectionError.conflictingStrategies(token as NodeBase); - } - - if (!skipSelf) { - const node = rootNode.obtain(token); - if (node) return node.instance; - } - - if (!self) { - const upstream = this._getFromParent(token); - if (upstream) return upstream.instance; - } - - if (!skipSelf && token instanceof MultiNodeToken) return []; - if (!skipSelf && token instanceof NodeToken && token.opts?.singleton) { - const singleton = this._getRootSingleton(token, true); - if (singleton) return singleton.instance; - } - - if (!optional) throw InjectionError.notFound(token); - return null; - }; + // Same algorithm as get(), so produce()'s injections honor scoping identically. + const retriever: InjectorFn = (token, options) => this._resolve(token, options); const middlewares = [...Illuma._middlewares, ...this.collectMiddlewares()]; const contextFactory = () => InjectionContext.instantiate(factory, retriever); @@ -388,17 +407,23 @@ export class NodeContainer extends Illuma implements iDIContainer { public destroy(): void { if (this._lifecycle.destroyed) throw InjectionError.destroyed(); - this._lifecycle.destroy(); - if (this._rootNode) { - this._rootNode.destroy(); - this._rootNode = undefined; - } + // Tear the container down even if a destroy hook throws, so a misbehaving + // hook cannot leave the container half-destroyed with no recovery path. + try { + this._lifecycle.destroy(); + } finally { + if (this._rootNode) { + this._rootNode.destroy(); + this._rootNode = undefined; + } - this._unsubParentBootstrap?.(); - this._unsubParentDestroy?.(); - this._bootstrapped = false; - this._protoNodes.clear(); + this._unsubParentBootstrap?.(); + this._unsubParentDestroy?.(); + this._bootstrapped = false; + this._protoNodes.clear(); + this._multiProtoNodes.clear(); + } } public child(): iDIContainer { @@ -473,29 +498,50 @@ export class NodeContainer extends Illuma implements iDIContainer { /** @internal */ private _getFromParent(token: Token): TreeNode | null { - if (!this._parent) return null; - const parentNode = this._parent as NodeContainer; - - const upstream = parentNode.findNode(token); - if (upstream) return upstream; + if (!(this._parent instanceof NodeContainer)) return null; + const parentNode = this._parent; + + // Walk the whole ancestor chain, not just the immediate parent: a plain + // token on a grandparent must still resolve from a grandchild. + for ( + let ancestor: NodeContainer | undefined = parentNode; + ancestor; + ancestor = ancestor._parent instanceof NodeContainer ? ancestor._parent : undefined + ) { + const upstream = ancestor.findNode(token); + if (upstream) return upstream; + } return this._resolveSingletonFrom(parentNode, token, true); } /** @internal */ private _resolverFromParent(token: Token): TreeNode | null { - if (!this._parent || !(this._parent instanceof NodeContainer)) return null; - - const upstream = this._parent._findNode(token); - if (upstream) return upstream; + if (!(this._parent instanceof NodeContainer)) return null; + + // Build-time wiring mirrors runtime resolution: search every ancestor's + // pool up to the root, regardless of how many levels separate them. + for ( + let ancestor: NodeContainer | undefined = this._parent; + ancestor; + ancestor = ancestor._parent instanceof NodeContainer ? ancestor._parent : undefined + ) { + const upstream = ancestor._findNode(token); + if (upstream) return upstream; + } return this._resolveSingletonFrom(this._parent, token, false); } /** @internal */ private _buildInjectionTree(): TreeRootNode { - const middlewares = [...Illuma._middlewares, ...this.collectMiddlewares()]; - const root = new TreeRootNode(this._opts?.instant, middlewares); + // A thunk, not a frozen array: a node materialized lazily after bootstrap + // reads the CURRENT middleware chain, so lazy get() matches produce() even + // for a global middleware registered post-bootstrap. + const root = new TreeRootNode(this._opts?.instant, () => [ + ...Illuma._middlewares, + ...this.collectMiddlewares(), + ]); const cache = new Map(); const nodes: ProtoNode[] = [ @@ -528,7 +574,11 @@ export class NodeContainer extends Illuma implements iDIContainer { /** @internal */ private _registerMultiDeclaration(token: MultiNodeToken): void { - if (this._multiProtoNodes.has(token)) { + const existing = this._multiProtoNodes.get(token); + if (existing) { + // Declaring a token that already has providers is a no-op, + // mirroring declaration-then-implementation order + if (existing.hasProviders()) return; throw InjectionError.duplicate(token); } @@ -537,7 +587,11 @@ export class NodeContainer extends Illuma implements iDIContainer { /** @internal */ private _registerSingleDeclaration(token: NodeToken): void { - if (this._protoNodes.has(token)) { + const existing = this._protoNodes.get(token); + if (existing) { + // Declaring a token that already has a factory is a no-op, + // mirroring declaration-then-implementation order + if (existing.hasFactory()) return; throw InjectionError.duplicate(token); } diff --git a/src/lib/container/lifecycle.ts b/src/lib/container/lifecycle.ts index 6b5345e..5961843 100644 --- a/src/lib/container/lifecycle.ts +++ b/src/lib/container/lifecycle.ts @@ -9,6 +9,14 @@ type iLifecycleGlobalThis = typeof globalThis & { const lcrGlobal = globalThis as iLifecycleGlobalThis; +/** @internal Snapshot of every lifecycle hook registration. */ +interface iLifecycleHookSnapshot { + bootstrap: Set<() => void>; + bootstrapChild: Set<() => void>; + destroy: Set<() => void>; + destroyChild: Set<() => void>; +} + /** @internal */ export class LifecycleRefImpl { private readonly _destroyCallbacks = new Set<() => void>(); @@ -56,8 +64,64 @@ export class LifecycleRefImpl { * Should be called by the container after the bootstrap process is complete. */ public runBootstrapHooks(): void { - for (const cb of this._bootstrapCallbacks) cb(); - for (const cb of this._bootstrapChildCallbacks) cb(); + // Isolate each hook so one throwing callback cannot strand sibling children; + // surface the first error after all have run. Snapshot so a hook that + // registers or clears callbacks cannot disturb the walk. + const errors = this._runGuarded([ + Array.from(this._bootstrapCallbacks), + Array.from(this._bootstrapChildCallbacks), + ]); + + if (errors.length) throw errors[0]; + } + + /** + * Runs every callback in each group, isolating throws, and returns the + * collected errors (empty if none threw). Groups run in the order given. + */ + private _runGuarded(groups: Array void>>): unknown[] { + const errors: unknown[] = []; + for (const group of groups) { + for (const cb of group) { + try { + cb(); + } catch (e) { + errors.push(e); + } + } + } + return errors; + } + + /** + * @internal + * Captures all four hook sets so a failing bootstrap can roll them back, + * dropping user hooks and child hooks a factory may have registered. + */ + public snapshotHooks(): iLifecycleHookSnapshot { + return { + bootstrap: new Set(this._bootstrapCallbacks), + bootstrapChild: new Set(this._bootstrapChildCallbacks), + destroy: new Set(this._destroyCallbacks), + destroyChild: new Set(this._destroyChildCallbacks), + }; + } + + /** + * @internal + * Restores the hooks captured by {@link snapshotHooks}, dropping any added + * since (e.g. by a factory in a failed build, or a child spawned during it). + */ + public restoreHooks(snapshot: iLifecycleHookSnapshot): void { + this._restore(this._bootstrapCallbacks, snapshot.bootstrap); + this._restore(this._bootstrapChildCallbacks, snapshot.bootstrapChild); + this._restore(this._destroyCallbacks, snapshot.destroy); + this._restore(this._destroyChildCallbacks, snapshot.destroyChild); + } + + private _restore(target: Set<() => void>, snapshot: Set<() => void>): void { + target.clear(); + for (const cb of snapshot) target.add(cb); } /** @@ -92,15 +156,24 @@ export class LifecycleRefImpl { */ public destroy(): void { if (this._destroyed) throw InjectionError.destroyed(); + // Mark destroyed before running hooks so a hook that (re-entrantly) checks + // `destroyed` observes true; a guarded re-entrant destroy is a safe no-op + // instead of recursing infinitely. + this._destroyed = true; - for (const cb of Array.from(this._destroyChildCallbacks).reverse()) cb(); - for (const cb of Array.from(this._destroyCallbacks).reverse()) cb(); + // Guard each hook so one throwing callback cannot strand sibling children + // or abort the cascade; surface the first error after every hook has run. + const errors = this._runGuarded([ + Array.from(this._destroyChildCallbacks).reverse(), + Array.from(this._destroyCallbacks).reverse(), + ]); this._bootstrapCallbacks.clear(); this._bootstrapChildCallbacks.clear(); this._destroyChildCallbacks.clear(); this._destroyCallbacks.clear(); - this._destroyed = true; + + if (errors.length) throw errors[0]; } } diff --git a/src/lib/container/tests/array-providers.spec.ts b/src/lib/container/tests/array-providers.spec.ts index 383076a..e90e58a 100644 --- a/src/lib/container/tests/array-providers.spec.ts +++ b/src/lib/container/tests/array-providers.spec.ts @@ -220,3 +220,20 @@ describe("array providers", () => { expect(container.get(tokenB)).toBe("value-b"); }); }); + +describe("multi member declaration order (#24)", () => { + it("resolves members in registration order regardless of provider kind", () => { + const M = new MultiNodeToken("ORDER_MULTI"); + const ALIAS_SRC = new NodeToken("ORDER_ALIAS_SRC"); + + const c = new NodeContainer(); + c.provide(ALIAS_SRC.withValue("alias")); + c.provide(M.withValue("first")); // value -> transparent + c.provide({ provide: M, alias: ALIAS_SRC }); // alias -> single + c.provide(M.withFactory(() => "factory")); // factory -> transparent + c.provide(M.withValue("last")); // value -> transparent + c.bootstrap(); + + expect(c.get(M)).toEqual(["first", "alias", "factory", "last"]); + }); +}); diff --git a/src/lib/container/tests/audit-fixes.spec.ts b/src/lib/container/tests/audit-fixes.spec.ts new file mode 100644 index 0000000..161b837 --- /dev/null +++ b/src/lib/container/tests/audit-fixes.spec.ts @@ -0,0 +1,789 @@ +import { afterEach, describe, expect, it } from "vitest"; +import { MultiNodeToken, NodeInjectable, NodeToken, nodeInject } from "../../api"; +import { InjectionContext } from "../../context/context"; +import { ERR_CODES, InjectionError } from "../../errors"; +import { Illuma } from "../../global"; +import type { iContextScanner } from "../../plugins/context/types"; +import { Injector } from "../../utils/injector"; +import { NodeContainer } from "../container"; +import { LifecycleRef } from "../lifecycle"; + +describe("diamond dependency dedup (resolver)", () => { + it("shares one diamond-shared instance across both consumers (order A,B,D)", () => { + const D = new NodeToken<{ marker: object }>("DIAMOND_D"); + const B = new NodeToken<{ d: { marker: object } }>("DIAMOND_B"); + const A = new NodeToken<{ d: { marker: object }; b: { d: { marker: object } } }>( + "DIAMOND_A", + ); + + const c = new NodeContainer(); + c.provide( + A.withFactory(() => { + const d = nodeInject(D); + const b = nodeInject(B); + return { d, b }; + }), + ); + c.provide(B.withFactory(() => ({ d: nodeInject(D) }))); + c.provide(D.withFactory(() => ({ marker: {} }))); + c.bootstrap(); + + const a = c.get(A); + + expect(a.d).toBe(a.b.d); + }); + + it("shares the same TreeNode across both parents (allocations === 2)", () => { + const D = new NodeToken<{ id: number }>("DIAMOND_ALLOC_D"); + const B = new NodeToken("DIAMOND_ALLOC_B"); + const A = new NodeToken("DIAMOND_ALLOC_A"); + + const c = new NodeContainer(); + c.provide( + A.withFactory(() => { + nodeInject(D); + nodeInject(B); + return {}; + }), + ); + c.provide( + B.withFactory(() => { + nodeInject(D); + return {}; + }), + ); + c.provide(D.withFactory(() => ({ id: 1 }))); + c.bootstrap(); + + const rootNode = (c as any)._rootNode; + expect(rootNode.find(D).allocations).toBe(2); + }); + + it("throws i401 (not stack-overflow) for mutually-referencing siblings", () => { + const P = new NodeToken("SIBLING_CYCLE_P"); + const A = new NodeToken("SIBLING_CYCLE_A"); + const B = new NodeToken("SIBLING_CYCLE_B"); + + const c = new NodeContainer(); + c.provide( + P.withFactory(() => { + nodeInject(A); + nodeInject(B); + return {}; + }), + ); + c.provide(A.withFactory(() => nodeInject(B))); + c.provide(B.withFactory(() => nodeInject(A))); + + let caught: InjectionError | undefined; + try { + c.bootstrap(); + } catch (e) { + caught = e as InjectionError; + } + expect(caught).toBeInstanceOf(InjectionError); + expect(caught?.code).toBe(ERR_CODES.CIRCULAR_DEPENDENCY); + }); + + it("throws i401 for a sibling cycle in lazy (instant:false) mode too", () => { + const P = new NodeToken("LAZY_CYCLE_P"); + const A = new NodeToken("LAZY_CYCLE_A"); + const B = new NodeToken("LAZY_CYCLE_B"); + + const c = new NodeContainer({ instant: false }); + c.provide( + P.withFactory(() => { + nodeInject(A); + nodeInject(B); + return {}; + }), + ); + c.provide(A.withFactory(() => nodeInject(B))); + c.provide(B.withFactory(() => nodeInject(A))); + + let caught: InjectionError | undefined; + try { + c.bootstrap(); + } catch (e) { + caught = e as InjectionError; + } + expect(caught).toBeInstanceOf(InjectionError); + expect(caught?.code).toBe(ERR_CODES.CIRCULAR_DEPENDENCY); + }); + + it("still throws a circular-dependency error for a cycle through a diamond", () => { + const S = new NodeToken("CYCLE_S"); + const B = new NodeToken("CYCLE_B"); + const A = new NodeToken("CYCLE_A"); + + const c = new NodeContainer(); + c.provide( + A.withFactory(() => { + nodeInject(S); + nodeInject(B); + return {}; + }), + ); + c.provide(B.withFactory(() => nodeInject(S))); + c.provide(S.withFactory(() => nodeInject(A))); + + let caught: InjectionError | undefined; + try { + c.bootstrap(); + } catch (e) { + caught = e as InjectionError; + } + expect(caught).toBeInstanceOf(InjectionError); + expect(caught?.code).toBe(ERR_CODES.CIRCULAR_DEPENDENCY); + }); + + it("shares a diamond dep across two bare-declared opts.factory tokens", () => { + const V = new NodeToken<{ marker: object }>("DIAMOND_BARE_V", { + factory: () => ({ marker: {} }), + }); + const X = new NodeToken<{ v: { marker: object } }>("DIAMOND_BARE_X", { + factory: () => ({ v: nodeInject(V) }), + }); + const Y = new NodeToken<{ v: { marker: object } }>("DIAMOND_BARE_Y", { + factory: () => ({ v: nodeInject(V) }), + }); + const ROOT = new NodeToken<{ x: { v: object }; y: { v: object } }>( + "DIAMOND_BARE_ROOT", + ); + + const c = new NodeContainer(); + c.provide(ROOT.withFactory(() => ({ x: nodeInject(X), y: nodeInject(Y) }))); + c.provide(X); + c.provide(Y); + c.provide(V); + c.bootstrap(); + + const root = c.get(ROOT); + expect(root.x.v).toBe(root.y.v); + }); +}); + +describe("nested injection context (no clobber)", () => { + it("keeps the outer context open after injector.get() of a lazy singleton", () => { + const SINGLETON = new NodeToken("NESTED_SINGLETON", { + singleton: true, + factory: () => "singleton-value", + }); + const OTHER = new NodeToken("NESTED_OTHER"); + const A = new NodeToken<{ s: string; o: string }>("NESTED_A"); + + const c = new NodeContainer({ instant: false }); + c.provide(OTHER.withValue("other")); + c.provide( + A.withFactory(() => { + const injector = nodeInject(Injector); + const s = injector.get(SINGLETON); + const o = nodeInject(OTHER); + return { s, o }; + }), + ); + c.bootstrap(); + + const a = c.get(A); + expect(a.s).toBe("singleton-value"); + expect(a.o).toBe("other"); + }); + + it("keeps the outer context open after injector.produce()", () => { + const OTHER = new NodeToken("NESTED_PRODUCE_OTHER"); + const A = new NodeToken<{ p: { x: number }; o: string }>("NESTED_PRODUCE_A"); + + const c = new NodeContainer({ instant: false }); + c.provide(OTHER.withValue("other")); + c.provide( + A.withFactory(() => { + const injector = nodeInject(Injector); + const p = injector.produce(() => ({ x: 42 })); + const o = nodeInject(OTHER); + return { p, o }; + }), + ); + c.bootstrap(); + + const a = c.get(A); + expect(a.p.x).toBe(42); + expect(a.o).toBe("other"); + }); + + it("restores the outer context even when a context scanner throws", () => { + const throwingScanner: iContextScanner = { + scan: () => { + throw new Error("scanner boom"); + }, + }; + Illuma.extendContextScanner(throwingScanner); + + try { + expect(() => { + const c = new NodeContainer(); + c.provide(new NodeToken("SCANNER_X").withFactory(() => 1)); + }).not.toThrow(); + + const c2 = new NodeContainer(); + const T = new NodeToken("SCANNER_T"); + const DEP = new NodeToken("SCANNER_DEP"); + c2.provide(DEP.withValue("dep")); + c2.provide( + T.withFactory(() => { + const d = nodeInject(DEP); + return `t-${d}`; + }), + ); + c2.bootstrap(); + expect(c2.get(T)).toBe("t-dep"); + } finally { + (Illuma as any).__resetPlugins(); + } + }); + + it("restores the context stack when a scanner opens a context then throws", () => { + const reentrantScanner: iContextScanner = { + scan: () => { + (InjectionContext as any).open(); + throw new Error("re-entrant scanner boom"); + }, + }; + Illuma.extendContextScanner(reentrantScanner); + + const state = (globalThis as any)[Symbol.for("@illuma/core/InjectionContextState")]; + const depthBefore = state.stack.length; + + try { + const c = new NodeContainer(); + c.provide(new NodeToken("REENTRANT_X").withFactory(() => 1)); + + expect(state.stack.length).toBe(depthBefore); + expect(InjectionContext.contextOpen).toBe(false); + expect(() => nodeInject(new NodeToken("OUTSIDE"))).toThrow(InjectionError); + } finally { + (Illuma as any).__resetPlugins(); + } + }); +}); + +describe("bare declaration tracks opts.factory dependencies", () => { + it("resolves a bare-declared token whose opts.factory injects another token", () => { + const VAL = new NodeToken("BARE_VAL"); + const X = new NodeToken("BARE_X", { + factory: () => nodeInject(VAL) + 1, + }); + + const c = new NodeContainer(); + c.provide(VAL.withValue(7)); + c.provide(X); + expect(() => c.bootstrap()).not.toThrow(); + expect(c.get(X)).toBe(8); + }); + + it("lets an explicit factory override fully replace opts.factory deps", () => { + const OPTS_DEP = new NodeToken("OVERRIDE_OPTS_DEP"); + const EXPLICIT_DEP = new NodeToken("OVERRIDE_EXPLICIT_DEP"); + const X = new NodeToken("OVERRIDE_X", { + factory: () => nodeInject(OPTS_DEP) + 1, + }); + + const c = new NodeContainer(); + c.provide(EXPLICIT_DEP.withValue(100)); + c.provide(X); + c.provide(X.withFactory(() => nodeInject(EXPLICIT_DEP) + 1)); + // OPTS_DEP is deliberately never provided: the override's deps must fully + // replace it, otherwise bootstrap would throw on the leaked dependency. + expect(() => c.bootstrap()).not.toThrow(); + expect(c.get(X)).toBe(101); + }); + + it("resolves a bare-declared singleton with opts.factory across a tree", () => { + const VAL = new NodeToken("BARE_SINGLETON_VAL", { + singleton: true, + factory: () => 5, + }); + const X = new NodeToken("BARE_SINGLETON_X", { + singleton: true, + factory: () => nodeInject(VAL) * 2, + }); + + const parent = new NodeContainer(); + parent.provide(VAL); + parent.provide(X); + parent.bootstrap(); + const child = parent.child(); + child.bootstrap(); + + expect(child.get(X)).toBe(10); + }); + + it("resolves a bare-declared opts.factory with an absent optional dep", () => { + const MISSING = new NodeToken("BARE_MISSING"); + const X = new NodeToken<{ v: number | null }>("BARE_OPTIONAL_X", { + factory: () => ({ v: nodeInject(MISSING, { optional: true }) }), + }); + + const c = new NodeContainer(); + c.provide(X); + expect(() => c.bootstrap()).not.toThrow(); + expect(c.get(X).v).toBeNull(); + }); + + it("still throws notFound for a bare token with no factory and no impl", () => { + const X = new NodeToken("BARE_NO_FACTORY"); + + const c = new NodeContainer(); + c.provide(X); + expect(() => c.bootstrap()).toThrow(InjectionError); + }); + + it("works with NodeInjectable classes that inject deps", () => { + const DEP = new NodeToken("CLASS_DEP"); + + @NodeInjectable() + class Service { + public readonly dep = nodeInject(DEP); + } + + const c = new NodeContainer(); + c.provide(DEP.withValue("injected")); + c.provide(Service); + c.bootstrap(); + expect(c.get(Service).dep).toBe("injected"); + }); +}); + +describe("re-entrant destroy", () => { + it("treats a guarded re-entrant destroy as a no-op", () => { + const c = new NodeContainer(); + c.bootstrap(); + const lifecycle = (c as any)._lifecycle; + + let observedDestroyed: boolean | undefined; + lifecycle.beforeDestroy(() => { + observedDestroyed = c.destroyed; + if (!c.destroyed) c.destroy(); + }); + + expect(() => c.destroy()).not.toThrow(); + expect(observedDestroyed).toBe(true); + expect(c.destroyed).toBe(true); + expect((c as any)._rootNode).toBeUndefined(); + }); + + it("reports destroyed as true inside a destroy hook", () => { + const c = new NodeContainer(); + c.bootstrap(); + const lifecycle = (c as any)._lifecycle; + + let seen = false; + lifecycle.beforeDestroy(() => { + seen = lifecycle.destroyed; + }); + + c.destroy(); + expect(seen).toBe(true); + }); + + it("throws (not stack-overflow) on an unguarded re-entrant container destroy", () => { + const c = new NodeContainer(); + c.bootstrap(); + const lifecycle = (c as any)._lifecycle; + lifecycle.beforeDestroy(() => c.destroy()); + + expect(() => c.destroy()).toThrow(InjectionError); + }); + + it("completes teardown (and surfaces the error) when a destroy hook throws", () => { + const c = new NodeContainer(); + c.bootstrap(); + const lifecycle = (c as any)._lifecycle; + lifecycle.beforeDestroy(() => { + throw new Error("boom"); + }); + + expect(() => c.destroy()).toThrow("boom"); + expect(c.destroyed).toBe(true); + expect(c.bootstrapped).toBe(false); + expect((c as any)._rootNode).toBeUndefined(); + }); + + it("destroys every sibling child even when one child's hook throws", () => { + const parent = new NodeContainer(); + parent.bootstrap(); + const childA = parent.child() as NodeContainer; + childA.bootstrap(); + const childB = parent.child() as NodeContainer; + childB.bootstrap(); + const childC = parent.child() as NodeContainer; + childC.bootstrap(); + + (childB as any)._lifecycle.beforeDestroy(() => { + throw new Error("flush failed"); + }); + + expect(() => parent.destroy()).toThrow("flush failed"); + + expect(childA.destroyed).toBe(true); + expect(childB.destroyed).toBe(true); + expect(childC.destroyed).toBe(true); + expect(parent.destroyed).toBe(true); + }); +}); + +describe("zombie bootstrap / destroyed container hardening", () => { + it("throws when bootstrapping a destroyed container", () => { + const c = new NodeContainer(); + c.provide(new NodeToken("ZOMBIE_T").withValue(1)); + c.bootstrap(); + c.destroy(); + + expect(() => c.bootstrap()).toThrow(InjectionError); + try { + c.bootstrap(); + } catch (e) { + expect((e as InjectionError).code).toBe(ERR_CODES.DESTROYED); + } + expect(c.destroyed).toBe(true); + expect(c.bootstrapped).toBe(false); + }); + + it("throws when bootstrapping after destroy without prior bootstrap", () => { + const c = new NodeContainer(); + c.bootstrap(); + c.destroy(); + expect(() => c.bootstrap()).toThrow(InjectionError); + }); + + it("clears _multiProtoNodes on destroy (even when bootstrapped)", () => { + const MULTI = new MultiNodeToken("ZOMBIE_MULTI"); + const c = new NodeContainer(); + c.provide(MULTI.withValue(1)); + c.bootstrap(); + c.destroy(); + + expect((c as any)._protoNodes.size).toBe(0); + expect((c as any)._multiProtoNodes.size).toBe(0); + }); + + it("throws when providing to a destroyed container", () => { + const c = new NodeContainer(); + c.bootstrap(); + c.destroy(); + expect(() => c.provide(new NodeToken("ZOMBIE_P").withValue(1))).toThrow( + InjectionError, + ); + }); + + it("does not resurrect a destroyed child when the parent bootstraps", () => { + const parent = new NodeContainer(); + const child = parent.child() as NodeContainer; + child.destroy(); + + expect(() => parent.bootstrap()).not.toThrow(); + expect(child.destroyed).toBe(true); + expect(child.bootstrapped).toBe(false); + }); +}); + +describe("LifecycleRef still resolvable after the fixes", () => { + afterEach(() => { + (Illuma as any).__resetPlugins(); + }); + + it("resolves LifecycleRef and Injector built-ins", () => { + const c = new NodeContainer(); + c.bootstrap(); + expect(c.get(LifecycleRef)).toBeDefined(); + expect(c.get(Injector)).toBeDefined(); + }); +}); + +describe("resolution failure does not strand a node as in-progress", () => { + it("lazy: a retried get() after a factory throw succeeds instead of faking a cycle", () => { + let shouldThrow = true; + const B = new NodeToken("RETRY_B"); + const A = new NodeToken("RETRY_A"); + + const c = new NodeContainer({ instant: false }); + c.provide( + B.withFactory(() => { + if (shouldThrow) throw new Error("transient boom"); + return 42; + }), + ); + c.provide(A.withFactory(() => nodeInject(B) + 1)); + c.bootstrap(); + + expect(() => c.get(A)).toThrow("transient boom"); + + shouldThrow = false; + expect(c.get(A)).toBe(43); + }); + + it("lazy: a sibling sharing a failed dep does not see a bogus circular dependency", () => { + let shouldThrow = true; + const DEP = new NodeToken("SHARED_DEP"); + const A = new NodeToken("SHARED_A"); + const C = new NodeToken("SHARED_C"); + + const c = new NodeContainer({ instant: false }); + c.provide( + DEP.withFactory(() => { + if (shouldThrow) throw new Error("dep boom"); + return 1; + }), + ); + c.provide(A.withFactory(() => nodeInject(DEP))); + c.provide(C.withFactory(() => nodeInject(DEP))); + c.bootstrap(); + + expect(() => c.get(A)).toThrow("dep boom"); + + shouldThrow = false; + let caught: InjectionError | undefined; + try { + c.get(C); + } catch (e) { + caught = e as InjectionError; + } + expect(caught?.code).not.toBe(ERR_CODES.CIRCULAR_DEPENDENCY); + expect(c.get(C)).toBe(1); + }); + + it("lazy multi: a retried get() does not accumulate duplicate members", () => { + let shouldThrow = true; + const M = new MultiNodeToken("RETRY_MULTI"); + const GOOD = new NodeToken("RETRY_MULTI_GOOD"); + const BAD = new NodeToken("RETRY_MULTI_BAD"); + + const c = new NodeContainer({ instant: false }); + c.provide(GOOD.withValue(1)); + c.provide( + BAD.withFactory(() => { + if (shouldThrow) throw new Error("multi member boom"); + return 2; + }), + ); + c.provide({ provide: M, alias: GOOD }); + c.provide({ provide: M, alias: BAD }); + c.bootstrap(); + + expect(() => c.get(M)).toThrow("multi member boom"); + + shouldThrow = false; + expect(c.get(M)).toEqual([1, 2]); + }); +}); + +describe("bootstrap() is atomic (rollback on a failed build)", () => { + it("a retry after a throwing factory keeps every user provider", () => { + const GOOD = new NodeToken<{ v: number }>("ATOMIC_GOOD"); + const BAD = new NodeToken("ATOMIC_BAD"); + let boom = true; + + const c = new NodeContainer(); + c.provide(GOOD.withFactory(() => ({ v: 1 }))); + c.provide( + BAD.withFactory(() => { + if (boom) throw new Error("boom"); + return 2; + }), + ); + + expect(() => c.bootstrap()).toThrow("boom"); + expect(c.bootstrapped).toBe(false); + + boom = false; + expect(() => c.bootstrap()).not.toThrow(); + expect(c.bootstrapped).toBe(true); + expect(c.get(GOOD)).toEqual({ v: 1 }); + expect(c.get(BAD)).toBe(2); + }); + + it("rolls back when an unresolved dependency aborts the build, then succeeds once provided", () => { + const DEP = new NodeToken("ATOMIC_DEP"); + const HOST = new NodeToken("ATOMIC_HOST"); + + const c = new NodeContainer(); + c.provide(HOST.withFactory(() => nodeInject(DEP) + 1)); + + // DEP is missing: the build aborts with notFound. + expect(() => c.bootstrap()).toThrow(InjectionError); + expect(c.bootstrapped).toBe(false); + + // Providing the missing dep and retrying must succeed with HOST intact. + c.provide(DEP.withValue(41)); + expect(() => c.bootstrap()).not.toThrow(); + expect(c.get(HOST)).toBe(42); + }); + + it("does not double-register the Injector/LifecycleRef built-ins across a failed attempt", () => { + const BAD = new NodeToken("ATOMIC_BUILTIN_BAD"); + let boom = true; + + const c = new NodeContainer(); + c.provide( + BAD.withFactory(() => { + if (boom) throw new Error("boom"); + return 1; + }), + ); + + expect(() => c.bootstrap()).toThrow("boom"); + boom = false; + // A clean retry proves the built-ins were rolled back (no duplicate-provider). + expect(() => c.bootstrap()).not.toThrow(); + expect(c.get(Injector)).toBeDefined(); + expect(c.get(LifecycleRef)).toBeDefined(); + expect(c.get(BAD)).toBe(1); + }); + + it("rolls back afterBootstrap hooks registered during a failed build", () => { + const EARLY = new NodeToken("ATOMIC_HOOK_EARLY"); + const BAD = new NodeToken("ATOMIC_HOOK_BAD"); + let boom = true; + let hookRuns = 0; + + const c = new NodeContainer(); + c.provide( + EARLY.withFactory(() => { + nodeInject(LifecycleRef).afterBootstrap(() => { + hookRuns++; + }); + return 1; + }), + ); + c.provide( + BAD.withFactory(() => { + if (boom) throw new Error("boom"); + return 2; + }), + ); + + expect(() => c.bootstrap()).toThrow("boom"); + boom = false; + c.bootstrap(); + + // The hook leaked from the failed attempt must NOT also fire. + expect(hookRuns).toBe(1); + }); + + it("rolls back beforeDestroy hooks registered during a failed build", () => { + const EARLY = new NodeToken("ATOMIC_DESTROY_EARLY"); + const BAD = new NodeToken("ATOMIC_DESTROY_BAD"); + let boom = true; + let destroyRuns = 0; + + const c = new NodeContainer(); + c.provide( + EARLY.withFactory(() => { + nodeInject(LifecycleRef).beforeDestroy(() => { + destroyRuns++; + }); + return 1; + }), + ); + c.provide( + BAD.withFactory(() => { + if (boom) throw new Error("boom"); + return 2; + }), + ); + + expect(() => c.bootstrap()).toThrow("boom"); + boom = false; + c.bootstrap(); + c.destroy(); + + // A beforeDestroy from a never-completed bootstrap must not run on teardown. + expect(destroyRuns).toBe(1); + }); + + it("rolls back child hooks from a child spawned during a failed build", () => { + const EARLY = new NodeToken("ATOMIC_CHILD_EARLY"); + const BAD = new NodeToken("ATOMIC_CHILD_BAD"); + let boom = true; + + const c = new NodeContainer(); + c.provide( + EARLY.withFactory(() => { + // Spawning a child registers onChildBootstrap/onChildDestroy on THIS + // (parent) lifecycle — those must roll back with a failed attempt. + nodeInject(Injector).spawnChild(); + return 1; + }), + ); + c.provide( + BAD.withFactory(() => { + if (boom) throw new Error("boom"); + return 2; + }), + ); + + expect(() => c.bootstrap()).toThrow("boom"); + boom = false; + c.bootstrap(); + + const lifecycle = (c as any)._lifecycle; + // Only the successful attempt's child remains: the failed attempt's child + // hooks were rolled back. The surviving child's bootstrap hook is then + // released once it bootstraps in the cascade (#38), leaving its destroy hook. + expect(lifecycle._bootstrapChildCallbacks.size).toBe(0); + expect(lifecycle._destroyChildCallbacks.size).toBe(1); + }); +}); + +describe("bootstrap hook error isolation (#4) and destroy-in-hook (#22)", () => { + afterEach(() => { + (Illuma as any).__resetPlugins(); + }); + + it("one throwing afterBootstrap hook does not abort sibling hooks", () => { + const c = new NodeContainer(); + c.bootstrap(); + const lifecycle = (c as any)._lifecycle; + + let secondRan = false; + lifecycle.afterBootstrap(() => { + throw new Error("hook boom"); + }); + lifecycle.afterBootstrap(() => { + secondRan = true; + }); + + expect(() => lifecycle.runBootstrapHooks()).toThrow("hook boom"); + expect(secondRan).toBe(true); + }); + + it("a throwing child bootstrap does not strand sibling children in the cascade", () => { + const parent = new NodeContainer(); + const childA = parent.child() as NodeContainer; + const childB = parent.child() as NodeContainer; + const childC = parent.child() as NodeContainer; + + // childB fails to bootstrap (a throwing factory). + childB.provide( + new NodeToken("CASCADE_BAD").withFactory(() => { + throw new Error("child boom"); + }), + ); + + expect(() => parent.bootstrap()).toThrow("child boom"); + + expect(childA.bootstrapped).toBe(true); + expect(childC.bootstrapped).toBe(true); + expect(childB.bootstrapped).toBe(false); + }); + + it("bootstrap does not crash when a hook destroys the container with diagnostics on", () => { + Illuma.extendDiagnostics({ onReport: () => {} }); + + const c = new NodeContainer(); + const lifecycle = (c as any)._lifecycle; + lifecycle.afterBootstrap(() => c.destroy()); + + expect(() => c.bootstrap()).not.toThrow(); + expect(c.destroyed).toBe(true); + }); +}); diff --git a/src/lib/container/tests/consistency.spec.ts b/src/lib/container/tests/consistency.spec.ts new file mode 100644 index 0000000..f91ee4d --- /dev/null +++ b/src/lib/container/tests/consistency.spec.ts @@ -0,0 +1,166 @@ +import { afterEach, describe, expect, it } from "vitest"; +import { MultiNodeToken, NodeToken, nodeInject } from "../../api"; +import { Illuma } from "../../global"; +import type { iDiagnosticsReport } from "../../plugins/diagnostics/types"; +import { Injector } from "../../utils/injector"; +import { NodeContainer } from "../container"; + +describe("multi token retrieval consistency", () => { + it("get() returns [] for an unprovided multi token with skipSelf", () => { + const parent = new NodeContainer(); + parent.bootstrap(); + const child = parent.child() as NodeContainer; + child.bootstrap(); + + const MULTI = new MultiNodeToken("MULTI_SKIP_SELF"); + + expect(child.get(MULTI)).toEqual([]); + expect(child.get(MULTI, { skipSelf: true })).toEqual([]); + }); + + it("get() returns [] for an unprovided multi token with optional", () => { + const container = new NodeContainer(); + container.bootstrap(); + + const MULTI = new MultiNodeToken("MULTI_OPTIONAL"); + + expect(container.get(MULTI, { optional: true })).toEqual([]); + }); + + it("produce() resolves an unprovided multi token to [] regardless of strategy", () => { + const container = new NodeContainer(); + container.bootstrap(); + + const MULTI = new MultiNodeToken("MULTI_PRODUCE"); + + const produced = container.produce(() => ({ + plain: nodeInject(MULTI), + skipSelf: nodeInject(MULTI, { skipSelf: true }), + })); + + expect(produced.plain).toEqual([]); + expect(produced.skipSelf).toEqual([]); + }); +}); + +describe("token declaration after implementation", () => { + it("accepts a bare declaration after a value provider for the same token", () => { + const TOKEN = new NodeToken("DECLARED_AFTER_VALUE"); + + const container = new NodeContainer(); + container.provide(TOKEN.withValue(7)); + expect(() => container.provide(TOKEN)).not.toThrow(); + + container.bootstrap(); + expect(container.get(TOKEN)).toBe(7); + }); + + it("accepts a bare declaration after a provider for the same multi token", () => { + const MULTI = new MultiNodeToken("MULTI_DECLARED_AFTER_VALUE"); + + const container = new NodeContainer(); + container.provide(MULTI.withValue(1)); + expect(() => container.provide(MULTI)).not.toThrow(); + + container.bootstrap(); + expect(container.get(MULTI)).toEqual([1]); + }); + + it("still throws on a duplicate bare declaration", () => { + const TOKEN = new NodeToken("DOUBLE_DECLARATION"); + const MULTI = new MultiNodeToken("DOUBLE_MULTI_DECLARATION"); + + const container = new NodeContainer(); + container.provide(TOKEN); + container.provide(MULTI); + + expect(() => container.provide(TOKEN)).toThrow(); + expect(() => container.provide(MULTI)).toThrow(); + }); +}); + +describe("diagnostics report consistency", () => { + afterEach(() => { + (Illuma as any).__resetPlugins(); + }); + + it("does not report built-in Injector and LifecycleRef as unused", () => { + const reports: iDiagnosticsReport[] = []; + Illuma.extendDiagnostics({ onReport: (r) => reports.push(r) }); + + const container = new NodeContainer(); + container.bootstrap(); + + expect(reports).toHaveLength(1); + expect(reports[0].unusedNodes).toEqual([]); + }); +}); + +describe("allocation counting accuracy", () => { + it("counts a dependency injected twice by the same factory once", () => { + const DEP = new NodeToken("DEP_TWICE"); + const HOST = new NodeToken("HOST_TWICE"); + + const container = new NodeContainer(); + container.provide(DEP.withValue("dep")); + container.provide( + HOST.withFactory(() => { + nodeInject(DEP); + nodeInject(DEP); + return "host"; + }), + ); + container.bootstrap(); + + const rootNode = (container as any)._rootNode; + expect(rootNode.find(DEP).allocations).toBe(1); + }); + + it("does not double-count a node listed twice in a multi token", () => { + const SINGLE = new NodeToken("MULTI_MEMBER"); + const MULTI = new MultiNodeToken("MULTI_DOUBLE_ADD"); + + const container = new NodeContainer(); + container.provide(SINGLE.withValue("member")); + container.provide({ provide: MULTI, alias: SINGLE }); + container.bootstrap(); + + const rootNode = (container as any)._rootNode; + const multiNode = rootNode.find(MULTI); + multiNode.addDependency(rootNode.find(SINGLE)); + multiNode.addDependency(rootNode.find(SINGLE)); + + expect(rootNode.find(SINGLE).allocations).toBe(1); + }); + + it("resolves Injector via get() when a factory also injects it", () => { + const HOST = new NodeToken("INJECTOR_GET_HOST"); + + const container = new NodeContainer(); + container.provide( + HOST.withFactory(() => { + nodeInject(Injector); + return "host"; + }), + ); + container.bootstrap(); + + expect(container.get(Injector)).toBeDefined(); + }); + + it("keeps the Injector allocation count intact", () => { + const HOST = new NodeToken("INJECTOR_HOST"); + + const container = new NodeContainer(); + container.provide( + HOST.withFactory(() => { + nodeInject(Injector); + return "host"; + }), + ); + container.bootstrap(); + + const rootNode = (container as any)._rootNode; + expect(rootNode.find(Injector).allocations).toBe(1); + }); +}); diff --git a/src/lib/container/tests/cross-container-isolation.spec.ts b/src/lib/container/tests/cross-container-isolation.spec.ts new file mode 100644 index 0000000..0d0b438 --- /dev/null +++ b/src/lib/container/tests/cross-container-isolation.spec.ts @@ -0,0 +1,125 @@ +import { afterEach, describe, expect, it } from "vitest"; +import { MultiNodeToken, NodeToken, nodeInject } from "../../api"; +import { Illuma } from "../../global"; +import type { iMiddleware } from "../../plugins/middlewares"; +import { NodeContainer } from "../container"; + +// Tags an object instance with a flag, so a test can observe which container's +// middleware chain actually wrapped a given node's factory. +const tagWith = + (flag: string): iMiddleware => + (params, next) => { + const instance = next(params) as any; + if (instance && typeof instance === "object") instance[flag] = true; + return instance; + }; + +describe("cross-container pool/middleware isolation", () => { + afterEach(() => { + (Illuma as any).__resetPlugins(); + }); + + it("a lazy parent's node injected by an instant child runs under the PARENT chain (#21)", () => { + const PTOK = new NodeToken>("CC_PTOK"); + let factoryRuns = 0; + + const parent = new NodeContainer({ instant: false }); + parent.registerMiddleware(tagWith("parentMw")); + parent.provide( + PTOK.withFactory(() => { + factoryRuns++; + return {}; + }), + ); + parent.bootstrap(); + // The provide()-time dependency-scan dry-run already executed the factory + // once (a throwaway, no middleware). Reset so we count only the real + // materialization in the tree. + factoryRuns = 0; + + const HOST = new NodeToken<{ p: Record }>("CC_HOST"); + const child = new NodeContainer({ parent, instant: true }); + child.registerMiddleware(tagWith("childMw")); + child.provide(HOST.withFactory(() => ({ p: nodeInject(PTOK) }))); + child.bootstrap(); + + const p = child.get(HOST).p; + expect(p.parentMw).toBe(true); // ran under the parent's middleware + expect(p.childMw).toBeUndefined(); // NOT contaminated by the child's middleware + expect(factoryRuns).toBe(1); // materialized exactly once + + // Same instance across the boundary. + expect(parent.get(PTOK)).toBe(p); + }); + + it("inherited parent multi members are not wrapped in child middleware (#8)", () => { + const M = new MultiNodeToken<{ name: string; childMw?: boolean; parentMw?: boolean }>( + "CC_MULTI", + ); + + const parent = new NodeContainer({ instant: false }); + parent.registerMiddleware(tagWith("parentMw")); + parent.provide(M.withFactory(() => ({ name: "p" }))); + parent.bootstrap(); + + const child = new NodeContainer({ parent, instant: true }); + child.registerMiddleware(tagWith("childMw")); + child.provide(M.withFactory(() => ({ name: "c" }))); + child.bootstrap(); + + const members = child.get(M); + const pMember = members.find((m) => m.name === "p"); + const cMember = members.find((m) => m.name === "c"); + + expect(pMember?.parentMw).toBe(true); + expect(pMember?.childMw).toBeUndefined(); // parent member keeps parent chain + expect(cMember?.childMw).toBe(true); // child member runs under child chain + + // The parent's own view of M is likewise un-contaminated. + expect(parent.get(M).every((m) => m.childMw === undefined)).toBe(true); + }); + + it("a parent token is not leaked into the child's own tree pool", () => { + const PTOK = new NodeToken>("CC_POOL_PTOK"); + const parent = new NodeContainer({ instant: false }); + parent.provide(PTOK.withFactory(() => ({}))); + parent.bootstrap(); + + const HOST = new NodeToken("CC_POOL_HOST"); + const child = new NodeContainer({ parent, instant: true }); + child.provide(HOST.withFactory(() => nodeInject(PTOK))); + child.bootstrap(); + + // The child reaches PTOK upstream; PTOK's node belongs to the parent's pool, + // not the child's own tree pool. + expect((child as any)._findNode(PTOK)).toBeNull(); + }); + + describe("post-bootstrap global middleware consistency (#39)", () => { + it("a global middleware registered after bootstrap applies to lazy get() (matching produce)", () => { + const TOKEN = new NodeToken>("CC_39_LAZY"); + const c = new NodeContainer({ instant: false }); + c.provide(TOKEN.withFactory(() => ({}))); + c.bootstrap(); + + Illuma.registerGlobalMiddleware(tagWith("lateMw")); + + const viaGet = c.get(TOKEN); + const viaProduce = c.produce(() => nodeInject(TOKEN, { optional: true })); + + expect(viaGet.lateMw).toBe(true); + expect((viaProduce as any).lateMw).toBe(true); + }); + + it("a global middleware registered after bootstrap does NOT retroactively wrap an instant node", () => { + const TOKEN = new NodeToken>("CC_39_INSTANT"); + const c = new NodeContainer(); // instant + c.provide(TOKEN.withFactory(() => ({}))); + c.bootstrap(); // node already materialized here + + Illuma.registerGlobalMiddleware(tagWith("lateMw")); + + expect(c.get(TOKEN).lateMw).toBeUndefined(); + }); + }); +}); diff --git a/src/lib/container/tests/hierarchy-resolution.spec.ts b/src/lib/container/tests/hierarchy-resolution.spec.ts new file mode 100644 index 0000000..bb47c9d --- /dev/null +++ b/src/lib/container/tests/hierarchy-resolution.spec.ts @@ -0,0 +1,172 @@ +import { describe, expect, it } from "vitest"; +import { MultiNodeToken, NodeToken, nodeInject } from "../../api"; +import { NodeContainer } from "../container"; + +// Resolution must traverse the full ancestor chain up to the root, not just the +// immediate parent (RESOLUTION_MODIFIERS.md: "traverse up the container +// hierarchy ... until it finds a provider or reaches the root container"). + +describe("multi-level hierarchy: single tokens", () => { + function threeLevels() { + const root = new NodeContainer(); + const ROOT_TOK = new NodeToken("HR_ROOT_TOK"); + root.provide(ROOT_TOK.withValue("from-root")); + root.bootstrap(); + const child = root.child() as NodeContainer; + child.bootstrap(); + const grandchild = child.child() as NodeContainer; + grandchild.bootstrap(); + return { root, child, grandchild, ROOT_TOK }; + } + + it("a grandchild resolves a non-singleton token provided on the root via get()", () => { + const { grandchild, ROOT_TOK } = threeLevels(); + expect(grandchild.get(ROOT_TOK)).toBe("from-root"); + }); + + it("a grandchild factory can depend on a root-provided non-singleton token", () => { + const ROOT_TOK = new NodeToken("HR_DEP_ROOT"); + const LOCAL = new NodeToken("HR_DEP_LOCAL"); + + const root = new NodeContainer(); + root.provide(ROOT_TOK.withValue(40)); + root.bootstrap(); + + const child = root.child() as NodeContainer; + child.bootstrap(); + + const grandchild = child.child() as NodeContainer; + grandchild.provide(LOCAL.withFactory(() => nodeInject(ROOT_TOK) + 2)); + expect(() => grandchild.bootstrap()).not.toThrow(); + expect(grandchild.get(LOCAL)).toBe(42); + }); + + it("the nearest ancestor wins when the token is provided at multiple levels", () => { + const TOK = new NodeToken("HR_NEAREST"); + + const root = new NodeContainer(); + root.provide(TOK.withValue("root")); + root.bootstrap(); + + const child = root.child() as NodeContainer; + child.provide(TOK.withValue("child")); + child.bootstrap(); + + const grandchild = child.child() as NodeContainer; + grandchild.bootstrap(); + + expect(grandchild.get(TOK)).toBe("child"); + }); + + it("still resolves from the immediate parent (regression)", () => { + const TOK = new NodeToken("HR_PARENT_ONLY"); + const parent = new NodeContainer(); + parent.provide(TOK.withValue("parent")); + parent.bootstrap(); + const child = parent.child() as NodeContainer; + child.bootstrap(); + expect(child.get(TOK)).toBe("parent"); + }); + + it("still throws notFound for a token provided nowhere in the chain", () => { + const { grandchild } = threeLevels(); + const MISSING = new NodeToken("HR_MISSING"); + expect(() => grandchild.get(MISSING)).toThrow(); + expect(grandchild.get(MISSING, { optional: true })).toBeNull(); + }); +}); + +describe("multi-level hierarchy: multi tokens", () => { + it("a grandchild get(MULTI) sees ancestor members instead of []", () => { + const M = new MultiNodeToken("HR_MULTI_ROOT"); + + const root = new NodeContainer(); + root.provide(M.withValue("root-member")); + root.bootstrap(); + + const child = root.child() as NodeContainer; + child.bootstrap(); + const grandchild = child.child() as NodeContainer; + grandchild.bootstrap(); + + expect(grandchild.get(M)).toEqual(["root-member"]); + }); + + it("aggregates members across all three levels", () => { + const M = new MultiNodeToken("HR_MULTI_AGG"); + + const root = new NodeContainer(); + root.provide(M.withValue("root")); + root.bootstrap(); + + const child = root.child() as NodeContainer; + child.provide(M.withValue("child")); + child.bootstrap(); + + const grandchild = child.child() as NodeContainer; + grandchild.provide(M.withValue("grandchild")); + grandchild.bootstrap(); + + expect(grandchild.get(M).sort()).toEqual(["child", "grandchild", "root"]); + }); + + it("aggregates across an empty intermediate level", () => { + const M = new MultiNodeToken("HR_MULTI_SKIP"); + + const root = new NodeContainer(); + root.provide(M.withValue("root")); + root.bootstrap(); + + const child = root.child() as NodeContainer; // no members here + child.bootstrap(); + + const grandchild = child.child() as NodeContainer; + grandchild.provide(M.withValue("grandchild")); + grandchild.bootstrap(); + + expect(grandchild.get(M).sort()).toEqual(["grandchild", "root"]); + }); + + it("returns [] for a multi token provided nowhere in the chain", () => { + const M = new MultiNodeToken("HR_MULTI_NONE"); + const root = new NodeContainer(); + root.bootstrap(); + const child = root.child() as NodeContainer; + child.bootstrap(); + const grandchild = child.child() as NodeContainer; + grandchild.bootstrap(); + expect(grandchild.get(M)).toEqual([]); + }); +}); + +describe("multi-level hierarchy: lazy (instant:false) mode", () => { + it("a grandchild resolves a root-provided non-singleton in lazy mode", () => { + const ROOT_TOK = new NodeToken("HR_LAZY_ROOT"); + + const root = new NodeContainer({ instant: false }); + root.provide(ROOT_TOK.withValue("from-root")); + root.bootstrap(); + const child = root.child() as NodeContainer; + child.bootstrap(); + const grandchild = child.child() as NodeContainer; + grandchild.bootstrap(); + + expect(grandchild.get(ROOT_TOK)).toBe("from-root"); + }); + + it("a lazy grandchild factory depends on a root-provided token", () => { + const ROOT_TOK = new NodeToken("HR_LAZY_DEP_ROOT"); + const LOCAL = new NodeToken("HR_LAZY_DEP_LOCAL"); + + const root = new NodeContainer({ instant: false }); + root.provide(ROOT_TOK.withValue(40)); + root.bootstrap(); + const child = root.child() as NodeContainer; + child.bootstrap(); + const grandchild = child.child() as NodeContainer; + grandchild.provide(LOCAL.withFactory(() => nodeInject(ROOT_TOK) + 2)); + grandchild.bootstrap(); + + expect(grandchild.get(LOCAL)).toBe(42); + }); +}); diff --git a/src/lib/container/tests/lifecycle.spec.ts b/src/lib/container/tests/lifecycle.spec.ts index 78972d3..2dd2f17 100644 --- a/src/lib/container/tests/lifecycle.spec.ts +++ b/src/lib/container/tests/lifecycle.spec.ts @@ -62,3 +62,28 @@ describe("Container lifecycle", () => { expect(() => container.destroy()).toThrowError(InjectionError); }); }); + +describe("cascade bootstrap hook cleanup (#38)", () => { + it("releases the parent's bootstrap-cascade hook after the child is bootstrapped", () => { + const parent = new NodeContainer(); + const child = parent.child() as NodeContainer; + const lc = (parent as any)._lifecycle; + + // Created before parent bootstrap → the child registered an onChildBootstrap hook. + expect(lc._bootstrapChildCallbacks.size).toBe(1); + + parent.bootstrap(); + + expect(child.bootstrapped).toBe(true); + // The hook is dropped once the child has bootstrapped (no parent retention). + expect(lc._bootstrapChildCallbacks.size).toBe(0); + }); + + it("still tears down a cascade-bootstrapped child on parent destroy", () => { + const parent = new NodeContainer(); + const child = parent.child() as NodeContainer; + parent.bootstrap(); + parent.destroy(); + expect(child.destroyed).toBe(true); + }); +}); diff --git a/src/lib/container/tests/resolution-modifiers.spec.ts b/src/lib/container/tests/resolution-modifiers.spec.ts new file mode 100644 index 0000000..de15745 --- /dev/null +++ b/src/lib/container/tests/resolution-modifiers.spec.ts @@ -0,0 +1,292 @@ +import { describe, expect, it } from "vitest"; +import { MultiNodeToken, NodeToken, nodeInject } from "../../api"; +import { createTestFactory } from "../../testkit/helpers"; +import { injectDefer } from "../../utils/defer"; +import { NodeContainer } from "../container"; + +describe("self:true on a multi token returns local-only members (#3)", () => { + function parentChild() { + const M = new MultiNodeToken("RM_SELF_MULTI"); + const parent = new NodeContainer(); + parent.provide(M.withValue("parent")); + parent.bootstrap(); + const child = parent.child() as NodeContainer; + child.provide(M.withValue("child")); + child.bootstrap(); + return { M, parent, child }; + } + + it("get(M, { self: true }) returns only the child's own members", () => { + const { M, child } = parentChild(); + expect(child.get(M, { self: true })).toEqual(["child"]); + }); + + it("get(M) without self still returns inherited + local members", () => { + const { M, child } = parentChild(); + expect(child.get(M).sort()).toEqual(["child", "parent"]); + }); + + it("factory nodeInject(M, { self: true }) is local-only", () => { + const M = new MultiNodeToken("RM_SELF_FACT_MULTI"); + const HOST = new NodeToken("RM_SELF_FACT_HOST"); + + const parent = new NodeContainer(); + parent.provide(M.withValue("parent")); + parent.bootstrap(); + + const child = parent.child() as NodeContainer; + child.provide(M.withValue("child")); + child.provide(HOST.withFactory(() => nodeInject(M, { self: true }))); + child.bootstrap(); + + expect(child.get(HOST)).toEqual(["child"]); + }); + + it("produce(() => nodeInject(M, { self: true })) is local-only", () => { + const { M, child } = parentChild(); + expect(child.produce(() => nodeInject(M, { self: true }))).toEqual(["child"]); + }); + + it("self:true returns [] when the child has no local members", () => { + const M = new MultiNodeToken("RM_SELF_EMPTY"); + const parent = new NodeContainer(); + parent.provide(M.withValue("parent")); + parent.bootstrap(); + const child = parent.child() as NodeContainer; + child.bootstrap(); + expect(child.get(M, { self: true })).toEqual([]); + }); + + it("skipSelf still returns ancestor-only members (regression)", () => { + const { M, child } = parentChild(); + expect(child.get(M, { skipSelf: true })).toEqual(["parent"]); + }); + + it("3-level: grandchild self returns only its own members", () => { + const M = new MultiNodeToken("RM_SELF_3LVL"); + const root = new NodeContainer(); + root.provide(M.withValue("root")); + root.bootstrap(); + const child = root.child() as NodeContainer; + child.provide(M.withValue("child")); + child.bootstrap(); + const grandchild = child.child() as NodeContainer; + grandchild.provide(M.withValue("grandchild")); + grandchild.bootstrap(); + + expect(grandchild.get(M, { self: true })).toEqual(["grandchild"]); + expect(grandchild.get(M).sort()).toEqual(["child", "grandchild", "root"]); + }); +}); + +describe("same token injected in two scopes in one factory (#20)", () => { + it("plain and skipSelf injections of the same token resolve to different scopes", () => { + const A = new NodeToken("RM_TWOSCOPE_A"); + const HOST = new NodeToken<{ local: string; parent: string }>("RM_TWOSCOPE_HOST"); + + const parent = new NodeContainer(); + parent.provide(A.withValue("parent")); + parent.bootstrap(); + + const child = parent.child() as NodeContainer; + child.provide(A.withValue("child")); + child.provide( + HOST.withFactory(() => ({ + local: nodeInject(A), + parent: nodeInject(A, { skipSelf: true }), + })), + ); + child.bootstrap(); + + const host = child.get(HOST); + expect(host.local).toBe("child"); + expect(host.parent).toBe("parent"); + }); + + it("self and skipSelf injections of the same token do not collide", () => { + const A = new NodeToken("RM_TWOSCOPE_SELF_A"); + const HOST = new NodeToken<{ self: string; parent: string }>("RM_TWOSCOPE_SELF_HOST"); + + const parent = new NodeContainer(); + parent.provide(A.withValue("parent")); + parent.bootstrap(); + + const child = parent.child() as NodeContainer; + child.provide(A.withValue("child")); + child.provide( + HOST.withFactory(() => ({ + self: nodeInject(A, { self: true }), + parent: nodeInject(A, { skipSelf: true }), + })), + ); + child.bootstrap(); + + const host = child.get(HOST); + expect(host.self).toBe("child"); + expect(host.parent).toBe("parent"); + }); + + it("multi token: plain (aggregated) and skipSelf (ancestor-only) in one factory", () => { + const M = new MultiNodeToken("RM_TWOSCOPE_MULTI"); + const HOST = new NodeToken<{ all: string[]; parent: string[] }>( + "RM_TWOSCOPE_MULTI_HOST", + ); + + const parent = new NodeContainer(); + parent.provide(M.withValue("parent")); + parent.bootstrap(); + + const child = parent.child() as NodeContainer; + child.provide(M.withValue("child")); + child.provide( + HOST.withFactory(() => ({ + all: nodeInject(M), + parent: nodeInject(M, { skipSelf: true }), + })), + ); + child.bootstrap(); + + const host = child.get(HOST); + expect(host.all.sort()).toEqual(["child", "parent"]); + expect(host.parent).toEqual(["parent"]); + }); + + it("a token injected only via skipSelf still resolves (no plain slot needed)", () => { + const A = new NodeToken("RM_SKIPONLY_A"); + const HOST = new NodeToken("RM_SKIPONLY_HOST"); + + const parent = new NodeContainer(); + parent.provide(A.withValue("parent")); + parent.bootstrap(); + + const child = parent.child() as NodeContainer; + child.provide(A.withValue("child")); + child.provide(HOST.withFactory(() => nodeInject(A, { skipSelf: true }))); + child.bootstrap(); + + expect(child.get(HOST)).toBe("parent"); + }); +}); + +describe("produce()/get() parity on skipSelf + root singleton (#36)", () => { + it("produce resolves a root singleton under skipSelf, matching get", () => { + const SINGLETON = new NodeToken("RM_SINGLETON", { + singleton: true, + factory: () => "singleton-value", + }); + + const c = new NodeContainer(); + c.provide(SINGLETON); + c.bootstrap(); + + const viaGet = c.get(SINGLETON, { skipSelf: true }); + const viaProduce = c.produce(() => nodeInject(SINGLETON, { skipSelf: true })); + + expect(viaGet).toBe("singleton-value"); + expect(viaProduce).toBe(viaGet); + }); + + it("produce and get agree on a plain skipSelf miss (both behave the same)", () => { + const PLAIN = new NodeToken("RM_PLAIN"); + const c = new NodeContainer(); + c.provide(PLAIN.withValue("local")); + c.bootstrap(); + + // skipSelf on a root container with no parent: neither resolves the local value. + const getThrew = (() => { + try { + c.get(PLAIN, { skipSelf: true }); + return false; + } catch { + return true; + } + })(); + const produceThrew = (() => { + try { + c.produce(() => nodeInject(PLAIN, { skipSelf: true })); + return false; + } catch { + return true; + } + })(); + + expect(produceThrew).toBe(getThrew); + }); +}); + +describe("injectDefer forwards skipSelf/self modifiers (#18)", () => { + it("a deferred skipSelf injection resolves from the parent, not self", () => { + const TOK = new NodeToken("RM_DEFER_TOK"); + const HOST = new NodeToken<() => string>("RM_DEFER_HOST"); + + const parent = new NodeContainer(); + parent.provide(TOK.withValue("parent")); + parent.bootstrap(); + + const child = parent.child() as NodeContainer; + child.provide(TOK.withValue("child")); + child.provide(HOST.withFactory(() => injectDefer(TOK, { skipSelf: true }))); + child.bootstrap(); + + const deferred = child.get(HOST); + expect(deferred()).toBe("parent"); + }); + + it("a deferred self injection resolves locally only", () => { + const TOK = new NodeToken("RM_DEFER_SELF_TOK"); + const HOST = new NodeToken<() => string>("RM_DEFER_SELF_HOST"); + + const parent = new NodeContainer(); + parent.provide(TOK.withValue("parent")); + parent.bootstrap(); + + const child = parent.child() as NodeContainer; + child.provide(TOK.withValue("child")); + child.provide(HOST.withFactory(() => injectDefer(TOK, { self: true }))); + child.bootstrap(); + + expect(child.get(HOST)()).toBe("child"); + }); + + it("deferred optional still returns null when unprovided", () => { + const MISSING = new NodeToken("RM_DEFER_MISSING"); + const HOST = new NodeToken<() => string | null>("RM_DEFER_OPT_HOST"); + + const c = new NodeContainer(); + c.provide(HOST.withFactory(() => injectDefer(MISSING, { optional: true }))); + c.bootstrap(); + + expect(c.get(HOST)()).toBeNull(); + }); +}); + +describe("testkit Spectator forwards modifiers (#27)", () => { + it("spectator.nodeInject honors skipSelf (standalone container has no parent)", () => { + const TARGET = new NodeToken("RM_TK_TARGET", { factory: () => "target" }); + const DEP = new NodeToken("RM_TK_DEP"); + + const factory = createTestFactory({ + target: TARGET, + provide: [DEP.withValue("dep")], + }); + const spectator = factory(); + + expect(spectator.nodeInject(DEP)).toBe("dep"); + // skipSelf is now forwarded: a standalone container resolves nothing upstream. + expect(() => spectator.nodeInject(DEP, { skipSelf: true })).toThrow(); + }); + + it("spectator.nodeInject still honors optional", () => { + const TARGET = new NodeToken("RM_TK_OPT_TARGET", { + factory: () => "target", + }); + const MISSING = new NodeToken("RM_TK_MISSING"); + + const factory = createTestFactory({ + target: TARGET, + }); + const spectator = factory(); + + expect(spectator.nodeInject(MISSING, { optional: true })).toBeNull(); + }); +}); diff --git a/src/lib/container/tests/singleton.spec.ts b/src/lib/container/tests/singleton.spec.ts index ac1ed7e..431b5e2 100644 --- a/src/lib/container/tests/singleton.spec.ts +++ b/src/lib/container/tests/singleton.spec.ts @@ -488,3 +488,33 @@ describe("singletons", () => { }); }); }); + +describe("on-demand singleton factory failure (#11)", () => { + it("does not strand dead nodes in the root when the singleton factory throws", () => { + let boom = true; + const SING = new NodeToken("STRAND_SINGLETON", { + singleton: true, + factory: () => { + if (boom) throw new Error("sing boom"); + return 42; + }, + }); + + const c = new NodeContainer(); // SING resolved on-demand, not provided + c.bootstrap(); + + const root = (c as any)._rootNode; + const depsBefore = root.dependencies.size; + + expect(() => c.get(SING)).toThrow("sing boom"); + expect(() => c.get(SING)).toThrow("sing boom"); + expect(() => c.get(SING)).toThrow("sing boom"); + + // Failed attempts must not accumulate dead nodes in the long-lived root. + expect(root.dependencies.size).toBe(depsBefore); + + // Once the transient failure clears, resolution succeeds. + boom = false; + expect(c.get(SING)).toBe(42); + }); +}); diff --git a/src/lib/context/context.ts b/src/lib/context/context.ts index 7d7eaf0..833db8a 100644 --- a/src/lib/context/context.ts +++ b/src/lib/context/context.ts @@ -4,12 +4,16 @@ import { Illuma } from "../global/global"; import type { iContextScanner } from "../plugins/context/types"; import type { iInjectionNode } from "./types"; -interface iInjectionContextState { +interface iInjectionContextFrame { contextOpen: boolean; injector: InjectorFn | null; calls: Set>; } +interface iInjectionContextState extends iInjectionContextFrame { + stack: iInjectionContextFrame[]; +} + const INJECTION_CONTEXT_KEY = Symbol.for("@illuma/core/InjectionContext"); const INJECTION_CONTEXT_STATE_KEY = Symbol.for("@illuma/core/InjectionContextState"); @@ -25,10 +29,14 @@ if (!contextGlobal[INJECTION_CONTEXT_STATE_KEY]) { contextOpen: false, injector: null, calls: new Set>(), + stack: [], }; } const injectionContextState = contextGlobal[INJECTION_CONTEXT_STATE_KEY]; +// Backfill if state was created by an older version of @illuma/core sharing +// the same globalThis (npm + jsr, dual-installs, etc.) +injectionContextState.stack ??= []; /** * Internal context manager for tracking dependency injections during factory execution. @@ -77,15 +85,23 @@ abstract class InjectionContextBase { } /** - * Opens a new injection context. - * Resets the calls set and sets the injector if provided. + * Opens a new injection context, suspending the current one. + * The previous context (open flag, injector, and collected calls) is pushed + * onto a stack and restored by the matching {@link close}, so a factory that + * triggers a nested instantiation/scan does not clobber its outer context. * * @param injector - Optional injector function to use for resolving dependencies */ public static open(injector?: InjectorFn): void { - InjectionContextBase._calls.clear(); - InjectionContextBase.contextOpen = true; - InjectionContextBase.injector = injector || null; + injectionContextState.stack.push({ + contextOpen: injectionContextState.contextOpen, + injector: injectionContextState.injector, + calls: injectionContextState.calls, + }); + + injectionContextState.contextOpen = true; + injectionContextState.injector = injector || null; + injectionContextState.calls = new Set>(); } /** @@ -99,27 +115,41 @@ abstract class InjectionContextBase { public static scanInto(factory: any, target: Set>): void { if (typeof factory !== "function") return; InjectionContextBase.open(); + const baseDepth = injectionContextState.stack.length; + // close() must run on every path: a throwing context scanner would + // otherwise orphan the frame opened above and leave the context corrupted + // for every subsequent instantiation sharing this globalThis state. try { - factory(); - } catch { - // No-op - } + try { + factory(); + } catch { + // No-op: dry-run, unresolved injections are expected here + } + + for (const scanner of InjectionContextBase._scanners) { + try { + const scanned = scanner.scan(factory); + for (const node of scanned) InjectionContextBase._calls.add(node); + } catch (err) { + // A misbehaving scanner must not break provide() or corrupt the + // shared context, but the error must not vanish silently. + Illuma.logger.error( + "[Illuma] A context scanner threw during dependency scan; its injections were skipped:", + err, + ); + } + } - const scanners = InjectionContextBase._scanners; - if (!scanners.length) { InjectionContextBase._flushInto(target); + } finally { + // Discard any frames a re-entrant scanner opened without closing, then + // close this scan's own frame, so the stack returns to its prior depth. + while (injectionContextState.stack.length > baseDepth) { + InjectionContextBase.close(); + } InjectionContextBase.close(); - return; - } - - for (const scanner of scanners) { - const scanned = scanner.scan(factory); - for (const node of scanned) InjectionContextBase._calls.add(node); } - - InjectionContextBase._flushInto(target); - InjectionContextBase.close(); } /** @@ -152,11 +182,23 @@ abstract class InjectionContextBase { } } - /** Closes the current injection context. */ + /** + * Closes the current injection context, restoring the one suspended by the + * matching {@link open}. Falls back to a fully-closed state when there is no + * outer context to restore. + */ public static close(): void { - InjectionContextBase.contextOpen = false; - InjectionContextBase._calls.clear(); - InjectionContextBase.injector = null; + const previous = injectionContextState.stack.pop(); + if (previous) { + injectionContextState.contextOpen = previous.contextOpen; + injectionContextState.injector = previous.injector; + injectionContextState.calls = previous.calls; + return; + } + + injectionContextState.contextOpen = false; + injectionContextState.injector = null; + injectionContextState.calls = new Set>(); } /** diff --git a/src/lib/errors.spec.ts b/src/lib/errors.spec.ts index a773a22..fb6dd01 100644 --- a/src/lib/errors.spec.ts +++ b/src/lib/errors.spec.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from "vitest"; import { MultiNodeToken, NodeInjectable, NodeToken, nodeInject } from "./api"; import { NodeContainer } from "./container"; -import { InjectionError } from "./errors"; +import { ERR_CODES, InjectionError } from "./errors"; describe("error handling", () => { it("should throw on duplicate token provider", () => { @@ -146,3 +146,36 @@ describe("error handling", () => { expect(() => container.bootstrap()).toThrow(InjectionError.notFound(missing)); }); }); + +describe("InjectionError factories", () => { + it("builds a global token conflict error", () => { + const error = InjectionError.globalTokenConflict( + "seam.alpha", + "NodeTokenImpl", + "MultiNodeTokenImpl", + ); + + expect(error).toBeInstanceOf(InjectionError); + expect(error.code).toBe(ERR_CODES.GLOBAL_TOKEN_CONFLICT); + expect(error.message).toContain('Global token "seam.alpha" is already registered'); + expect(error.message).toContain("[i600]"); + }); + + it("builds a middleware next() reuse error", () => { + const error = InjectionError.middlewareNextReused(); + + expect(error).toBeInstanceOf(InjectionError); + expect(error.code).toBe(ERR_CODES.MIDDLEWARE_NEXT_REUSED); + expect(error.message).toContain("next() was called more than once"); + expect(error.message).toContain("[i700]"); + }); + + it("builds an unknown ProtoNode error", () => { + const error = InjectionError.unknownProtoNode(); + + expect(error).toBeInstanceOf(InjectionError); + expect(error.code).toBe(ERR_CODES.UNKNOWN_PROTO_NODE); + expect(error.message).toContain("Unknown ProtoNode type"); + expect(error.message).toContain("[i800]"); + }); +}); diff --git a/src/lib/errors.ts b/src/lib/errors.ts index 198a8a3..0305f13 100644 --- a/src/lib/errors.ts +++ b/src/lib/errors.ts @@ -31,6 +31,15 @@ export const ERR_CODES = { CALLED_UTILS_OUTSIDE_CONTEXT: 502, INSTANCE_ACCESS_FAILED: 503, ACCESS_FAILED: 504, + + // Token errors + GLOBAL_TOKEN_CONFLICT: 600, + + // Middleware errors + MIDDLEWARE_NEXT_REUSED: 700, + + // Internal invariant errors + UNKNOWN_PROTO_NODE: 800, } as const; /** @@ -201,6 +210,32 @@ export class InjectionError extends Error { "Failed to access the requested instance due to an unknown error.", ); } + + // Token errors + public static globalTokenConflict( + name: string, + existing: string, + attempted: string | undefined, + ): InjectionError { + return new InjectionError( + ERR_CODES.GLOBAL_TOKEN_CONFLICT, + `Global token "${name}" is already registered as ${existing}; ` + + `cannot redeclare it as ${attempted}.`, + ); + } + + // Middleware errors + public static middlewareNextReused(): InjectionError { + return new InjectionError( + ERR_CODES.MIDDLEWARE_NEXT_REUSED, + "Middleware next() was called more than once.", + ); + } + + // Internal invariant errors + public static unknownProtoNode(): InjectionError { + return new InjectionError(ERR_CODES.UNKNOWN_PROTO_NODE, "Unknown ProtoNode type."); + } } /** diff --git a/src/lib/global/global.ts b/src/lib/global/global.ts index 52211b5..75664d5 100644 --- a/src/lib/global/global.ts +++ b/src/lib/global/global.ts @@ -153,7 +153,9 @@ abstract class IllumaBase { } protected static onReport(report: iDiagnosticsReport): void { - for (const diag of IllumaBase._diagnostics) diag.onReport(report); + // Iterate a snapshot so a reporter that registers another module during + // onReport doesn't mutate the set mid-iteration. + for (const diag of Array.from(IllumaBase._diagnostics)) diag.onReport(report); } /** @@ -173,6 +175,11 @@ abstract class IllumaBase { IllumaBase._scanners.length = 0; IllumaBase._middlewares.length = 0; illumaState.logger = defaultLogger; + // Clear the built-in diagnostics idempotency flag too (referenced by its + // shared symbol to avoid a circular import) so a reset fully re-arms it. + (globalThis as Record)[ + Symbol.for("@illuma/core/DiagnosticsEnabled") + ] = false; } } diff --git a/src/lib/plugins/diagnostics/built-in.ts b/src/lib/plugins/diagnostics/built-in.ts index 2118fa8..8d0f515 100644 --- a/src/lib/plugins/diagnostics/built-in.ts +++ b/src/lib/plugins/diagnostics/built-in.ts @@ -2,12 +2,18 @@ import { Illuma } from "../../global"; import { performanceDiagnostics } from "../middlewares/diagnostics.middleware"; import { DiagnosticsDefaultReporter } from "./default-impl"; -const state = { enabled: false }; +// The idempotency flag lives on globalThis (like the registries it guards) so a +// dual-installed copy of @illuma/core can't double-register the reporter + +// middleware, and a global plugin reset clears it too. +export const DIAGNOSTICS_ENABLED_KEY = Symbol.for("@illuma/core/DiagnosticsEnabled"); +const diagGlobal = globalThis as typeof globalThis & { + [DIAGNOSTICS_ENABLED_KEY]?: boolean; +}; /** @internal */ export function enableIllumaDiagnostics() { - if (state.enabled) return; - state.enabled = true; + if (diagGlobal[DIAGNOSTICS_ENABLED_KEY]) return; + diagGlobal[DIAGNOSTICS_ENABLED_KEY] = true; Illuma.extendDiagnostics(new DiagnosticsDefaultReporter()); Illuma.registerGlobalMiddleware(performanceDiagnostics); @@ -18,5 +24,5 @@ export function enableIllumaDiagnostics() { * Reset diagnostics state (for testing only) */ export function __resetDiagnosticsState() { - state.enabled = false; + diagGlobal[DIAGNOSTICS_ENABLED_KEY] = false; } diff --git a/src/lib/plugins/diagnostics/diagnostics.spec.ts b/src/lib/plugins/diagnostics/diagnostics.spec.ts index 3ee330f..08bdcb3 100644 --- a/src/lib/plugins/diagnostics/diagnostics.spec.ts +++ b/src/lib/plugins/diagnostics/diagnostics.spec.ts @@ -339,9 +339,8 @@ describe("Plugin: Diagnostics", () => { container.bootstrap(); - // Multi token and LifecycleRef should be unused if not injected expect(consoleLogSpy).toHaveBeenCalledWith( - expect.stringContaining("2 were not used"), + expect.stringContaining("1 were not used"), ); expect(consoleLogSpy).toHaveBeenCalledWith(expect.stringContaining("Multi")); }); @@ -402,3 +401,61 @@ describe("Plugin: Diagnostics", () => { expect(typeof report.bootstrapDuration).toBe("number"); }); }); + +describe("diagnostics idempotency across a plugin reset (#30)", () => { + afterEach(() => { + (Illuma as any).__resetPlugins(); + }); + + it("re-enables diagnostics after __resetPlugins (no stale idempotency flag)", () => { + builtIn.enableIllumaDiagnostics(); + expect(Illuma.hasDiagnostics()).toBe(true); + + (Illuma as any).__resetPlugins(); + expect(Illuma.hasDiagnostics()).toBe(false); + + // Before the fix this was a no-op (module-local flag stayed true), leaving + // diagnostics silently disabled. + builtIn.enableIllumaDiagnostics(); + expect(Illuma.hasDiagnostics()).toBe(true); + }); +}); + +describe("diagnostics report consistency (#37 / #34)", () => { + afterEach(() => { + (Illuma as any).__resetPlugins(); + }); + + it("totalNodes excludes the Injector/LifecycleRef built-ins, matching the unused population (#37)", () => { + const reports: any[] = []; + Illuma.extendDiagnostics({ onReport: (r: any) => reports.push(r) }); + + const T = new NodeToken("DIAG_TOTAL_T"); + const c = new NodeContainer(); + c.provide(T.withValue("v")); // one user node, unused + c.bootstrap(); + + expect(reports).toHaveLength(1); + // Only the user node is counted (not Injector + LifecycleRef → would be 3). + expect(reports[0].totalNodes).toBe(1); + expect(reports[0].unusedNodes).toHaveLength(1); + }); + + it("a reporter registering another module during onReport is not pulled into the same pass (#34)", () => { + const calls: string[] = []; + const second = { onReport: () => calls.push("second") }; + const first = { + onReport: () => { + calls.push("first"); + Illuma.extendDiagnostics(second); + }, + }; + Illuma.extendDiagnostics(first); + + const c = new NodeContainer(); + c.bootstrap(); + + // The snapshot iteration must not run `second` during the pass that registered it. + expect(calls).toEqual(["first"]); + }); +}); diff --git a/src/lib/plugins/middlewares/middlewares.spec.ts b/src/lib/plugins/middlewares/middlewares.spec.ts index 23677ca..f8427c6 100644 --- a/src/lib/plugins/middlewares/middlewares.spec.ts +++ b/src/lib/plugins/middlewares/middlewares.spec.ts @@ -1,6 +1,7 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import { NodeToken } from "../../api/token"; import { NodeContainer } from "../../container/container"; +import { InjectionError } from "../../errors"; import { Illuma } from "../../global"; import type { iMiddleware } from "./types"; @@ -185,3 +186,41 @@ describe("Plugin: Middlewares", () => { expect(logs).toContain("deferred"); }); }); + +describe("middleware next() called more than once (#31)", () => { + it("rejects a middleware that calls next() twice instead of silently skipping downstream", () => { + const Token = new NodeToken("MW_DOUBLE_NEXT"); + let downstreamRuns = 0; + + const container = new NodeContainer(); + const doubleNext: iMiddleware = (params, next) => { + next(params); + return next(params); // second call must throw, not silently skip downstream + }; + const counter: iMiddleware = (params, next) => { + downstreamRuns++; + return next(params); + }; + + container.registerMiddleware(doubleNext); + container.registerMiddleware(counter); + container.provide(Token.withValue("v")); + + expect(() => container.bootstrap()).toThrow(InjectionError.middlewareNextReused()); + // The downstream middleware ran from the FIRST next() (not skipped). + expect(downstreamRuns).toBeGreaterThanOrEqual(1); + }); + + it("composes a well-behaved chain in registration order", () => { + const Token = new NodeToken("MW_ORDER"); + + const container = new NodeContainer(); + // First-registered is outermost; each wraps the inner result. + container.registerMiddleware((p, next) => `${next(p)}-a`); + container.registerMiddleware((p, next) => `${next(p)}-b`); + container.provide(Token.withValue("base")); + container.bootstrap(); + + expect(container.get(Token)).toBe("base-b-a"); + }); +}); diff --git a/src/lib/plugins/middlewares/runner.ts b/src/lib/plugins/middlewares/runner.ts index d8b19f3..8f408fd 100644 --- a/src/lib/plugins/middlewares/runner.ts +++ b/src/lib/plugins/middlewares/runner.ts @@ -1,3 +1,4 @@ +import { InjectionError } from "../../errors"; import type { iInstantiationParams, iMiddleware } from "./types"; /** @internal */ @@ -8,11 +9,21 @@ export function runMiddlewares( const ms = middlewares as iMiddleware[]; if (ms.length === 0) return params.factory(); - let i = 0; - const next = (current: iInstantiationParams): T => { - if (i >= ms.length) return current.factory(); - return ms[i++](current, next); + // Dispatch by index so each middleware's `next` continues from its own + // position, and reject a double next() explicitly rather than letting it + // silently skip downstream middlewares. + const dispatch = (index: number, current: iInstantiationParams): T => { + if (index >= ms.length) return current.factory(); + + let called = false; + return ms[index](current, (forwarded) => { + if (called) { + throw InjectionError.middlewareNextReused(); + } + called = true; + return dispatch(index + 1, forwarded); + }); }; - return next(params); + return dispatch(0, params); } diff --git a/src/lib/provider/multi-result.spec.ts b/src/lib/provider/multi-result.spec.ts new file mode 100644 index 0000000..6b616ed --- /dev/null +++ b/src/lib/provider/multi-result.spec.ts @@ -0,0 +1,108 @@ +import { describe, expect, it } from "vitest"; +import { MultiNodeToken, NodeToken, nodeInject } from "../api"; +import { NodeContainer } from "../container"; + +describe("multi token result is a defensive copy (#7)", () => { + it("mutating one consumer's array does not corrupt other consumers", () => { + const M = new MultiNodeToken("MR_SHARED"); + const A = new NodeToken("MR_A"); + const B = new NodeToken("MR_B"); + + const c = new NodeContainer(); + c.provide(M.withValue(1)); + c.provide(M.withValue(2)); + c.provide(A.withFactory(() => nodeInject(M))); + c.provide(B.withFactory(() => nodeInject(M))); + c.bootstrap(); + + const a = c.get(A); + a.push(999); + a.sort((x, y) => y - x); + + expect(c.get(B)).toEqual([1, 2]); + }); + + it("direct container.get(MULTI) hands out independent arrays", () => { + const M = new MultiNodeToken("MR_GET"); + const c = new NodeContainer(); + c.provide(M.withValue(1)); + c.provide(M.withValue(2)); + c.bootstrap(); + + const first = c.get(M); + first.push(3); + expect(c.get(M)).toEqual([1, 2]); + }); + + it("members themselves are still shared instances (shallow copy)", () => { + const M = new MultiNodeToken<{ id: number }>("MR_IDENTITY"); + const obj = { id: 1 }; + const c = new NodeContainer(); + c.provide(M.withValue(obj)); + c.bootstrap(); + + const a = c.get(M); + const b = c.get(M); + expect(a).not.toBe(b); // different array containers + expect(a[0]).toBe(b[0]); // same member instance + expect(a[0]).toBe(obj); + }); +}); + +describe("optional multi injection resolves to [] not null (#9/#10)", () => { + it("optional nodeInject of an unprovided multi returns [] (spreadable)", () => { + const M = new MultiNodeToken("MR_OPT"); + const HOST = new NodeToken("MR_OPT_HOST"); + + const c = new NodeContainer(); + c.provide( + HOST.withFactory(() => { + const items = nodeInject(M, { optional: true }); + return [...items]; + }), + ); + c.bootstrap(); + + expect(c.get(HOST)).toEqual([]); + }); + + it("optional multi inside a multi-token factory member (transparent retriever) returns []", () => { + const OUTER = new MultiNodeToken("MR_OUTER"); + const INNER = new MultiNodeToken("MR_INNER"); // never provided + + const c = new NodeContainer(); + c.provide( + OUTER.withFactory(() => { + const inner = nodeInject(INNER, { optional: true }); + return inner.length; // must not throw on null.length + }), + ); + c.bootstrap(); + + expect(c.get(OUTER)).toEqual([0]); + }); + + it("optional multi with members still returns the members", () => { + const M = new MultiNodeToken("MR_OPT_PRESENT"); + const HOST = new NodeToken("MR_OPT_PRESENT_HOST"); + + const c = new NodeContainer(); + c.provide(M.withValue(5)); + c.provide(M.withValue(6)); + c.provide(HOST.withFactory(() => [...nodeInject(M, { optional: true })])); + c.bootstrap(); + + expect(c.get(HOST)).toEqual([5, 6]); + }); + + it("optional single injection still returns null when unprovided", () => { + const SINGLE = new NodeToken("MR_OPT_SINGLE"); + const HOST = new NodeToken("MR_OPT_SINGLE_HOST"); + + const c = new NodeContainer(); + c.provide(HOST.withFactory(() => nodeInject(SINGLE, { optional: true }))); + c.bootstrap(); + + expect(c.get(HOST)).toBeNull(); + }); +}); diff --git a/src/lib/provider/proto.ts b/src/lib/provider/proto.ts index 28d2b7a..3c99f96 100644 --- a/src/lib/provider/proto.ts +++ b/src/lib/provider/proto.ts @@ -19,6 +19,11 @@ export class ProtoNodeSingle { if (factory) { this.factory = factory; InjectionContext.scanInto(factory, this.injections); + } else if (typeof token.opts?.factory === "function") { + // Track the deps of the token's own factory so the resolver wires them, + // without claiming a factory: hasFactory() stays false so an explicit + // provider may still override this bare declaration. + InjectionContext.scanInto(token.opts.factory, this.injections); } } @@ -30,6 +35,9 @@ export class ProtoNodeSingle { if (this.factory) throw InjectionError.duplicateFactory(this.token); this.factory = factory; + // Replace any deps scanned from token.opts.factory so the explicit + // factory's injections fully supersede them. + this.injections.clear(); InjectionContext.scanInto(factory, this.injections); } @@ -62,16 +70,31 @@ export class ProtoNodeMulti { public readonly multiNodes: Set> = new Set(); public readonly transparentNodes: Set> = new Set(); + // Providers in declaration order across all kinds, so resolved members follow + // the order they were registered rather than grouping aliases before factories. + public readonly orderedProviders: Array< + NodeToken | MultiNodeToken | ProtoNodeTransparent + > = []; + constructor(public readonly token: MultiNodeToken) {} + public hasProviders(): boolean { + return this.orderedProviders.length > 0; + } + public addProvider(retriever: NodeBase | (() => T)): void { if (retriever instanceof NodeToken) { + if (this.singleNodes.has(retriever)) return; this.singleNodes.add(retriever); + this.orderedProviders.push(retriever); } else if (retriever instanceof MultiNodeToken) { + if (this.multiNodes.has(retriever)) return; this.multiNodes.add(retriever); + this.orderedProviders.push(retriever); } else if (typeof retriever === "function") { const transparentProto = new ProtoNodeTransparent(this, retriever); this.transparentNodes.add(transparentProto); + this.orderedProviders.push(transparentProto); } } diff --git a/src/lib/provider/resolver.spec.ts b/src/lib/provider/resolver.spec.ts index 09e3e54..e52b8ef 100644 --- a/src/lib/provider/resolver.spec.ts +++ b/src/lib/provider/resolver.spec.ts @@ -274,7 +274,7 @@ describe("resolveTreeNode", () => { it("should throw on unknown ProtoNode type", () => { const fakeProto = { token: new NodeToken("fake") } as any; expect(() => resolveTreeNode(fakeProto, new Map(), new Map(), new Map())).toThrow( - "Unknown ProtoNode type", + InjectionError.unknownProtoNode(), ); }); diff --git a/src/lib/provider/resolver.ts b/src/lib/provider/resolver.ts index 6577a64..e036e85 100644 --- a/src/lib/provider/resolver.ts +++ b/src/lib/provider/resolver.ts @@ -78,6 +78,9 @@ export function resolveTreeNode( if (inCache) return inCache; const rootNode = createTreeNode(rootProto); + // Register every node in the dedup cache at creation time so a proto that is + // queued (pushed but not yet processed) is reused instead of re-created. + cache.set(rootProto, rootNode); const stack: StackFrame[] = [{ proto: rootProto, node: rootNode, processed: false }]; const visiting = new Set(); @@ -89,7 +92,6 @@ export function resolveTreeNode( if (frame.processed) { stack.pop(); visiting.delete(proto); - cache.set(proto, node); continue; } @@ -100,7 +102,7 @@ export function resolveTreeNode( visiting.add(proto); frame.processed = true; - const deps: (ProtoNode | TreeNode)[] = []; + const deps: { dep: ProtoNode | TreeNode; skipSelf: boolean }[] = []; if (proto instanceof ProtoNodeSingle || proto instanceof ProtoNodeTransparent) { for (const injection of proto.injections) { @@ -112,72 +114,82 @@ export function resolveTreeNode( upstreamGetter, ); - if (resolvedDep) deps.push(resolvedDep); + // Carry the injection's skipSelf so the wired node lands in the right + // scope: plain and skipSelf injections of one token resolve to different nodes. + if (resolvedDep) deps.push({ dep: resolvedDep, skipSelf: !!injection.skipSelf }); } } if (proto instanceof ProtoNodeMulti) { const parentNodes = upstreamGetter?.(proto.token); - if (parentNodes instanceof TreeNodeMulti) { - node.addDependency(parentNodes); + if (parentNodes instanceof TreeNodeMulti && node instanceof TreeNodeMulti) { + // Record the ancestor's members as the inherited tail (excluded by self:true). + node.setInherited(parentNodes); } - for (const single of proto.singleNodes) { - let p = singleNodes.get(single); - if (!p) { - if (single.opts?.singleton) { - const rootSingleton = upstreamGetter?.(single); - if (rootSingleton) { - deps.push(rootSingleton); - continue; + // Iterate in declaration order so multi members keep their registration + // order regardless of provider kind (token/alias vs factory/value/class). + for (const provider of proto.orderedProviders) { + if (provider instanceof NodeToken) { + let p = singleNodes.get(provider); + if (!p) { + if (provider.opts?.singleton) { + const rootSingleton = upstreamGetter?.(provider); + if (rootSingleton) { + deps.push({ dep: rootSingleton, skipSelf: false }); + continue; + } } - } - - p = new ProtoNodeSingle(single); - singleNodes.set(single, p); - } - deps.push(p); - } + p = new ProtoNodeSingle(provider); + singleNodes.set(provider, p); + } - for (const multi of proto.multiNodes) { - let p = multiNodes.get(multi); - if (!p) { - p = new ProtoNodeMulti(multi); - multiNodes.set(multi, p); + deps.push({ dep: p, skipSelf: false }); + } else if (provider instanceof MultiNodeToken) { + let p = multiNodes.get(provider); + if (!p) { + p = new ProtoNodeMulti(provider); + multiNodes.set(provider, p); + } + deps.push({ dep: p, skipSelf: false }); + } else { + // ProtoNodeTransparent (factory / value / useClass member) + deps.push({ dep: provider, skipSelf: false }); } - deps.push(p); - } - - for (const transparent of proto.transparentNodes) { - deps.push(transparent); } } - for (const dep of deps) { + for (const { dep, skipSelf } of deps) { if ( dep instanceof TreeNodeSingle || dep instanceof TreeNodeMulti || dep instanceof TreeNodeTransparent ) { - node.addDependency(dep); + wireDependency(node, dep, skipSelf); continue; } const depProto = dep as ProtoNode; + // Cycle detection must run BEFORE the dedup lookup: in-progress ancestors + // on the active DFS path are now present in `cache`, so reusing them first + // would silently wire a cycle instead of reporting it. + if (visiting.has(depProto) && isNotTransparentProto(depProto)) { + throwCircularDependencyCycle(stack, depProto, true); + } + const cached = cache.get(depProto); if (cached) { - node.addDependency(cached); + // A proto-resolved dependency is always plain scope: skipSelf injections + // resolve to an upstream TreeNode, handled by the branch above. + wireDependency(node, cached, false); continue; } - if (visiting.has(depProto) && isNotTransparentProto(depProto)) { - throwCircularDependencyCycle(stack, depProto, true); - } - const childNode = createTreeNode(depProto); - node.addDependency(childNode); + cache.set(depProto, childNode); + wireDependency(node, childNode, false); stack.push({ proto: depProto, node: childNode, processed: false }); } } @@ -189,7 +201,17 @@ function createTreeNode(p: ProtoNode): TreeNode { if (p instanceof ProtoNodeSingle) return new TreeNodeSingle(p); if (p instanceof ProtoNodeMulti) return new TreeNodeMulti(p); if (p instanceof ProtoNodeTransparent) return new TreeNodeTransparent(p); - throw new Error("Unknown ProtoNode type"); + throw InjectionError.unknownProtoNode(); +} + +/** + * Wires `dep` onto `node`, passing the injection scope where it matters. Multi + * nodes aggregate positionally (no per-token scope); single/transparent nodes + * slot deps by (token, scope) and take the skipSelf flag. + */ +function wireDependency(node: TreeNode, dep: TreeNode, skipSelf: boolean): void { + if (node instanceof TreeNodeMulti) node.addDependency(dep); + else node.addDependency(dep, skipSelf); } function throwCircularDependencyCycle( diff --git a/src/lib/provider/tree-node.ts b/src/lib/provider/tree-node.ts index f56a6f1..c2e4311 100644 --- a/src/lib/provider/tree-node.ts +++ b/src/lib/provider/tree-node.ts @@ -21,9 +21,16 @@ export class TreeRootNode { constructor( public readonly instant = true, - protected readonly middlewares: iMiddleware[] = [], + // A thunk, not a frozen array: a node materialized after bootstrap reads the + // CURRENT chain, so lazy get() matches produce() for post-bootstrap middleware. + private readonly _middlewaresProvider: () => iMiddleware[] = () => [], ) {} + /** The owning container's current middleware chain. */ + public get middlewares(): iMiddleware[] { + return this._middlewaresProvider(); + } + public get dependencies(): Set> { return this._deps; } @@ -33,23 +40,25 @@ export class TreeRootNode { } public registerDependency(node: TreeNode): void { - this._deps.add(node); - - if ("token" in node.proto) { - const existing = this._treePool.get(node.proto.token); - if (existing) return; + const alreadyPooled = + "token" in node.proto && this._treePool.get(node.proto.token) !== undefined; + + // Materialize BEFORE recording: if the factory throws, the half-built node + // must not be stranded in this long-lived root's _deps (a retry would add one). + if (!alreadyPooled) { + if (this.instant) node.instantiate(this._treePool, this); + else node.collectPool(this._treePool, this); } - if (this.instant) node.instantiate(this._treePool, this.middlewares); - else node.collectPool(this._treePool); + this._deps.add(node); } public build(): void { for (const dep of this._deps) { if ("token" in dep.proto) this._treePool.set(dep.proto.token, dep); - if (this.instant) dep.instantiate(this._treePool, this.middlewares); - else dep.collectPool(this._treePool); + if (this.instant) dep.instantiate(this._treePool, this); + else dep.collectPool(this._treePool, this); } } @@ -57,7 +66,7 @@ export class TreeRootNode { const node = this._treePool.get(token); if (!node) return null; - if (!this.instant) node.instantiate(this._treePool, this.middlewares); + if (!this.instant) node.instantiate(this._treePool, this); return node as TreeNode; } @@ -77,6 +86,40 @@ export class TreeRootNode { } } +/** + * @internal + * Binds a tree node to its OWNING root's pool + middleware chain. A parent + * bootstraps before any child that consumes its nodes, so the owner touches the + * node first and the first capture wins. Cross-container consumers then + * materialize an upstream node through its owner's pool + chain, not their own, + * so a child's pool/middleware never contaminates a parent-provided instance. + */ +class HomeBinding { + private _pool: InjectionPool | null = null; + private _root: TreeRootNode | null = null; + private _homed = false; + + public capture(pool: InjectionPool | undefined, root: TreeRootNode | undefined): void { + if (this._homed || !root) return; + this._homed = true; + this._pool = pool ?? null; + this._root = root; + } + + public poolFor(pool?: InjectionPool): InjectionPool | undefined { + return this._pool ?? pool; + } + + public rootFor(root?: TreeRootNode): TreeRootNode | undefined { + return this._root ?? root; + } + + public middlewaresFor(root?: TreeRootNode): iMiddleware[] { + const owner = this._root ?? root; + return owner ? owner.middlewares : []; + } +} + /** @internal */ export class TreeNodeSingle { private readonly _transparentMap: Map, TreeNodeTransparent> = new Map(); @@ -88,9 +131,17 @@ export class TreeNodeSingle { private readonly _depsIndex = new Map, number>(); private readonly _depsTokens = new Set>(); + // skipSelf injections resolve to a different (upstream) node than a plain/self + // injection of the same token, so they need their own keyed-by-token storage. + private readonly _skipSelfDeps: DependencyPool = new Map(); + private readonly _skipSelfList: TreeNode[] = []; + private readonly _skipSelfIndex = new Map, number>(); + + private readonly _home = new HomeBinding(); private _instance: T | null = null; private _collected = false; private _resolved = false; + private _inProgress = false; public allocations = 0; public get instance(): T { @@ -106,17 +157,20 @@ export class TreeNodeSingle { options?: iNodeInjectorOptions, ): any => { const optional = options ? options.optional : false; - const depNode = this._deps.get(token); + const self = options?.self ?? false; + const skipSelf = options?.skipSelf ?? false; - if (!depNode && !optional) { - const transparent = this._transparentMap.get(token); - if (transparent) return transparent.instance; - if (token instanceof MultiNodeToken) return [] as unknown as any; + const depNode = (skipSelf ? this._skipSelfDeps : this._deps).get(token); + if (depNode) return readNodeInstance(depNode, self); - throw InjectionError.untracked(token, this.proto.token); - } + const transparent = this._transparentMap.get(token); + if (transparent) return transparent.instance; + + // A multi token always resolves to an array, never null (even when optional). + if (token instanceof MultiNodeToken) return [] as unknown as any; - return depNode ? depNode.instance : null; + if (optional) return null; + throw InjectionError.untracked(token, this.proto.token); }; constructor(public readonly proto: ProtoNodeSingle) { @@ -127,17 +181,29 @@ export class TreeNodeSingle { } } - public addDependency(node: TreeNode): void { + public addDependency(node: TreeNode, skipSelf = false): void { if (node instanceof TreeNodeTransparent) { const token = node.proto.parent.token; + const existing = this._transparentMap.get(token); + if (existing === node) return; + if (existing) existing.allocations--; + this._transparentMap.set(token, node); upsertIndexedDependency(token, node, this._transparentIndex, this._transparentList); this._depsTokens.add(token); } else { const token = node.proto.token; - this._deps.set(token, node); - upsertIndexedDependency(token, node, this._depsIndex, this._depsList); + const deps = skipSelf ? this._skipSelfDeps : this._deps; + const index = skipSelf ? this._skipSelfIndex : this._depsIndex; + const list = skipSelf ? this._skipSelfList : this._depsList; + + const existing = deps.get(token); + if (existing === node) return; + if (existing) existing.allocations--; + + deps.set(token, node); + upsertIndexedDependency(token, node, index, list); this._depsTokens.add(token); } @@ -145,52 +211,100 @@ export class TreeNodeSingle { node.allocations++; } - public collectPool(pool: InjectionPool): void { + public collectPool(pool: InjectionPool, root?: TreeRootNode): void { + this._home.capture(pool, root); + const homePool = this._home.poolFor(pool) ?? pool; + const homeRoot = this._home.rootFor(root); + if (this._collected) { - poolSetOnce(pool, this.proto.token, this); + poolSetOnce(homePool, this.proto.token, this); return; } - for (let i = 0; i < this._depsList.length; i++) { - this._depsList[i].collectPool(pool); + // Re-entry before completion means a genuine cycle the resolver's + // build-time check could not see (e.g. mutually-referencing siblings); + // report it instead of overflowing the stack. + if (this._inProgress) { + throw InjectionError.circularDependency(this.proto.token, [this.proto.token]); } + this._inProgress = true; + + // Reset the guard on every exit so a throwing dependency can't fake a later + // cycle (e.g. a lazy get() retried after the first attempt threw). + try { + for (let i = 0; i < this._depsList.length; i++) { + this._depsList[i].collectPool(homePool, homeRoot); + } + + for (let i = 0; i < this._skipSelfList.length; i++) { + this._skipSelfList[i].collectPool(homePool, homeRoot); + } - for (let i = 0; i < this._transparentList.length; i++) { - this._transparentList[i].collectPool(pool); + for (let i = 0; i < this._transparentList.length; i++) { + this._transparentList[i].collectPool(homePool, homeRoot); + } + } finally { + this._inProgress = false; } this._collected = true; - poolSetOnce(pool, this.proto.token, this); + poolSetOnce(homePool, this.proto.token, this); } - public instantiate(pool?: InjectionPool, middlewares: iMiddleware[] = []): void { - if (this._resolved) return; + public instantiate(pool?: InjectionPool, root?: TreeRootNode): void { + this._home.capture(pool, root); + const homePool = this._home.poolFor(pool); + const homeRoot = this._home.rootFor(root); - for (let i = 0; i < this._depsList.length; i++) { - this._depsList[i].instantiate(pool, middlewares); + if (this._resolved) { + // Pre-resolved nodes (e.g. Injector) still have to be discoverable + if (homePool) poolSetOnce(homePool, this.proto.token, this); + return; } - for (let i = 0; i < this._transparentList.length; i++) { - this._transparentList[i].instantiate(pool, middlewares); + if (this._inProgress) { + throw InjectionError.circularDependency(this.proto.token, [this.proto.token]); } + this._inProgress = true; - const factory = this.proto.factory ?? this.proto.token.opts?.factory; - if (!factory) throw InjectionError.notFound(this.proto.token); + // Materialize through this node's OWN middleware chain, never a cross-container + // consumer's, so a parent-provided instance isn't wrapped in a child's middleware. + const middlewares = this._home.middlewaresFor(root); - if (!middlewares.length) { - this._instance = InjectionContext.instantiate(factory, this._retriever); - } else { - const contextFactory = () => InjectionContext.instantiate(factory, this._retriever); - this._instance = runMiddlewares(middlewares, { - token: this.proto.token, - factory: contextFactory, - deps: new Set(this._depsTokens), - }); + try { + for (let i = 0; i < this._depsList.length; i++) { + this._depsList[i].instantiate(homePool, homeRoot); + } + + for (let i = 0; i < this._skipSelfList.length; i++) { + this._skipSelfList[i].instantiate(homePool, homeRoot); + } + + for (let i = 0; i < this._transparentList.length; i++) { + this._transparentList[i].instantiate(homePool, homeRoot); + } + + const factory = this.proto.factory ?? this.proto.token.opts?.factory; + if (!factory) throw InjectionError.notFound(this.proto.token); + + if (!middlewares.length) { + this._instance = InjectionContext.instantiate(factory, this._retriever); + } else { + const contextFactory = () => + InjectionContext.instantiate(factory, this._retriever); + this._instance = runMiddlewares(middlewares, { + token: this.proto.token, + factory: contextFactory, + deps: new Set(this._depsTokens), + }); + } + } finally { + this._inProgress = false; } this._resolved = true; - if (pool) poolSetOnce(pool, this.proto.token, this); + if (homePool) poolSetOnce(homePool, this.proto.token, this); } public toString(): string { @@ -209,9 +323,17 @@ export class TreeNodeTransparent { private readonly _depsIndex = new Map, number>(); private readonly _depsTokens = new Set>(); + // skipSelf injections resolve to a different (upstream) node than a plain/self + // injection of the same token; keep them in their own keyed-by-token storage. + private readonly _skipSelfDeps: DependencyPool = new Map(); + private readonly _skipSelfList: TreeNode[] = []; + private readonly _skipSelfIndex = new Map, number>(); + + private readonly _home = new HomeBinding(); private _instance: T | null = null; private _collected = false; private _resolved = false; + private _inProgress = false; public allocations = 0; public get instance(): T { @@ -224,32 +346,47 @@ export class TreeNodeTransparent { options?: iNodeInjectorOptions, ): any => { const optional = options ? options.optional : false; - const depNode = this._deps.get(token); + const self = options?.self ?? false; + const skipSelf = options?.skipSelf ?? false; - if (!depNode && !optional) { - const transparent = this._transparentMap.get(token); - if (transparent) return transparent.instance; - if (token instanceof MultiNodeToken) return [] as unknown as any; + const depNode = (skipSelf ? this._skipSelfDeps : this._deps).get(token); + if (depNode) return readNodeInstance(depNode, self); - throw InjectionError.untracked(token, this.proto.parent.token); - } + const transparent = this._transparentMap.get(token); + if (transparent) return transparent.instance; - return depNode ? depNode.instance : null; + // A multi token always resolves to an array, never null (even when optional). + if (token instanceof MultiNodeToken) return [] as unknown as any; + + if (optional) return null; + throw InjectionError.untracked(token, this.proto.parent.token); }; constructor(public readonly proto: ProtoNodeTransparent) {} - public addDependency(node: TreeNode): void { + public addDependency(node: TreeNode, skipSelf = false): void { if (node instanceof TreeNodeTransparent) { const token = node.proto.parent.token; + const existing = this._transparentMap.get(token); + if (existing === node) return; + if (existing) existing.allocations--; + this._transparentMap.set(token, node); upsertIndexedDependency(token, node, this._transparentIndex, this._transparentList); this._depsTokens.add(token); } else { const token = node.proto.token; - this._deps.set(token, node); - upsertIndexedDependency(token, node, this._depsIndex, this._depsList); + const deps = skipSelf ? this._skipSelfDeps : this._deps; + const index = skipSelf ? this._skipSelfIndex : this._depsIndex; + const list = skipSelf ? this._skipSelfList : this._depsList; + + const existing = deps.get(token); + if (existing === node) return; + if (existing) existing.allocations--; + + deps.set(token, node); + upsertIndexedDependency(token, node, index, list); this._depsTokens.add(token); } @@ -257,43 +394,87 @@ export class TreeNodeTransparent { node.allocations++; } - public collectPool(pool: InjectionPool): void { + public collectPool(pool: InjectionPool, root?: TreeRootNode): void { + this._home.capture(pool, root); + const homePool = this._home.poolFor(pool) ?? pool; + const homeRoot = this._home.rootFor(root); + if (this._collected) return; - for (let i = 0; i < this._depsList.length; i++) { - this._depsList[i].collectPool(pool); + if (this._inProgress) { + throw InjectionError.circularDependency(this.proto.parent.token, [ + this.proto.parent.token, + ]); } + this._inProgress = true; + + // Reset the guard on every exit so a throwing dependency can't fake a later cycle. + try { + for (let i = 0; i < this._depsList.length; i++) { + this._depsList[i].collectPool(homePool, homeRoot); + } + + for (let i = 0; i < this._skipSelfList.length; i++) { + this._skipSelfList[i].collectPool(homePool, homeRoot); + } - for (let i = 0; i < this._transparentList.length; i++) { - this._transparentList[i].collectPool(pool); + for (let i = 0; i < this._transparentList.length; i++) { + this._transparentList[i].collectPool(homePool, homeRoot); + } + } finally { + this._inProgress = false; } this._collected = true; } - public instantiate(pool?: InjectionPool, middlewares: iMiddleware[] = []): void { + public instantiate(pool?: InjectionPool, root?: TreeRootNode): void { + this._home.capture(pool, root); + const homePool = this._home.poolFor(pool); + const homeRoot = this._home.rootFor(root); + if (this._resolved) return; - for (let i = 0; i < this._transparentList.length; i++) { - this._transparentList[i].instantiate(pool, middlewares); + if (this._inProgress) { + throw InjectionError.circularDependency(this.proto.parent.token, [ + this.proto.parent.token, + ]); } + this._inProgress = true; - for (let i = 0; i < this._depsList.length; i++) { - this._depsList[i].instantiate(pool, middlewares); - } + const middlewares = this._home.middlewaresFor(root); - if (!middlewares.length) { - this._instance = InjectionContext.instantiate(this.proto.factory, this._retriever); - } else { - const refFactory = () => { - return InjectionContext.instantiate(this.proto.factory, this._retriever); - }; - - this._instance = runMiddlewares(middlewares, { - token: this.proto.parent.token, - factory: refFactory, - deps: new Set(this._depsTokens), - }); + try { + for (let i = 0; i < this._transparentList.length; i++) { + this._transparentList[i].instantiate(homePool, homeRoot); + } + + for (let i = 0; i < this._depsList.length; i++) { + this._depsList[i].instantiate(homePool, homeRoot); + } + + for (let i = 0; i < this._skipSelfList.length; i++) { + this._skipSelfList[i].instantiate(homePool, homeRoot); + } + + if (!middlewares.length) { + this._instance = InjectionContext.instantiate( + this.proto.factory, + this._retriever, + ); + } else { + const refFactory = () => { + return InjectionContext.instantiate(this.proto.factory, this._retriever); + }; + + this._instance = runMiddlewares(middlewares, { + token: this.proto.parent.token, + factory: refFactory, + deps: new Set(this._depsTokens), + }); + } + } finally { + this._inProgress = false; } this._resolved = true; @@ -308,56 +489,123 @@ export class TreeNodeTransparent { export class TreeNodeMulti { private readonly _deps = new Set>(); private readonly _depsList: TreeNode[] = []; - public readonly instance: T[] = []; + private readonly _members: T[] = []; + // The nearest ancestor's multi node, aggregated as the inherited tail. Kept + // separate from local members so a `self: true` read can exclude it. + private _inherited: TreeNodeMulti | null = null; + private readonly _home = new HomeBinding(); private _collected = false; private _resolved = false; + private _inProgress = false; public allocations = 0; constructor(public readonly proto: ProtoNodeMulti) {} - public collectPool(pool: InjectionPool): void { + /** + * Resolved members: inherited ancestor members then this container's own. + * Returns a fresh copy each access so a consumer that mutates the result can't + * corrupt the shared member list seen by others. + */ + public get instance(): T[] { + if (this._inherited) return [...this._inherited.instance, ...this._members]; + return [...this._members]; + } + + /** + * Only this container's own members, excluding anything inherited from an + * ancestor. Backs `self: true` resolution. + */ + public get localInstance(): T[] { + return [...this._members]; + } + + /** @internal Record the nearest ancestor's multi node as the inherited tail. */ + public setInherited(node: TreeNodeMulti): void { + if (this._inherited === node) return; + if (this._inherited) this._inherited.allocations--; + this._inherited = node; + node.allocations++; + } + + public collectPool(pool: InjectionPool, root?: TreeRootNode): void { + this._home.capture(pool, root); + const homePool = this._home.poolFor(pool) ?? pool; + const homeRoot = this._home.rootFor(root); + if (this._collected) { - poolSetOnce(pool, this.proto.token, this); + poolSetOnce(homePool, this.proto.token, this); return; } - for (let i = 0; i < this._depsList.length; i++) { - this._depsList[i].collectPool(pool); + if (this._inProgress) { + throw InjectionError.circularDependency(this.proto.token, [this.proto.token]); + } + this._inProgress = true; + + // Reset the guard on every exit so a throwing dependency can't fake a later cycle. + try { + for (let i = 0; i < this._depsList.length; i++) { + this._depsList[i].collectPool(homePool, homeRoot); + } + // The inherited node belongs to an ancestor and uses its own captured home. + this._inherited?.collectPool(homePool, homeRoot); + } finally { + this._inProgress = false; } this._collected = true; - poolSetOnce(pool, this.proto.token, this); + poolSetOnce(homePool, this.proto.token, this); } - public instantiate(pool?: InjectionPool, middlewares: iMiddleware[] = []): void { - if (this._resolved) return; + public instantiate(pool?: InjectionPool, root?: TreeRootNode): void { + this._home.capture(pool, root); + const homePool = this._home.poolFor(pool); + const homeRoot = this._home.rootFor(root); - for (let i = 0; i < this._depsList.length; i++) { - const dep = this._depsList[i]; - dep.instantiate(pool, middlewares); + if (this._resolved) return; - if (dep instanceof TreeNodeSingle) { - this.instance.push(dep.instance); - } else if (dep instanceof TreeNodeMulti) { - this.instance.push(...dep.instance); - } else if (dep instanceof TreeNodeTransparent) { - this.instance.push(dep.instance); + if (this._inProgress) { + throw InjectionError.circularDependency(this.proto.token, [this.proto.token]); + } + this._inProgress = true; + + // Start each attempt from an empty array: a prior attempt that threw + // mid-loop would otherwise leave partial members to be duplicated on retry. + this._members.length = 0; + + try { + for (let i = 0; i < this._depsList.length; i++) { + const dep = this._depsList[i]; + dep.instantiate(homePool, homeRoot); + + if (dep instanceof TreeNodeSingle) { + this._members.push(dep.instance); + } else if (dep instanceof TreeNodeMulti) { + this._members.push(...dep.instance); + } else if (dep instanceof TreeNodeTransparent) { + this._members.push(dep.instance); + } } + // Inherited members are read lazily via the `instance` getter, but the + // ancestor node must still be materialized (through its own home) so + // its `.instance` is ready. + this._inherited?.instantiate(homePool, homeRoot); + } finally { + this._inProgress = false; } this._resolved = true; - if (pool) poolSetOnce(pool, this.proto.token, this); + if (homePool) poolSetOnce(homePool, this.proto.token, this); } public addDependency(...nodes: TreeNode[]): void { for (let i = 0; i < nodes.length; i++) { const node = nodes[i]; - if (!this._deps.has(node)) { - this._deps.add(node); - this._depsList.push(node); - } + if (this._deps.has(node)) continue; + this._deps.add(node); + this._depsList.push(node); node.allocations++; } } @@ -372,6 +620,17 @@ export type TreeNode = | TreeNodeMulti | TreeNodeTransparent; +/** + * @internal + * Reads a resolved node's instance, honoring `self: true` for multi nodes by + * returning only their local members (excluding inherited ancestor members). + * `self` is a no-op for single/transparent nodes. + */ +export function readNodeInstance(node: TreeNode, localOnly: boolean): any { + if (localOnly && node instanceof TreeNodeMulti) return node.localInstance; + return node.instance; +} + function poolSetOnce(pool: InjectionPool, token: NodeBase, node: TreeNode): void { if (pool.get(token) === node) return; if (pool.has(token)) return; diff --git a/src/lib/provider/types.ts b/src/lib/provider/types.ts index 7045812..d34a9fb 100644 --- a/src/lib/provider/types.ts +++ b/src/lib/provider/types.ts @@ -26,6 +26,15 @@ export interface iNodeTokenBaseOptions { * Marks token as root-scoped singleton in hierarchical containers. */ singleton?: boolean; + + /** + * Dedupe this token by name in a process-global registry, so an identically + * named token declared in another bundle is the SAME object. Containers key + * providers by token reference, so cross-bundle sharing needs identical + * instances. Reserve for cross-bundle seam tokens: the name becomes a global + * identity and must be unique and stable. + */ + global?: boolean; } /** diff --git a/src/lib/testkit/helpers.ts b/src/lib/testkit/helpers.ts index 6e6e175..28359f8 100644 --- a/src/lib/testkit/helpers.ts +++ b/src/lib/testkit/helpers.ts @@ -1,7 +1,6 @@ import type { NodeInjectFn } from "../api/injection"; import type { iNodeInjectorOptions } from "../api/types"; import { NodeContainer } from "../container/container"; -import { isNotFoundError } from "../errors"; import type { Provider, Token } from "../provider/types"; /** @@ -56,17 +55,8 @@ export function createTestFactory(cfg: iTestFactoryConfig): TestFactoryFn< container.bootstrap(); const instance = container.get(cfg.target as any); - const nodeInject = (token: Token, opts?: iNodeInjectorOptions) => { - try { - return container.get(token as any); - } catch (e) { - if (isNotFoundError(e) && opts?.optional) { - return null as any; - } - - throw e; - } - }; + const nodeInject = (token: Token, opts?: iNodeInjectorOptions) => + container.get(token as any, opts); return { instance, diff --git a/src/lib/utils/defer.spec.ts b/src/lib/utils/defer.spec.ts index 7899d56..d82c7eb 100644 --- a/src/lib/utils/defer.spec.ts +++ b/src/lib/utils/defer.spec.ts @@ -286,3 +286,26 @@ describe("injectDefer", () => { expect(factorySpy).toHaveBeenCalledTimes(2); }); }); + +describe("injectDefer lifecycle callback leak (#17)", () => { + it("does not accumulate a beforeDestroy hook per produced instance", () => { + const parent = new NodeContainer(); + + const DEP = new NodeToken("DEFER_LEAK_DEP"); + parent.provide(DEP.withValue(1)); + + @NodeInjectable() + class Service { + public readonly dep = injectDefer(DEP); + } + parent.provide(Service); + parent.bootstrap(); + + const destroyCallbacks = (parent as any)._lifecycle._destroyCallbacks as Set; + const before = destroyCallbacks.size; + + for (let i = 0; i < 25; i++) parent.produce(Service); + + expect(destroyCallbacks.size).toBe(before); + }); +}); diff --git a/src/lib/utils/defer.ts b/src/lib/utils/defer.ts index ca33461..f0fafed 100644 --- a/src/lib/utils/defer.ts +++ b/src/lib/utils/defer.ts @@ -4,7 +4,7 @@ import type { MultiNodeToken, NodeToken } from "../api/token"; import { extractToken } from "../api/token-utils"; import type { ExtractInjectedType, iNodeInjectorOptions } from "../api/types"; import { LifecycleRef } from "../container/lifecycle"; -import { InjectionError, isNotFoundError } from "../errors"; +import { InjectionError } from "../errors"; import type { Token } from "../provider/types"; import { Injector } from "./injector"; @@ -57,35 +57,19 @@ export function injectDefer< const token = extractToken(provider as Token); + // No beforeDestroy hook resets this state: the getter already refuses to + // resolve once the container is destroyed, and a per-call hook on the shared + // lifecycle would leak one callback per produce()d instance. let resolved = false; let instance: ExtractInjectedType | typeof SHAPE_SHIFTER | null = SHAPE_SHIFTER; - lifecycle.beforeDestroy(() => { - resolved = false; - instance = null; - }); - return () => { if (lifecycle.destroyed) throw InjectionError.destroyed(); - if (resolved) return instance; - if (options?.optional) { - try { - instance = injector.get(token as any) as ExtractInjectedType; - resolved = true; - return instance; - } catch (e) { - if (isNotFoundError(e)) { - resolved = true; - instance = null; - return instance; - } - - throw e; - } - } - instance = injector.get(token as any) as ExtractInjectedType; + // Forward every modifier: get() honors them natively, so deferred resolution + // resolves from the same scope an eager nodeInject(token, options) would. + instance = injector.get(token as any, options) as ExtractInjectedType; resolved = true; return instance; }; diff --git a/src/lib/utils/inheritance.spec.ts b/src/lib/utils/inheritance.spec.ts index abb71ea..6cc8ba4 100644 --- a/src/lib/utils/inheritance.spec.ts +++ b/src/lib/utils/inheritance.spec.ts @@ -665,3 +665,162 @@ describe("injectEntryAsync", () => { expect(logSpy).toHaveBeenCalledWith("Generating report..."); }); }); + +describe("async injection failure handling (#5 / #29)", () => { + it("evicts a cached rejection so a later call retries with a fresh attempt (#5)", async () => { + const parent = new NodeContainer(); + + @NodeInjectable() + class TestService { + public readonly value = "ok"; + } + parent.provide(TestService); + + let attempt = 0; + + @NodeInjectable() + class ParentService { + public readonly load = injectAsync(() => { + attempt++; + if (attempt === 1) throw new Error("transient"); + return TestService; + }); + } + parent.provide(ParentService); + parent.bootstrap(); + + const ps = parent.get(ParentService); + + await expect(ps.load()).rejects.toThrow("transient"); + // The poisoned attempt must not be cached: a retry succeeds. + const svc = await ps.load(); + expect(svc).toBeInstanceOf(TestService); + expect(attempt).toBe(2); + }); + + it("caches a successful result after a failed-then-retried attempt (#5)", async () => { + const parent = new NodeContainer(); + + @NodeInjectable() + class TestService {} + parent.provide(TestService); + + let attempt = 0; + + @NodeInjectable() + class ParentService { + public readonly load = injectAsync(() => { + attempt++; + if (attempt === 1) throw new Error("transient"); + return TestService; + }); + } + parent.provide(ParentService); + parent.bootstrap(); + + const ps = parent.get(ParentService); + await expect(ps.load()).rejects.toThrow("transient"); + + const a = await ps.load(); + const b = await ps.load(); + expect(a).toBe(b); // second success is cached + expect(attempt).toBe(2); + }); + + it("rejects cleanly (no crash) when the parent is destroyed mid-flight (#29)", async () => { + const parent = new NodeContainer(); + let release: (() => void) | undefined; + const gate = new Promise((r) => { + release = r; + }); + + @NodeInjectable() + class TestService {} + parent.provide(TestService); + + @NodeInjectable() + class ParentService { + public readonly load = injectAsync(async () => { + await gate; + return TestService; + }); + } + parent.provide(ParentService); + parent.bootstrap(); + + const ps = parent.get(ParentService); + const pending = ps.load(); // starts the sub-container build, suspends at gate + parent.destroy(); // tears the sub-container down underneath the factory + release?.(); + + await expect(pending).rejects.toThrow(InjectionError); + }); +}); + +describe("lifecycle callback leak (#16)", () => { + it("does not accumulate a beforeDestroy hook per produced instance using injectAsync", () => { + const parent = new NodeContainer(); + + @NodeInjectable() + class Dep {} + parent.provide(Dep); + + @NodeInjectable() + class Service { + public readonly load = injectAsync(() => Dep); + } + parent.provide(Service); + parent.bootstrap(); + + const destroyCallbacks = (parent as any)._lifecycle._destroyCallbacks as Set; + const before = destroyCallbacks.size; + + for (let i = 0; i < 25; i++) parent.produce(Service); + + expect(destroyCallbacks.size).toBe(before); + }); +}); + +// The runtime contract the (fixed) overloads must encode: a NodeToken +// entrypoint resolves to a single value, a MultiNodeToken entrypoint to an +// array (#15). NOTE: spec files are excluded from tsc, so this guards the +// runtime contract; the overload return TYPES are kept consistent with this. +describe("injectEntryAsync entrypoint kind (#15)", () => { + it("a NodeToken entrypoint resolves to a single value", async () => { + const parent = new NodeContainer(); + const SINGLE = new NodeToken("ET_SINGLE_TOK"); + + @NodeInjectable() + class Host { + public readonly get = injectEntryAsync(() => ({ + entrypoint: SINGLE, + providers: [SINGLE.withValue("only")], + })); + } + parent.provide(Host); + parent.bootstrap(); + + const result = await parent.get(Host).get(); + expect(Array.isArray(result)).toBe(false); + expect(result).toBe("only"); + }); + + it("a MultiNodeToken entrypoint resolves to an array of all members", async () => { + const parent = new NodeContainer(); + const MULTI = new MultiNodeToken("ET_MULTI_TOK"); + + @NodeInjectable() + class Host { + public readonly get = injectEntryAsync(() => ({ + entrypoint: MULTI, + providers: [MULTI.withValue("a"), MULTI.withValue("b")], + })); + } + parent.provide(Host); + parent.bootstrap(); + + const result = await parent.get(Host).get(); + expect(Array.isArray(result)).toBe(true); + expect(result).toEqual(["a", "b"]); + }); +}); diff --git a/src/lib/utils/inheritance.ts b/src/lib/utils/inheritance.ts index 1f636fa..d45b346 100644 --- a/src/lib/utils/inheritance.ts +++ b/src/lib/utils/inheritance.ts @@ -12,7 +12,16 @@ type MaybeAsyncFactory = () => T | Promise; interface iInjectionOptions { /** * Whether to cache the result of the injection function - * Prevents multiple invocations from creating multiple sub-containers or injections + * Prevents multiple invocations from creating multiple sub-containers or injections. + * + * When `false`, every call creates a fresh sub-container bound to the parent's + * lifecycle (destroyed when the parent is destroyed). On a long-lived parent + * these accumulate one per call until the parent is destroyed — by design, + * since caching is opted out. To release request-scoped work eagerly, prefer + * {@link injectGroupAsync} (which returns the sub-container's `Injector`, so + * you can call `injector.destroy()`) or manage a child container explicitly; + * `injectAsync`/`injectEntryAsync` return only the produced instance and do + * not expose a per-call disposal handle. * @default true */ withCache?: boolean; @@ -113,7 +122,7 @@ export interface iEntrypointConfig> { export function injectEntryAsync( fn: MaybeAsyncFactory>>, opts?: iInjectionOptions, -): () => Promise; +): () => Promise; export function injectEntryAsync( fn: MaybeAsyncFactory>>, opts?: iInjectionOptions, @@ -121,7 +130,7 @@ export function injectEntryAsync( export function injectEntryAsync( fn: MaybeAsyncFactory>>, opts?: iInjectionOptions, -): () => Promise; +): () => Promise; export function injectEntryAsync( fn: MaybeAsyncFactory>>, opts?: iInjectionOptions, @@ -145,10 +154,15 @@ function createSubContainerCache( : nodeInject(LifecycleRef); const withCache = opts?.withCache ?? true; - const factory = () => { + const factory = (): Promise => { const subContainer = new NodeContainer({ parent }); if (opts?.config) subContainer.provide(opts.config); - return factoryFn(subContainer); + // Tear down the sub-container if the async build rejects, so repeated + // attempts don't accumulate dead scopes on the parent's lifecycle. + return factoryFn(subContainer).catch((err) => { + if (!subContainer.destroyed) subContainer.destroy(); + throw err; + }); }; if (!withCache) { @@ -158,15 +172,23 @@ function createSubContainerCache( }; } + // No beforeDestroy hook clears this cache: the getter already refuses to + // resolve once the parent is destroyed, and a per-call hook on the shared + // lifecycle would leak one callback per produce()d instance. let cache: Promise | null = null; - lifecycle.beforeDestroy(() => { - cache = null; - }); - return () => { if (lifecycle.destroyed) throw InjectionError.destroyed(); - cache ??= factory(); + if (cache) return cache; + + const pending = factory(); + cache = pending; + // Evict a rejected attempt so a later call retries with a fresh sub-container + // instead of replaying the cached rejection. This also marks the promise as + // handled, so a fire-and-forget rejection isn't reported as unhandled. + pending.catch(() => { + if (cache === pending) cache = null; + }); return cache; }; }