@codefast/di 0.10.1 → 0.11.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 (55) hide show
  1. package/CHANGELOG.md +265 -0
  2. package/README.md +69 -6
  3. package/dist/ambient/active-container.d.ts +7 -2
  4. package/dist/ambient/active-container.js +6 -1
  5. package/dist/container/binding-builders.d.ts +19 -1
  6. package/dist/container/binding-builders.js +75 -16
  7. package/dist/container/container.js +224 -47
  8. package/dist/core/binding-declaration.d.ts +120 -0
  9. package/dist/core/binding-declaration.js +186 -0
  10. package/dist/core/binding.d.ts +57 -5
  11. package/dist/core/binding.js +48 -1
  12. package/dist/core/module.d.ts +7 -4
  13. package/dist/core/module.js +17 -3
  14. package/dist/core/registry.d.ts +11 -7
  15. package/dist/core/registry.js +122 -38
  16. package/dist/core/state-epoch.d.ts +18 -1
  17. package/dist/core/state-epoch.js +17 -0
  18. package/dist/core/tag.js +1 -1
  19. package/dist/decorators/decorator-metadata.d.ts +9 -0
  20. package/dist/decorators/decorator-metadata.js +20 -0
  21. package/dist/decorators/inject.js +2 -1
  22. package/dist/decorators/injectable.js +3 -1
  23. package/dist/decorators/lifecycle-decorators.js +8 -2
  24. package/dist/errors/errors.d.ts +77 -3
  25. package/dist/errors/errors.js +93 -10
  26. package/dist/index.d.ts +3 -1
  27. package/dist/index.js +2 -1
  28. package/dist/injection/descriptor.js +3 -7
  29. package/dist/injection/resolve-options.js +6 -4
  30. package/dist/introspection/dependency-graph.d.ts +7 -2
  31. package/dist/introspection/dependency-graph.js +46 -23
  32. package/dist/introspection/graph-adapters/reactflow.js +6 -4
  33. package/dist/introspection/inspector.js +6 -9
  34. package/dist/lifecycle/lifecycle-manager.js +14 -2
  35. package/dist/lifecycle/scope-manager.js +28 -10
  36. package/dist/metadata/verifying-metadata-reader.d.ts +4 -3
  37. package/dist/metadata/verifying-metadata-reader.js +28 -6
  38. package/dist/resolution/async-fan-out.d.ts +12 -0
  39. package/dist/resolution/async-fan-out.js +26 -0
  40. package/dist/resolution/cache/activation-need.d.ts +0 -1
  41. package/dist/resolution/cache/activation-need.js +11 -18
  42. package/dist/resolution/cache/binding-lookup-cache.d.ts +0 -7
  43. package/dist/resolution/cache/binding-lookup-cache.js +30 -17
  44. package/dist/resolution/cache/class-introspector.d.ts +11 -0
  45. package/dist/resolution/cache/class-introspector.js +18 -0
  46. package/dist/resolution/context.d.ts +15 -23
  47. package/dist/resolution/context.js +47 -56
  48. package/dist/resolution/path/resolution-path.d.ts +48 -13
  49. package/dist/resolution/path/resolution-path.js +89 -38
  50. package/dist/resolution/plan/instantiation-plan.js +61 -21
  51. package/dist/resolution/plan/plan-codegen.d.ts +7 -4
  52. package/dist/resolution/plan/plan-codegen.js +60 -32
  53. package/dist/resolution/resolver.d.ts +4 -5
  54. package/dist/resolution/resolver.js +288 -258
  55. package/package.json +14 -2
@@ -1,4 +1,4 @@
1
- import { bindingSlotEquals, bindingSlotToString, writableMembership, writablePredicate } from "#core/binding";
1
+ import { bindingSlotEquals, UNREGISTERED_ORDER, writableMembership, writablePredicate } from "#core/binding";
2
2
  import { getOrInsert } from "#core/map-upsert";
3
3
  import { advanceStateEpoch } from "#core/state-epoch";
4
4
  const NO_BINDINGS = Object.freeze([]);
@@ -16,15 +16,29 @@ function createTokenRecord(bindings) {
16
16
  *
17
17
  * @since 0.3.16-canary.0
18
18
  */
19
+ /**
20
+ * One shared empty map answers every read of a registry nothing was bound into, so a container
21
+ * that only ever resolves through its parent — every per-request child — allocates no map.
22
+ */
23
+ const EMPTY_LONE = new Map();
24
+ // Process-wide, so registration order compares across every registry a binding could be restored into.
25
+ let registrationCounter = 0;
26
+ /**
27
+ * @since 0.11.0
28
+ */
19
29
  export class BindingRegistry {
20
30
  // Monotonic mutation counter — lets resolvers version-stamp lookup caches across a container chain.
21
31
  #version = 0;
22
32
  // The common token lives here and nowhere else: exactly one default-slot binding, so the hot read
23
33
  // of every resolve is a bare `Map.get` and a plain bind is one map write.
24
- #lone = new Map();
34
+ #lone = EMPTY_LONE;
25
35
  // Every other token — several bindings, a tagged slot, a predicate — has a record here, and a
26
36
  // token is in exactly one of the two maps. Allocated by the first token that needs a record.
27
37
  #records;
38
+ // The binding the last `add` placed and where it landed: the fluent chain refines what it just
39
+ // registered, so a refinement that follows its own add re-slots without a map probe.
40
+ #lastAdded;
41
+ #lastAddedRecord;
28
42
  // Built on the first id-keyed read and maintained from then on: a bind-and-resolve container
29
43
  // never asks by id, so it never pays for the second map.
30
44
  #byId;
@@ -74,46 +88,62 @@ export class BindingRegistry {
74
88
  */
75
89
  add(binding) {
76
90
  this.#bump();
91
+ if (binding.registrationOrder === UNREGISTERED_ORDER) {
92
+ registrationCounter += 1;
93
+ binding.registrationOrder = registrationCounter;
94
+ }
77
95
  if (binding.kind === "constant") {
78
96
  this.#heldConstantBinding = true;
79
97
  }
80
98
  const key = binding.token;
81
- const record = this.#records?.get(key);
82
- if (record !== undefined) {
83
- return this.#addToRecord(key, record, binding);
99
+ const records = this.#records;
100
+ if (records !== undefined) {
101
+ const record = records.get(key);
102
+ if (record !== undefined) {
103
+ this.#lastAdded = binding;
104
+ this.#lastAddedRecord = record;
105
+ return this.#addToRecord(key, record, binding);
106
+ }
84
107
  }
85
- const lone = this.#lone.get(key);
86
- if (lone === undefined) {
87
- this.#byId?.set(binding.identifier, binding);
108
+ const occupant = this.#lone.get(key);
109
+ this.#lastAdded = binding;
110
+ if (occupant === undefined) {
111
+ // The common bind: a fresh token taking the lone seat, one probe and one write.
112
+ if (this.#byId !== undefined) {
113
+ this.#byId.set(binding.identifier, binding);
114
+ }
88
115
  if (isDefaultSlotBinding(binding)) {
89
- this.#lone.set(key, binding);
116
+ this.#setLone(key, binding);
117
+ this.#lastAddedRecord = undefined;
90
118
  }
91
119
  else {
92
- this.#createRecord(key, [binding]);
120
+ this.#lastAddedRecord = this.#createRecord(key, [binding]);
93
121
  }
94
122
  return undefined;
95
123
  }
96
124
  // Same slot, last wins: the newcomer takes the lone seat and nothing else moves.
97
125
  if (isDefaultSlotBinding(binding)) {
98
- this.#lone.set(key, binding);
126
+ this.#setLone(key, binding);
127
+ this.#lastAddedRecord = undefined;
99
128
  if (this.#byId !== undefined) {
100
- this.#byId.delete(lone.identifier);
129
+ this.#byId.delete(occupant.identifier);
101
130
  this.#byId.set(binding.identifier, binding);
102
131
  }
103
- return lone;
132
+ return occupant;
104
133
  }
105
134
  // A second shape joins the token, which is what a record is for.
106
- this.#lone.delete(key);
135
+ this.#deleteLone(key);
107
136
  this.#byId?.set(binding.identifier, binding);
108
- this.#createRecord(key, [lone, binding]);
137
+ this.#lastAddedRecord = this.#createRecord(key, occupant.registrationOrder < binding.registrationOrder ? [occupant, binding] : [binding, occupant]);
109
138
  return undefined;
110
139
  }
111
140
  /** Remove all bindings for a token. Returns removed bindings. */
112
141
  removeByToken(token) {
113
142
  this.#bump();
143
+ this.#lastAdded = undefined;
114
144
  const lone = this.#lone.get(token);
115
145
  if (lone !== undefined) {
116
- this.#lone.delete(token);
146
+ this.#deleteLone(token);
117
147
  this.#byId?.delete(lone.identifier);
118
148
  return [lone];
119
149
  }
@@ -138,10 +168,11 @@ export class BindingRegistry {
138
168
  return undefined;
139
169
  }
140
170
  this.#bump();
171
+ this.#lastAdded = undefined;
141
172
  byId.delete(id);
142
173
  const key = binding.token;
143
174
  if (this.#lone.get(key)?.identifier === id) {
144
- this.#lone.delete(key);
175
+ this.#deleteLone(key);
145
176
  return binding;
146
177
  }
147
178
  const record = this.#records?.get(key);
@@ -179,14 +210,6 @@ export class BindingRegistry {
179
210
  getRecorded(token) {
180
211
  return this.#records?.get(token)?.bindings ?? NO_BINDINGS;
181
212
  }
182
- /** How many bindings a token holds, without materialising a lone binding's list. */
183
- countBindings(token) {
184
- const record = this.#records?.get(token);
185
- if (record !== undefined) {
186
- return record.bindings.length;
187
- }
188
- return this.#lone.has(token) ? 1 : 0;
189
- }
190
213
  /** Get binding by ID. */
191
214
  getById(id) {
192
215
  return this.#ensureById().get(id);
@@ -213,8 +236,9 @@ export class BindingRegistry {
213
236
  /** Remove all bindings. Returns all removed. */
214
237
  clear() {
215
238
  this.#bump();
239
+ this.#lastAdded = undefined;
216
240
  const all = this.allBindings();
217
- this.#lone.clear();
241
+ this.#clearLone();
218
242
  this.#records?.clear();
219
243
  this.#byId?.clear();
220
244
  return all;
@@ -260,6 +284,26 @@ export class BindingRegistry {
260
284
  getMultiTagged(token, criterion) {
261
285
  return this.#records?.get(token)?.multi?.get(criterion);
262
286
  }
287
+ /**
288
+ * Whether any binding for the token carries a `when()` predicate.
289
+ *
290
+ * @remarks Only a token that keeps a record can hold a predicate — a lone binding is default-slot
291
+ * with none — so an index fast lane consults this to decline to full selection when the
292
+ * more-specific rule's predicate step could apply.
293
+ */
294
+ hasPredicateCandidate(token) {
295
+ const bindings = this.getRecorded(token);
296
+ for (let index = 0; index < bindings.length; index += 1) {
297
+ if (bindings[index].predicate !== undefined) {
298
+ return true;
299
+ }
300
+ }
301
+ return false;
302
+ }
303
+ /** The binding holding a token's default slot — lone or recorded — or `undefined` when none does. */
304
+ getDefaultSlotBinding(token) {
305
+ return this.#lone.get(token) ?? this.#records?.get(token)?.defaultOccupant;
306
+ }
263
307
  /** A token's lone default-slot binding — the first read of every synchronous resolve. */
264
308
  getFastDefault(token) {
265
309
  return this.#lone.get(token);
@@ -276,7 +320,8 @@ export class BindingRegistry {
276
320
  const key = binding.token;
277
321
  if (this.#lone.get(key) === binding) {
278
322
  this.#bump();
279
- this.#lone.delete(key);
323
+ this.#lastAdded = undefined;
324
+ this.#deleteLone(key);
280
325
  }
281
326
  else {
282
327
  const record = this.#records?.get(key);
@@ -285,6 +330,7 @@ export class BindingRegistry {
285
330
  return false;
286
331
  }
287
332
  this.#bump();
333
+ this.#lastAdded = undefined;
288
334
  // Replaced, never spliced: a walk holding the current array must not lose its place.
289
335
  record.bindings = record.bindings.toSpliced(index, 1);
290
336
  this.#deindexSlot(record, binding);
@@ -301,6 +347,18 @@ export class BindingRegistry {
301
347
  this.#bump();
302
348
  // Set before any (re)indexing so `#indexSlot` sees a member and leaves it out of every slot.
303
349
  writableMembership(binding).isMany = true;
350
+ if (binding === this.#lastAdded) {
351
+ // The chain refining what it just added: where the binding sits is known without a probe.
352
+ const record = this.#lastAddedRecord;
353
+ if (record === undefined) {
354
+ this.#deleteLone(binding.token);
355
+ this.#lastAddedRecord = this.#createRecord(binding.token, [binding]);
356
+ }
357
+ else if (record.defaultOccupant === binding) {
358
+ record.defaultOccupant = undefined;
359
+ }
360
+ return;
361
+ }
304
362
  if (this.#promoteLoneToRecord(binding.token, binding)) {
305
363
  return;
306
364
  }
@@ -338,7 +396,7 @@ export class BindingRegistry {
338
396
  if (this.#lone.get(key) !== binding) {
339
397
  return false;
340
398
  }
341
- this.#lone.delete(key);
399
+ this.#deleteLone(key);
342
400
  this.#createRecord(key, [binding]);
343
401
  return true;
344
402
  }
@@ -350,10 +408,6 @@ export class BindingRegistry {
350
408
  }
351
409
  return record;
352
410
  }
353
- /** Summarize available slot strings for a token (for error messages). */
354
- availableSlotStrings(token) {
355
- return this.getAll(token).map((binding) => bindingSlotToString(binding.slot));
356
- }
357
411
  #createRecord(key, bindings) {
358
412
  const record = createTokenRecord(bindings);
359
413
  (this.#records ??= new Map()).set(key, record);
@@ -388,12 +442,10 @@ export class BindingRegistry {
388
442
  }
389
443
  // A selection may be walking this list inside a `when()` predicate. An append past the length it
390
444
  // read cannot shift it, so it lands in place; a displacement replaces the array instead.
391
- if (displacedBinding === undefined) {
392
- record.bindings.push(binding);
393
- }
394
- else {
395
- record.bindings = [...record.bindings.filter((candidate) => candidate !== displacedBinding), binding];
445
+ if (displacedBinding !== undefined) {
446
+ record.bindings = record.bindings.filter((candidate) => candidate !== displacedBinding);
396
447
  }
448
+ insertInRegistrationOrder(record, binding);
397
449
  this.#byId?.set(binding.identifier, binding);
398
450
  this.#indexSlot(record, binding);
399
451
  this.#settle(key, record);
@@ -407,8 +459,22 @@ export class BindingRegistry {
407
459
  }
408
460
  else if (bindings.length === 1 && isDefaultSlotBinding(bindings[0])) {
409
461
  this.#records.delete(key);
410
- this.#lone.set(key, bindings[0]);
462
+ this.#setLone(key, bindings[0]);
463
+ }
464
+ }
465
+ // Every write to the lone map goes through these three, so a cache of its entries has one place to follow them.
466
+ #setLone(key, binding) {
467
+ let lone = this.#lone;
468
+ if (lone === EMPTY_LONE) {
469
+ lone = this.#lone = new Map();
411
470
  }
471
+ lone.set(key, binding);
472
+ }
473
+ #deleteLone(key) {
474
+ this.#lone.delete(key);
475
+ }
476
+ #clearLone() {
477
+ this.#lone.clear();
412
478
  }
413
479
  #ensureById() {
414
480
  if (this.#byId === undefined) {
@@ -474,6 +540,24 @@ export class BindingRegistry {
474
540
  }
475
541
  }
476
542
  }
543
+ /**
544
+ * Places a binding in its record by registration order: a fresh registration appends, and a binding a
545
+ * chain took out and put back returns to its place.
546
+ */
547
+ function insertInRegistrationOrder(record, binding) {
548
+ const { bindings } = record;
549
+ const order = binding.registrationOrder;
550
+ if (bindings.length === 0 || bindings.at(-1).registrationOrder < order) {
551
+ bindings.push(binding);
552
+ return;
553
+ }
554
+ let index = bindings.length - 1;
555
+ while (index > 0 && bindings[index - 1].registrationOrder > order) {
556
+ index -= 1;
557
+ }
558
+ // Replaced, never spliced: a walk holding the current array must not lose its place.
559
+ record.bindings = bindings.toSpliced(index, 0, binding);
560
+ }
477
561
  /** A binding nothing has to be matched against: the default slot, no predicate, not a collection member. */
478
562
  function isDefaultSlotBinding(binding) {
479
563
  return binding.slot.tags.length === 0 && binding.predicate === undefined && !binding.isMany;
@@ -13,4 +13,21 @@ export declare function stateEpoch(): number;
13
13
  *
14
14
  * @since 0.10.0
15
15
  */
16
- export declare function advanceStateEpoch(): void;
16
+ export declare function advanceStateEpoch(): void;
17
+ /**
18
+ * The container-disposal counter, held in a cell so a hot reader dereferences a field rather than
19
+ * paying a cross-module call — apart from {@link stateEpoch} so a per-request child's `dispose()`
20
+ * does not invalidate the chain-version memo that keeps deep resolves cheap. A child compares it to
21
+ * learn whether an ancestor was disposed, re-walking the chain only after a disposal somewhere.
22
+ *
23
+ * @since 0.11.0
24
+ */
25
+ export declare const disposeEpochRef: {
26
+ value: number;
27
+ };
28
+ /**
29
+ * Advances the dispose epoch; a container's teardown calls it so descendants re-check the chain.
30
+ *
31
+ * @since 0.11.0
32
+ */
33
+ export declare function advanceDisposeEpoch(): void;
@@ -18,4 +18,21 @@ export function stateEpoch() {
18
18
  */
19
19
  export function advanceStateEpoch() {
20
20
  epoch += 1;
21
+ }
22
+ /**
23
+ * The container-disposal counter, held in a cell so a hot reader dereferences a field rather than
24
+ * paying a cross-module call — apart from {@link stateEpoch} so a per-request child's `dispose()`
25
+ * does not invalidate the chain-version memo that keeps deep resolves cheap. A child compares it to
26
+ * learn whether an ancestor was disposed, re-walking the chain only after a disposal somewhere.
27
+ *
28
+ * @since 0.11.0
29
+ */
30
+ export const disposeEpochRef = { value: 0 };
31
+ /**
32
+ * Advances the dispose epoch; a container's teardown calls it so descendants re-check the chain.
33
+ *
34
+ * @since 0.11.0
35
+ */
36
+ export function advanceDisposeEpoch() {
37
+ disposeEpochRef.value += 1;
21
38
  }
package/dist/core/tag.js CHANGED
@@ -1,6 +1,6 @@
1
1
  /** Tag keys and the interned pairs they mint — the one way a slot criterion is built. */
2
2
  /**
3
- * How many distinct tag keys one mask bit is shared by.
3
+ * The bits a tag-key mask holds: the width of a 32-bit integer, a constant of the machine.
4
4
  *
5
5
  * @remarks Ids past this wrap, so two keys can share a bit. The mask is a prefilter only — a shared
6
6
  * bit costs a false positive that the identity comparison then rejects, never a false negative.
@@ -0,0 +1,9 @@
1
+ /**
2
+ * Returns the metadata record a decorator context carries.
3
+ *
4
+ * @throws `MissingDecoratorMetadataError` when the transpiler handed none, which is how a runtime
5
+ * without `Symbol.metadata` shows up.
6
+ *
7
+ * @since 0.11.0
8
+ */
9
+ export declare function decoratorMetadataOf(context: DecoratorContext, decoratorName: string): Record<string | symbol, unknown>;
@@ -0,0 +1,20 @@
1
+ /**
2
+ * The metadata record a decorator writes into, checked once for every decorator the library ships.
3
+ */
4
+ import { MissingDecoratorMetadataError } from "#errors/errors";
5
+ /**
6
+ * Returns the metadata record a decorator context carries.
7
+ *
8
+ * @throws `MissingDecoratorMetadataError` when the transpiler handed none, which is how a runtime
9
+ * without `Symbol.metadata` shows up.
10
+ *
11
+ * @since 0.11.0
12
+ */
13
+ export function decoratorMetadataOf(context, decoratorName) {
14
+ // Widened, not asserted: the lib types promise a record that a runtime without `Symbol.metadata` never hands over.
15
+ const metadata = context.metadata;
16
+ if (metadata === undefined || metadata === null) {
17
+ throw new MissingDecoratorMetadataError(decoratorName);
18
+ }
19
+ return metadata;
20
+ }
@@ -1,4 +1,5 @@
1
1
  import { getActiveContainer, getAmbientResolution } from "#ambient/active-container";
2
+ import { decoratorMetadataOf } from "#decorators/decorator-metadata";
2
3
  import { MissingContainerContextError, StaticMemberDecoratorError } from "#errors/errors";
3
4
  import { buildInjectionDescriptor } from "#injection/descriptor";
4
5
  import { injectionSlotToResolveOptions } from "#injection/resolve-options";
@@ -36,7 +37,7 @@ export function inject(token, options) {
36
37
  if (context.static) {
37
38
  throw new StaticMemberDecoratorError("inject", String(context.name));
38
39
  }
39
- const meta = context.metadata;
40
+ const meta = decoratorMetadataOf(context, "inject");
40
41
  // Own bucket only: the metadata record inherits the base class's, and pushing into an inherited
41
42
  // array would register this accessor on the base class instead.
42
43
  if (!Object.hasOwn(meta, INJECT_ACCESSOR_KEY) || !Array.isArray(meta[INJECT_ACCESSOR_KEY])) {
@@ -1,3 +1,4 @@
1
+ import { decoratorMetadataOf } from "#decorators/decorator-metadata";
1
2
  import { normalizeToDescriptor } from "#injection/descriptor";
2
3
  import { INJECTABLE_KEY } from "#metadata/metadata-keys";
3
4
  /**
@@ -23,6 +24,7 @@ export function createAutoRegisterRegistry() {
23
24
  */
24
25
  export function injectable(deps, options) {
25
26
  return function (target, context) {
27
+ const meta = decoratorMetadataOf(context, "injectable");
26
28
  const parameterMetadataList = (deps ?? []).map((dependency, index) => {
27
29
  const descriptor = normalizeToDescriptor(dependency);
28
30
  const baseParameterMetadata = {
@@ -44,7 +46,7 @@ export function injectable(deps, options) {
44
46
  });
45
47
  // Field decorators run before the class decorator — accessor @inject entries are
46
48
  // already on context.metadata by the time this runs.
47
- context.metadata[INJECTABLE_KEY] = {
49
+ meta[INJECTABLE_KEY] = {
48
50
  params: parameterMetadataList,
49
51
  };
50
52
  if (options?.autoRegister !== undefined) {
@@ -1,4 +1,5 @@
1
- import { StaticMemberDecoratorError } from "#errors/errors";
1
+ import { decoratorMetadataOf } from "#decorators/decorator-metadata";
2
+ import { StaticMemberDecoratorError, SymbolKeyedLifecycleError } from "#errors/errors";
2
3
  import { LIFECYCLE_KEY } from "#metadata/metadata-keys";
3
4
  /** Records the decorated method under one lifecycle phase; both decorators differ only in that phase. */
4
5
  function recordLifecycleMethod(phase) {
@@ -6,7 +7,12 @@ function recordLifecycleMethod(phase) {
6
7
  if (context.static) {
7
8
  throw new StaticMemberDecoratorError(phase, String(context.name));
8
9
  }
9
- const meta = context.metadata;
10
+ // The lifecycle reader keys methods by their string name, so a symbol-keyed method can never be
11
+ // found again — fail here, where the declaration is, rather than at resolve.
12
+ if (typeof context.name === "symbol") {
13
+ throw new SymbolKeyedLifecycleError(phase, String(context.name));
14
+ }
15
+ const meta = decoratorMetadataOf(context, phase);
10
16
  // Own bucket only: `context.metadata` inherits the base class's record, and writing through an
11
17
  // inherited bucket would register this hook on the base class instead.
12
18
  if (!Object.hasOwn(meta, LIFECYCLE_KEY)) {