@codefast/di 0.8.1 → 0.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (56) hide show
  1. package/CHANGELOG.md +164 -0
  2. package/LICENSE +1 -1
  3. package/README.md +29 -20
  4. package/dist/ambient/active-container.d.ts +13 -0
  5. package/dist/ambient/active-container.js +24 -0
  6. package/dist/container/binding-builders.d.ts +42 -20
  7. package/dist/container/binding-builders.js +155 -87
  8. package/dist/container/container.d.ts +10 -10
  9. package/dist/container/container.js +55 -23
  10. package/dist/core/binding.d.ts +55 -57
  11. package/dist/core/binding.js +11 -37
  12. package/dist/core/constraint-requirement.d.ts +19 -4
  13. package/dist/core/constraint-requirement.js +19 -9
  14. package/dist/core/registry.d.ts +47 -4
  15. package/dist/core/registry.js +361 -159
  16. package/dist/core/state-epoch.d.ts +16 -0
  17. package/dist/core/state-epoch.js +21 -0
  18. package/dist/core/tag.d.ts +2 -2
  19. package/dist/core/tag.js +2 -2
  20. package/dist/core/token.d.ts +24 -4
  21. package/dist/core/token.js +1 -1
  22. package/dist/core/types.d.ts +15 -6
  23. package/dist/decorators/inject.d.ts +1 -1
  24. package/dist/decorators/inject.js +6 -4
  25. package/dist/errors/diagnostics.d.ts +2 -0
  26. package/dist/errors/errors.d.ts +33 -1
  27. package/dist/errors/errors.js +44 -3
  28. package/dist/index.d.ts +2 -2
  29. package/dist/index.js +1 -1
  30. package/dist/injection/descriptor.d.ts +16 -8
  31. package/dist/injection/descriptor.js +3 -1
  32. package/dist/injection/resolve-options.d.ts +6 -0
  33. package/dist/injection/resolve-options.js +16 -0
  34. package/dist/introspection/dependency-graph.js +10 -5
  35. package/dist/introspection/inspector.d.ts +3 -1
  36. package/dist/introspection/inspector.js +10 -24
  37. package/dist/lifecycle/lifecycle-manager.js +10 -8
  38. package/dist/lifecycle/scope-manager.js +1 -1
  39. package/dist/metadata/metadata-reader-token.js +1 -1
  40. package/dist/resolution/cache/activation-need.d.ts +2 -0
  41. package/dist/resolution/cache/activation-need.js +13 -6
  42. package/dist/resolution/cache/binding-lookup-cache.d.ts +34 -1
  43. package/dist/resolution/cache/binding-lookup-cache.js +96 -12
  44. package/dist/resolution/cache/class-introspector.d.ts +1 -1
  45. package/dist/resolution/cache/class-introspector.js +28 -14
  46. package/dist/resolution/context.d.ts +8 -8
  47. package/dist/resolution/plan/instantiation-plan.d.ts +12 -0
  48. package/dist/resolution/plan/instantiation-plan.js +156 -65
  49. package/dist/resolution/plan/plan-codegen.d.ts +100 -0
  50. package/dist/resolution/plan/plan-codegen.js +185 -0
  51. package/dist/resolution/resolver.d.ts +14 -4
  52. package/dist/resolution/resolver.js +375 -134
  53. package/dist/resolution/select/binding-select.js +12 -7
  54. package/dist/resolution/select/constraints.d.ts +7 -4
  55. package/dist/resolution/select/constraints.js +34 -13
  56. package/package.json +14 -40
@@ -2,7 +2,7 @@ import { NO_INSTANCE } from "#/core/binding";
2
2
  import { NO_TAG_KEYS, slotNameCriterionOf } from "#/core/tag";
3
3
  import { tokenName } from "#/core/token";
4
4
  import { AsyncActivationError, AsyncResolutionError, CircularDependencyError, DisposedContainerError, InternalError, MissingMetadataError, MissingScopeContextError, NoMatchingBindingError, TokenNotBoundError, } from "#/errors/errors";
5
- import { resolveOptionsForSlot, singleCriterionForSlot, singleCriterionOnlyOf } from "#/injection/resolve-options";
5
+ import { loneTagBesideNameOf, resolveOptionsForSlot, singleCriterionForSlot, singleCriterionOnlyOf, } from "#/injection/resolve-options";
6
6
  import { SCOPED_MISS } from "#/lifecycle/scope-manager";
7
7
  import { ActivationNeedCache } from "#/resolution/cache/activation-need";
8
8
  import { BindingLookupCache } from "#/resolution/cache/binding-lookup-cache";
@@ -16,6 +16,18 @@ const MULTI_TAG_INDEX_THRESHOLD = 8;
16
16
  const EMPTY_STRING_LIST = [];
17
17
  const EMPTY_FRAME_LIST = [];
18
18
  const EMPTY_PARAM_LIST = [];
19
+ /** Plans compiled so far, with the `null` unplannable marks left out. */
20
+ function countCompiledPlans(plans) {
21
+ let count = 0;
22
+ if (plans !== undefined) {
23
+ for (const plan of plans.values()) {
24
+ if (plan !== null) {
25
+ count += 1;
26
+ }
27
+ }
28
+ }
29
+ return count;
30
+ }
19
31
  const ROOT_CONSTRAINT_CONTEXT = {
20
32
  resolutionPath: EMPTY_STRING_LIST,
21
33
  resolutionStack: EMPTY_FRAME_LIST,
@@ -45,12 +57,13 @@ export class DependencyResolver {
45
57
  // because synchronous code does not interleave.
46
58
  #cascadeStack = [];
47
59
  #cascadeContext;
48
- // Compiled plans; `null` marks a binding as unplannable under the current cache versions.
49
- #classPlanByBindingId = new Map();
60
+ // Compiled plans; `null` marks a binding as unplannable under the current cache versions. Both
61
+ // maps are allocated by the first plan request, which only a class or resolved binding makes.
62
+ #classPlanByBindingId;
50
63
  #classPlanRegistryVersion = -1;
51
64
  #classPlanActivationVersion = -1;
52
65
  // The async lane's plans, stamped and invalidated apart so neither lane pays the other's misses.
53
- #asyncPlanByBindingId = new Map();
66
+ #asyncPlanByBindingId;
54
67
  #asyncPlanRegistryVersion = -1;
55
68
  #asyncPlanActivationVersion = -1;
56
69
  #registry;
@@ -60,6 +73,7 @@ export class DependencyResolver {
60
73
  #parent;
61
74
  #lookup;
62
75
  #classes;
76
+ // Built by the first interpreted resolve that asks; a plan-served or constant-only container never does.
63
77
  #activation;
64
78
  constructor(registry, scope, lifecycle, metadataReader, container, parent) {
65
79
  this.#registry = registry;
@@ -68,31 +82,37 @@ export class DependencyResolver {
68
82
  this.#metadataReader = metadataReader;
69
83
  this.#parent = parent;
70
84
  this.#lookup = new BindingLookupCache(registry, this, parent === undefined ? undefined : parent.#lookup);
71
- this.#classes = new ClassIntrospector(metadataReader, container);
72
- this.#activation = new ActivationNeedCache(lifecycle, this.#classes, registry);
85
+ this.#classes = new ClassIntrospector(metadataReader, container, parent === undefined ? undefined : parent.#classes);
73
86
  }
74
87
  /** The reader this resolver was built with, which is the one its container answers with. */
75
88
  get metadataReader() {
76
89
  return this.#metadataReader;
77
90
  }
78
- /** Structural counts for the {@link ResolutionDiagnostics} a container reports. */
91
+ #activationNeed() {
92
+ return (this.#activation ??= new ActivationNeedCache(this.#lifecycle, this.#classes, this.#registry));
93
+ }
94
+ /** Structural counts and the resolver-owned collaborators built so far, for the {@link ResolutionDiagnostics} a container reports. */
79
95
  describeCaches() {
80
- let compiledPlanCount = 0;
81
- for (const plan of this.#classPlanByBindingId.values()) {
82
- if (plan !== null) {
83
- compiledPlanCount += 1;
84
- }
96
+ const builtSubsystems = [];
97
+ if (this.#planCompiler !== undefined) {
98
+ builtSubsystems.push("resolver.planCompiler");
85
99
  }
86
- let compiledAsyncPlanCount = 0;
87
- for (const plan of this.#asyncPlanByBindingId.values()) {
88
- if (plan !== null) {
89
- compiledAsyncPlanCount += 1;
90
- }
100
+ if (this.#lookup.isMemoBuilt) {
101
+ builtSubsystems.push("resolver.lookupMemo");
102
+ }
103
+ if (this.#activation?.isMemoBuilt === true) {
104
+ builtSubsystems.push("resolver.activationNeedMemo");
105
+ }
106
+ const generatedPlanCount = this.#planCompiler?.generatedPlanCount ?? 0;
107
+ if (generatedPlanCount > 0) {
108
+ builtSubsystems.push("resolver.planCodegen");
91
109
  }
92
110
  return {
93
- compiledPlanCount,
94
- compiledAsyncPlanCount,
111
+ compiledPlanCount: countCompiledPlans(this.#classPlanByBindingId),
112
+ compiledAsyncPlanCount: countCompiledPlans(this.#asyncPlanByBindingId),
113
+ generatedPlanCount,
95
114
  syncContextPoolSize: this.#syncResolutionContextPool.length,
115
+ builtSubsystems,
96
116
  };
97
117
  }
98
118
  // ── Binding lookup ─────────────────────────────────────────────────────────────────────────────────────────────────
@@ -111,37 +131,67 @@ export class DependencyResolver {
111
131
  }
112
132
  else if (singleCriterion !== undefined) {
113
133
  const indexed = this.#registry.getSimpleTagged(token, singleCriterion);
114
- if (indexed !== undefined && this.#satisfiesPredicate(indexed, options, resolutionStack)) {
134
+ if (indexed === undefined) {
135
+ // A one-criterion request matches only a slot carrying exactly that criterion, and every such
136
+ // slot is in the index, so a miss here is a miss for this registry: nothing left to scan.
137
+ return this.#parent === undefined
138
+ ? undefined
139
+ : this.#parent.#findBinding(token, options, resolutionStack, singleCriterion);
140
+ }
141
+ if (this.#satisfiesPredicate(indexed, options, resolutionStack)) {
115
142
  return { binding: indexed, owner: this };
116
143
  }
117
144
  }
118
- else if (
119
- // A threshold switches the data structure, never the semantics: under it the generic scan
120
- // below beats walking the indexes, and both paths answer identically. Sized first, so a
121
- // small list pays one length read and nothing else.
122
- this.#registry.getAll(token).length > MULTI_TAG_INDEX_THRESHOLD &&
123
- requestedTagKeyMask(options) !== NO_TAG_KEYS) {
124
- // A multi-criterion request matches only slots whose every criterion it carries, and every
125
- // such slot is in the two tag indexes — their union is the whole candidate set, unscanned.
126
- const selected = this.#selectMultiTagged(token, options, resolutionStack);
127
- if (selected !== undefined) {
128
- return { binding: selected, owner: this };
129
- }
130
- return this.#parent === undefined
131
- ? undefined
132
- : this.#parent.#findBinding(token, options, resolutionStack, singleCriterion);
133
- }
134
- const bindings = this.#registry.getAll(token);
135
- if (bindings.length > 0) {
136
- // A lone candidate is its own selection: matching it is the whole decision, with no
137
- // specificity to weigh and no ambiguity to report.
138
- const selected = bindings.length === 1
139
- ? matchesSlot(bindings[0].slot, options) && this.#satisfiesPredicate(bindings[0], options, resolutionStack)
140
- ? bindings[0]
141
- : undefined
142
- : selectBinding(bindings, options, this.#makeConstraintContext(resolutionStack, options), tokenName(token));
143
- if (selected !== undefined) {
144
- return { binding: selected, owner: this };
145
+ else {
146
+ if (options.name !== undefined) {
147
+ const pairTag = loneTagBesideNameOf(options);
148
+ if (pairTag !== undefined) {
149
+ const nameCriterion = slotNameCriterionOf(options.name);
150
+ if (nameCriterion === undefined) {
151
+ // No binding anywhere has declared this name, so no slot in any registry can carry it.
152
+ return undefined;
153
+ }
154
+ // The exact two-criterion slot, memoized over the chain; a predicate or an alias declines to the scan.
155
+ const entry = this.#lookup.namedTaggedEntry(token, nameCriterion, pairTag);
156
+ if (entry !== null) {
157
+ return entry;
158
+ }
159
+ }
160
+ }
161
+ if (
162
+ // A threshold switches the data structure, never the semantics: under it the generic scan
163
+ // below beats walking the indexes, and both paths answer identically. Sized first, so a
164
+ // small list pays one length read and nothing else.
165
+ this.#registry.countBindings(token) > MULTI_TAG_INDEX_THRESHOLD &&
166
+ requestedTagKeyMask(options) !== NO_TAG_KEYS) {
167
+ // A multi-criterion request matches only slots whose every criterion it carries, and every
168
+ // such slot is in the two tag indexes — their union is the whole candidate set, unscanned.
169
+ const selected = this.#selectMultiTagged(token, options, resolutionStack);
170
+ if (selected !== undefined) {
171
+ return { binding: selected, owner: this };
172
+ }
173
+ return this.#parent === undefined
174
+ ? undefined
175
+ : this.#parent.#findBinding(token, options, resolutionStack, singleCriterion);
176
+ }
177
+ }
178
+ // A lone default-slot candidate is its own selection: the slot match is the whole decision,
179
+ // it carries no predicate, and asking for it first keeps the registry from materialising its list.
180
+ // An options-less request already probed the lone map above, so only a request with options asks again.
181
+ const lone = options === undefined ? undefined : this.#registry.getFastDefault(token);
182
+ if (lone !== undefined) {
183
+ if (matchesSlot(lone.slot, options)) {
184
+ return { binding: lone, owner: this };
185
+ }
186
+ }
187
+ else {
188
+ // The lone map has missed either way, so the record map is all that is left to read.
189
+ const bindings = this.#registry.getRecorded(token);
190
+ if (bindings.length > 0) {
191
+ const selected = this.#selectFromList(bindings, options, resolutionStack, token);
192
+ if (selected !== undefined) {
193
+ return { binding: selected, owner: this };
194
+ }
145
195
  }
146
196
  }
147
197
  if (this.#parent !== undefined) {
@@ -149,6 +199,31 @@ export class DependencyResolver {
149
199
  }
150
200
  return undefined;
151
201
  }
202
+ /**
203
+ * The scan a candidate list gets before full selection.
204
+ *
205
+ * @remarks A single slot match is the whole answer once its predicate, if any, agrees, and no
206
+ * match is a clean miss; neither needs a display name or a candidate array. Two slot matches
207
+ * hand the same list to full selection, which weighs specificity and reports ambiguity — so both
208
+ * lanes answer identically.
209
+ */
210
+ #selectFromList(bindings, options, resolutionStack, token) {
211
+ let match;
212
+ for (let index = 0; index < bindings.length; index += 1) {
213
+ const candidate = bindings[index];
214
+ if (candidate.isMany || !matchesSlot(candidate.slot, options)) {
215
+ continue;
216
+ }
217
+ if (match !== undefined) {
218
+ return selectBinding(bindings, options, this.#makeConstraintContext(resolutionStack, options), tokenName(token));
219
+ }
220
+ match = candidate;
221
+ }
222
+ if (match === undefined) {
223
+ return undefined;
224
+ }
225
+ return this.#satisfiesPredicate(match, options, resolutionStack) ? match : undefined;
226
+ }
152
227
  /**
153
228
  * The binding a token resolves to with alias hops followed, or a diagnostic throw.
154
229
  *
@@ -214,7 +289,7 @@ export class DependencyResolver {
214
289
  // Container-level hooks belong to the binding's owner — a child-registered hook must not
215
290
  // fire for a parent-owned binding, and the owner's must.
216
291
  const containerHooks = owner.#lifecycle.activationVersion === 0 ? undefined : owner.#lifecycle.activationHandlersFor(binding.token);
217
- if (binding.onActivation === undefined && (containerHooks === undefined || containerHooks.length === 0)) {
292
+ if (binding.activationHook === undefined && (containerHooks === undefined || containerHooks.length === 0)) {
218
293
  return this.#resolveTransientDynamicSyncFromContext(binding, resolutionStack);
219
294
  }
220
295
  return this.#resolveTransientDynamicActivatedSync(binding, containerHooks, resolutionStack);
@@ -268,8 +343,8 @@ export class DependencyResolver {
268
343
  throw new AsyncResolutionError(resolutionStack[0]?.tokenName ?? tokenDisplayName, tokenDisplayName);
269
344
  }
270
345
  let activated = factoryResult;
271
- if (binding.onActivation !== undefined) {
272
- const activationResult = binding.onActivation(resolutionCtx, activated);
346
+ if (binding.activationHook !== undefined) {
347
+ const activationResult = binding.activationHook(resolutionCtx, activated);
273
348
  if (activationResult instanceof Promise) {
274
349
  throw new AsyncActivationError(tokenDisplayName, "onActivation");
275
350
  }
@@ -291,8 +366,14 @@ export class DependencyResolver {
291
366
  binding.inFlight = false;
292
367
  }
293
368
  }
294
- /** Chain-summed activation version: a plan can inline a parent-owned binding, so a parent's hook registration must invalidate it. */
369
+ /**
370
+ * Chain-summed activation version: a plan can inline a parent-owned binding, so a parent's hook
371
+ * registration must invalidate it.
372
+ */
295
373
  #chainActivationVersion() {
374
+ if (this.#parent === undefined) {
375
+ return this.#lifecycle.activationVersion;
376
+ }
296
377
  let version = this.#lifecycle.activationVersion;
297
378
  for (let current = this.#parent; current !== undefined; current = current.#parent) {
298
379
  version += current.#lifecycle.activationVersion;
@@ -303,93 +384,137 @@ export class DependencyResolver {
303
384
  const registryVersion = this.#lookup.chainVersion();
304
385
  const activationVersion = this.#chainActivationVersion();
305
386
  if (registryVersion !== this.#classPlanRegistryVersion || activationVersion !== this.#classPlanActivationVersion) {
306
- this.#classPlanByBindingId.clear();
387
+ // The stamps start at -1, so the first request always lands here: allocate then, clear after.
388
+ if (this.#classPlanByBindingId === undefined) {
389
+ this.#classPlanByBindingId = new Map();
390
+ }
391
+ else {
392
+ this.#classPlanByBindingId.clear();
393
+ }
307
394
  this.#classPlanRegistryVersion = registryVersion;
308
395
  this.#classPlanActivationVersion = activationVersion;
309
396
  }
310
- const cached = this.#classPlanByBindingId.get(binding.id);
397
+ const plans = this.#classPlanByBindingId;
398
+ const cached = plans.get(binding.identifier);
311
399
  if (cached !== undefined) {
312
400
  return cached;
313
401
  }
314
- const compiled = this.#planCompiler.compile(binding);
402
+ const compiled = this.#compiler().compile(binding);
315
403
  if (compiled === PLAN_RETRY) {
316
404
  // Lifecycle metadata not discovered yet — the fallback resolve discovers it; retry then.
317
405
  return null;
318
406
  }
319
- this.#classPlanByBindingId.set(binding.id, compiled);
407
+ plans.set(binding.identifier, compiled);
320
408
  return compiled;
321
409
  }
322
- // Compiler behind #getInstantiationPlan — cold path, so the host indirection costs nothing hot.
323
- #planCompiler = new InstantiationPlanCompiler({
324
- hasActivationHandlers: (binding) => this.#ownerOf(binding).#lifecycle.hasActivationHandlers(binding.token),
325
- knownPostConstruct: (target) => this.#classes.knownPostConstruct(target),
326
- needsActiveContainer: (target) => this.#classes.needsActiveContainer(target),
327
- getConstructorMetadata: (target) => this.#classes.constructorMetadata(target),
328
- lookupDependencyEntry: (token) => {
329
- const entry = this.#lookup.defaultEntry(token);
330
- return entry === null ? null : { binding: entry.binding };
331
- },
332
- // Exactly what #findBinding's single-criterion lane accepts, minus the half that reads a path:
333
- // a predicate is the compiler's cue to leave the selection to the runtime.
334
- lookupPathIndependentEntry: (token, options) => {
335
- const singleCriterion = singleCriterionOnlyOf(options);
336
- if (singleCriterion === undefined) {
337
- return null;
338
- }
339
- const entry = this.#lookup.taggedEntry(token, singleCriterion);
340
- if (entry === null || entry.binding.predicate !== undefined || !matchesSlot(entry.binding.slot, options)) {
341
- return null;
342
- }
343
- return { binding: entry.binding };
344
- },
345
- getResolutionFrame: (binding) => this.#getResolutionFrame(binding),
346
- // Dispatches exactly as #resolveDep does, so an escaped dep is indistinguishable
347
- // from the same dep on a fully interpreted resolve.
348
- resolveEscaped: (token, options, arity, resolutionStack) => {
349
- if (arity === "all") {
350
- return this.resolveAll(token, options, resolutionStack);
351
- }
352
- if (arity === "optional") {
353
- return this.resolveOptional(token, options, resolutionStack);
354
- }
355
- if (options === undefined) {
356
- return this.resolveFromContext(token, resolutionStack);
357
- }
358
- return this.resolve(token, options, resolutionStack);
359
- },
360
- // Dispatches exactly as #resolveDepAsync does, for the async lane's escapes.
361
- resolveEscapedAsync: (token, options, arity, resolutionStack) => {
362
- if (arity === "all") {
363
- return this.resolveAllAsync(token, options, resolutionStack, UNOWNED_BRANCH);
364
- }
365
- if (arity === "optional") {
366
- return this.resolveOptionalAsync(token, options, resolutionStack, UNOWNED_BRANCH);
367
- }
368
- if (options === undefined) {
369
- return this.resolveAsyncFromContext(token, resolutionStack, UNOWNED_BRANCH);
370
- }
371
- return this.resolveAsync(token, options, resolutionStack, UNOWNED_BRANCH);
372
- },
373
- });
410
+ // Compiler behind #getInstantiationPlan — a cold path, and built on the first plan so a container
411
+ // that never resolves a class or a resolved factory never pays for the host.
412
+ #planCompiler;
413
+ #compiler() {
414
+ return (this.#planCompiler ??= new InstantiationPlanCompiler({
415
+ hasActivationHandlers: (binding) => this.#ownerOf(binding).#lifecycle.hasActivationHandlers(binding.token),
416
+ knownPostConstruct: (target) => this.#classes.knownPostConstruct(target),
417
+ needsActiveContainer: (target) => this.#classes.needsActiveContainer(target),
418
+ // A plan runs at the top level, so the lent root stack is free when it is; a plan reached
419
+ // with the root stack held mints its own path, exactly as the interpreted lane would.
420
+ constructWithAccessors: (binding, target, deps) => {
421
+ const stack = this.rootStack.length === 0 ? this.rootStack : [];
422
+ const frame = this.#getResolutionFrame(binding);
423
+ const resolutionSet = enterResolutionPath(stack, frame);
424
+ try {
425
+ return this.#classes.instantiate(target, deps, this.#ambientResolutionFor(stack));
426
+ }
427
+ finally {
428
+ stack.pop();
429
+ resolutionSet?.delete(frame.bindingId);
430
+ }
431
+ },
432
+ getConstructorMetadata: (target) => this.#classes.constructorMetadata(target),
433
+ lookupDependencyEntry: (token) => {
434
+ const entry = this.#lookup.defaultEntry(token);
435
+ return entry === null ? null : { binding: entry.binding };
436
+ },
437
+ // Exactly what #findBinding's single-criterion lane accepts, minus the half that reads a path:
438
+ // a predicate is the compiler's cue to leave the selection to the runtime.
439
+ lookupPathIndependentEntry: (token, options) => {
440
+ const singleCriterion = singleCriterionOnlyOf(options);
441
+ if (singleCriterion === undefined) {
442
+ return null;
443
+ }
444
+ const entry = this.#lookup.taggedEntry(token, singleCriterion);
445
+ if (entry === null || entry.binding.predicate !== undefined || !matchesSlot(entry.binding.slot, options)) {
446
+ return null;
447
+ }
448
+ return { binding: entry.binding };
449
+ },
450
+ getResolutionFrame: (binding) => this.#getResolutionFrame(binding),
451
+ // Identity-guarded: a map cleared and recompiled since no longer holds the plan being replaced.
452
+ replacePlan: (binding, current, next) => {
453
+ const plans = this.#classPlanByBindingId;
454
+ if (plans !== undefined && plans.get(binding.identifier) === current) {
455
+ plans.set(binding.identifier, next);
456
+ }
457
+ },
458
+ replaceAsyncPlan: (binding, current, next) => {
459
+ const plans = this.#asyncPlanByBindingId;
460
+ if (plans !== undefined && plans.get(binding.identifier) === current) {
461
+ plans.set(binding.identifier, next);
462
+ }
463
+ },
464
+ // Dispatches exactly as #resolveDep does, so an escaped dep is indistinguishable
465
+ // from the same dep on a fully interpreted resolve.
466
+ resolveEscaped: (token, options, arity, resolutionStack) => {
467
+ if (arity === "all") {
468
+ return this.resolveAll(token, options, resolutionStack);
469
+ }
470
+ if (arity === "optional") {
471
+ return this.resolveOptional(token, options, resolutionStack);
472
+ }
473
+ if (options === undefined) {
474
+ return this.resolveFromContext(token, resolutionStack);
475
+ }
476
+ return this.resolve(token, options, resolutionStack);
477
+ },
478
+ // Dispatches exactly as #resolveDepAsync does, for the async lane's escapes.
479
+ resolveEscapedAsync: (token, options, arity, resolutionStack) => {
480
+ if (arity === "all") {
481
+ return this.resolveAllAsync(token, options, resolutionStack, UNOWNED_BRANCH);
482
+ }
483
+ if (arity === "optional") {
484
+ return this.resolveOptionalAsync(token, options, resolutionStack, UNOWNED_BRANCH);
485
+ }
486
+ if (options === undefined) {
487
+ return this.resolveAsyncFromContext(token, resolutionStack, UNOWNED_BRANCH);
488
+ }
489
+ return this.resolveAsync(token, options, resolutionStack, UNOWNED_BRANCH);
490
+ },
491
+ }));
492
+ }
374
493
  /** The async lane's plan for a statically-visible transient binding, mirroring the sync getter. */
375
494
  #getAsyncInstantiationPlan(binding) {
376
495
  const registryVersion = this.#lookup.chainVersion();
377
496
  const activationVersion = this.#chainActivationVersion();
378
497
  if (registryVersion !== this.#asyncPlanRegistryVersion || activationVersion !== this.#asyncPlanActivationVersion) {
379
- this.#asyncPlanByBindingId.clear();
498
+ if (this.#asyncPlanByBindingId === undefined) {
499
+ this.#asyncPlanByBindingId = new Map();
500
+ }
501
+ else {
502
+ this.#asyncPlanByBindingId.clear();
503
+ }
380
504
  this.#asyncPlanRegistryVersion = registryVersion;
381
505
  this.#asyncPlanActivationVersion = activationVersion;
382
506
  }
383
- const cached = this.#asyncPlanByBindingId.get(binding.id);
507
+ const plans = this.#asyncPlanByBindingId;
508
+ const cached = plans.get(binding.identifier);
384
509
  if (cached !== undefined) {
385
510
  return cached;
386
511
  }
387
- const compiled = this.#planCompiler.compileAsync(binding);
512
+ const compiled = this.#compiler().compileAsync(binding);
388
513
  if (compiled === PLAN_RETRY) {
389
514
  // Lifecycle metadata not discovered yet — the fallback resolve discovers it; retry then.
390
515
  return null;
391
516
  }
392
- this.#asyncPlanByBindingId.set(binding.id, compiled);
517
+ plans.set(binding.identifier, compiled);
393
518
  return compiled;
394
519
  }
395
520
  resolve(token, options, resolutionStack, precomputedCriterion) {
@@ -430,7 +555,7 @@ export class DependencyResolver {
430
555
  return binding.instance;
431
556
  }
432
557
  // An async materialization already in flight must not be raced by a second, sync one.
433
- if (this.#scope.getInflight(binding.id) !== undefined) {
558
+ if (this.#scope.getInflight(binding.identifier) !== undefined) {
434
559
  throw new AsyncResolutionError(resolutionStack[0]?.tokenName ?? tokenName(binding.token), tokenName(binding.token));
435
560
  }
436
561
  if (this.#scope.isClosed) {
@@ -442,7 +567,7 @@ export class DependencyResolver {
442
567
  if (cachedScoped !== SCOPED_MISS) {
443
568
  return cachedScoped;
444
569
  }
445
- if (this.#scope.getInflight(binding.id) !== undefined) {
570
+ if (this.#scope.getInflight(binding.identifier) !== undefined) {
446
571
  throw new AsyncResolutionError(resolutionStack[0]?.tokenName ?? tokenName(binding.token), tokenName(binding.token));
447
572
  }
448
573
  if (this.#scope.isClosed) {
@@ -453,7 +578,7 @@ export class DependencyResolver {
453
578
  const tokenDisplayName = frame.tokenName;
454
579
  const resolutionSet = enterResolutionPath(resolutionStack, frame);
455
580
  try {
456
- const needsActivation = owner.#activation.needsActivation(binding);
581
+ const needsActivation = owner.#activationNeed().needsActivation(binding);
457
582
  if (!needsActivation && scope === "transient" && binding.kind === "dynamic") {
458
583
  const resolutionCtx = this.#acquireSyncResolutionContext(resolutionStack, options);
459
584
  const dynamicResult = binding.factory(resolutionCtx);
@@ -467,7 +592,7 @@ export class DependencyResolver {
467
592
  : undefined;
468
593
  const instance = this.#instantiateSync(binding, resolutionCtx, resolutionStack);
469
594
  this.#mirrorPostConstructFromOwner(binding, owner);
470
- const activated = owner.#activation.refreshAfterFirstInstantiation(binding, needsActivation)
595
+ const activated = owner.#activationNeed().refreshAfterFirstInstantiation(binding, needsActivation)
471
596
  ? owner.#lifecycle.runActivationSync(resolutionCtx, binding, instance, owner.#metadataReader)
472
597
  : instance;
473
598
  if (scope === "singleton") {
@@ -484,7 +609,16 @@ export class DependencyResolver {
484
609
  }
485
610
  }
486
611
  /** Path-continuing resolution handed to the ambient slot while an accessor class constructs. */
612
+ // The ambient resolution a top-level construction hands its accessors: the lent root stack is one
613
+ // array for the resolver's lifetime, so the pair of closures over it is built once and reused.
614
+ #rootAmbientResolution;
487
615
  #ambientResolutionFor(resolutionStack) {
616
+ if (resolutionStack === this.rootStack) {
617
+ return (this.#rootAmbientResolution ??= this.#buildAmbientResolution(resolutionStack));
618
+ }
619
+ return this.#buildAmbientResolution(resolutionStack);
620
+ }
621
+ #buildAmbientResolution(resolutionStack) {
488
622
  return {
489
623
  resolve: (token, options) => options === undefined
490
624
  ? this.resolveFromContext(token, resolutionStack)
@@ -572,6 +706,19 @@ export class DependencyResolver {
572
706
  return this.resolve(dep.token, options, resolutionStack, singleCriterionForSlot(dep));
573
707
  }
574
708
  resolveOptional(token, options, resolutionStack, precomputedCriterion) {
709
+ if (options === undefined) {
710
+ // The lane a plain resolve takes: a lone default in this registry is the answer, predicate-free by
711
+ // construction, and a root that keeps no records has nothing else that could hold the token.
712
+ const fastBinding = this.#registry.getFastDefault(token);
713
+ if (fastBinding !== undefined) {
714
+ if (fastBinding.kind !== "alias") {
715
+ return this.#resolveDefaultEntry(fastBinding, this, resolutionStack);
716
+ }
717
+ }
718
+ else if (this.#parent === undefined && !this.#registry.isRecordMapBuilt) {
719
+ return undefined;
720
+ }
721
+ }
575
722
  const entry = this.#findBinding(token, options, resolutionStack, precomputedCriterion === undefined ? singleCriterionOnlyOf(options) : (precomputedCriterion ?? undefined));
576
723
  if (entry === undefined) {
577
724
  return undefined;
@@ -595,6 +742,80 @@ export class DependencyResolver {
595
742
  }
596
743
  return resolved;
597
744
  }
745
+ /**
746
+ * A root-level, options-less collection read through its memo: the value list when it is stable,
747
+ * the candidate list otherwise.
748
+ *
749
+ * @remarks Its own entry rather than a branch in `resolveAll`, so the options lane keeps the exact
750
+ * shape it had; the container routes a top-level read with no options here.
751
+ */
752
+ resolveRootCollection(token) {
753
+ const resolutionStack = this.rootStack;
754
+ const memo = this.#rootCollection(token, resolutionStack);
755
+ if (memo.values !== undefined && memo.activationVersion === this.#chainActivationVersion()) {
756
+ return memo.values;
757
+ }
758
+ const { candidates } = memo;
759
+ const resolved = new Array(candidates.length);
760
+ for (let index = 0; index < candidates.length; index += 1) {
761
+ resolved[index] = this.#resolveCandidateSync(candidates[index], undefined, resolutionStack);
762
+ }
763
+ // The members this read materialised may have made the list stable for the next one.
764
+ this.#settleCollectionValues(memo);
765
+ return resolved;
766
+ }
767
+ /** The async twin of `resolveRootCollection`: a stable value list settles at once, candidates fan out as usual. */
768
+ resolveRootCollectionAsync(token) {
769
+ // The async lane appends to its branch and never unwinds, so it works on a stack of its own.
770
+ const resolutionStack = [];
771
+ const memo = this.#rootCollection(token, resolutionStack);
772
+ if (memo.values !== undefined && memo.activationVersion === this.#chainActivationVersion()) {
773
+ return Promise.resolve(memo.values);
774
+ }
775
+ return Promise.all(memo.candidates.map((candidate) => this.#resolveCandidateAsync(candidate, undefined, resolutionStack, UNOWNED_BRANCH))).then((values) => {
776
+ this.#settleCollectionValues(memo);
777
+ return values;
778
+ });
779
+ }
780
+ /**
781
+ * The memoized candidate list of a root-level, options-less collection, built on its first read.
782
+ *
783
+ * @remarks Sound because a `when()` predicate is pure over its context and the root context is a
784
+ * constant: the list can only change when a registry in the chain does, which is the version the
785
+ * memo is stamped with. The value list is kept too while every member is a hook-free constant
786
+ * and no activation hook exists anywhere in the chain.
787
+ */
788
+ #rootCollection(token, resolutionStack) {
789
+ const memo = this.#lookup.collection(token);
790
+ if (memo !== undefined) {
791
+ return memo;
792
+ }
793
+ const candidates = this.#candidateBindings(token, undefined, resolutionStack);
794
+ const entry = { candidates, values: undefined, activationVersion: -1 };
795
+ this.#settleCollectionValues(entry);
796
+ this.#lookup.rememberCollection(token, entry);
797
+ return entry;
798
+ }
799
+ /**
800
+ * Fills a collection memo's value list once every member is stable: a hook-free constant, or a
801
+ * hook-free singleton whose instance is cached — anything that changes either bumps a registry
802
+ * version the memo is keyed on. A member still to be materialised leaves the list unfilled.
803
+ */
804
+ #settleCollectionValues(entry) {
805
+ if (entry.values !== undefined || this.#chainActivationVersion() !== 0) {
806
+ return;
807
+ }
808
+ const { candidates } = entry;
809
+ for (let index = 0; index < candidates.length; index += 1) {
810
+ if (!isStableCollectionMember(candidates[index])) {
811
+ return;
812
+ }
813
+ }
814
+ // Handed out as is, unfrozen: a frozen array iterates through a slow elements kind, so the
815
+ // contract's read-only return is the guard against a caller writing into the memo.
816
+ entry.values = candidates.map(stableMemberValue);
817
+ entry.activationVersion = 0;
818
+ }
598
819
  /** Every binding in the chain a `resolveAll` request matches, in chain order. */
599
820
  #candidateBindings(token, options, resolutionStack) {
600
821
  if (options !== undefined) {
@@ -648,7 +869,7 @@ export class DependencyResolver {
648
869
  }
649
870
  }
650
871
  else if (this.#scope.isChild) {
651
- const cachedScoped = this.#scope.readScoped(binding.id);
872
+ const cachedScoped = this.#scope.readScoped(binding.identifier);
652
873
  if (cachedScoped !== SCOPED_MISS) {
653
874
  return Promise.resolve(cachedScoped);
654
875
  }
@@ -685,7 +906,7 @@ export class DependencyResolver {
685
906
  return binding.instance;
686
907
  }
687
908
  // In-flight dedup: concurrent callers share the first creation.
688
- const inflight = this.#scope.getInflight(binding.id);
909
+ const inflight = this.#scope.getInflight(binding.identifier);
689
910
  if (inflight !== undefined) {
690
911
  return inflight;
691
912
  }
@@ -699,7 +920,7 @@ export class DependencyResolver {
699
920
  return cachedScoped;
700
921
  }
701
922
  // In-flight dedup, scoped flavor: one instance per scope even under concurrency.
702
- const inflight = this.#scope.getInflight(binding.id);
923
+ const inflight = this.#scope.getInflight(binding.identifier);
703
924
  if (inflight !== undefined) {
704
925
  return inflight;
705
926
  }
@@ -711,7 +932,7 @@ export class DependencyResolver {
711
932
  // This level appends to its own branch and never unwinds.
712
933
  const levelStack = extendResolutionBranch(resolutionStack, branchDepth, frame);
713
934
  const levelDepth = branchDepthOf(levelStack);
714
- const needsActivation = owner.#activation.needsActivation(binding);
935
+ const needsActivation = owner.#activationNeed().needsActivation(binding);
715
936
  if (!needsActivation && scope === "transient" && (binding.kind === "dynamic" || binding.kind === "dynamic-async")) {
716
937
  const resolutionCtx = new AsyncLevelContext(this, levelStack, options);
717
938
  if (binding.kind === "dynamic-async") {
@@ -727,26 +948,26 @@ export class DependencyResolver {
727
948
  // The promise is published before it settles, so concurrent callers dedup onto it.
728
949
  const singletonPromise = this.#instantiateAndActivateAsync(binding, resolutionCtx, levelStack, levelDepth, needsActivation, owner).then((activated) => {
729
950
  this.#scope.setSingleton(binding, activated);
730
- this.#scope.clearInflight(binding.id);
951
+ this.#scope.clearInflight(binding.identifier);
731
952
  return activated;
732
953
  }, (error) => {
733
- this.#scope.clearInflight(binding.id);
954
+ this.#scope.clearInflight(binding.identifier);
734
955
  throw error;
735
956
  });
736
- this.#scope.setInflight(binding.id, singletonPromise);
957
+ this.#scope.setInflight(binding.identifier, singletonPromise);
737
958
  return await singletonPromise;
738
959
  }
739
960
  if (scope === "scoped") {
740
961
  // Published before it settles, like the singleton lane: concurrent callers share one creation.
741
962
  const scopedPromise = this.#instantiateAndActivateAsync(binding, resolutionCtx, levelStack, levelDepth, needsActivation, owner).then((activated) => {
742
963
  this.#scope.setScoped(binding, activated);
743
- this.#scope.clearInflight(binding.id);
964
+ this.#scope.clearInflight(binding.identifier);
744
965
  return activated;
745
966
  }, (error) => {
746
- this.#scope.clearInflight(binding.id);
967
+ this.#scope.clearInflight(binding.identifier);
747
968
  throw error;
748
969
  });
749
- this.#scope.setInflight(binding.id, scopedPromise);
970
+ this.#scope.setInflight(binding.identifier, scopedPromise);
750
971
  return await scopedPromise;
751
972
  }
752
973
  return await this.#instantiateAndActivateAsync(binding, resolutionCtx, levelStack, levelDepth, needsActivation, owner);
@@ -766,7 +987,7 @@ export class DependencyResolver {
766
987
  async #instantiateAndActivateAsync(binding, ctx, resolutionStack, branchDepth, needsActivation, owner) {
767
988
  const instance = await this.#instantiateAsync(binding, ctx, resolutionStack, branchDepth);
768
989
  this.#mirrorPostConstructFromOwner(binding, owner);
769
- if (!owner.#activation.refreshAfterFirstInstantiation(binding, needsActivation)) {
990
+ if (!owner.#activationNeed().refreshAfterFirstInstantiation(binding, needsActivation)) {
770
991
  return instance;
771
992
  }
772
993
  return owner.#lifecycle.runActivation(ctx, binding, instance, owner.#metadataReader);
@@ -916,12 +1137,12 @@ export class DependencyResolver {
916
1137
  /** A constant with no activation anywhere resolves to its value with no pipeline at all. */
917
1138
  #isPlainConstant(binding) {
918
1139
  return (binding.kind === "constant" &&
919
- binding.onActivation === undefined &&
1140
+ binding.activationHook === undefined &&
920
1141
  (this.#lifecycle.activationVersion === 0 || !this.#lifecycle.hasActivationHandlers(binding.token)));
921
1142
  }
922
1143
  /** Whether either an own hook or a container-level hook would run for this binding. */
923
1144
  #hasAnyActivation(binding) {
924
- if (binding.onActivation !== undefined) {
1145
+ if (binding.activationHook !== undefined) {
925
1146
  return true;
926
1147
  }
927
1148
  return this.#lifecycle.activationVersion !== 0 && this.#lifecycle.hasActivationHandlers(binding.token);
@@ -936,7 +1157,7 @@ export class DependencyResolver {
936
1157
  if (!this.#scope.isChild) {
937
1158
  throw new MissingScopeContextError(tokenName(binding.token));
938
1159
  }
939
- return this.#scope.readScoped(binding.id);
1160
+ return this.#scope.readScoped(binding.identifier);
940
1161
  }
941
1162
  // The shared root context answers every top-level request; building one is the rarer half and
942
1163
  // lives outside, so what a selection inlines is the test and not the literal.
@@ -1137,7 +1358,7 @@ export class DependencyResolver {
1137
1358
  #resolveCandidateSync(binding, options, resolutionStack) {
1138
1359
  // Fan-outs are dominated by constants: with no activation hook anywhere in the chain, a
1139
1360
  // hook-free constant is plain no matter which container owns it — skip the owner probe.
1140
- if (binding.kind === "constant" && binding.onActivation === undefined && this.#chainActivationVersion() === 0) {
1361
+ if (binding.kind === "constant" && binding.activationHook === undefined && this.#chainActivationVersion() === 0) {
1141
1362
  return binding.value;
1142
1363
  }
1143
1364
  const owner = this.#ownerOf(binding);
@@ -1157,7 +1378,7 @@ export class DependencyResolver {
1157
1378
  return this.#resolveBinding(binding, options, resolutionStack, owner);
1158
1379
  }
1159
1380
  #resolveCandidateAsync(binding, options, resolutionStack, branchDepth) {
1160
- if (binding.kind === "constant" && binding.onActivation === undefined && this.#chainActivationVersion() === 0) {
1381
+ if (binding.kind === "constant" && binding.activationHook === undefined && this.#chainActivationVersion() === 0) {
1161
1382
  return Promise.resolve(binding.value);
1162
1383
  }
1163
1384
  const owner = this.#ownerOf(binding);
@@ -1173,16 +1394,25 @@ export class DependencyResolver {
1173
1394
  }
1174
1395
  return owner.#resolveBindingAsync(binding, options, resolutionStack, branchDepth, owner);
1175
1396
  }
1397
+ // The dominant collection member — a transient factory with no activation, asked with no
1398
+ // options — takes the non-async lane a single resolve takes, so a fan-out costs one factory
1399
+ // promise per member rather than a state machine on top of each.
1400
+ if (options === undefined &&
1401
+ binding.scope === "transient" &&
1402
+ (binding.kind === "dynamic" || binding.kind === "dynamic-async") &&
1403
+ !owner.#hasAnyActivation(binding)) {
1404
+ return this.#resolveTransientDynamicAsyncFromContext(binding, resolutionStack, branchDepth);
1405
+ }
1176
1406
  return this.#resolveBindingAsync(binding, options, resolutionStack, branchDepth, owner);
1177
1407
  }
1178
1408
  /** The resolver whose registry holds `binding` — `this` (the common case) when it is own. */
1179
1409
  #ownerOf(binding) {
1180
1410
  // A root resolver can only hold its own bindings, so the per-candidate id probe is chain-only.
1181
- if (this.#parent === undefined || this.#registry.getById(binding.id) !== undefined) {
1411
+ if (this.#parent === undefined || this.#registry.getById(binding.identifier) !== undefined) {
1182
1412
  return this;
1183
1413
  }
1184
1414
  for (let current = this.#parent; current !== undefined; current = current.#parent) {
1185
- if (current.#registry.getById(binding.id) !== undefined) {
1415
+ if (current.#registry.getById(binding.identifier) !== undefined) {
1186
1416
  return current;
1187
1417
  }
1188
1418
  }
@@ -1196,7 +1426,7 @@ export class DependencyResolver {
1196
1426
  if (existing !== undefined) {
1197
1427
  return existing;
1198
1428
  }
1199
- const frame = buildResolutionFrame(tokenName(binding.token), binding.scope, binding.id, binding.kind, binding.slot);
1429
+ const frame = buildResolutionFrame(tokenName(binding.token), binding.scope, binding.identifier, binding.kind, binding.slot);
1200
1430
  binding.frame = frame;
1201
1431
  return frame;
1202
1432
  }
@@ -1234,6 +1464,17 @@ export class DependencyResolver {
1234
1464
  return created;
1235
1465
  }
1236
1466
  }
1467
+ /** A constant whose value is its answer on every read: no own hook, and the caller has ruled out container hooks. */
1468
+ // A cached singleton reads like a constant until a registry change evicts it, which also drops the memo.
1469
+ function isStableCollectionMember(binding) {
1470
+ if (binding.kind === "alias" || binding.activationHook !== undefined) {
1471
+ return false;
1472
+ }
1473
+ return binding.kind === "constant" || (binding.scope === "singleton" && binding.instance !== NO_INSTANCE);
1474
+ }
1475
+ function stableMemberValue(binding) {
1476
+ return binding.kind === "constant" ? binding.value : binding.instance;
1477
+ }
1237
1478
  function anyPredicate(bindings) {
1238
1479
  for (let index = 0; index < bindings.length; index += 1) {
1239
1480
  if (bindings[index].predicate !== undefined) {