@codefast/di 0.5.0-canary.7 → 0.5.0-canary.8

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 (100) hide show
  1. package/CHANGELOG.md +68 -0
  2. package/README.md +3 -1
  3. package/dist/binding.d.ts +47 -5
  4. package/dist/binding.d.ts.map +1 -1
  5. package/dist/binding.js +44 -0
  6. package/dist/binding.js.map +1 -1
  7. package/dist/constructor-type.d.ts +4 -5
  8. package/dist/constructor-type.d.ts.map +1 -1
  9. package/dist/container/binding-builders.d.ts +32 -11
  10. package/dist/container/binding-builders.d.ts.map +1 -1
  11. package/dist/container/binding-builders.js +140 -191
  12. package/dist/container/binding-builders.js.map +1 -1
  13. package/dist/container/container.d.ts.map +1 -1
  14. package/dist/container/container.js +75 -92
  15. package/dist/container/container.js.map +1 -1
  16. package/dist/decorators/inject.d.ts +2 -4
  17. package/dist/decorators/inject.d.ts.map +1 -1
  18. package/dist/decorators/inject.js.map +1 -1
  19. package/dist/errors.d.ts +14 -0
  20. package/dist/errors.d.ts.map +1 -1
  21. package/dist/errors.js +17 -0
  22. package/dist/errors.js.map +1 -1
  23. package/dist/index.d.ts +1 -1
  24. package/dist/index.d.ts.map +1 -1
  25. package/dist/index.js +1 -1
  26. package/dist/index.js.map +1 -1
  27. package/dist/introspection/inspector.js +1 -1
  28. package/dist/introspection/inspector.js.map +1 -1
  29. package/dist/metadata/metadata-keys.d.ts +3 -6
  30. package/dist/metadata/metadata-keys.d.ts.map +1 -1
  31. package/dist/metadata/metadata-keys.js +3 -6
  32. package/dist/metadata/metadata-keys.js.map +1 -1
  33. package/dist/registry.d.ts +14 -2
  34. package/dist/registry.d.ts.map +1 -1
  35. package/dist/registry.js +35 -45
  36. package/dist/registry.js.map +1 -1
  37. package/dist/resolution/activation-need.d.ts +25 -0
  38. package/dist/resolution/activation-need.d.ts.map +1 -0
  39. package/dist/resolution/activation-need.js +64 -0
  40. package/dist/resolution/activation-need.js.map +1 -0
  41. package/dist/resolution/binding-lookup-cache.d.ts +41 -0
  42. package/dist/resolution/binding-lookup-cache.d.ts.map +1 -0
  43. package/dist/resolution/binding-lookup-cache.js +102 -0
  44. package/dist/resolution/binding-lookup-cache.js.map +1 -0
  45. package/dist/resolution/binding-select.js +1 -4
  46. package/dist/resolution/binding-select.js.map +1 -1
  47. package/dist/resolution/class-introspector.d.ts +27 -0
  48. package/dist/resolution/class-introspector.d.ts.map +1 -0
  49. package/dist/resolution/class-introspector.js +60 -0
  50. package/dist/resolution/class-introspector.js.map +1 -0
  51. package/dist/resolution/diagnostics.d.ts +41 -0
  52. package/dist/resolution/diagnostics.d.ts.map +1 -0
  53. package/dist/resolution/diagnostics.js +18 -0
  54. package/dist/resolution/diagnostics.js.map +1 -0
  55. package/dist/resolution/environment.d.ts +22 -1
  56. package/dist/resolution/environment.d.ts.map +1 -1
  57. package/dist/resolution/environment.js +27 -1
  58. package/dist/resolution/environment.js.map +1 -1
  59. package/dist/resolution/instantiation-plan.d.ts +15 -15
  60. package/dist/resolution/instantiation-plan.d.ts.map +1 -1
  61. package/dist/resolution/instantiation-plan.js +68 -48
  62. package/dist/resolution/instantiation-plan.js.map +1 -1
  63. package/dist/resolution/lifecycle.d.ts +2 -0
  64. package/dist/resolution/lifecycle.d.ts.map +1 -1
  65. package/dist/resolution/lifecycle.js +16 -11
  66. package/dist/resolution/lifecycle.js.map +1 -1
  67. package/dist/resolution/resolution-path.d.ts +4 -13
  68. package/dist/resolution/resolution-path.d.ts.map +1 -1
  69. package/dist/resolution/resolution-path.js +3 -16
  70. package/dist/resolution/resolution-path.js.map +1 -1
  71. package/dist/resolution/resolver.d.ts +7 -2
  72. package/dist/resolution/resolver.d.ts.map +1 -1
  73. package/dist/resolution/resolver.js +116 -328
  74. package/dist/resolution/resolver.js.map +1 -1
  75. package/dist/resolution/scope.d.ts +7 -15
  76. package/dist/resolution/scope.d.ts.map +1 -1
  77. package/dist/resolution/scope.js +48 -44
  78. package/dist/resolution/scope.js.map +1 -1
  79. package/package.json +7 -97
  80. package/src/binding.ts +104 -5
  81. package/src/constructor-type.ts +4 -5
  82. package/src/container/binding-builders.ts +180 -283
  83. package/src/container/container.ts +90 -103
  84. package/src/decorators/inject.ts +3 -5
  85. package/src/errors.ts +21 -0
  86. package/src/index.ts +1 -0
  87. package/src/introspection/inspector.ts +1 -1
  88. package/src/metadata/metadata-keys.ts +3 -6
  89. package/src/registry.ts +38 -59
  90. package/src/resolution/activation-need.ts +81 -0
  91. package/src/resolution/binding-lookup-cache.ts +132 -0
  92. package/src/resolution/binding-select.ts +1 -4
  93. package/src/resolution/class-introspector.ts +74 -0
  94. package/src/resolution/diagnostics.ts +43 -0
  95. package/src/resolution/environment.ts +31 -1
  96. package/src/resolution/instantiation-plan.ts +113 -61
  97. package/src/resolution/lifecycle.ts +16 -11
  98. package/src/resolution/resolution-path.ts +5 -20
  99. package/src/resolution/resolver.ts +142 -371
  100. package/src/resolution/scope.ts +50 -49
@@ -1,6 +1,7 @@
1
1
  import type { Binding, BindToBuilder } from "#/binding";
2
- import { bindingSlotEquals } from "#/binding";
3
- import { BindingEntry } from "#/container/binding-builders";
2
+ import { NO_INSTANCE } from "#/binding";
3
+ import type { BindingRegistration } from "#/container/binding-builders";
4
+ import { BindingChain } from "#/container/binding-builders";
4
5
  import type { AutoRegisterRegistry } from "#/decorators/injectable";
5
6
  import {
6
7
  AsyncModuleLoadError,
@@ -23,6 +24,8 @@ import type { AsyncModuleBuilder } from "#/module";
23
24
  import { isSyncModule, MODULE_SETUP } from "#/module";
24
25
  import { BindingRegistry } from "#/registry";
25
26
  import { effectiveBindingScope } from "#/resolution/binding-scope";
27
+ import type { ResolutionDiagnostics } from "#/resolution/diagnostics";
28
+ import { RESOLUTION_DIAGNOSTICS } from "#/resolution/diagnostics";
26
29
  import { LifecycleManager } from "#/resolution/lifecycle";
27
30
  import { injectionSlotToResolveOptions, bindingSlotToResolveOptions } from "#/resolution/resolve-options";
28
31
  import { DependencyResolver } from "#/resolution/resolver";
@@ -38,17 +41,6 @@ import type {
38
41
  ResolveOptions,
39
42
  } from "#/types";
40
43
 
41
- // True when re-adding `restored` would immediately be displaced again by `current`
42
- // (both slot-based with equal slots) — in that case the replacement was legitimate.
43
- function displacesRestoredBinding(current: Binding, restored: Binding): boolean {
44
- const currentIsPurePredicate =
45
- current.predicate !== undefined && current.slot.name === undefined && current.slot.tags.length === 0;
46
- if (currentIsPurePredicate) {
47
- return false;
48
- }
49
- return bindingSlotEquals(current.slot, restored.slot);
50
- }
51
-
52
44
  // ── Container interface ────────────────────────────────────────────────────────
53
45
 
54
46
  /**
@@ -119,23 +111,54 @@ class DefaultContainer implements Container {
119
111
  readonly #scope: ScopeManager;
120
112
  readonly #lifecycle: LifecycleManager;
121
113
  #resolver!: DependencyResolver;
122
- readonly #inspector: Inspector;
114
+ // Built on the first introspecting call — a container that only binds and resolves never needs it.
115
+ #inspector: Inspector | undefined;
123
116
  readonly #parent: DefaultContainer | undefined;
124
117
 
125
- // Module tracking: module -> ref count
126
- readonly #moduleRefs = new Map<object, number>();
118
+ // Module tracking: module -> ref count. Both tables stay unallocated until a module is loaded.
119
+ #moduleRefs: Map<object, number> | undefined;
127
120
  // Module bindings: module -> array of binding IDs registered by it
128
- readonly #moduleBindingIds = new Map<object, Array<BindingIdentifier>>();
121
+ #moduleBindingIds: Map<object, Array<BindingIdentifier>> | undefined;
122
+ // One shared registration for every chain this container's own `bind()` creates.
123
+ #registration: BindingRegistration | undefined;
129
124
 
130
125
  constructor(parent?: DefaultContainer) {
131
126
  this.#parent = parent;
132
127
  this.#registry = new BindingRegistry();
133
128
  this.#scope = new ScopeManager(parent !== undefined);
134
129
  this.#lifecycle = new LifecycleManager();
135
- this.#inspector = new Inspector(this.#registry, this.#scope, parent !== undefined, () => this.#disposed);
136
130
  this.#initResolver();
137
131
  }
138
132
 
133
+ #getInspector(): Inspector {
134
+ return (this.#inspector ??= new Inspector(
135
+ this.#registry,
136
+ this.#scope,
137
+ this.#parent !== undefined,
138
+ () => this.#disposed,
139
+ ));
140
+ }
141
+
142
+ [RESOLUTION_DIAGNOSTICS](): ResolutionDiagnostics {
143
+ const builtSubsystems: Array<string> = [];
144
+ if (this.#inspector !== undefined) {
145
+ builtSubsystems.push("container.inspector");
146
+ }
147
+ if (this.#moduleRefs !== undefined || this.#moduleBindingIds !== undefined) {
148
+ builtSubsystems.push("container.moduleTables");
149
+ }
150
+ if (this.#registry.isBuilt) {
151
+ builtSubsystems.push("registry.namedIndex");
152
+ }
153
+ if (this.#scope.isBuilt) {
154
+ builtSubsystems.push("scope.scoped");
155
+ }
156
+ if (this.#lifecycle.isBuilt) {
157
+ builtSubsystems.push("lifecycle.activationHooks");
158
+ }
159
+ return { ...this.#resolver.describeCaches(), builtSubsystems };
160
+ }
161
+
139
162
  #initResolver(): void {
140
163
  const metadataReader = this.#getMetadataReader();
141
164
  const parentResolver = this.#parent === undefined ? undefined : this.#parent.#resolver;
@@ -176,63 +199,24 @@ class DefaultContainer implements Container {
176
199
  return this.#createBindToBuilder(token);
177
200
  }
178
201
 
202
+ /** The registration every non-module chain shares, so `bind()` allocates only the builder. */
203
+ #ownRegistration(): BindingRegistration {
204
+ return (this.#registration ??= { registry: this.#registry, moduleBindingIds: undefined });
205
+ }
206
+
207
+ /** One registration per module load, holding that module's id list directly. */
208
+ #moduleRegistration(moduleRef: object): BindingRegistration {
209
+ return {
210
+ registry: this.#registry,
211
+ moduleBindingIds: (this.#moduleBindingIds ??= new Map()).getOrInsert(moduleRef, []),
212
+ };
213
+ }
214
+
179
215
  #createBindToBuilder<const Value>(
180
216
  token: Token<Value> | Constructor<Value>,
181
- moduleRef?: object,
217
+ registration: BindingRegistration = this.#ownRegistration(),
182
218
  ): BindToBuilder<Value> {
183
- const registry = this.#registry;
184
- // Bindings displaced by this fluent chain's commits — each stays restorable
185
- // until the chain settles on a shape that genuinely conflicts with it. A list,
186
- // because one chain can displace several bindings (the default at an
187
- // intermediate commit, then a named/tagged binding at the final one).
188
- const displacedByChain: Array<Binding> = [];
189
-
190
- const commitBinding = <BindingValue>(
191
- binding: Binding<BindingValue>,
192
- previousId?: BindingIdentifier,
193
- ): BindingIdentifier => {
194
- if (previousId !== undefined) {
195
- registry.removeById(previousId);
196
- if (moduleRef !== undefined) {
197
- const ids = this.#moduleBindingIds.get(moduleRef);
198
- if (ids !== undefined) {
199
- const idx = ids.indexOf(previousId);
200
- if (idx !== -1) {
201
- ids.splice(idx, 1);
202
- }
203
- }
204
- }
205
- }
206
- // The registry stores value-erased bindings — this is the single erasure point for the builder chain.
207
- const displaced = registry.add(binding as Binding);
208
- if (displaced !== undefined) {
209
- // A fluent chain commits eagerly on each refinement, so an intermediate
210
- // commit can displace a binding that the final shape would never conflict
211
- // with. Remember it so a later re-commit that morphs away (named/tagged
212
- // slot, pure predicate) can restore it.
213
- displacedByChain.push(displaced);
214
- }
215
- if (previousId !== undefined && displacedByChain.length > 0) {
216
- for (let index = displacedByChain.length - 1; index >= 0; index -= 1) {
217
- const candidate = displacedByChain[index]!;
218
- if (!displacesRestoredBinding(binding as Binding, candidate)) {
219
- registry.add(candidate);
220
- displacedByChain.splice(index, 1);
221
- }
222
- }
223
- }
224
- if (moduleRef !== undefined) {
225
- let ids = this.#moduleBindingIds.get(moduleRef);
226
- if (ids === undefined) {
227
- ids = [];
228
- this.#moduleBindingIds.set(moduleRef, ids);
229
- }
230
- ids.push(binding.id);
231
- }
232
- return binding.id;
233
- };
234
-
235
- return new BindingEntry<Value>(token, commitBinding);
219
+ return new BindingChain<Value>(token, registration);
236
220
  }
237
221
 
238
222
  unbind(tokenOrId: Token<unknown> | Constructor | BindingIdentifier): void {
@@ -255,9 +239,9 @@ class DefaultContainer implements Container {
255
239
  #drainSingletons(bindings: ReadonlyArray<Binding>): Array<[Binding, unknown]> {
256
240
  const pairs: Array<[Binding, unknown]> = [];
257
241
  for (const binding of bindings) {
258
- if (this.#scope.hasSingleton(binding.id)) {
259
- pairs.push([binding, this.#scope.getSingleton(binding.id)]);
260
- this.#scope.deleteSingleton(binding.id);
242
+ if (binding.instance !== NO_INSTANCE) {
243
+ pairs.push([binding, binding.instance]);
244
+ this.#scope.deleteSingleton(binding);
261
245
  }
262
246
  }
263
247
  return pairs;
@@ -319,12 +303,13 @@ class DefaultContainer implements Container {
319
303
  throw new AsyncModuleLoadError(module.name);
320
304
  }
321
305
  const moduleRef = module as object;
322
- const existing = this.#moduleRefs.get(moduleRef);
306
+ const moduleRefs = (this.#moduleRefs ??= new Map());
307
+ const existing = moduleRefs.get(moduleRef);
323
308
  if (existing !== undefined) {
324
- this.#moduleRefs.set(moduleRef, existing + 1);
309
+ moduleRefs.set(moduleRef, existing + 1);
325
310
  continue;
326
311
  }
327
- this.#moduleRefs.set(moduleRef, 1);
312
+ moduleRefs.set(moduleRef, 1);
328
313
  const builder = this.#createModuleBuilder(moduleRef);
329
314
  module[MODULE_SETUP](builder);
330
315
  }
@@ -359,12 +344,13 @@ class DefaultContainer implements Container {
359
344
 
360
345
  async #loadOneModuleAsync(module: SyncModule | AsyncModule): Promise<void> {
361
346
  const moduleRef = module as object;
362
- const existing = this.#moduleRefs.get(moduleRef);
347
+ const moduleRefs = (this.#moduleRefs ??= new Map());
348
+ const existing = moduleRefs.get(moduleRef);
363
349
  if (existing !== undefined) {
364
- this.#moduleRefs.set(moduleRef, existing + 1);
350
+ moduleRefs.set(moduleRef, existing + 1);
365
351
  return;
366
352
  }
367
- this.#moduleRefs.set(moduleRef, 1);
353
+ moduleRefs.set(moduleRef, 1);
368
354
 
369
355
  if (isSyncModule(module)) {
370
356
  const builder = this.#createModuleBuilder(moduleRef);
@@ -381,9 +367,10 @@ class DefaultContainer implements Container {
381
367
  }
382
368
 
383
369
  #createModuleBuilder(moduleRef: object): ModuleBuilder {
370
+ const registration = this.#moduleRegistration(moduleRef);
384
371
  return {
385
372
  bind: <const Value>(token: Token<Value> | Constructor<Value>): BindToBuilder<Value> =>
386
- this.#createBindToBuilder(token, moduleRef),
373
+ this.#createBindToBuilder(token, registration),
387
374
  import: (...modules: Array<SyncModule>): void => {
388
375
  this.#loadSyncModules(modules);
389
376
  },
@@ -391,9 +378,10 @@ class DefaultContainer implements Container {
391
378
  }
392
379
 
393
380
  #createAsyncModuleBuilder(moduleRef: object, importPromises: Array<Promise<void>>): AsyncModuleBuilder {
381
+ const registration = this.#moduleRegistration(moduleRef);
394
382
  return {
395
383
  bind: <const Value>(token: Token<Value> | Constructor<Value>): BindToBuilder<Value> =>
396
- this.#createBindToBuilder(token, moduleRef),
384
+ this.#createBindToBuilder(token, registration),
397
385
  import: (...modules: Array<SyncModule | AsyncModule>): void => {
398
386
  for (const module of modules) {
399
387
  importPromises.push(this.#loadOneModuleAsync(module));
@@ -411,17 +399,17 @@ class DefaultContainer implements Container {
411
399
 
412
400
  /** Unregister module bindings and collect [binding, instance] pairs for deactivation. */
413
401
  #removeModuleBindings(ref: object): Array<[Binding, unknown]> {
414
- this.#moduleRefs.delete(ref);
415
- const ids = this.#moduleBindingIds.get(ref) ?? [];
416
- this.#moduleBindingIds.delete(ref);
402
+ this.#moduleRefs?.delete(ref);
403
+ const ids = this.#moduleBindingIds?.get(ref) ?? [];
404
+ this.#moduleBindingIds?.delete(ref);
417
405
  const pairs: Array<[Binding, unknown]> = [];
418
406
  for (const id of ids) {
419
407
  const binding = this.#registry.getById(id);
420
408
  if (binding !== undefined) {
421
409
  this.#registry.removeById(id);
422
- if (this.#scope.hasSingleton(id)) {
423
- pairs.push([binding, this.#scope.getSingleton(id)]);
424
- this.#scope.deleteSingleton(id);
410
+ if (binding.instance !== NO_INSTANCE) {
411
+ pairs.push([binding, binding.instance]);
412
+ this.#scope.deleteSingleton(binding);
425
413
  }
426
414
  }
427
415
  }
@@ -429,14 +417,14 @@ class DefaultContainer implements Container {
429
417
  }
430
418
 
431
419
  #unloadModuleSync(ref: object): void {
432
- const count = this.#moduleRefs.get(ref) ?? 0;
420
+ const count = this.#moduleRefs?.get(ref) ?? 0;
433
421
  if (count <= 1) {
434
422
  const reader = this.#getMetadataReader();
435
423
  for (const [binding, instance] of this.#removeModuleBindings(ref)) {
436
424
  this.#lifecycle.runDeactivationSync(binding, instance, reader);
437
425
  }
438
426
  } else {
439
- this.#moduleRefs.set(ref, count - 1);
427
+ this.#moduleRefs!.set(ref, count - 1);
440
428
  }
441
429
  }
442
430
 
@@ -448,14 +436,14 @@ class DefaultContainer implements Container {
448
436
  }
449
437
 
450
438
  async #unloadModuleAsync(ref: object): Promise<void> {
451
- const count = this.#moduleRefs.get(ref) ?? 0;
439
+ const count = this.#moduleRefs?.get(ref) ?? 0;
452
440
  if (count <= 1) {
453
441
  const reader = this.#getMetadataReader();
454
442
  for (const [binding, instance] of this.#removeModuleBindings(ref)) {
455
443
  await this.#lifecycle.runDeactivation(binding, instance, reader);
456
444
  }
457
445
  } else {
458
- this.#moduleRefs.set(ref, count - 1);
446
+ this.#moduleRefs!.set(ref, count - 1);
459
447
  }
460
448
  }
461
449
 
@@ -549,11 +537,10 @@ class DefaultContainer implements Container {
549
537
 
550
538
  // Deactivate all singletons in this container (own only)
551
539
  const reader = this.#getMetadataReader();
552
- for (const [id, instance] of this.#scope.getAllSingletons()) {
553
- const binding = this.#registry.getById(id);
554
- if (binding !== undefined) {
555
- await this.#lifecycle.runDeactivation(binding, instance, reader);
556
- }
540
+ // Iterate a copy: a deactivation handler is user code, and the live list is what
541
+ // materializing or dropping a singleton mutates.
542
+ for (const binding of this.#scope.cachedSingletons().slice()) {
543
+ await this.#lifecycle.runDeactivation(binding, binding.instance, reader);
557
544
  }
558
545
 
559
546
  this.#scope.clearAll();
@@ -577,7 +564,7 @@ class DefaultContainer implements Container {
577
564
  continue;
578
565
  }
579
566
  const scope = effectiveBindingScope(binding);
580
- if (scope === "singleton" && !this.#scope.hasSingleton(binding.id)) {
567
+ if (scope === "singleton" && binding.instance === NO_INSTANCE) {
581
568
  if (binding.predicate !== undefined) {
582
569
  continue;
583
570
  }
@@ -777,22 +764,22 @@ class DefaultContainer implements Container {
777
764
 
778
765
  has(token: Token<unknown> | Constructor, options?: ResolveOptions): boolean {
779
766
  this.#assertNotDisposed();
780
- return this.#inspector.has(token, options, () => this.#parent?.has(token, options) ?? false);
767
+ return this.#getInspector().has(token, options, () => this.#parent?.has(token, options) ?? false);
781
768
  }
782
769
 
783
770
  hasOwn(token: Token<unknown> | Constructor, options?: ResolveOptions): boolean {
784
771
  this.#assertNotDisposed();
785
- return this.#inspector.hasOwn(token, options);
772
+ return this.#getInspector().hasOwn(token, options);
786
773
  }
787
774
 
788
775
  lookupBindings<const Value>(token: Token<Value> | Constructor<Value>): ReadonlyArray<BindingSnapshot> {
789
776
  this.#assertNotDisposed();
790
- return this.#inspector.lookupBindings(token);
777
+ return this.#getInspector().lookupBindings(token);
791
778
  }
792
779
 
793
780
  inspect(): ContainerSnapshot {
794
781
  this.#assertNotDisposed();
795
- return this.#inspector.inspect();
782
+ return this.#getInspector().inspect();
796
783
  }
797
784
 
798
785
  generateDependencyGraph(options?: GraphOptions): ContainerGraphJson {
@@ -34,10 +34,8 @@ export type InjectableDependency<Value = unknown> = Token<Value> | Constructor<V
34
34
  /**
35
35
  * The value a factory receives for one declared dependency.
36
36
  *
37
- * @remarks `optional()` and `injectAll()` already fold their effect into the descriptor's own
38
- * type parameter — `InjectionDescriptor<Value | undefined>` and `InjectionDescriptor<Array<Value>>`
39
- * — so reading that parameter back is enough; bare tokens and constructors fall through to
40
- * {@link TokenValue}.
37
+ * @remarks `optional()` and `injectAll()` fold their effect into the descriptor's own type
38
+ * parameter, so reading it back is enough; bare tokens fall through to {@link TokenValue}.
41
39
  *
42
40
  * @since 0.5.0-canary.7
43
41
  */
@@ -195,7 +193,7 @@ export function inject<const Value>(
195
193
  }
196
194
  Object.defineProperties(decoratorFn, props);
197
195
 
198
- return decoratorFn as unknown as InjectionDescriptor<Value> & ClassAccessorDecorator<unknown, Value>;
196
+ return decoratorFn as InjectionDescriptor<Value> & ClassAccessorDecorator<unknown, Value>;
199
197
  }
200
198
 
201
199
  // ── optional() ────────────────────────────────────────────────────────────────
package/src/errors.ts CHANGED
@@ -212,6 +212,27 @@ export class MissingContainerContextError extends DiError {
212
212
  }
213
213
  }
214
214
 
215
+ /**
216
+ * A fluent chain was refined before a `to*()` call gave it a binding to refine.
217
+ *
218
+ * @remarks The builder types make this unreachable from TypeScript — `bind()` returns
219
+ * `BindToBuilder`, which exposes only `to*()`. It exists for JavaScript callers and for anyone who
220
+ * casts past the types, so the misuse fails loudly instead of mutating nothing.
221
+ *
222
+ * @since 0.5.0-canary.8
223
+ */
224
+ export class ChainNotRegisteredError extends DiError {
225
+ readonly code = "CHAIN_NOT_REGISTERED";
226
+ readonly tokenName: string;
227
+
228
+ constructor(tokenName: string) {
229
+ super(
230
+ `Cannot refine the binding for token '${tokenName}' before choosing a target. Call a to*() method first — for example .to(SomeClass), .toConstantValue(value) or .toDynamic(factory).`,
231
+ );
232
+ this.tokenName = tokenName;
233
+ }
234
+ }
235
+
215
236
  /**
216
237
  * @since 0.3.16-canary.0
217
238
  */
package/src/index.ts CHANGED
@@ -85,6 +85,7 @@ export {
85
85
  AsyncDeactivationError,
86
86
  AsyncModuleLoadError,
87
87
  AsyncResolutionError,
88
+ ChainNotRegisteredError,
88
89
  CircularDependencyError,
89
90
  DiError,
90
91
  DisposedContainerError,
@@ -63,7 +63,7 @@ export class Inspector {
63
63
  const snapshots = this.allBindingSnapshots();
64
64
  return {
65
65
  ownBindings: snapshots,
66
- cachedSingletonCount: this.#scope.getAllSingletons().size,
66
+ cachedSingletonCount: this.#scope.cachedSingletons().length,
67
67
  hasParent: this.#hasParent,
68
68
  isDisposed: this.#isDisposed(),
69
69
  };
@@ -12,13 +12,10 @@ export const LIFECYCLE_KEY: unique symbol = Symbol("di:lifecycle");
12
12
  export const INJECT_ACCESSOR_KEY: unique symbol = Symbol("di:inject-accessor");
13
13
 
14
14
  /**
15
- * The well-known symbol used by TC39 Stage 3 decorator transforms to store class metadata.
15
+ * The symbol TC39 Stage 3 decorator transforms store class metadata under.
16
16
  *
17
- * `Symbol.metadata` is defined natively once the runtime ships the full TC39 decorator
18
- * proposal. Until then (current Node.js / browsers), Babel and esbuild both fall back to
19
- * `Symbol.for("Symbol.metadata")` — a global-registry symbol with the same string key.
20
- * Resolving it here once keeps the reader and the decorator transforms in sync regardless
21
- * of which path is taken.
17
+ * @remarks Falls back to the global-registry symbol, which is what Babel and esbuild emit until
18
+ * a runtime ships `Symbol.metadata` natively.
22
19
  *
23
20
  * @since 0.3.16-canary.0
24
21
  */
package/src/registry.ts CHANGED
@@ -3,44 +3,6 @@ import { bindingSlotEquals, bindingSlotToString } from "#/binding";
3
3
  import type { Token } from "#/token";
4
4
  import type { BindingIdentifier, Constructor, DependencyKey } from "#/types";
5
5
 
6
- // Superset of every binding kind's fields, used to normalize hidden classes below.
7
- type BindingFieldSuperset = Binding & {
8
- readonly scope?: unknown;
9
- readonly target?: unknown;
10
- readonly factory?: unknown;
11
- readonly deps?: unknown;
12
- readonly value?: unknown;
13
- readonly onActivation?: unknown;
14
- readonly onDeactivation?: unknown;
15
- };
16
-
17
- /**
18
- * Rebuilds a binding with every kind's fields in one fixed key order, so all stored
19
- * bindings share a single V8 hidden class. Mixed binding kinds otherwise turn the
20
- * resolver's hot property reads (kind/scope/factory/...) megamorphic, which costs
21
- * ~30% throughput in processes that exercise many kinds. The value-erasing cast is
22
- * safe: every declared field is copied verbatim and `kind` stays the discriminant.
23
- */
24
- function normalizeBindingShape(binding: Binding): Binding {
25
- const source = binding as BindingFieldSuperset;
26
- return {
27
- kind: source.kind,
28
- id: source.id,
29
- inFlight: false,
30
- frame: undefined,
31
- token: source.token,
32
- slot: source.slot,
33
- predicate: source.predicate,
34
- scope: source.scope,
35
- target: source.target,
36
- factory: source.factory,
37
- deps: source.deps,
38
- value: source.value,
39
- onActivation: source.onActivation,
40
- onDeactivation: source.onDeactivation,
41
- } as Binding;
42
- }
43
-
44
6
  /**
45
7
  * @since 0.3.16-canary.0
46
8
  */
@@ -51,22 +13,35 @@ export class BindingRegistry {
51
13
  readonly #bindings = new Map<DependencyKey, Array<Binding>>();
52
14
  // Fast lookup by binding ID
53
15
  readonly #byId = new Map<BindingIdentifier, Binding>();
54
- // Fast lookup for slot { name, tags: [] }
55
- readonly #simpleNamed = new Map<DependencyKey, Map<string, Binding>>();
16
+ // Fast lookup for slot { name, tags: [] } — unallocated until a named binding is registered.
17
+ #simpleNamed: Map<DependencyKey, Map<string, Binding>> | undefined;
56
18
  // Fast path for one default slot binding with no predicate
57
19
  readonly #fastDefault = new Map<DependencyKey, Binding>();
58
- // Fast lookup for slot { name: undefined, tags: [[key, value]] } with no predicate
59
- readonly #simpleTagged = new Map<DependencyKey, Map<string, Map<unknown, Binding>>>();
20
+ // Fast lookup for slot { name: undefined, tags: [[key, value]] } with no predicate — likewise
21
+ // unallocated until a tagged binding is registered.
22
+ #simpleTagged: Map<DependencyKey, Map<string, Map<unknown, Binding>>> | undefined;
60
23
 
61
24
  /** Monotonic version — increments on every mutation. */
62
25
  get version(): number {
63
26
  return this.#version;
64
27
  }
65
28
 
66
- /** Add or replace binding using slot-aware last-wins. Returns the displaced binding, if any. */
67
- add(uncommittedBinding: Binding): Binding | undefined {
29
+ /**
30
+ * Registers a mutation the indexes don't care about (a fluent chain refining scope or an
31
+ * activation hook in place), so version-stamped resolver caches still invalidate.
32
+ */
33
+ touch(): void {
34
+ this.#version += 1;
35
+ }
36
+
37
+ /**
38
+ * Add or replace binding using slot-aware last-wins. Returns the displaced binding, if any.
39
+ *
40
+ * @remarks The binding is stored by reference — it must come from `createBinding`, which is
41
+ * what guarantees the single hidden class the resolver's hot reads depend on.
42
+ */
43
+ add(binding: Binding): Binding | undefined {
68
44
  this.#version += 1;
69
- const binding = normalizeBindingShape(uncommittedBinding);
70
45
  const key = binding.token as DependencyKey;
71
46
  // ✓ TS6.0: Map.getOrInsert (ES2025) replaces the manual get+check+set upsert
72
47
  const bindingsForToken = this.#bindings.getOrInsert(key, []);
@@ -100,8 +75,8 @@ export class BindingRegistry {
100
75
  const key = token as DependencyKey;
101
76
  const bindingsForToken = this.#bindings.get(key) ?? [];
102
77
  this.#bindings.delete(key);
103
- this.#simpleNamed.delete(key);
104
- this.#simpleTagged.delete(key);
78
+ this.#simpleNamed?.delete(key);
79
+ this.#simpleTagged?.delete(key);
105
80
  this.#fastDefault.delete(key);
106
81
  for (const binding of bindingsForToken) {
107
82
  this.#byId.delete(binding.id);
@@ -128,8 +103,8 @@ export class BindingRegistry {
128
103
  this.#deindexSimpleTaggedBinding(key, binding);
129
104
  if (bindingsForToken.length === 0) {
130
105
  this.#bindings.delete(key);
131
- this.#simpleNamed.delete(key);
132
- this.#simpleTagged.delete(key);
106
+ this.#simpleNamed?.delete(key);
107
+ this.#simpleTagged?.delete(key);
133
108
  this.#fastDefault.delete(key);
134
109
  } else {
135
110
  this.#refreshFastDefaultForToken(key);
@@ -170,19 +145,19 @@ export class BindingRegistry {
170
145
  const all = this.allBindings();
171
146
  this.#bindings.clear();
172
147
  this.#byId.clear();
173
- this.#simpleNamed.clear();
174
- this.#simpleTagged.clear();
148
+ this.#simpleNamed?.clear();
149
+ this.#simpleTagged?.clear();
175
150
  this.#fastDefault.clear();
176
151
  return all;
177
152
  }
178
153
 
179
154
  getSimpleNamed(token: Token<unknown> | Constructor, name: string): Binding | undefined {
180
- return this.#simpleNamed.get(token as DependencyKey)?.get(name);
155
+ return this.#simpleNamed?.get(token as DependencyKey)?.get(name);
181
156
  }
182
157
 
183
158
  getSimpleTagged(token: Token<unknown> | Constructor, tagKey: string, tagValue: unknown): Binding | undefined {
184
159
  return this.#simpleTagged
185
- .get(token as DependencyKey)
160
+ ?.get(token as DependencyKey)
186
161
  ?.get(tagKey)
187
162
  ?.get(tagValue);
188
163
  }
@@ -203,7 +178,7 @@ export class BindingRegistry {
203
178
  return;
204
179
  }
205
180
  const [tagKey, tagValue] = slot.tags[0]!;
206
- const byTagKey = this.#simpleTagged.getOrInsert(tokenKey, new Map<string, Map<unknown, Binding>>());
181
+ const byTagKey = (this.#simpleTagged ??= new Map()).getOrInsert(tokenKey, new Map<string, Map<unknown, Binding>>());
207
182
  const byTagValue = byTagKey.getOrInsert(tagKey, new Map<unknown, Binding>());
208
183
  byTagValue.set(tagValue, binding);
209
184
  }
@@ -214,7 +189,7 @@ export class BindingRegistry {
214
189
  return;
215
190
  }
216
191
  const [tagKey, tagValue] = slot.tags[0]!;
217
- const byTagKey = this.#simpleTagged.get(tokenKey);
192
+ const byTagKey = this.#simpleTagged?.get(tokenKey);
218
193
  if (byTagKey === undefined) {
219
194
  return;
220
195
  }
@@ -228,7 +203,7 @@ export class BindingRegistry {
228
203
  if (byTagValue.size === 0) {
229
204
  byTagKey.delete(tagKey);
230
205
  if (byTagKey.size === 0) {
231
- this.#simpleTagged.delete(tokenKey);
206
+ this.#simpleTagged!.delete(tokenKey);
232
207
  }
233
208
  }
234
209
  }
@@ -247,7 +222,7 @@ export class BindingRegistry {
247
222
  if (slot.name === undefined || slot.tags.length > 0) {
248
223
  return;
249
224
  }
250
- const bindingsByName = this.#simpleNamed.getOrInsert(tokenKey, new Map<string, Binding>());
225
+ const bindingsByName = (this.#simpleNamed ??= new Map()).getOrInsert(tokenKey, new Map<string, Binding>());
251
226
  bindingsByName.set(slot.name, binding);
252
227
  }
253
228
 
@@ -256,7 +231,7 @@ export class BindingRegistry {
256
231
  if (slot.name === undefined || slot.tags.length > 0) {
257
232
  return;
258
233
  }
259
- const bindingsByName = this.#simpleNamed.get(tokenKey);
234
+ const bindingsByName = this.#simpleNamed?.get(tokenKey);
260
235
  if (bindingsByName === undefined) {
261
236
  return;
262
237
  }
@@ -264,7 +239,7 @@ export class BindingRegistry {
264
239
  if (currentBinding?.id === binding.id) {
265
240
  bindingsByName.delete(slot.name);
266
241
  if (bindingsByName.size === 0) {
267
- this.#simpleNamed.delete(tokenKey);
242
+ this.#simpleNamed!.delete(tokenKey);
268
243
  }
269
244
  }
270
245
  }
@@ -283,4 +258,8 @@ export class BindingRegistry {
283
258
  }
284
259
  this.#fastDefault.set(tokenKey, onlyBinding);
285
260
  }
261
+ /** Whether the deferred table behind `#simpleNamed` has had to be built. */
262
+ get isBuilt(): boolean {
263
+ return this.#simpleNamed !== undefined;
264
+ }
286
265
  }