@codefast/di 0.6.0 → 0.6.2

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 (37) hide show
  1. package/CHANGELOG.md +123 -0
  2. package/dist/container/binding-builders.d.ts.map +1 -1
  3. package/dist/container/binding-builders.js +7 -1
  4. package/dist/container/binding-builders.js.map +1 -1
  5. package/dist/container/container.d.ts.map +1 -1
  6. package/dist/container/container.js +7 -5
  7. package/dist/container/container.js.map +1 -1
  8. package/dist/core/constraint-requirement.d.ts +15 -0
  9. package/dist/core/constraint-requirement.d.ts.map +1 -1
  10. package/dist/core/constraint-requirement.js +35 -1
  11. package/dist/core/constraint-requirement.js.map +1 -1
  12. package/dist/core/tag.d.ts.map +1 -1
  13. package/dist/core/tag.js +12 -0
  14. package/dist/core/tag.js.map +1 -1
  15. package/dist/decorators/injectable.d.ts +7 -4
  16. package/dist/decorators/injectable.d.ts.map +1 -1
  17. package/dist/decorators/injectable.js.map +1 -1
  18. package/dist/resolution/cache/binding-lookup-cache.d.ts +3 -0
  19. package/dist/resolution/cache/binding-lookup-cache.d.ts.map +1 -1
  20. package/dist/resolution/cache/binding-lookup-cache.js +55 -0
  21. package/dist/resolution/cache/binding-lookup-cache.js.map +1 -1
  22. package/dist/resolution/plan/instantiation-plan.d.ts +7 -0
  23. package/dist/resolution/plan/instantiation-plan.d.ts.map +1 -1
  24. package/dist/resolution/plan/instantiation-plan.js +32 -3
  25. package/dist/resolution/plan/instantiation-plan.js.map +1 -1
  26. package/dist/resolution/resolver.d.ts.map +1 -1
  27. package/dist/resolution/resolver.js +29 -0
  28. package/dist/resolution/resolver.js.map +1 -1
  29. package/package.json +2 -2
  30. package/src/container/binding-builders.ts +8 -1
  31. package/src/container/container.ts +7 -5
  32. package/src/core/constraint-requirement.ts +44 -1
  33. package/src/core/tag.ts +14 -0
  34. package/src/decorators/injectable.ts +15 -4
  35. package/src/resolution/cache/binding-lookup-cache.ts +57 -0
  36. package/src/resolution/plan/instantiation-plan.ts +39 -3
  37. package/src/resolution/resolver.ts +28 -0
@@ -3,7 +3,7 @@ import { BindingChain } from "#/container/binding-builders";
3
3
  import type { Binding, BindingBuilder, BindToBuilder, ConstantBinding } from "#/core/binding";
4
4
  import { NO_INSTANCE } from "#/core/binding";
5
5
  import { effectiveBindingScope } from "#/core/binding-scope";
6
- import { constraintRequirementOf } from "#/core/constraint-requirement";
6
+ import { constraintRequirementsOf } from "#/core/constraint-requirement";
7
7
  import type { AsyncModule, AsyncModuleBuilder, ModuleBuilder, SyncModule } from "#/core/module";
8
8
  import { isSyncModule, MODULE_SETUP } from "#/core/module";
9
9
  import { BindingRegistry } from "#/core/registry";
@@ -731,13 +731,15 @@ class DefaultContainer implements Container {
731
731
  if (predicate === undefined) {
732
732
  continue;
733
733
  }
734
- const requirement = constraintRequirementOf(predicate);
735
- if (requirement === undefined) {
734
+ const requirements = constraintRequirementsOf(predicate);
735
+ if (requirements.length === 0) {
736
736
  continue;
737
737
  }
738
738
  declaredSlotNames ??= this.#slotNamesInChain();
739
- if (!declaredSlotNames.has(requirement.name)) {
740
- throw new UnreachableConstraintError(tokenName(binding.token), requirement.name, requirement.helperName);
739
+ for (const requirement of requirements) {
740
+ if (!declaredSlotNames.has(requirement.name)) {
741
+ throw new UnreachableConstraintError(tokenName(binding.token), requirement.name, requirement.helperName);
742
+ }
741
743
  }
742
744
  }
743
745
  }
@@ -44,8 +44,51 @@ export function requiringAncestorSlotName(
44
44
  /**
45
45
  * The requirement a predicate carries, if it was built by a helper that records one.
46
46
  *
47
+ * @remarks A composed predicate may carry several; this answers the first. `validate()` reads
48
+ * {@link constraintRequirementsOf} so no recorded requirement is skipped.
49
+ *
47
50
  * @since 0.6.0
48
51
  */
49
52
  export function constraintRequirementOf(predicate: BindingConstraint): ConstraintRequirement | undefined {
50
- return (predicate as { [CONSTRAINT_REQUIREMENT]?: ConstraintRequirement })[CONSTRAINT_REQUIREMENT];
53
+ return constraintRequirementsOf(predicate)[0];
54
+ }
55
+
56
+ const NO_REQUIREMENTS: ReadonlyArray<ConstraintRequirement> = [];
57
+
58
+ /**
59
+ * Every requirement a predicate carries — one from a helper, several from a composed chain.
60
+ *
61
+ * @since 0.6.1
62
+ */
63
+ export function constraintRequirementsOf(predicate: BindingConstraint): ReadonlyArray<ConstraintRequirement> {
64
+ const payload = (
65
+ predicate as { [CONSTRAINT_REQUIREMENT]?: ConstraintRequirement | ReadonlyArray<ConstraintRequirement> }
66
+ )[CONSTRAINT_REQUIREMENT];
67
+ if (payload === undefined) {
68
+ return NO_REQUIREMENTS;
69
+ }
70
+ return Array.isArray(payload)
71
+ ? (payload as ReadonlyArray<ConstraintRequirement>)
72
+ : [payload as ConstraintRequirement];
73
+ }
74
+
75
+ /**
76
+ * Carries both sides' requirements onto a composed predicate, so chaining does not lose them.
77
+ *
78
+ * @since 0.6.1
79
+ */
80
+ export function mergingConstraintRequirements(
81
+ composite: BindingConstraint,
82
+ left: BindingConstraint,
83
+ right: BindingConstraint,
84
+ ): BindingConstraint {
85
+ const merged = [...constraintRequirementsOf(left), ...constraintRequirementsOf(right)];
86
+ if (merged.length === 0) {
87
+ return composite;
88
+ }
89
+ Object.defineProperty(composite, CONSTRAINT_REQUIREMENT, {
90
+ value: merged.length === 1 ? merged[0] : merged,
91
+ enumerable: false,
92
+ });
93
+ return composite;
51
94
  }
package/src/core/tag.ts CHANGED
@@ -95,22 +95,36 @@ export function tag<Value = unknown>(name: string): TagKey<Value> {
95
95
  const id = tagKeyCounter;
96
96
  const mask = (1 << (id % MASK_WIDTH)) as TagKeyMask;
97
97
  const interned = new Map<unknown, BindingTag<Value>>();
98
+ // One-entry cache in front of the intern map: an inline `.of()` at a call site usually repeats
99
+ // one value, and `Object.is` is the slot contract's own comparison, so a hit is exact — ±0 stay
100
+ // split and `NaN` hits itself, with no `internKeyFor` detour.
101
+ let lastValue: Value | undefined;
102
+ let lastPair: BindingTag<Value> | undefined;
98
103
 
99
104
  const key: TagKey<Value> = {
100
105
  name,
101
106
  id,
102
107
  mask,
103
108
  of(value: Value): BindingTag<Value> {
109
+ if (lastPair !== undefined && Object.is(value, lastValue)) {
110
+ return lastPair;
111
+ }
112
+
104
113
  const cacheKey = internKeyFor(value);
105
114
  const existing = interned.get(cacheKey);
106
115
 
107
116
  if (existing !== undefined) {
117
+ lastValue = value;
118
+ lastPair = existing;
119
+
108
120
  return existing;
109
121
  }
110
122
 
111
123
  const pair = { key, value, mask } as BindingTag<Value>;
112
124
 
113
125
  interned.set(cacheKey, pair);
126
+ lastValue = value;
127
+ lastPair = pair;
114
128
 
115
129
  return pair;
116
130
  },
@@ -60,16 +60,27 @@ export function injectable(): (target: unknown, context: ClassDecoratorContext)
60
60
 
61
61
  /**
62
62
  * @remarks Declaring dependencies constrains the class: the decorator only accepts one whose
63
- * constructor takes exactly what `deps` resolve to, in that order. A class taking fewer parameters
64
- * than `deps` declares still satisfies it, which is the one mismatch TypeScript's arity rules let
65
- * through — the surplus dependency is resolved and discarded.
63
+ * constructor takes exactly what `deps` resolve to, in that order — including arity, so a literal
64
+ * deps list longer than the constructor is a compile error rather than a resolved-and-discarded
65
+ * value. Optional trailing parameters admit every arity they declare, and a rest parameter admits
66
+ * any list. A deps *array* — one whose length the compiler cannot know — skips the arity check,
67
+ * which is also the deliberate spelling for declaring more dependencies than the constructor
68
+ * takes, e.g. for the dependency graph's edges.
66
69
  *
67
70
  * @since 0.3.16-canary.0
68
71
  */
69
72
  export function injectable<const Deps extends ReadonlyArray<InjectableDependency>>(
70
73
  deps: Deps,
71
74
  options?: InjectableOptions,
72
- ): (target: abstract new (...args: InjectedParameters<Deps>) => unknown, context: ClassDecoratorContext) => void;
75
+ ): <Target extends abstract new (...args: InjectedParameters<Deps>) => unknown>(
76
+ target: Target &
77
+ (number extends Deps["length"]
78
+ ? unknown
79
+ : Deps["length"] extends ConstructorParameters<Target>["length"]
80
+ ? unknown
81
+ : never),
82
+ context: ClassDecoratorContext,
83
+ ) => void;
73
84
 
74
85
  /**
75
86
  * @since 0.6.0
@@ -6,6 +6,7 @@
6
6
  */
7
7
  import type { Binding } from "#/core/binding";
8
8
  import type { BindingRegistry } from "#/core/registry";
9
+ import type { BindingTag } from "#/core/tag";
9
10
  import type { Token } from "#/core/token";
10
11
  import type { Constructor } from "#/core/types";
11
12
 
@@ -34,6 +35,8 @@ export const ALIAS_HOP_LIMIT = 32;
34
35
  */
35
36
  const newNameToEntryMap = <Owner>(): Map<string, DefaultLookupEntry<Owner> | null> => new Map();
36
37
 
38
+ const newTagToEntryMap = <Owner>(): Map<BindingTag, DefaultLookupEntry<Owner> | null> => new Map();
39
+
37
40
  /**
38
41
  * @since 0.5.0-canary.9
39
42
  */
@@ -47,6 +50,14 @@ export class BindingLookupCache<Owner> {
47
50
  #lastEntry: DefaultLookupEntry<Owner> | null = null;
48
51
  readonly #byTokenAndName = new Map<Token<unknown> | Constructor, Map<string, DefaultLookupEntry<Owner> | null>>();
49
52
  #namedVersion = -1;
53
+ readonly #byTokenAndTag = new Map<Token<unknown> | Constructor, Map<BindingTag, DefaultLookupEntry<Owner> | null>>();
54
+ #taggedVersion = -1;
55
+ // One entry in front of the tag map, and the map is not written until a second distinct request
56
+ // shape appears: a per-request child usually asks one (token, tag) once, and the inner-map
57
+ // allocation was that shape's whole regression when this memo landed.
58
+ #lastTagToken: Token<unknown> | Constructor | undefined;
59
+ #lastTag: BindingTag | undefined;
60
+ #lastTaggedEntry: DefaultLookupEntry<Owner> | null = null;
50
61
 
51
62
  readonly #registry: BindingRegistry;
52
63
  readonly #owner: Owner;
@@ -105,6 +116,37 @@ export class BindingLookupCache<Owner> {
105
116
  return entry;
106
117
  }
107
118
 
119
+ /** `null` when the tag's shape needs the full selection path. */
120
+ taggedEntry(token: Token<unknown> | Constructor, tag: BindingTag): DefaultLookupEntry<Owner> | null {
121
+ const version = this.chainVersion();
122
+ if (version !== this.#taggedVersion) {
123
+ this.#byTokenAndTag.clear();
124
+ this.#taggedVersion = version;
125
+ this.#lastTagToken = undefined;
126
+ this.#lastTag = undefined;
127
+ } else if (token === this.#lastTagToken && tag === this.#lastTag) {
128
+ return this.#lastTaggedEntry;
129
+ }
130
+ let entry: DefaultLookupEntry<Owner> | null | undefined;
131
+ if (this.#lastTagToken === undefined) {
132
+ // First shape this cache generation sees: answer from the walk and defer the map entirely.
133
+ entry = this.#findTaggedInChain(token, tag);
134
+ } else {
135
+ // Keyed by the criterion object itself: criteria are interned, so identity is the slot
136
+ // contract's own `Object.is` — the same exactness the registry's tagged index relies on.
137
+ const byTag = this.#byTokenAndTag.getOrInsertComputed(token, newTagToEntryMap);
138
+ entry = byTag.get(tag);
139
+ if (entry === undefined) {
140
+ entry = this.#findTaggedInChain(token, tag);
141
+ byTag.set(tag, entry);
142
+ }
143
+ }
144
+ this.#lastTagToken = token;
145
+ this.#lastTag = tag;
146
+ this.#lastTaggedEntry = entry;
147
+ return entry;
148
+ }
149
+
108
150
  #foldAliases(token: Token<unknown> | Constructor): DefaultLookupEntry<Owner> | null {
109
151
  let current = token;
110
152
  for (let hop = 0; hop < ALIAS_HOP_LIMIT; hop += 1) {
@@ -146,4 +188,19 @@ export class BindingLookupCache<Owner> {
146
188
  }
147
189
  return this.#parent === undefined ? null : this.#parent.#findNamedInChain(token, name);
148
190
  }
191
+
192
+ #findTaggedInChain(token: Token<unknown> | Constructor, tag: BindingTag): DefaultLookupEntry<Owner> | null {
193
+ const tagged = this.#registry.getSimpleTagged(token, tag);
194
+ if (tagged !== undefined) {
195
+ // Predicates need a live context; aliases carry options through the full path.
196
+ if (tagged.predicate !== undefined || tagged.kind === "alias") {
197
+ return null;
198
+ }
199
+ return { binding: tagged, owner: this.#owner };
200
+ }
201
+ if (this.#registry.has(token)) {
202
+ return null;
203
+ }
204
+ return this.#parent === undefined ? null : this.#parent.#findTaggedInChain(token, tag);
205
+ }
149
206
  }
@@ -124,6 +124,16 @@ export interface InstantiationPlanHost {
124
124
  token: Token<unknown> | Constructor,
125
125
  options: ResolveOptions & { name: string },
126
126
  ): InstantiationPlanDependencyEntry | null;
127
+ /**
128
+ * The named lookup's single-tag twin, or `null` under the same rule.
129
+ *
130
+ * @remarks Optional so a host predating it stays a valid host — a compiler given none simply
131
+ * escapes the dependency, which is exactly the pre-settlement behavior.
132
+ */
133
+ lookupPathIndependentTaggedEntry?(
134
+ token: Token<unknown> | Constructor,
135
+ options: ResolveOptions,
136
+ ): InstantiationPlanDependencyEntry | null;
127
137
  /** The frame the interpreted path pushes for this binding, so escapes can replay it. */
128
138
  getResolutionFrame(binding: Binding): ResolutionFrame;
129
139
  /** Runtime resolve for an escaped dependency, seeded with the ancestor frames above it. */
@@ -169,8 +179,10 @@ export class InstantiationPlanCompiler {
169
179
  /**
170
180
  * Re-entry into the runtime resolver for a dependency the plan can't see through.
171
181
  *
172
- * The ancestors are fixed at compile time, so the seeds are built once; each call copies
173
- * them because the resolver pushes and pops on the arrays it is given.
182
+ * The ancestors are fixed at compile time, so the seed is built once and lent per call — the
183
+ * root-stack rule one level down: every sync lane pops what it pushes, so the owned array still
184
+ * holds exactly the seed when a call returns. A reentrant call finds it claimed and mints its
185
+ * own copy; a return that did not restore the length hands nothing back, so the next call mints.
174
186
  */
175
187
  #compileEscapeThunk(
176
188
  token: Token<unknown> | Constructor,
@@ -180,7 +192,19 @@ export class InstantiationPlanCompiler {
180
192
  ): () => unknown {
181
193
  const host = this.#host;
182
194
  const frames = ancestors.map((ancestor) => host.getResolutionFrame(ancestor));
183
- return () => host.resolveEscaped(token, options, arity, [...frames]);
195
+ const depth = frames.length;
196
+ let owned: Array<ResolutionFrame> | undefined = [...frames];
197
+ return () => {
198
+ const stack = owned ?? [...frames];
199
+ owned = undefined;
200
+ try {
201
+ return host.resolveEscaped(token, options, arity, stack);
202
+ } finally {
203
+ if (stack.length === depth) {
204
+ owned = stack;
205
+ }
206
+ }
207
+ };
184
208
  }
185
209
 
186
210
  // A resolved binding declares its deps as explicit descriptors — same rules as
@@ -246,6 +270,12 @@ export class InstantiationPlanCompiler {
246
270
  if (named !== null) {
247
271
  return this.#compileDepThunk(named, compileStack, depth, ancestors, options);
248
272
  }
273
+ } else {
274
+ // The host owns the whole single-tag decision, including whether the options qualify.
275
+ const tagged = this.#host.lookupPathIndependentTaggedEntry?.(token, options);
276
+ if (tagged !== null && tagged !== undefined) {
277
+ return this.#compileDepThunk(tagged, compileStack, depth, ancestors, options);
278
+ }
249
279
  }
250
280
  return this.#compileEscapeThunk(token, ancestors, "single", options);
251
281
  }
@@ -514,6 +544,12 @@ export class InstantiationPlanCompiler {
514
544
  if (named !== null) {
515
545
  return this.#compileAsyncDepThunk(named, compileStack, depth, ancestors, options);
516
546
  }
547
+ } else {
548
+ // The host owns the whole single-tag decision, including whether the options qualify.
549
+ const tagged = this.#host.lookupPathIndependentTaggedEntry?.(token, options);
550
+ if (tagged !== null && tagged !== undefined) {
551
+ return this.#compileAsyncDepThunk(tagged, compileStack, depth, ancestors, options);
552
+ }
517
553
  }
518
554
  return this.#compileAsyncEscapeThunk(token, ancestors, "single", options);
519
555
  }
@@ -436,6 +436,18 @@ export class DependencyResolver implements ResolverCallbacks {
436
436
  }
437
437
  return { binding: entry.binding };
438
438
  },
439
+ // The named rule verbatim, on the single-tag lane's memo.
440
+ lookupPathIndependentTaggedEntry: (token, options) => {
441
+ const singleTag = singleTagOnlyOf(options);
442
+ if (singleTag === undefined) {
443
+ return null;
444
+ }
445
+ const entry = this.#lookup.taggedEntry(token, singleTag);
446
+ if (entry === null || entry.binding.predicate !== undefined || !matchesSlot(entry.binding.slot, options)) {
447
+ return null;
448
+ }
449
+ return { binding: entry.binding };
450
+ },
439
451
  getResolutionFrame: (binding) => this.#getResolutionFrame(binding),
440
452
  // Dispatches exactly as #resolveDep does, so an escaped dep is indistinguishable
441
453
  // from the same dep on a fully interpreted resolve.
@@ -509,6 +521,22 @@ export class DependencyResolver implements ResolverCallbacks {
509
521
  }
510
522
  // Everything else keeps the full path (context, activation, guards).
511
523
  }
524
+ } else if (options !== undefined) {
525
+ // Single-tag fast lane: the named lane's tagged twin, memoizing the chain walk.
526
+ const singleTag = singleTagOnlyOf(options);
527
+ if (singleTag !== undefined) {
528
+ const taggedEntry = this.#lookup.taggedEntry(token, singleTag);
529
+ if (taggedEntry !== null) {
530
+ const taggedBinding = taggedEntry.binding;
531
+ if (taggedEntry.owner.#isPlainConstant(taggedBinding)) {
532
+ return taggedBinding.value as Value;
533
+ }
534
+ if (taggedBinding.scope === "singleton" && taggedBinding.instance !== NO_INSTANCE) {
535
+ return taggedBinding.instance as Value;
536
+ }
537
+ // Everything else keeps the full path (context, activation, guards).
538
+ }
539
+ }
512
540
  }
513
541
 
514
542
  const { binding, owner } = this.#requireBinding(token, options, resolutionStack);