di-bag 0.3.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/AGENTS.md CHANGED
@@ -12,9 +12,9 @@ Every compiler and runtime message: [docs/agent/errors.md](docs/agent/errors.md)
12
12
 
13
13
  1. **Import from `di-bag`:** `import { DiBag } from 'di-bag';`. It configures
14
14
  itself on Node, Bun, and Deno; `di-bag/node` is the same API in explicit form.
15
- In browsers and workers `build()` throws
16
- [`DI_BAG_CLASSIFIER_REQUIRED`](docs/agent/errors.md#di-bag-classifier-required)
17
- for factories without an explicit `acquisitionMode`.
15
+ For browsers and workers register with `DiBag.fromSyncFactory` / `fromAsyncFactory`
16
+ ([portable recipe](docs/agent/recipes.md#portable-graph)); a plain factory there fails
17
+ `build()` with [`DI_BAG_CLASSIFIER_REQUIRED`](docs/agent/errors.md#di-bag-classifier-required), which names it.
18
18
  2. **A factory declares its dependencies in the type of its one object
19
19
  parameter; destructure it** (`({ clock }: { clock: Clock }) => ...`) or read
20
20
  `deps.clock` directly. The object is a Proxy that resolves each property when
@@ -31,8 +31,9 @@ Every compiler and runtime message: [docs/agent/errors.md](docs/agent/errors.md)
31
31
  method (query builders) is [rejected](docs/agent/errors.md#structural-thenable).
32
32
  Return `Promise.resolve(builder)` or use `DiBag.fromFactory(create, { acquisitionMode: 'raw' })`.
33
33
  6. **Ownership.** `DiBag.withDisposal(factory, dispose)` makes the bag own the
34
- value; `close()` runs disposers, dependents first. Close every scope and fork
35
- you create. A parent closes its live scopes, never forks.
34
+ returned value; `close()` runs disposers, dependents first. Close every scope and
35
+ fork you create; a parent closes its live scopes, never forks. Inside a factory,
36
+ [`factoryCtx.pushDisposer`](docs/agent/recipes.md#partial-acquisition) owns what it acquires on the way; if that is also the returned value, act only when `disposerCtx.reason !== 'service-disposed'`.
36
37
  7. **Replace dependencies in tests with `fork(keys, overrides)`**; each
37
38
  override must satisfy the original contract.
38
39
  8. **Modules.** Register a feature's factories, then `buildModule(['exported'])`.
package/README.md CHANGED
@@ -1,7 +1,7 @@
1
1
  # DI Bag
2
2
 
3
- TypeScript dependency composition and resource ownership for modular codebases,
4
- including the ones coding agents build one feature at a time.
3
+ TypeScript dependency composition and resource ownership for modular codebases.
4
+ Built for coding agents that ship one feature at a time.
5
5
 
6
6
  [Documentation](https://dany-fedorov.github.io/di-bag/) · [Quickstart](#quickstart) · [Modules as units of work](#modules-as-units-of-work) · [Comparison](#how-it-compares) · [Tutorial](docs/guides/tutorial.md) · [API reference](docs/guides/api-reference.md)
7
7
 
@@ -225,9 +225,9 @@ The package has **zero runtime dependencies** and two entry points:
225
225
  | `di-bag/node` | The same API with detection configured explicitly at import, for Node and Bun. |
226
226
 
227
227
  On hosts without `process.getBuiltinModule` (browsers, workers), `build()`
228
- rejects automatic acquisition stages unless you configure a trusted classifier
229
- or give each stage an explicit acquisition mode. See
230
- [portable mode](docs/guides/tutorial.md#portable-mode) for both options.
228
+ rejects automatic acquisition stages and names them. Register with
229
+ `DiBag.fromSyncFactory` / `DiBag.fromAsyncFactory` there, or configure a trusted
230
+ classifier. See [portable mode](docs/guides/tutorial.md#portable-mode).
231
231
 
232
232
  ## Tradeoffs and limits
233
233
 
@@ -242,13 +242,16 @@ or give each stage an explicit acquisition mode. See
242
242
  - **Type safety follows the declared graph.** Casts, unchecked JavaScript, and
243
243
  unknown plugins need appropriate runtime checks. Dependency cycles are detected
244
244
  at runtime, or before running by [`di-bag-graph`](tools/graph/README.md).
245
- - **Graph types have a compiler cost.** Very long fluent expressions can exceed
246
- compiler limits. Classic TypeScript still fails the recorded 1,000-call named
247
- registration and replacement cases; use bulk registration or smaller groups.
248
- See the [compiler evidence](docs/benchmarks/typescript.md) for tested forms and limits.
245
+ - **Graph types have a compiler cost.** One fluent expression is bounded by the
246
+ compiler's recursion budget: classic TypeScript 6.0.3 accepts about 1,000
247
+ chained calls and overflows beyond that (about 950 for a bulk map followed by
248
+ individual replacements); native 7.0.2 has no such ceiling. Keep an
249
+ expression to 500 calls or fewer and use bulk registration, groups, or named
250
+ modules beyond that. See the [compiler evidence](docs/benchmarks/typescript.md).
249
251
  - **Framework integration belongs to the application.** DI Bag provides the
250
252
  composition and ownership primitives; the host connects request, job, or UI
251
- lifecycles.
253
+ lifecycles. The [server guide](docs/guides/server-integration.md) and the
254
+ [React guide](docs/guides/react-integration.md) are tested recipes for both.
252
255
 
253
256
  ## Explore further
254
257
 
@@ -257,6 +260,7 @@ or give each stage an explicit acquisition mode. See
257
260
  | [Complete tutorial](docs/guides/tutorial.md) | Learn every public API through examples, from first composition to advanced ownership. |
258
261
  | [API reference](docs/guides/api-reference.md) | Exact generated signatures, overloads, type parameters, and API inventories. |
259
262
  | [Server guide](docs/guides/server-integration.md) | Node HTTP, Express, Fastify, Bun, and Deno: shared services, request scopes, startup, and shutdown. |
263
+ | [React guide](docs/guides/react-integration.md) | Browser applications: one app runtime at bootstrap, project runtimes owned from effects, Strict Mode, cancellation, bounded teardown, and `useSyncExternalStore`. |
260
264
  | [Radical modularity](docs/guides/examples-modularity.md) | The recommended module layout, separately owned features, isolated tests, and contributed tools. |
261
265
  | [Agent docs](AGENTS.md) | Rules, module layout, and check commands for coding agents, with [recipes](docs/agent/recipes.md) and [errors](docs/agent/errors.md). Shipped in the package. |
262
266
  | [Agent harnesses and graphs](docs/guides/agent-harnesses-and-graphs.md) | One worked application: model and tool modules, metadata inspection, and node tests with typed fixtures. |
@@ -1,16 +1,39 @@
1
1
  import type { Provider } from './provider';
2
2
  import type { Factory } from './registration';
3
- import type { Acquired, AcquisitionMode, AutoOutput, NativeOutput, ModeOptions } from './acquisition-mode';
3
+ import type { Acquired, AcquisitionMode, AsyncOutput, AutoOutput, NativeOutput, ModeOptions, SyncOutput } from './acquisition-mode';
4
4
  import type { TokenDependencyContract } from './token-types';
5
5
  /**
6
- * Cooperative cancellation information supplied to a context-aware acquisition.
6
+ * Why a pushed disposer is running: the factory never returned, or it did and the
7
+ * service disposer — the `withDisposal` on the value this factory returned — has
8
+ * just run. Ownership a consumer attaches to a transformed value does not count.
9
+ * @see https://dany-fedorov.github.io/di-bag/guides/tutorial.html#release-partial-acquisition
10
+ */
11
+ export interface DisposerContext {
12
+ /**
13
+ * `'factory-failed'`: the factory threw, rejected, or was cancelled; no service exists.
14
+ * `'no-service-disposer'`: the factory returned and no `withDisposal` owns that value.
15
+ * `'service-disposed'`: the service disposer ran without throwing.
16
+ * `'service-disposal-failed'`: the service disposer threw; pushed disposers still run.
17
+ */
18
+ readonly reason: 'factory-failed' | 'no-service-disposer' | 'service-disposed' | 'service-disposal-failed';
19
+ }
20
+ /**
21
+ * Cooperative cancellation and acquisition-local ownership supplied to a context-aware factory.
7
22
  * @see https://dany-fedorov.github.io/di-bag/guides/tutorial.html#start-selected-services-and-cancel-cooperatively
8
23
  */
9
24
  export interface AcquisitionContext {
10
25
  /** Aborted when the acquisition's owning scope begins closing. */
11
26
  readonly signal: AbortSignal;
27
+ /**
28
+ * Own a resource this factory has already acquired. Pushed disposers run exactly once, last
29
+ * pushed first: at once if the factory fails, otherwise at `close()` after every disposer of the
30
+ * service, with `disposerCtx.reason` saying which. `withDisposal` owns the returned value; push
31
+ * what is acquired on the way, and test `reason` before releasing the returned value itself.
32
+ * @param disposer - Releases the resource acquired immediately before this call.
33
+ */
34
+ pushDisposer(this: void, disposer: (this: void, disposerCtx: DisposerContext) => void | Promise<void>): void;
12
35
  }
13
- type ContextFactory = (this: void, deps: never, context: AcquisitionContext) => unknown;
36
+ type ContextFactory = (this: void, deps: never, factoryCtx: AcquisitionContext) => unknown;
14
37
  /**
15
38
  * The named-dependency factory contract retained by an acquisition-context callback.
16
39
  * @see https://dany-fedorov.github.io/di-bag/guides/tutorial.html#start-selected-services-and-cancel-cooperatively
@@ -32,7 +55,7 @@ type FactoryOptions<M extends AcquisitionMode> = 'auto' extends M ? [options?: {
32
55
  * @typeParam F - The complete callback signature, retaining dependency and output inference.
33
56
  * @typeParam M - The raw, nativePromise, or configured auto acquisition policy.
34
57
  */
35
- export declare function fromFactory<F extends (this: void, deps: never, context: AcquisitionContext) => ('nativePromise' extends M ? Promise<unknown> : unknown), M extends AcquisitionMode = 'auto'>(callback: F & AutoOutput<ReturnType<NoInfer<F>>, NoInfer<M>>, options: {
58
+ export declare function fromFactory<F extends (this: void, deps: never, factoryCtx: AcquisitionContext) => ('nativePromise' extends M ? Promise<unknown> : unknown), M extends AcquisitionMode = 'auto'>(callback: F & AutoOutput<ReturnType<NoInfer<F>>, NoInfer<M>>, options: {
36
59
  readonly context: 'acquisition';
37
60
  } & ModeOptions<M>): Provider<ContextualFactory<F>, Readonly<{}>, readonly [], TokenDependencyContract, Acquired<ReturnType<F>, M>>;
38
61
  /**
@@ -45,4 +68,50 @@ export declare function fromFactory<F extends (this: void, deps: never, context:
45
68
  * @typeParam M - The raw, nativePromise, or configured auto acquisition policy.
46
69
  */
47
70
  export declare function fromFactory<F extends Factory, M extends AcquisitionMode = 'auto'>(callback: F & NativeOutput<ReturnType<NoInfer<F>>, NoInfer<M>> & AutoOutput<ReturnType<NoInfer<F>>, NoInfer<M>>, ...options: FactoryOptions<M>): Provider<F, Readonly<{}>, readonly [], TokenDependencyContract, Acquired<ReturnType<F>, M>>;
71
+ type PortableFactoryOptions = {
72
+ readonly context?: never;
73
+ readonly acquisitionMode?: never;
74
+ };
75
+ type ContextualPortableFactoryOptions = {
76
+ readonly context: 'acquisition';
77
+ readonly acquisitionMode?: never;
78
+ };
79
+ /**
80
+ * Describe a synchronous named-dependency factory that runs on every host: a `raw` stage whose
81
+ * exact return value is the service, so `then` is never read and no Promise classifier is needed.
82
+ * @param callback - A receiver-free factory taking its named dependency object and the acquisition context.
83
+ * @param options - `context: 'acquisition'`; the acquisition mode is fixed and `acquisitionMode` is rejected.
84
+ * @returns A lazy provider preserving exact output and named dependencies; adds no ownership.
85
+ * @typeParam F - The complete callback signature, retaining dependency and output inference.
86
+ */
87
+ export declare function fromSyncFactory<F extends ContextFactory>(callback: F & SyncOutput<ReturnType<NoInfer<F>>>, options: ContextualPortableFactoryOptions): Provider<ContextualFactory<F>, Readonly<{}>, readonly [], TokenDependencyContract, ReturnType<F>>;
88
+ /**
89
+ * Describe a synchronous named-dependency factory that runs on every host: a `raw` stage whose
90
+ * exact return value is the service. A Promise or thenable output is rejected at compile time;
91
+ * use `fromAsyncFactory`, or `fromFactory` with `acquisitionMode: 'raw'` when the Promise object is the service.
92
+ * @param callback - A receiver-free factory taking its named dependency object.
93
+ * @param options - Optional; `acquisitionMode` is rejected because the helper fixes it.
94
+ * @returns A lazy provider retaining exact output and dependency types without adding ownership.
95
+ * @typeParam F - The exact factory signature and exposed result.
96
+ */
97
+ export declare function fromSyncFactory<F extends Factory>(callback: F & SyncOutput<ReturnType<NoInfer<F>>>, options?: PortableFactoryOptions): Provider<F, Readonly<{}>, readonly [], TokenDependencyContract, ReturnType<F>>;
98
+ /**
99
+ * Describe an asynchronous named-dependency factory that runs on every host: a `nativePromise`
100
+ * stage whose service is the returned Promise and whose owners receive the fulfilled value.
101
+ * @param callback - A receiver-free async factory taking its named dependency object and the acquisition context.
102
+ * @param options - `context: 'acquisition'`; the acquisition mode is fixed and `acquisitionMode` is rejected.
103
+ * @returns A lazy provider exposing the factory's own Promise; adds no ownership.
104
+ * @typeParam F - The complete callback signature, retaining dependency and output inference.
105
+ */
106
+ export declare function fromAsyncFactory<F extends (this: void, deps: never, factoryCtx: AcquisitionContext) => Promise<unknown>>(callback: F, options: ContextualPortableFactoryOptions): Provider<ContextualFactory<F>, Readonly<{}>, readonly [], TokenDependencyContract, Awaited<ReturnType<F>>>;
107
+ /**
108
+ * Describe an asynchronous named-dependency factory that runs on every host: a `nativePromise`
109
+ * stage whose service is the returned Promise and whose owners receive the fulfilled value.
110
+ * A non-Promise output is rejected at compile time; a thenable that is not a native Promise fails the acquisition with a `TypeError`.
111
+ * @param callback - A receiver-free factory returning a native Promise.
112
+ * @param options - Optional; `acquisitionMode` is rejected because the helper fixes it.
113
+ * @returns A lazy provider exposing the factory's own Promise; `withDisposal` receives its fulfilled value.
114
+ * @typeParam F - The exact factory signature and exposed Promise.
115
+ */
116
+ export declare function fromAsyncFactory<F extends Factory>(callback: F & AsyncOutput<ReturnType<NoInfer<F>>>, options?: PortableFactoryOptions): Provider<F, Readonly<{}>, readonly [], TokenDependencyContract, Awaited<ReturnType<F>>>;
48
117
  export {};
@@ -1,6 +1,8 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.fromFactory = fromFactory;
4
+ exports.fromSyncFactory = fromSyncFactory;
5
+ exports.fromAsyncFactory = fromAsyncFactory;
4
6
  const errors_1 = require("./errors");
5
7
  const provider_1 = require("./provider");
6
8
  const provider_operations_1 = require("./provider-operations");
@@ -11,9 +13,35 @@ function fromFactory(callback, options) {
11
13
  const mode = (0, acquisition_mode_1.acquisitionMode)(options);
12
14
  if (options?.context !== undefined && options.context !== 'acquisition')
13
15
  throw (0, errors_1.libraryError)('DI_BAG_INVALID_FACTORY', 'fromFactory context must be acquisition', { operation: 'fromFactory' });
14
- const contextual = options?.context === 'acquisition';
16
+ return factoryProvider(callback, mode, options?.context === 'acquisition');
17
+ }
18
+ /** Build the provider for one validated factory; `fromFactory` and both portable helpers end here. */
19
+ function factoryProvider(callback, mode, contextual) {
15
20
  const handle = (0, provider_1.createProvider)();
16
- const create = contextual ? ((deps, context) => callback(deps, context)) : callback;
21
+ const create = contextual ? ((deps, factoryCtx) => callback(deps, factoryCtx)) : callback;
17
22
  (0, provider_operations_1.retainDescription)(handle, (0, provider_operations_1.sourceDescription)(create, undefined, [], mode, contextual));
18
23
  return handle;
19
24
  }
25
+ /** Validate a portable helper's options; the mode is the helper's, so `acquisitionMode` is refused. */
26
+ function portableContext(operation, options) {
27
+ if (options === undefined)
28
+ return false;
29
+ if (typeof options !== 'object' || options === null)
30
+ throw (0, errors_1.libraryError)('DI_BAG_INVALID_FACTORY', `${operation} options must be an object`, { operation });
31
+ if ('acquisitionMode' in options)
32
+ throw (0, errors_1.libraryError)('DI_BAG_INVALID_FACTORY', `${operation} selects its acquisitionMode itself`, { operation });
33
+ const context = options.context;
34
+ if (context !== undefined && context !== 'acquisition')
35
+ throw (0, errors_1.libraryError)('DI_BAG_INVALID_FACTORY', `${operation} context must be acquisition`, { operation });
36
+ return context === 'acquisition';
37
+ }
38
+ function fromSyncFactory(callback, options) {
39
+ if (typeof callback !== 'function')
40
+ throw (0, errors_1.libraryError)('DI_BAG_INVALID_FACTORY', 'fromSyncFactory requires a function', { operation: 'fromSyncFactory' });
41
+ return factoryProvider(callback, 'raw', portableContext('fromSyncFactory', options));
42
+ }
43
+ function fromAsyncFactory(callback, options) {
44
+ if (typeof callback !== 'function')
45
+ throw (0, errors_1.libraryError)('DI_BAG_INVALID_FACTORY', 'fromAsyncFactory requires a function', { operation: 'fromAsyncFactory' });
46
+ return factoryProvider(callback, 'nativePromise', portableContext('fromAsyncFactory', options));
47
+ }
@@ -1,5 +1,5 @@
1
1
  import type { LifecycleObservers } from './observers';
2
- import type { SeeErrors, StructuralThenable, Unsatisfied } from './types';
2
+ import type { IsAny, SeeErrors, StructuralThenable, Unsatisfied } from './types';
3
3
  /**
4
4
  * How an acquisition stage treats its returned value: configured classification,
5
5
  * the exact raw value, or an observed native Promise fulfillment.
@@ -34,8 +34,16 @@ export type StageOptions<M extends AcquisitionMode> = 'auto' extends M ? [option
34
34
  export type NativeOutput<O, M extends AcquisitionMode> = 'nativePromise' extends M ? [O] extends [Promise<unknown>] ? unknown : Unsatisfied<'nativePromise acquisition requires a Promise output', {}> : unknown;
35
35
  /** Reject a structural thenable output when the stage would classify it automatically. */
36
36
  export type AutoOutput<O, M extends AcquisitionMode> = 'auto' extends M ? true extends StructuralThenable<O> ? Unsatisfied<`factory output is a structural thenable; return a native Promise or select acquisitionMode raw or nativePromise${SeeErrors<'structural-thenable'>}`, {}> : unknown : unknown;
37
+ type PromiseOutput<O> = O extends infer T & {} ? T extends Promise<unknown> ? true : false : false;
38
+ /** Reject a Promise or thenable output where the helper declares the stage synchronous; `any` is exempt. */
39
+ export type SyncOutput<O> = IsAny<O> extends true ? unknown : true extends PromiseOutput<O> | StructuralThenable<O> ? Unsatisfied<`fromSyncFactory output must not be a Promise or thenable; use fromAsyncFactory for a Promise, or fromFactory with acquisitionMode raw to make the Promise object the service${SeeErrors<'portable-factory-output'>}`, {}> : unknown;
40
+ /** Require a Promise output where the helper declares the stage asynchronous. */
41
+ export type AsyncOutput<O> = [O] extends [Promise<unknown>] ? unknown : Unsatisfied<`fromAsyncFactory requires a Promise output; use fromSyncFactory for a synchronous value${SeeErrors<'portable-factory-output'>}`, {}>;
37
42
  export declare function acquisitionMode(options: {
38
43
  readonly acquisitionMode?: AcquisitionMode;
39
44
  } | undefined, fallback?: AcquisitionMode): AcquisitionMode;
40
45
  /** Resolve the classifier when a graph first needs one; a configured classifier always wins. */
41
- export declare function requireClassifier(context: RuntimeContext): RuntimeContext;
46
+ export declare function resolveClassifier(context: RuntimeContext): RuntimeContext | undefined;
47
+ /** The graph has automatic stages and no classifier; `bindings` labels every such registration. */
48
+ export declare function classifierRequired(bindings: readonly string[]): Error;
49
+ export {};
@@ -3,7 +3,8 @@ Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.unconfigured = void 0;
4
4
  exports.runtimeContext = runtimeContext;
5
5
  exports.acquisitionMode = acquisitionMode;
6
- exports.requireClassifier = requireClassifier;
6
+ exports.resolveClassifier = resolveClassifier;
7
+ exports.classifierRequired = classifierRequired;
7
8
  const errors_1 = require("./errors");
8
9
  exports.unconfigured = Object.freeze({});
9
10
  function runtimeContext(options, previous = exports.unconfigured) {
@@ -43,11 +44,18 @@ function hostClassifier() {
43
44
  return typeof isPromise === 'function' ? isPromise : undefined;
44
45
  }
45
46
  /** Resolve the classifier when a graph first needs one; a configured classifier always wins. */
46
- function requireClassifier(context) {
47
+ function resolveClassifier(context) {
47
48
  if (context.isNativePromise)
48
49
  return context;
49
50
  const isNativePromise = hostClassifier();
50
- if (isNativePromise)
51
- return Object.freeze({ ...context, isNativePromise });
52
- throw (0, errors_1.libraryError)('DI_BAG_CLASSIFIER_REQUIRED', 'this host has no process.getBuiltinModule; configure DiBag.withConfiguration({ runtime: { isNativePromise } }) or give each automatic registration an explicit acquisitionMode', { option: 'runtime.isNativePromise' });
51
+ return isNativePromise ? Object.freeze({ ...context, isNativePromise }) : undefined;
52
+ }
53
+ const namedBindings = 8;
54
+ /** The graph has automatic stages and no classifier; `bindings` labels every such registration. */
55
+ function classifierRequired(bindings) {
56
+ const sorted = [...bindings].sort();
57
+ const shown = sorted.slice(0, namedBindings).map(label => JSON.stringify(label)).join(', ');
58
+ const rest = sorted.length - Math.min(sorted.length, namedBindings);
59
+ const count = sorted.length === 1 ? '1 registration uses' : `${sorted.length} registrations use`;
60
+ return (0, errors_1.libraryError)('DI_BAG_CLASSIFIER_REQUIRED', `this host has no process.getBuiltinModule; ${count} automatic acquisition: ${shown}${rest ? `, and ${rest} more` : ''}; use DiBag.fromSyncFactory or DiBag.fromAsyncFactory (or an explicit acquisitionMode) for each, or configure DiBag.withConfiguration({ runtime: { isNativePromise } })`, { option: 'runtime.isNativePromise', bindings: Object.freeze(sorted) });
53
61
  }
@@ -16,7 +16,6 @@ export declare class ScopeAcquisitions {
16
16
  private state;
17
17
  private closing;
18
18
  private controller;
19
- private acquisitionContext;
20
19
  private cancellationStarted;
21
20
  private cancellationCause;
22
21
  private readonly shared;
@@ -41,7 +40,17 @@ export declare class ScopeAcquisitions {
41
40
  close(beforeDispose?: Promise<void>, cause?: unknown): Promise<void>;
42
41
  /** Labels of this scope's running disposers and of acquisitions close is still draining. */
43
42
  collectProgress(pending: string[], acquiring: string[]): void;
44
- private getContext;
43
+ /** One controller per scope; every acquisition observes the same cancellation. */
44
+ private cancellationSignal;
45
+ /**
46
+ * The signal is scope-wide; deferred cleanup is local to this attempt, so each
47
+ * contextual acquisition receives its own frozen context. The context captures
48
+ * only its disposer stack, never the execution or this scope, so an
49
+ * application that retains it past `close()` retains nothing else. Built here
50
+ * rather than in `resolveBinding` for the same reason: every closure of a
51
+ * function shares one scope, and a factory can retain the dependency proxy.
52
+ */
53
+ private acquisitionContext;
45
54
  private resolveBinding;
46
55
  private eventFields;
47
56
  private observeAttempt;
@@ -5,6 +5,19 @@ const errors_1 = require("./errors");
5
5
  const errors_2 = require("./errors");
6
6
  const provider_execution_1 = require("./provider-execution");
7
7
  const acquisition_family_1 = require("./acquisition-family");
8
+ /**
9
+ * The reason a close() without a cause aborts with. Built once at load: an error
10
+ * created inside close() keeps an unformatted stack whose frames retain the
11
+ * closing callbacks, and through them the scope and its graph, on every signal an
12
+ * application kept after the bag closed. It stays a plain `AbortError`, so its
13
+ * legacy numeric `code` is what an automatic abort reason had; the message names
14
+ * the diagnostic.
15
+ */
16
+ const closingReason = (() => {
17
+ const reason = new DOMException((0, errors_1.diagnosticMessage)('DI_BAG_CLOSING', 'bag is closing'), 'AbortError');
18
+ void reason.stack; // format the load-time frames now, so nothing is retained lazily
19
+ return Object.freeze(reason);
20
+ })();
8
21
  /** Mutable, runtime-local attempts. Binding descriptions never carry ownership. */
9
22
  class ScopeAcquisitions {
10
23
  graph;
@@ -21,7 +34,6 @@ class ScopeAcquisitions {
21
34
  state = 'open';
22
35
  closing;
23
36
  controller;
24
- acquisitionContext;
25
37
  cancellationStarted = false;
26
38
  cancellationCause;
27
39
  shared;
@@ -132,8 +144,8 @@ class ScopeAcquisitions {
132
144
  this.closing = Promise.resolve().then(() => {
133
145
  // Every descendant admission gate is closed before abort listeners run.
134
146
  this.cancellationStarted = true;
135
- this.cancellationCause = cause;
136
- this.controller?.abort(cause);
147
+ this.cancellationCause = cause === undefined ? closingReason : cause;
148
+ this.controller?.abort(this.cancellationCause);
137
149
  return this.disposeAll(beforeDispose);
138
150
  });
139
151
  return this.closing;
@@ -141,20 +153,34 @@ class ScopeAcquisitions {
141
153
  /** Labels of this scope's running disposers and of acquisitions close is still draining. */
142
154
  collectProgress(pending, acquiring) {
143
155
  for (const attempt of this.attempts.values()) {
144
- if (attempt.state === 'disposing' || this.retired.has(attempt.id))
156
+ if (attempt.state === 'disposing' || this.retired.has(attempt.id) || attempt.execution.rollingBack)
145
157
  pending.push(attempt.label);
146
158
  else if (attempt.state === 'creating' || attempt.state === 'pending')
147
159
  acquiring.push(attempt.label);
148
160
  }
149
161
  }
150
- getContext() {
151
- if (!this.acquisitionContext) {
162
+ /** One controller per scope; every acquisition observes the same cancellation. */
163
+ cancellationSignal() {
164
+ if (!this.controller) {
152
165
  this.controller = new AbortController();
153
166
  if (this.cancellationStarted)
154
167
  this.controller.abort(this.cancellationCause);
155
- this.acquisitionContext = Object.freeze({ signal: this.controller.signal });
156
168
  }
157
- return this.acquisitionContext;
169
+ return this.controller.signal;
170
+ }
171
+ /**
172
+ * The signal is scope-wide; deferred cleanup is local to this attempt, so each
173
+ * contextual acquisition receives its own frozen context. The context captures
174
+ * only its disposer stack, never the execution or this scope, so an
175
+ * application that retains it past `close()` retains nothing else. Built here
176
+ * rather than in `resolveBinding` for the same reason: every closure of a
177
+ * function shares one scope, and a factory can retain the dependency proxy.
178
+ */
179
+ acquisitionContext(disposers) {
180
+ return Object.freeze({
181
+ signal: this.cancellationSignal(),
182
+ pushDisposer: (disposer) => { disposers.push(disposer); },
183
+ });
158
184
  }
159
185
  resolveBinding(bindingId, from, path = []) {
160
186
  // Sharing an alias borrows its lexical parent graph before following targets.
@@ -271,6 +297,7 @@ class ScopeAcquisitions {
271
297
  this.observeAttempt(attempt, 'acquisition-started');
272
298
  this.family.enter(attempt);
273
299
  const directSource = !description.contextual && !description.operations.length;
300
+ const { disposers } = execution;
274
301
  try {
275
302
  let value;
276
303
  if (directSource) {
@@ -279,7 +306,7 @@ class ScopeAcquisitions {
279
306
  execution.publishSource(value, description);
280
307
  }
281
308
  else {
282
- value = execution.evaluate(description, deps, () => this.getContext());
309
+ value = execution.evaluate(description, deps, disposers && this.acquisitionContext(disposers));
283
310
  }
284
311
  attempt.exposed = value;
285
312
  attempt.state = attempt.execution.state;
package/dist/di-bag.d.ts CHANGED
@@ -10,7 +10,7 @@ import type { CompositionReport } from './composition-report';
10
10
  import type { CheckedConstraints, CompleteConstraints, ExternalRequirements, IncrementalConstraints, ModulePublicProviders, ModuleSealedConstraints, NeedConstraint } from './module-types';
11
11
  import type { CheckedLifetimes, SealAdmission, WithoutExportObligations } from './lifetime-types';
12
12
  import { withLifetime } from './lifetime';
13
- import { fromFactory } from './acquisition-context';
13
+ import { fromFactory, fromSyncFactory, fromAsyncFactory } from './acquisition-context';
14
14
  import type { ScopeOptions, DisjointScopeSelection, UnsharedAliases, ScopedAliases } from './scope-types';
15
15
  import type { CheckedScopeLifetimes } from './lifetime-types';
16
16
  import type { CloseOptions, StartupOptions } from './startup';
@@ -399,6 +399,29 @@ export interface DiBagApi {
399
399
  * ```
400
400
  */
401
401
  fromFactory: typeof fromFactory;
402
+ /**
403
+ * Describe a synchronous factory that runs on every host: the exact return value is the service and `then` is never read.
404
+ * A Promise or thenable output is rejected at compile time; use `fromAsyncFactory`, or `fromFactory` with `acquisitionMode: 'raw'` when the Promise object itself is the service.
405
+ * @throws `DI_BAG_INVALID_FACTORY` for a non-function, an unknown `context`, or an `acquisitionMode` option.
406
+ * @example
407
+ * ```ts
408
+ * const config = DiBag.fromSyncFactory(() => ({ url: 'memory:' }));
409
+ * ```
410
+ */
411
+ fromSyncFactory: typeof fromSyncFactory;
412
+ /**
413
+ * Describe an asynchronous factory that runs on every host: the service is the returned native Promise and `withDisposal` receives its fulfilled value.
414
+ * A non-Promise output is rejected at compile time; a thenable that is not a native Promise fails the acquisition with a `TypeError`.
415
+ * @throws `DI_BAG_INVALID_FACTORY` for a non-function, an unknown `context`, or an `acquisitionMode` option.
416
+ * @example
417
+ * ```ts
418
+ * const db = DiBag.withDisposal(
419
+ * DiBag.fromAsyncFactory(async ({ config }: { config: { url: string } }) => ({ url: config.url, end: async () => {} })),
420
+ * db => db.end(),
421
+ * );
422
+ * ```
423
+ */
424
+ fromAsyncFactory: typeof fromAsyncFactory;
402
425
  /**
403
426
  * Create a typed token from a unique symbol; `.of<Service>()` fixes its service type.
404
427
  * @throws `DI_BAG_INVALID_TOKEN` when the key is not a symbol.
@@ -536,6 +559,6 @@ export interface DiBagApi {
536
559
  }
537
560
  /**
538
561
  * The immutable DI Bag facade. `auto` acquisition uses the host classifier where `process.getBuiltinModule`
539
- * exists; elsewhere configure one or use explicit modes.
562
+ * exists; elsewhere register with `fromSyncFactory` and `fromAsyncFactory`, use explicit modes, or configure a classifier.
540
563
  */
541
564
  export declare const DiBag: DiBagApi;
package/dist/di-bag.js CHANGED
@@ -279,13 +279,13 @@ function facade(context) {
279
279
  }
280
280
  return facade(configured);
281
281
  },
282
- fromFactory: acquisition_context_1.fromFactory, token: tokens_1.token, optional: dependency_references_1.optional, lazy: dependency_references_1.lazy, all: dependency_references_1.all, fromPlugin: plugins_1.fromPlugin, fromFunction: composition_1.fromFunction, fromClass: composition_1.fromClass,
282
+ fromFactory: acquisition_context_1.fromFactory, fromSyncFactory: acquisition_context_1.fromSyncFactory, fromAsyncFactory: acquisition_context_1.fromAsyncFactory, token: tokens_1.token, optional: dependency_references_1.optional, lazy: dependency_references_1.lazy, all: dependency_references_1.all, fromPlugin: plugins_1.fromPlugin, fromFunction: composition_1.fromFunction, fromClass: composition_1.fromClass,
283
283
  createBuilder: () => new Builder(new runtime_1.BindingGraph(), context),
284
284
  withDisposal: registration_1.withDisposal, withLifetime: lifetime_1.withLifetime, withMetadata: provider_1.withMetadata, transformService: provider_1.transformService,
285
285
  });
286
286
  }
287
287
  /**
288
288
  * The immutable DI Bag facade. `auto` acquisition uses the host classifier where `process.getBuiltinModule`
289
- * exists; elsewhere configure one or use explicit modes.
289
+ * exists; elsewhere register with `fromSyncFactory` and `fromAsyncFactory`, use explicit modes, or configure a classifier.
290
290
  */
291
291
  exports.DiBag = facade(acquisition_mode_1.unconfigured);
package/dist/index.d.ts CHANGED
@@ -9,7 +9,7 @@ export type { FactoryWithDisposal, Registration } from './registration';
9
9
  export type { Provider, ProviderFactory, ProviderGraphContract, ProviderOutput, ProviderAcquiredValue, ProviderNamedDependencies, ProviderRegistrationMetadata, ProviderAcquisitionMetadata, ProviderRequiredTokens, ProviderOptionalTokens, ProviderCollectionTokens } from './provider';
10
10
  export type { AcquisitionMode, RuntimeOptions } from './acquisition-mode';
11
11
  export type { Lifetime } from './lifetime';
12
- export type { AcquisitionContext, ContextualFactory } from './acquisition-context';
12
+ export type { AcquisitionContext, ContextualFactory, DisposerContext } from './acquisition-context';
13
13
  export type { CloseOptions, StartupOptions } from './startup';
14
14
  export type { ScopeOptions, DisjointScopeSelection, UnsharedAliases, ScopedAliases, SharedAliasProviders } from './scope-types';
15
15
  export type { Token, TokenBase, TokenKey, TokenService } from './tokens';
@@ -1,8 +1,26 @@
1
1
  import type { normalize } from './provider-operations';
2
2
  import type { AcquisitionMetadataPresence } from './inspection';
3
3
  import type { RuntimeContext } from './acquisition-mode';
4
- import type { AcquisitionContext } from './acquisition-context';
4
+ import type { AcquisitionContext, DisposerContext } from './acquisition-context';
5
5
  type RegistrationDescription = ReturnType<typeof normalize>;
6
+ type PushedDisposer = (this: void, disposerCtx: DisposerContext) => void | Promise<void>;
7
+ /**
8
+ * One factory's pushed disposers. It is held by the frozen acquisition context
9
+ * handed to that factory, so it deliberately references neither the execution
10
+ * nor its scope: a context the application retains must keep nothing but its
11
+ * own registrations alive.
12
+ */
13
+ export declare class DisposerStack {
14
+ private readonly disposers;
15
+ private settled;
16
+ /** Own a resource the running factory already holds. */
17
+ push(disposer: PushedDisposer): void;
18
+ /** The factory settled; nothing more can be pushed. */
19
+ settle(): void;
20
+ get pending(): boolean;
21
+ /** Take every pushed disposer, last pushed first, and close registration for good. */
22
+ drain(): readonly PushedDisposer[];
23
+ }
6
24
  interface ExecutionEvents {
7
25
  accepted(): void;
8
26
  settled(): void;
@@ -18,6 +36,8 @@ export declare class CompletedExecution {
18
36
  readonly state = "ready";
19
37
  readonly sourceInFlight = false;
20
38
  readonly hasOwnership = false;
39
+ readonly rollingBack = false;
40
+ readonly disposers: undefined;
21
41
  readonly error: undefined;
22
42
  readonly work: readonly Promise<void>[];
23
43
  constructor(frames: AcquisitionMetadataPresence<readonly unknown[]>);
@@ -32,6 +52,10 @@ export declare class ProviderExecution {
32
52
  private readonly context;
33
53
  private readonly frames;
34
54
  private readonly stages;
55
+ /** Allocated only for a context-aware factory, which is the only source that can push disposers. */
56
+ readonly disposers: DisposerStack | undefined;
57
+ private disposersOwned;
58
+ private rollback;
35
59
  private readonly pending;
36
60
  private result;
37
61
  private cleaning;
@@ -41,13 +65,25 @@ export declare class ProviderExecution {
41
65
  get state(): 'pending' | 'ready' | 'failed';
42
66
  get error(): unknown;
43
67
  get hasOwnership(): boolean;
68
+ get rollingBack(): boolean;
44
69
  get work(): readonly Promise<void>[];
70
+ /**
71
+ * The source stage settles the stack. A factory that returned hands what it
72
+ * pushed to the bag; a failed one releases it at once, as pending work of this
73
+ * execution. Anchoring on the source rather than on the attempt's result covers
74
+ * a direct projection that is already ready while its source is still running,
75
+ * which no retirement reaches.
76
+ */
77
+ private settleDisposers;
78
+ private rollbackDisposers;
79
+ /** Run every pushed disposer, last pushed first, all attempted; true when one threw. */
80
+ private runDisposers;
45
81
  /** Observe the selected stage, retaining its failure even after retirement. */
46
82
  ready(): Promise<void>;
47
83
  compact(): ProviderExecution | CompletedExecution;
48
84
  /** Classify/own only after the direct operation-free source call has returned. */
49
85
  publishSource(value: unknown, description: RegistrationDescription): void;
50
- evaluate(description: RegistrationDescription, deps: unknown, acquisitionContext: () => AcquisitionContext): unknown;
86
+ evaluate(description: RegistrationDescription, deps: unknown, context: AcquisitionContext | undefined): unknown;
51
87
  private consume;
52
88
  private capture;
53
89
  private own;