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.
@@ -1,7 +1,35 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.ProviderExecution = exports.CompletedExecution = void 0;
3
+ exports.ProviderExecution = exports.CompletedExecution = exports.DisposerStack = void 0;
4
4
  const errors_1 = require("./errors");
5
+ /**
6
+ * One factory's pushed disposers. It is held by the frozen acquisition context
7
+ * handed to that factory, so it deliberately references neither the execution
8
+ * nor its scope: a context the application retains must keep nothing but its
9
+ * own registrations alive.
10
+ */
11
+ class DisposerStack {
12
+ disposers = [];
13
+ settled = false;
14
+ /** Own a resource the running factory already holds. */
15
+ push(disposer) {
16
+ if (typeof disposer !== 'function')
17
+ throw (0, errors_1.libraryError)('DI_BAG_INVALID_CLEANUP', 'pushDisposer requires a function', { operation: 'pushDisposer', provided: typeof disposer });
18
+ // A retained context is a leak, not a stack: registration closes with the factory.
19
+ if (this.settled)
20
+ throw (0, errors_1.libraryError)('DI_BAG_CLEANUP_AFTER_FACTORY', 'pushDisposer is only available while its factory is running', { operation: 'pushDisposer' });
21
+ this.disposers.push(disposer);
22
+ }
23
+ /** The factory settled; nothing more can be pushed. */
24
+ settle() { this.settled = true; }
25
+ get pending() { return this.disposers.length > 0; }
26
+ /** Take every pushed disposer, last pushed first, and close registration for good. */
27
+ drain() {
28
+ this.settled = true;
29
+ return this.disposers.splice(0).reverse();
30
+ }
31
+ }
32
+ exports.DisposerStack = DisposerStack;
5
33
  const observePromise = (Promise.prototype.then);
6
34
  const emptyFrames = Object.freeze([]);
7
35
  const emptyWork = Object.freeze([]);
@@ -11,6 +39,8 @@ class CompletedExecution {
11
39
  state = 'ready';
12
40
  sourceInFlight = false;
13
41
  hasOwnership = false;
42
+ rollingBack = false;
43
+ disposers = undefined;
14
44
  error = undefined;
15
45
  work = emptyWork;
16
46
  constructor(frames) {
@@ -34,6 +64,12 @@ class ProviderExecution {
34
64
  context;
35
65
  frames;
36
66
  stages = [];
67
+ /** Allocated only for a context-aware factory, which is the only source that can push disposers. */
68
+ disposers;
69
+ // The factory returned, so the bag owns what it pushed, below every accepted stage.
70
+ disposersOwned = false;
71
+ // The failure-path run of the stack, while it is in flight.
72
+ rollback;
37
73
  pending = new Set();
38
74
  result;
39
75
  cleaning;
@@ -45,12 +81,68 @@ class ProviderExecution {
45
81
  // Reserve every metadata frame before the source can reenter inspection.
46
82
  this.frames = description.operations.filter(operation => operation.kind === 'frame-sync' || operation.kind === 'frame-async')
47
83
  .map(() => Object.freeze({ present: false }));
84
+ if (description.contextual)
85
+ this.disposers = new DisposerStack();
48
86
  }
49
87
  inspectFrames() { return Object.freeze([...this.frames]); }
50
88
  get state() { return this.result?.state ?? 'failed'; }
51
89
  get error() { return this.result?.error; }
52
- get hasOwnership() { return this.stages.length > 0; }
90
+ get hasOwnership() { return this.stages.length > 0 || this.disposersOwned; }
91
+ get rollingBack() { return this.rollback !== undefined; }
53
92
  get work() { return [...this.pending]; }
93
+ /**
94
+ * The source stage settles the stack. A factory that returned hands what it
95
+ * pushed to the bag; a failed one releases it at once, as pending work of this
96
+ * execution. Anchoring on the source rather than on the attempt's result covers
97
+ * a direct projection that is already ready while its source is still running,
98
+ * which no retirement reaches.
99
+ */
100
+ settleDisposers(state) {
101
+ if (!this.disposers)
102
+ return;
103
+ this.disposers.settle();
104
+ if (!this.disposers.pending)
105
+ return;
106
+ if (state === 'ready') {
107
+ this.disposersOwned = true;
108
+ this.events.accepted();
109
+ return;
110
+ }
111
+ const work = this.rollbackDisposers().then(() => {
112
+ this.rollback = undefined;
113
+ this.pending.delete(work);
114
+ if (!this.pending.size)
115
+ this.events.drained();
116
+ });
117
+ this.rollback = work;
118
+ this.pending.add(work);
119
+ }
120
+ async rollbackDisposers() {
121
+ // One microtask after the source settles: a synchronous failure never runs
122
+ // cleanup inline, and an unprojected attempt has already reported
123
+ // acquisition-failed. Under a projection the result settles later, so this
124
+ // run can precede both acquisition-failed and the consumer's rejection.
125
+ await undefined;
126
+ this.events.cleanupStarted?.();
127
+ const failed = await this.runDisposers('factory-failed');
128
+ this.events.cleanupCompleted?.(failed ? 'failure' : 'success');
129
+ }
130
+ /** Run every pushed disposer, last pushed first, all attempted; true when one threw. */
131
+ async runDisposers(reason) {
132
+ const disposerCtx = Object.freeze({ reason });
133
+ let failed = false;
134
+ for (const disposer of this.disposers?.drain() ?? []) {
135
+ const sequence = this.events.invoking();
136
+ try {
137
+ await disposer(disposerCtx);
138
+ }
139
+ catch (error) {
140
+ failed = true;
141
+ this.events.cleanupFailed(sequence, error);
142
+ }
143
+ }
144
+ return failed;
145
+ }
54
146
  /** Observe the selected stage, retaining its failure even after retirement. */
55
147
  async ready() {
56
148
  const result = this.result;
@@ -64,7 +156,13 @@ class ProviderExecution {
64
156
  compact() {
65
157
  if (this.state !== 'ready' || this.pending.size || this.hasOwnership)
66
158
  return this;
67
- return this.frames.length ? new CompletedExecution(this.inspectFrames()) : completedWithoutFrames;
159
+ if (!this.frames.length)
160
+ return completedWithoutFrames;
161
+ // The copy owns the frames from here; a retained reference to this record
162
+ // must not keep the application's frame payloads alive.
163
+ const completed = new CompletedExecution(this.inspectFrames());
164
+ this.frames.length = 0;
165
+ return completed;
68
166
  }
69
167
  /** Classify/own only after the direct operation-free source call has returned. */
70
168
  publishSource(value, description) {
@@ -72,32 +170,39 @@ class ProviderExecution {
72
170
  this.sourceInFlight = false;
73
171
  this.result = { exposed: undefined, consumed: true, state: 'ready', value: undefined, error: undefined, owners: [] };
74
172
  if (description.dispose)
75
- this.accept(0, value, description.dispose);
173
+ this.accept(0, value, description.dispose, true);
76
174
  return;
77
175
  }
78
176
  const stage = this.capture(() => value, true, description.acquisitionMode);
79
177
  if (description.dispose)
80
- this.own(stage, 0, description.dispose);
178
+ this.own(stage, 0, description.dispose, true);
81
179
  this.result = stage;
82
180
  this.consume(stage);
83
181
  if (stage.state === 'failed')
84
182
  throw stage.error;
85
183
  }
86
- evaluate(description, deps, acquisitionContext) {
184
+ evaluate(description, deps, context) {
87
185
  const { create, dispose } = description;
88
186
  let current = this.capture(() => description.contextual
89
- ? Reflect.apply(create, undefined, [deps, acquisitionContext()])
187
+ ? Reflect.apply(create, undefined, [deps, context])
90
188
  : create(deps), true, description.acquisitionMode);
189
+ // A pending source settles its own rollback list from the promise handler.
190
+ if (current.state !== 'pending')
191
+ this.settleDisposers(current.state);
91
192
  let nextFrame = 0;
193
+ // Ownership attached before the first transform owns the value the factory
194
+ // returned; metadata frames preserve the value, so they do not end it.
195
+ let returned = true;
92
196
  if (dispose)
93
- this.own(current, 0, dispose);
197
+ this.own(current, 0, dispose, true);
94
198
  description.operations.forEach((operation, offset) => {
95
199
  const inputStage = current;
96
200
  const index = offset + 1;
97
201
  if (operation.kind === 'owned') {
98
- this.own(current, index, operation.dispose);
202
+ this.own(current, index, operation.dispose, returned);
99
203
  }
100
204
  else if (operation.kind === 'map-sync') {
205
+ returned = false;
101
206
  if (current.state !== 'failed') {
102
207
  const input = current.exposed;
103
208
  const { project } = operation;
@@ -105,6 +210,7 @@ class ProviderExecution {
105
210
  }
106
211
  }
107
212
  else if (operation.kind === 'map-async') {
213
+ returned = false;
108
214
  const input = current;
109
215
  const { project } = operation;
110
216
  current = this.capture(async () => {
@@ -179,7 +285,7 @@ class ProviderExecution {
179
285
  if (!stage.consumed)
180
286
  stage.value = value;
181
287
  for (const owner of stage.owners)
182
- this.accept(owner.index, value, owner.dispose);
288
+ this.accept(owner.index, value, owner.dispose, owner.returned);
183
289
  stage.owners.length = 0;
184
290
  finish();
185
291
  }, error => {
@@ -193,8 +299,10 @@ class ProviderExecution {
193
299
  stage.settled = barrier;
194
300
  this.pending.add(barrier);
195
301
  const finish = () => {
196
- if (source)
302
+ if (source) {
197
303
  this.sourceInFlight = false;
304
+ this.settleDisposers(stage.state === 'ready' ? 'ready' : 'failed');
305
+ }
198
306
  this.pending.delete(barrier);
199
307
  settled();
200
308
  if (this.result === stage)
@@ -214,14 +322,14 @@ class ProviderExecution {
214
322
  return { exposed: undefined, consumed: true, state: 'failed', value: undefined, error, owners: [] };
215
323
  }
216
324
  }
217
- own(stage, index, dispose) {
325
+ own(stage, index, dispose, returned) {
218
326
  if (stage.state === 'ready')
219
- this.accept(index, stage.value, dispose);
327
+ this.accept(index, stage.value, dispose, returned);
220
328
  else if (stage.state === 'pending')
221
- stage.owners.push({ index, dispose });
329
+ stage.owners.push({ index, dispose, returned });
222
330
  }
223
- accept(index, value, dispose) {
224
- this.stages.push({ index, value, dispose, state: 'accepted' });
331
+ accept(index, value, dispose, returned) {
332
+ this.stages.push({ index, value, dispose, returned, state: 'accepted' });
225
333
  this.events.accepted();
226
334
  }
227
335
  dispose() {
@@ -237,12 +345,16 @@ class ProviderExecution {
237
345
  // All later acceptances must be known before reversing stable stage indices.
238
346
  while (this.pending.size)
239
347
  await Promise.all(this.pending);
240
- const owned = this.stages.length > 0;
348
+ const owned = this.hasOwnership;
241
349
  let failed = false;
350
+ let returnedOwned = false;
351
+ let returnedFailed = false;
242
352
  if (owned)
243
353
  this.events.cleanupStarted?.();
244
354
  for (const stage of this.stages.sort((a, b) => b.index - a.index)) {
245
355
  stage.state = 'disposing';
356
+ if (stage.returned)
357
+ returnedOwned = true;
246
358
  const sequence = this.events.invoking();
247
359
  const { dispose, value } = stage;
248
360
  try {
@@ -250,20 +362,36 @@ class ProviderExecution {
250
362
  }
251
363
  catch (error) {
252
364
  failed = true;
365
+ if (stage.returned)
366
+ returnedFailed = true;
253
367
  this.events.cleanupFailed(sequence, error);
254
368
  }
255
369
  finally {
256
370
  stage.state = 'disposed';
257
371
  }
258
372
  }
373
+ // Pushed disposers are the bottom of the ownership stack: they run after every
374
+ // accepted stage and are told how the returned value's own disposer went. A
375
+ // projection owner belongs to whoever transformed the value; its outcome is
376
+ // reported through cleanup-failed, not through the reason.
377
+ if (this.disposersOwned) {
378
+ const reason = !returnedOwned ? 'no-service-disposer' : returnedFailed ? 'service-disposal-failed' : 'service-disposed';
379
+ if (await this.runDisposers(reason))
380
+ failed = true;
381
+ this.disposersOwned = false;
382
+ }
259
383
  if (owned)
260
384
  this.events.cleanupCompleted?.(failed ? 'failure' : 'success');
261
385
  this.stages.length = 0;
262
386
  this.result = undefined;
263
387
  }
388
+ // Reached only after every barrier and rollback has drained; draining the stack
389
+ // here is for a context the application retained, never for a running one.
264
390
  release() {
265
391
  this.frames.length = 0;
266
392
  this.stages.length = 0;
393
+ this.disposers?.drain();
394
+ this.disposersOwned = false;
267
395
  this.pending.clear();
268
396
  this.result = undefined;
269
397
  }
package/dist/runtime.d.ts CHANGED
@@ -40,6 +40,7 @@ export declare class BindingGraph {
40
40
  /**
41
41
  * Return the context acquisitions use, resolving the host classifier before any factory runs.
42
42
  * Immutable graphs need explicit-mode validation only once; configured forks are O(1).
43
+ * A host without a classifier gets every automatic registration named, so the fix is one pass.
43
44
  */
44
45
  preflight(context: RuntimeContext): RuntimeContext;
45
46
  publicBinding(key: BindingKey): BindingId;
package/dist/runtime.js CHANGED
@@ -126,14 +126,25 @@ class BindingGraph {
126
126
  /**
127
127
  * Return the context acquisitions use, resolving the host classifier before any factory runs.
128
128
  * Immutable graphs need explicit-mode validation only once; configured forks are O(1).
129
+ * A host without a classifier gets every automatic registration named, so the fix is one pass.
129
130
  */
130
131
  preflight(context) {
131
132
  if (context.isNativePromise || this.#explicitlyClassified)
132
133
  return context;
133
- for (const [, { normalized: description }] of this.#bindings) {
134
- if (description.acquisitionMode === 'auto' || description.operations.some(operation => 'acquisitionMode' in operation && operation.acquisitionMode === 'auto'))
135
- return (0, acquisition_mode_1.requireClassifier)(context);
134
+ const automatic = [];
135
+ for (const [, { description, normalized }] of this.#bindings) {
136
+ if (normalized.acquisitionMode === 'auto' || normalized.operations.some(operation => 'acquisitionMode' in operation && operation.acquisitionMode === 'auto')) {
137
+ // The host answers once for the whole graph; only a host without a classifier needs the full list.
138
+ if (!automatic.length) {
139
+ const resolved = (0, acquisition_mode_1.resolveClassifier)(context);
140
+ if (resolved)
141
+ return resolved;
142
+ }
143
+ automatic.push(description.label);
144
+ }
136
145
  }
146
+ if (automatic.length)
147
+ throw (0, acquisition_mode_1.classifierRequired)(automatic);
137
148
  this.#explicitlyClassified = true;
138
149
  return context;
139
150
  }
package/dist/startup.js CHANGED
@@ -7,6 +7,15 @@ const runtime_1 = require("./runtime");
7
7
  const tokens_1 = require("./tokens");
8
8
  const errors_2 = require("./errors");
9
9
  /** Snapshot own cancellation options once, so getters and prototypes cannot change them later. */
10
+ /**
11
+ * Format a timeout error's stack before handing it on. An unformatted stack keeps
12
+ * the frames that created it alive, and these are closures over the runtime; a
13
+ * startup timeout also becomes the reason on every signal the bag handed out.
14
+ */
15
+ function formatted(error) {
16
+ void error.stack;
17
+ return error;
18
+ }
10
19
  function snapshotOptions(options, operation, code, supported) {
11
20
  if (options === undefined)
12
21
  return {};
@@ -79,7 +88,7 @@ function closeRuntime(runtime, options) {
79
88
  if (settled || timeoutMs === undefined)
80
89
  return;
81
90
  if (performance.now() - began >= timeoutMs) {
82
- cancel('timeout', (0, errors_1.diagnostic)(new DOMException((0, errors_1.diagnosticMessage)('DI_BAG_CLOSE_TIMEOUT', 'Bag close timed out'), 'TimeoutError'), 'DI_BAG_CLOSE_TIMEOUT', { operation: 'close', timeoutMs }));
91
+ cancel('timeout', formatted((0, errors_1.diagnostic)(new DOMException((0, errors_1.diagnosticMessage)('DI_BAG_CLOSE_TIMEOUT', 'Bag close timed out'), 'TimeoutError'), 'DI_BAG_CLOSE_TIMEOUT', { operation: 'close', timeoutMs })));
83
92
  return;
84
93
  }
85
94
  // Long deadlines must not wrap into an immediate timer on Node/Bun.
@@ -131,7 +140,7 @@ function startRuntime(graph, context, keys, options) {
131
140
  const checkCancellation = () => {
132
141
  aborted();
133
142
  if (!settled && timeoutMs !== undefined && performance.now() - began >= timeoutMs) {
134
- cancel('timeout', (0, errors_1.diagnostic)(new DOMException((0, errors_1.diagnosticMessage)('DI_BAG_STARTUP_TIMEOUT', 'Bag startup timed out'), 'TimeoutError'), 'DI_BAG_STARTUP_TIMEOUT', { operation: 'buildAndStart', timeoutMs }));
143
+ cancel('timeout', formatted((0, errors_1.diagnostic)(new DOMException((0, errors_1.diagnosticMessage)('DI_BAG_STARTUP_TIMEOUT', 'Bag startup timed out'), 'TimeoutError'), 'DI_BAG_STARTUP_TIMEOUT', { operation: 'buildAndStart', timeoutMs })));
135
144
  }
136
145
  return settled;
137
146
  };
package/dist/types.d.ts CHANGED
@@ -46,7 +46,7 @@ export interface DiBagPolicy {
46
46
  type StructuralThenablesAllowed = DiBagPolicy extends {
47
47
  readonly structuralThenables: 'allow';
48
48
  } ? true : false;
49
- type IsAny<T> = 0 extends 1 & T ? true : false;
49
+ export type IsAny<T> = 0 extends 1 & T ? true : false;
50
50
  /** True for a declared output with a callable `then` that is not a native Promise; `any` is exempt. */
51
51
  export type StructuralThenable<O> = StructuralThenablesAllowed extends true ? false : IsAny<O> extends true ? false : O extends infer T & {} ? T extends Promise<unknown> ? false : T extends {
52
52
  then(...args: never[]): unknown;
@@ -12,6 +12,8 @@ An example without an import line uses `import { DiBag } from 'di-bag';`. The ru
12
12
  | --- | --- |
13
13
  | Start a graph | [`DiBag.createBuilder()`](#dibag-createbuilder) |
14
14
  | Register a service | [`builder.register(more)`](#builder-register) |
15
+ | Register a synchronous factory for a browser or worker | [`DiBag.fromSyncFactory(callback, options)`](#dibag-fromsyncfactory) |
16
+ | Register an async factory for a browser or worker | [`DiBag.fromAsyncFactory(callback, options)`](#dibag-fromasyncfactory) |
15
17
  | Attach cleanup | [`DiBag.withDisposal(create, dispose)`](#dibag-withdisposal) |
16
18
  | Choose a lifetime | [`DiBag.withLifetime(registration, lifetime)`](#dibag-withlifetime) |
17
19
  | Seal a module | [`builder.buildModule(keys, options?)`](#builder-buildmodule) |
@@ -40,6 +42,21 @@ type Query = { then(done: (rows: string[]) => void): void };
40
42
  const query = DiBag.fromFactory((): Query => ({ then: done => done([]) }), { acquisitionMode: 'raw' });
41
43
  ```
42
44
 
45
+ ### `DiBag.fromSyncFactory(callback, options)` {#dibag-fromsyncfactory}
46
+ Describe a synchronous factory that runs on every host: the exact return value is the service and `then` is never read. Throws: [`DI_BAG_INVALID_FACTORY`](errors.md#di-bag-invalid-factory).
47
+ ```ts
48
+ const config = DiBag.fromSyncFactory(() => ({ url: 'memory:' }));
49
+ ```
50
+
51
+ ### `DiBag.fromAsyncFactory(callback, options)` {#dibag-fromasyncfactory}
52
+ Describe an asynchronous factory that runs on every host: the service is the returned native Promise and `withDisposal` receives its fulfilled value. Throws: [`DI_BAG_INVALID_FACTORY`](errors.md#di-bag-invalid-factory).
53
+ ```ts
54
+ const db = DiBag.withDisposal(
55
+ DiBag.fromAsyncFactory(async ({ config }: { config: { url: string } }) => ({ url: config.url, end: async () => {} })),
56
+ db => db.end(),
57
+ );
58
+ ```
59
+
43
60
  ### `DiBag.token(key)` {#dibag-token}
44
61
  Create a typed token from a unique symbol; `.of<Service>()` fixes its service type. Throws: [`DI_BAG_INVALID_TOKEN`](errors.md#di-bag-invalid-token).
45
62
  ```ts
@@ -190,6 +190,37 @@ DiBag.createBuilder()
190
190
 
191
191
  **Recipe:** [add and consume an async client](recipes.md#async-client).
192
192
 
193
+ ### Portable factory output {#portable-factory-output}
194
+
195
+ **When:** `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; see https://dany-fedorov.github.io/di-bag/agent/errors.html#portable-factory-output`,
196
+ or `fromAsyncFactory requires a Promise output; use fromSyncFactory for a synchronous value; see https://dany-fedorov.github.io/di-bag/agent/errors.html#portable-factory-output`.
197
+
198
+ **Cause:** the helper fixes the acquisition mode from its name, so the factory's
199
+ declared output must agree with it. `fromSyncFactory` is a `raw` stage that never
200
+ reads `then`: an `async` function, a `Promise`-returning function, a union with a
201
+ Promise member, or a thenable such as a query builder cannot be its service.
202
+ `fromAsyncFactory` is a `nativePromise` stage: a plain value, a union, or a
203
+ `PromiseLike` cannot be its service.
204
+
205
+ **Fix:** pick the helper that matches the output. When the Promise object itself
206
+ is the service, use `DiBag.fromFactory(create, { acquisitionMode: 'raw' })`.
207
+
208
+ ```ts
209
+ // expect-error: fromSyncFactory output must not be a Promise or thenable
210
+ import { DiBag } from 'di-bag';
211
+
212
+ const config = DiBag.fromSyncFactory(async () => ({ url: 'memory:' }));
213
+ ```
214
+
215
+ ```ts
216
+ import { DiBag } from 'di-bag';
217
+
218
+ const config = DiBag.fromAsyncFactory(async () => ({ url: 'memory:' }));
219
+ const ownedPromise = DiBag.fromFactory(() => Promise.resolve({ url: 'memory:' }), { acquisitionMode: 'raw' });
220
+ ```
221
+
222
+ **Recipe:** [make a graph portable to browsers and workers](recipes.md#portable-graph).
223
+
193
224
  ### Wrong shape at a call {#wrong-shape}
194
225
 
195
226
  **When:** `register`, `installModule`, or `replace` reports
@@ -244,22 +275,59 @@ app.fork(['port'], { port: () => 'eighty' });
244
275
  `process.getBuiltinModule`: browsers, Web Workers, and other non-Node runtimes.
245
276
  Node, Bun, and Deno never raise it.
246
277
 
247
- **Cause:** a factory uses automatic acquisition, no native-Promise classifier
248
- is configured, and the host offers none.
278
+ **Cause:** a registration uses automatic acquisition, no native-Promise classifier
279
+ is configured, and the host offers none. The message and `details.bindings` name
280
+ every such registration, sorted, with private module services as `<label>/<key>`;
281
+ a direct `transformService` without an `acquisitionMode` counts under its
282
+ registration's name.
249
283
 
250
- **Fix:** configure a trusted classifier with
251
- `withConfiguration({ runtime: { isNativePromise } })`, or give each automatic
252
- registration an explicit `acquisitionMode`.
284
+ **Fix:** register each named service with `DiBag.fromSyncFactory` or
285
+ `DiBag.fromAsyncFactory`; give `fromFunction`, `fromClass`, and direct
286
+ `transformService` an explicit `acquisitionMode`; or configure a trusted
287
+ classifier with `withConfiguration({ runtime: { isNativePromise } })`.
253
288
 
254
289
  ```ts
255
290
  import { DiBag } from 'di-bag';
256
291
 
257
292
  const app = DiBag.createBuilder()
258
- .register({ answer: DiBag.fromFactory(() => 42, { acquisitionMode: 'raw' }) })
293
+ .register({
294
+ answer: DiBag.fromSyncFactory(() => 42),
295
+ later: DiBag.fromAsyncFactory(async ({ answer }: { answer: number }) => answer * 2),
296
+ })
259
297
  .build();
260
298
  ```
261
299
 
262
- **Recipe:** none; see [rule 1](../../AGENTS.md#rules).
300
+ **Recipe:** [make a graph portable to browsers and workers](recipes.md#portable-graph).
301
+
302
+ ### DI_BAG_CLEANUP_AFTER_FACTORY {#di-bag-cleanup-after-factory}
303
+
304
+ **When:** `factoryCtx.pushDisposer(disposer)` throws because the factory that
305
+ owns the context has already returned or failed. Its projections may still be
306
+ running; the factory is the boundary, not the whole acquisition.
307
+
308
+ **Cause:** the acquisition context escaped its factory and was called later —
309
+ from the service it produced, from a projection of the registration, or from
310
+ inside a pushed disposer already running. A context belongs to one running
311
+ factory, not to the service it produced.
312
+
313
+ **Fix:** push inside the factory, immediately after acquiring the resource; own
314
+ the returned value with `DiBag.withDisposal`, and give a pushed disposer for that
315
+ same value a `reason` check.
316
+
317
+ ```ts
318
+ import { DiBag } from 'di-bag';
319
+
320
+ const handle = DiBag.withDisposal(
321
+ DiBag.fromFactory(async (_deps: {}, factoryCtx) => {
322
+ const socket = { close: async () => {} };
323
+ factoryCtx.pushDisposer(disposerCtx => { if (disposerCtx.reason !== 'service-disposed') return socket.close(); });
324
+ return socket;
325
+ }, { context: 'acquisition' }),
326
+ socket => socket.close(),
327
+ );
328
+ ```
329
+
330
+ **Recipe:** [own a resource a factory acquires on the way](recipes.md#partial-acquisition).
263
331
 
264
332
  ### DI_BAG_CLEANUP_FAILED {#di-bag-cleanup-failed}
265
333
 
@@ -390,7 +458,9 @@ await app.close();
390
458
  ### DI_BAG_CLOSING {#di-bag-closing}
391
459
 
392
460
  **When:** the same operations as [`DI_BAG_CLOSED`](#di-bag-closed), while
393
- `close()` is still in progress.
461
+ `close()` is still in progress. Also the message of `factoryCtx.signal.reason`
462
+ after `close()`: an `AbortError` that is the same object for every bag. A
463
+ cancelled or failed startup aborts with its own cause instead.
394
464
 
395
465
  **Cause:** a request, timer, or factory started new resolution after shutdown
396
466
  began. Only a factory already running when `close()` started may still read its
@@ -543,6 +613,29 @@ const DiBag = CoreDiBag.withConfiguration({
543
613
 
544
614
  **Recipe:** none.
545
615
 
616
+ ### DI_BAG_INVALID_CLEANUP {#di-bag-invalid-cleanup}
617
+
618
+ **When:** `factoryCtx.pushDisposer(disposer)` throws because `disposer` is not a
619
+ function.
620
+
621
+ **Cause:** a value was passed where a disposer callback belongs, usually the
622
+ result of calling the release instead of passing it.
623
+
624
+ **Fix:** pass a function: `factoryCtx.pushDisposer(() => socket.close())`, not
625
+ `factoryCtx.pushDisposer(socket.close())`.
626
+
627
+ ```ts
628
+ import { DiBag } from 'di-bag';
629
+
630
+ const socket = DiBag.fromFactory(async (_deps: {}, factoryCtx) => {
631
+ const handle = { close: async () => {} };
632
+ factoryCtx.pushDisposer(() => handle.close());
633
+ return handle;
634
+ }, { context: 'acquisition' });
635
+ ```
636
+
637
+ **Recipe:** [own a resource a factory acquires on the way](recipes.md#partial-acquisition).
638
+
546
639
  ### DI_BAG_INVALID_CLOSE {#di-bag-invalid-close}
547
640
 
548
641
  **When:** `close(options)` rejects because options are not
@@ -650,13 +743,16 @@ const east = reports.renameExport('service', 'eastReports');
650
743
 
651
744
  ### DI_BAG_INVALID_FACTORY {#di-bag-invalid-factory}
652
745
 
653
- **When:** `DiBag.fromFactory(callback, options)` receives a non-function, or a
654
- `context` option other than `'acquisition'`.
746
+ **When:** `DiBag.fromFactory`, `fromSyncFactory`, or `fromAsyncFactory` receives a
747
+ non-function, or a `context` option other than `'acquisition'`; the two portable
748
+ helpers also refuse an `acquisitionMode` option, because they fix it themselves.
655
749
 
656
- **Cause:** a value passed where a factory is expected.
750
+ **Cause:** a value passed where a factory is expected, or a mode passed to a
751
+ helper whose name already selects it.
657
752
 
658
753
  **Fix:** pass a function; use `{ context: 'acquisition' }` to receive the
659
- acquisition signal as the second argument.
754
+ acquisition context as the second argument; choose `fromSyncFactory` or
755
+ `fromAsyncFactory` instead of passing a mode to them.
660
756
 
661
757
  ```ts
662
758
  import { DiBag } from 'di-bag';