@fluojs/di 2.0.0 → 3.0.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.
package/dist/container.js CHANGED
@@ -1,6 +1,7 @@
1
1
  import { formatTokenName, InvariantError } from '@fluojs/core';
2
2
  import { getClassDiMetadata } from '@fluojs/core/internal';
3
3
  import { CircularDependencyError, ContainerResolutionError, DuplicateProviderError, RequestScopeResolutionError, ScopeMismatchError } from './errors.js';
4
+ import { registerMultiContributionResolver } from './multi-contribution-registry.js';
4
5
  import { normalizeProvider } from './provider-normalization.js';
5
6
  import { isForwardRef, isOptionalToken, Scope } from './types.js';
6
7
 
@@ -98,17 +99,89 @@ class ReadonlyMultiRegistrationMapView {
98
99
  function isPromiseLike(value) {
99
100
  return (typeof value === 'object' || typeof value === 'function') && value !== null && typeof value.then === 'function';
100
101
  }
102
+ const pendingResolutionContexts = new WeakMap();
103
+ const pendingResolutionOwners = new WeakMap();
104
+ function trackPendingResolution(promise, activeTokens) {
105
+ const context = pendingResolutionContexts.get(activeTokens) ?? {
106
+ dependencies: new Map(),
107
+ pendingPromises: 0
108
+ };
109
+ context.pendingPromises += 1;
110
+ pendingResolutionContexts.set(activeTokens, context);
111
+ pendingResolutionOwners.set(promise, activeTokens);
112
+ }
113
+ function untrackPendingResolution(promise, activeTokens) {
114
+ const context = pendingResolutionContexts.get(activeTokens);
115
+ pendingResolutionOwners.delete(promise);
116
+ if (!context) {
117
+ return;
118
+ }
119
+ context.pendingPromises -= 1;
120
+ if (context.pendingPromises === 0 && context.dependencies.size === 0) {
121
+ pendingResolutionContexts.delete(activeTokens);
122
+ }
123
+ }
124
+ function findPendingResolutionPath(start, target, visited) {
125
+ if (start === target) {
126
+ return [start];
127
+ }
128
+ if (visited.has(start)) {
129
+ return undefined;
130
+ }
131
+ visited.add(start);
132
+ for (const dependency of pendingResolutionContexts.get(start)?.dependencies.keys() ?? []) {
133
+ const path = findPendingResolutionPath(dependency, target, visited);
134
+ if (path) {
135
+ return [start, ...path];
136
+ }
137
+ }
138
+ return undefined;
139
+ }
140
+ function linkPendingResolution(promise, activeTokens, token) {
141
+ const owner = pendingResolutionOwners.get(promise);
142
+ if (!owner || activeTokens.size === 0) {
143
+ return undefined;
144
+ }
145
+ const cyclePath = findPendingResolutionPath(owner, activeTokens, new Set());
146
+ if (cyclePath) {
147
+ throw new CircularDependencyError([...activeTokens, token, ...cyclePath.flatMap(context => [...context])]);
148
+ }
149
+ const context = pendingResolutionContexts.get(activeTokens) ?? {
150
+ dependencies: new Map(),
151
+ pendingPromises: 0
152
+ };
153
+ context.dependencies.set(owner, (context.dependencies.get(owner) ?? 0) + 1);
154
+ pendingResolutionContexts.set(activeTokens, context);
155
+ return () => {
156
+ const dependencyCount = context.dependencies.get(owner);
157
+ if (dependencyCount === undefined) {
158
+ return;
159
+ }
160
+ if (dependencyCount === 1) {
161
+ context.dependencies.delete(owner);
162
+ } else {
163
+ context.dependencies.set(owner, dependencyCount - 1);
164
+ }
165
+ if (context.pendingPromises === 0 && context.dependencies.size === 0) {
166
+ pendingResolutionContexts.delete(activeTokens);
167
+ }
168
+ };
169
+ }
101
170
 
102
171
  /**
103
172
  * Scope-aware dependency injection container for Fluo providers.
104
173
  */
174
+ // allow: SIZE_OK — Container is the package's existing DI lifecycle state machine.
105
175
  export class Container {
176
+ static #childScopeConstruction;
106
177
  registrations = new Map();
107
178
  multiRegistrations = new Map();
108
179
  multiOverriddenTokens = new Set();
109
180
  requestCache;
110
181
  multiRequestCache;
111
182
  multiSingletonCache = new Map();
183
+ materializedCachePromises = [];
184
+ pendingDisposables = [];
112
185
  staleDisposalTasks = new Set();
113
186
  singletonCache;
114
187
  forwardRefTokenCache = new WeakMap();
@@ -122,10 +195,38 @@ export class Container {
122
195
  disposed = false;
123
196
  trackedByParent = false;
124
197
  graphRevision = 0;
125
- constructor(parent, requestScopeEnabled = false, singletonCache) {
126
- this.parent = parent;
127
- this.requestScopeEnabled = requestScopeEnabled;
128
- this.singletonCache = singletonCache ?? new Map();
198
+ parent;
199
+ requestScopeEnabled;
200
+
201
+ /**
202
+ * Creates a root container that owns its own singleton cache.
203
+ *
204
+ * Child request scopes are package-owned and must be created with
205
+ * {@link Container.createRequestScope}; caller-supplied parent, request-scope,
206
+ * or singleton-cache wiring is rejected.
207
+ *
208
+ * @throws {ContainerResolutionError} When any constructor argument is supplied.
209
+ */
210
+ constructor(...construction) {
211
+ if (construction.length > 0) {
212
+ throw new ContainerResolutionError('Container child-scope construction is package-owned and cannot be invoked directly.', {
213
+ hint: 'Construct root containers with new Container() and create child scopes with container.createRequestScope().'
214
+ });
215
+ }
216
+ const childScope = Container.#childScopeConstruction;
217
+ this.parent = childScope?.parent;
218
+ this.requestScopeEnabled = childScope?.requestScopeEnabled ?? false;
219
+ this.singletonCache = childScope?.singletonCache ?? new Map();
220
+ registerMultiContributionResolver(this, (token, contributionIndex) => this.resolveMultiContribution(token, contributionIndex));
221
+ }
222
+ static #createChildScope(construction) {
223
+ const parentConstruction = Container.#childScopeConstruction;
224
+ Container.#childScopeConstruction = construction;
225
+ try {
226
+ return new Container();
227
+ } finally {
228
+ Container.#childScopeConstruction = parentConstruction;
229
+ }
129
230
  }
130
231
 
131
232
  /**
@@ -179,10 +280,16 @@ export class Container {
179
280
  * set — the whole set is replaced. If you need to preserve other entries, re-register them
180
281
  * together with the replacement in one `override()` call.
181
282
  *
283
+ * **Batch atomicity**: the whole batch is validated before any registration or cache is touched,
284
+ * so a rejected `override()` call leaves every provider, cached instance, and disposal ownership
285
+ * exactly as it was before the call.
286
+ *
182
287
  * @param providers Provider definitions that should replace existing registrations for each token.
183
288
  * @returns The same container instance for fluent override chains.
184
289
  * @throws {ContainerResolutionError} When called after the container was disposed.
290
+ * @throws {ScopeMismatchError} When a request-scope override would introduce a new singleton token.
185
291
  * @throws {InvalidProviderError} When a provider definition is structurally invalid.
292
+ * @throws {DuplicateProviderError} When one token mixes single and multi replacements or repeats a single replacement.
186
293
  */
187
294
  override(...providers) {
188
295
  if (this.isDisposedInHierarchy()) {
@@ -200,6 +307,19 @@ export class Container {
200
307
  }
201
308
  normalizedByToken.set(normalized.provide, [normalized]);
202
309
  }
310
+ if (this.requestScopeEnabled) {
311
+ for (const [token, normalizedProviders] of normalizedByToken) {
312
+ const introducesSingleton = normalizedProviders.some(normalized => normalized.scope === Scope.DEFAULT);
313
+ if (introducesSingleton && !this.has(token)) {
314
+ throw new ScopeMismatchError(`Singleton provider ${formatTokenName(token)} cannot be introduced by override() on a request-scope container.`, {
315
+ token,
316
+ scope: 'singleton',
317
+ hint: 'Register it on the root container before creating the request scope, or register a request/transient provider in the request scope.'
318
+ });
319
+ }
320
+ }
321
+ }
322
+ const plannedOverrides = new Map();
203
323
  for (const [token, normalizedProviders] of normalizedByToken) {
204
324
  const firstProvider = normalizedProviders[0];
205
325
  if (!firstProvider) {
@@ -212,6 +332,17 @@ export class Container {
212
332
  if (!containsMultiProvider && normalizedProviders.length > 1) {
213
333
  throw new DuplicateProviderError(token);
214
334
  }
335
+ plannedOverrides.set(token, {
336
+ containsMultiProvider,
337
+ firstProvider,
338
+ normalizedProviders
339
+ });
340
+ }
341
+ for (const [token, {
342
+ containsMultiProvider,
343
+ firstProvider,
344
+ normalizedProviders
345
+ }] of plannedOverrides) {
215
346
  this.invalidateAffectedCachedEntriesInHierarchy(token);
216
347
  this.registrations.delete(token);
217
348
  this.multiRegistrations.delete(token);
@@ -282,10 +413,12 @@ export class Container {
282
413
  setMultiSingleton: (provider, promise) => {
283
414
  this.multiSingletonCache.set(provider, promise);
284
415
  multiSingletonCacheSnapshot.set(provider, promise);
416
+ this.trackCacheMaterialization(promise);
285
417
  },
286
418
  setSingleton: (token, promise) => {
287
419
  this.singletonCache.set(token, promise);
288
420
  singletonCacheSnapshot.set(token, promise);
421
+ this.trackCacheMaterialization(promise);
289
422
  }
290
423
  });
291
424
  }
@@ -323,7 +456,11 @@ export class Container {
323
456
  hint: 'Create request scopes before calling container.dispose().'
324
457
  });
325
458
  }
326
- return new Container(this, true, this.root().singletonCache);
459
+ return Container.#createChildScope({
460
+ parent: this,
461
+ requestScopeEnabled: true,
462
+ singletonCache: this.root().singletonCache
463
+ });
327
464
  }
328
465
 
329
466
  /**
@@ -346,40 +483,76 @@ export class Container {
346
483
  await this.assertStaleDisposalsSettled();
347
484
  return this.resolveWithChain(token, [], new Set());
348
485
  }
486
+ async resolveMultiContribution(token, contributionIndex) {
487
+ if (this.isDisposedInHierarchy()) {
488
+ throw new ContainerResolutionError('Container has been disposed and can no longer resolve providers.', {
489
+ token,
490
+ hint: 'Ensure all resolves complete before calling container.dispose().'
491
+ });
492
+ }
493
+ await this.assertStaleDisposalsSettled();
494
+ const providers = this.collectMultiProviders(token);
495
+ const provider = providers[contributionIndex];
496
+ if (!provider) {
497
+ throw new ContainerResolutionError(`Multi-provider contribution ${contributionIndex} is not registered for ${formatTokenName(token)}.`, {
498
+ token,
499
+ hint: 'Resolve a contribution index returned by the registered multi-provider order.'
500
+ });
501
+ }
502
+ this.assertSingletonDependencyScopes(provider);
503
+ return await this.withTokenInChain(token, [], new Set(), async (chain, activeTokens) => this.resolveMultiProviderInstance(provider, chain, activeTokens));
504
+ }
349
505
 
350
506
  /**
351
507
  * Disposes cached instances and nested request scopes.
352
508
  *
509
+ * Concurrent callers share the active disposal attempt. After a failed attempt,
510
+ * a later explicit call retries only `onDestroy()` hooks that did not complete;
511
+ * successfully completed hooks are never repeated. Disposal remains terminal
512
+ * for registration, resolution, overrides, and child-scope creation. A directly
513
+ * disposed child owns its remaining retries after that attempt settles, while a
514
+ * parent-started failed attempt remains owned by the parent hierarchy.
515
+ *
353
516
  * @returns A promise that settles after all cached disposable instances are torn down.
354
517
  * @throws {Error} Propagates one or more disposal errors (`AggregateError` when multiple failures occur).
355
518
  */
356
519
  async dispose() {
520
+ await this.disposeWithOrigin('direct');
521
+ }
522
+ async disposeFromParent() {
523
+ await this.disposeWithOrigin('parent');
524
+ }
525
+ async disposeWithOrigin(origin) {
357
526
  if (this.disposePromise) {
358
527
  await this.disposePromise;
359
528
  return;
360
529
  }
361
530
  this.disposed = true;
362
531
  this.advanceGraphRevision();
363
- this.disposePromise = this.disposeAll();
532
+ const activeDispose = this.disposeAll(origin);
533
+ this.disposePromise = activeDispose;
364
534
  try {
365
- await this.disposePromise;
535
+ await activeDispose;
536
+ if (this.disposePromise === activeDispose && this.hasRetainedStaleDisposalTasksInSubtree()) {
537
+ this.disposePromise = undefined;
538
+ }
366
539
  } catch (error) {
367
540
  this.disposePromise = undefined;
368
541
  throw error;
369
542
  }
370
543
  }
371
- async disposeAll() {
544
+ async disposeAll(origin) {
372
545
  const errors = [];
546
+ let completed = false;
373
547
  try {
374
548
  // Dispose all live request-scope children before tearing down this scope's cache.
375
549
  if (this.childScopes && this.childScopes.size > 0) {
376
- const childResults = await Promise.allSettled(Array.from(this.childScopes).map(child => child.dispose()));
550
+ const childResults = await Promise.allSettled(Array.from(this.childScopes).map(child => child.disposeFromParent()));
377
551
  for (const result of childResults) {
378
552
  if (result.status === 'rejected') {
379
553
  this.collectDisposalError(result.reason, errors);
380
554
  }
381
555
  }
382
- this.childScopes.clear();
383
556
  }
384
557
  try {
385
558
  await this.disposeCache(this.disposalCacheEntries());
@@ -387,8 +560,13 @@ export class Container {
387
560
  this.collectDisposalError(error, errors);
388
561
  }
389
562
  this.throwDisposalErrors(errors);
563
+ completed = true;
390
564
  } finally {
391
- if (this.parent && this.trackedByParent) {
565
+ const retainsStaleRetries = this.hasRetainedStaleDisposalTasksInSubtree();
566
+ if ((origin === 'direct' || completed && !retainsStaleRetries) && this.parent && this.trackedByParent) {
567
+ if (origin === 'direct') {
568
+ this.releaseNonOwnerStaleTaskObserversInSubtree();
569
+ }
392
570
  this.parent.childScopes?.delete(this);
393
571
  this.trackedByParent = false;
394
572
  }
@@ -522,7 +700,12 @@ export class Container {
522
700
  }
523
701
  const cachedInstance = this.getCachedScopedOrSingletonInstance(provider);
524
702
  if (cachedInstance) {
525
- return await cachedInstance;
703
+ const releasePendingResolution = linkPendingResolution(cachedInstance, activeTokens, token);
704
+ try {
705
+ return await cachedInstance;
706
+ } finally {
707
+ releasePendingResolution?.();
708
+ }
526
709
  }
527
710
  return await this.withTokenInChain(token, chain, activeTokens, async (c, at) => this.resolveScopedOrSingletonInstance(provider, c, at));
528
711
  }
@@ -562,16 +745,29 @@ export class Container {
562
745
  if (provider.scope === 'transient') {
563
746
  return await this.instantiate(provider, chain, activeTokens);
564
747
  }
565
- if (this.shouldResolveMultiProviderFromRoot(provider)) {
566
- return await this.root().resolveMultiProviderInstance(provider, chain, activeTokens);
748
+ const cacheOwner = this.cacheOwnerFor(provider);
749
+ if (cacheOwner !== this) {
750
+ return await cacheOwner.resolveMultiProviderInstance(provider, chain, activeTokens);
567
751
  }
568
752
  const cache = this.multiCacheFor(provider);
569
- if (!cache.has(provider)) {
570
- const promise = this.instantiate(provider, chain, activeTokens);
571
- cache.set(provider, promise);
572
- promise.catch(() => cache.delete(provider));
573
- }
574
- return await cache.get(provider);
753
+ const cachedInstance = cache.get(provider);
754
+ if (cachedInstance) {
755
+ const releasePendingResolution = linkPendingResolution(cachedInstance, activeTokens, provider.provide);
756
+ return releasePendingResolution ? cachedInstance.finally(releasePendingResolution) : cachedInstance;
757
+ }
758
+ let promise;
759
+ promise = this.instantiate(provider, chain, activeTokens).then(value => {
760
+ untrackPendingResolution(promise, activeTokens);
761
+ return value;
762
+ }, error => {
763
+ cache.delete(provider);
764
+ untrackPendingResolution(promise, activeTokens);
765
+ throw error;
766
+ });
767
+ trackPendingResolution(promise, activeTokens);
768
+ cache.set(provider, promise);
769
+ this.trackCacheMaterialization(promise);
770
+ return promise;
575
771
  }
576
772
  resolveExistingProviderTarget(provider) {
577
773
  if (provider.type !== 'existing') {
@@ -580,33 +776,48 @@ export class Container {
580
776
  return provider.useExisting;
581
777
  }
582
778
  async resolveScopedOrSingletonInstance(provider, chain, activeTokens) {
583
- if (this.shouldResolveFromRoot(provider)) {
584
- return await this.root().resolveScopedOrSingletonInstance(provider, chain, activeTokens);
779
+ const cacheOwner = this.cacheOwnerFor(provider);
780
+ if (cacheOwner !== this) {
781
+ return await cacheOwner.resolveScopedOrSingletonInstance(provider, chain, activeTokens);
585
782
  }
586
783
  const cache = this.cacheFor(provider);
587
- if (!cache.has(provider.provide)) {
588
- const promise = this.instantiate(provider, chain, activeTokens).catch(error => {
589
- cache.delete(provider.provide);
590
- throw error;
591
- });
592
- cache.set(provider.provide, promise);
593
- }
594
- return cache.get(provider.provide);
784
+ const cachedInstance = cache.get(provider.provide);
785
+ if (cachedInstance) {
786
+ const releasePendingResolution = linkPendingResolution(cachedInstance, activeTokens, provider.provide);
787
+ return releasePendingResolution ? cachedInstance.finally(releasePendingResolution) : cachedInstance;
788
+ }
789
+ let promise;
790
+ promise = this.instantiate(provider, chain, activeTokens).then(value => {
791
+ untrackPendingResolution(promise, activeTokens);
792
+ return value;
793
+ }, error => {
794
+ cache.delete(provider.provide);
795
+ untrackPendingResolution(promise, activeTokens);
796
+ throw error;
797
+ });
798
+ trackPendingResolution(promise, activeTokens);
799
+ cache.set(provider.provide, promise);
800
+ this.trackCacheMaterialization(promise);
801
+ return promise;
595
802
  }
596
803
  getCachedScopedOrSingletonInstance(provider) {
597
804
  if (provider.scope !== Scope.DEFAULT) {
598
805
  return undefined;
599
806
  }
600
- if (this.shouldResolveFromRoot(provider)) {
601
- return this.root().getCachedScopedOrSingletonInstance(provider);
807
+ const cacheOwner = this.cacheOwnerFor(provider);
808
+ if (cacheOwner !== this) {
809
+ return cacheOwner.getCachedScopedOrSingletonInstance(provider);
602
810
  }
603
811
  return this.cacheFor(provider).get(provider.provide);
604
812
  }
605
- shouldResolveFromRoot(provider) {
606
- return provider.scope === Scope.DEFAULT && this.requestScopeEnabled && !this.registrations.has(provider.provide);
607
- }
608
- shouldResolveMultiProviderFromRoot(provider) {
609
- return provider.scope === Scope.DEFAULT && this.requestScopeEnabled && !this.hasLocalMultiProvider(provider);
813
+ cacheOwnerFor(provider) {
814
+ if (provider.scope !== Scope.DEFAULT || !this.requestScopeEnabled) {
815
+ return this;
816
+ }
817
+ if (this.registrations.get(provider.provide) === provider || this.hasLocalMultiProvider(provider)) {
818
+ return this;
819
+ }
820
+ return this.parent?.cacheOwnerFor(provider) ?? this;
610
821
  }
611
822
  async resolveDepToken(depEntry, chain, activeTokens) {
612
823
  if (isOptionalToken(depEntry)) {
@@ -726,15 +937,20 @@ export class Container {
726
937
  }
727
938
  async disposeCache(entries) {
728
939
  const errors = [];
940
+ const retryableStaleTasks = this.retainedStaleDisposalTasks();
941
+ const attemptedStaleInstances = new Set();
729
942
  try {
730
943
  await this.assertStaleDisposalsSettled();
731
944
  } catch (error) {
732
945
  this.collectDisposalError(error, errors);
733
946
  }
947
+ errors.push(...(await this.retryFailedStaleDisposals(retryableStaleTasks, attemptedStaleInstances)));
734
948
  const {
735
- disposables,
949
+ disposables: materializedDisposables,
736
950
  errors: resolutionErrors
737
951
  } = await this.collectDisposableInstances(entries);
952
+ const disposalCandidates = this.pendingDisposables.length > 0 ? this.pendingDisposables : materializedDisposables;
953
+ const disposables = disposalCandidates.filter(instance => !attemptedStaleInstances.has(instance));
738
954
  errors.push(...resolutionErrors);
739
955
  errors.push(...(await this.disposeInstancesInReverseOrder(disposables)));
740
956
  this.clearDisposalCaches();
@@ -744,13 +960,18 @@ export class Container {
744
960
  const disposables = [];
745
961
  const seenInstances = new Set();
746
962
  const errors = [];
963
+ const activePromises = new Set(entries.map(([, promise]) => promise));
747
964
  const settled = await Promise.allSettled(entries.map(([, p]) => p));
748
965
  for (const result of settled) {
749
966
  if (result.status === 'rejected') {
750
967
  errors.push(result.reason);
968
+ }
969
+ }
970
+ for (const promise of this.materializedCachePromises) {
971
+ if (!activePromises.has(promise)) {
751
972
  continue;
752
973
  }
753
- const instance = result.value;
974
+ const instance = await promise;
754
975
  if (this.isDisposable(instance) && !seenInstances.has(instance)) {
755
976
  seenInstances.add(instance);
756
977
  disposables.push(instance);
@@ -763,16 +984,20 @@ export class Container {
763
984
  }
764
985
  async disposeInstancesInReverseOrder(disposables) {
765
986
  const errors = [];
987
+ const pendingDisposables = [];
766
988
  for (const instance of [...disposables].reverse()) {
767
989
  try {
768
990
  await instance.onDestroy();
769
991
  } catch (error) {
770
992
  errors.push(error);
993
+ pendingDisposables.unshift(instance);
771
994
  }
772
995
  }
996
+ this.pendingDisposables.splice(0, this.pendingDisposables.length, ...pendingDisposables);
773
997
  return errors;
774
998
  }
775
999
  clearDisposalCaches() {
1000
+ this.materializedCachePromises.length = 0;
776
1001
  if (this.parent) {
777
1002
  this.requestCache?.clear();
778
1003
  this.multiRequestCache?.clear();
@@ -783,6 +1008,9 @@ export class Container {
783
1008
  this.multiSingletonCache.clear();
784
1009
  this.clearResolutionPlanCaches();
785
1010
  }
1011
+ trackCacheMaterialization(promise) {
1012
+ void promise.then(() => this.materializedCachePromises.push(promise), () => undefined);
1013
+ }
786
1014
  currentLineageRevision() {
787
1015
  const parentRevision = this.parent?.currentLineageRevision();
788
1016
  return parentRevision ? `${parentRevision}/${this.graphRevision}` : String(this.graphRevision);
@@ -813,38 +1041,118 @@ export class Container {
813
1041
  }
814
1042
  async assertStaleDisposalsSettled() {
815
1043
  const errors = [];
816
- while (this.staleDisposalTasks.size > 0) {
817
- const tasks = Array.from(this.staleDisposalTasks);
1044
+ const settled = new Set();
1045
+ while (true) {
1046
+ const tasks = Array.from(this.staleDisposalTasks).filter(task => !settled.has(task));
1047
+ if (tasks.length === 0) {
1048
+ break;
1049
+ }
818
1050
  await Promise.all(tasks.map(task => task.promise));
819
1051
  for (const task of tasks) {
820
- this.staleDisposalTasks.delete(task);
821
- if (task.failed && !task.errorConsumed) {
1052
+ settled.add(task);
1053
+ if (!task.failed) {
1054
+ this.staleDisposalTasks.delete(task);
1055
+ continue;
1056
+ }
1057
+
1058
+ // A rejected materialization has no hook to retry. Deliver its error
1059
+ // once, then release it from every observer's stale-task ledger.
1060
+ if (!task.errorConsumed) {
822
1061
  task.errorConsumed = true;
823
1062
  errors.push(task.error);
824
1063
  }
1064
+ if (!task.retryInstance) {
1065
+ for (const observer of task.observers) {
1066
+ observer.staleDisposalTasks.delete(task);
1067
+ }
1068
+ }
825
1069
  }
826
1070
  }
827
1071
  this.throwDisposalErrors(errors);
828
1072
  }
1073
+ retainedStaleDisposalTasks() {
1074
+ // Only the scheduling container retries a stale hook, and only when the
1075
+ // failure was already delivered to an earlier caller. A failure first
1076
+ // surfaced by this attempt is reported, not retried within the same pass.
1077
+ return Array.from(this.staleDisposalTasks).filter(task => task.retryOwner === this && task.failed && task.errorConsumed);
1078
+ }
1079
+ hasRetainedStaleDisposalTasksInSubtree() {
1080
+ return this.retainedStaleDisposalTasks().length > 0 || Array.from(this.childScopes ?? []).some(childScope => childScope.hasRetainedStaleDisposalTasksInSubtree());
1081
+ }
1082
+ releaseNonOwnerStaleTaskObservers() {
1083
+ for (const task of this.staleDisposalTasks) {
1084
+ if (task.retryOwner !== this) {
1085
+ continue;
1086
+ }
1087
+ for (const observer of task.observers) {
1088
+ if (observer !== this) {
1089
+ observer.staleDisposalTasks.delete(task);
1090
+ }
1091
+ }
1092
+ }
1093
+ }
1094
+ releaseNonOwnerStaleTaskObserversInSubtree() {
1095
+ this.releaseNonOwnerStaleTaskObservers();
1096
+ for (const childScope of this.childScopes ?? []) {
1097
+ childScope.releaseNonOwnerStaleTaskObserversInSubtree();
1098
+ }
1099
+ }
1100
+ async retryFailedStaleDisposals(tasks, attemptedInstances) {
1101
+ const errors = [];
1102
+ for (const task of tasks) {
1103
+ const instance = task.retryInstance;
1104
+ if (!task.failed || !instance) {
1105
+ continue;
1106
+ }
1107
+ attemptedInstances.add(instance);
1108
+ try {
1109
+ await instance.onDestroy();
1110
+ task.failed = false;
1111
+ task.retryInstance = undefined;
1112
+ for (const observer of task.observers) {
1113
+ observer.staleDisposalTasks.delete(task);
1114
+ }
1115
+ } catch (error) {
1116
+ task.error = error;
1117
+ task.errorConsumed = true;
1118
+ errors.push(error);
1119
+ }
1120
+ }
1121
+ return errors;
1122
+ }
829
1123
  scheduleStaleDisposal(instancePromise, staleDisposalOwner) {
830
1124
  const observers = staleDisposalOwner === this ? [this] : [this, staleDisposalOwner];
831
1125
  const task = {
832
1126
  error: undefined,
833
1127
  errorConsumed: false,
834
1128
  failed: false,
835
- promise: Promise.resolve()
1129
+ observers,
1130
+ promise: Promise.resolve(),
1131
+ retryInstance: undefined,
1132
+ retryOwner: this
836
1133
  };
837
1134
  task.promise = (async () => {
1135
+ let instance;
838
1136
  try {
839
- const instance = await instancePromise;
840
- if (this.isDisposable(instance)) {
841
- await instance.onDestroy();
842
- }
1137
+ instance = await instancePromise;
843
1138
  } catch (error) {
844
1139
  task.error = error;
845
1140
  task.failed = true;
1141
+ return;
1142
+ }
1143
+ if (!this.isDisposable(instance)) {
1144
+ return;
1145
+ }
1146
+ try {
1147
+ await instance.onDestroy();
1148
+ } catch (error) {
1149
+ task.error = error;
1150
+ task.failed = true;
1151
+ task.retryInstance = instance;
846
1152
  }
847
1153
  })().finally(() => {
1154
+ const retainedMaterializations = this.materializedCachePromises.filter(promise => promise !== instancePromise);
1155
+ this.materializedCachePromises.splice(0, this.materializedCachePromises.length, ...retainedMaterializations);
848
1156
  if (!task.failed) {
849
1157
  for (const observer of observers) {
850
1158
  observer.staleDisposalTasks.delete(task);
@@ -934,6 +1242,10 @@ export class Container {
934
1242
  }
935
1243
  visited.add(token);
936
1244
  try {
1245
+ const multiRequestScopedToken = this.findRequestScopedMultiContribution(token, visited);
1246
+ if (multiRequestScopedToken) {
1247
+ return multiRequestScopedToken;
1248
+ }
937
1249
  const provider = this.resolveEffectiveProvider(token);
938
1250
  if (provider) {
939
1251
  if (provider.scope === Scope.REQUEST) {
@@ -953,6 +1265,28 @@ export class Container {
953
1265
  visited.delete(token);
954
1266
  }
955
1267
  }
1268
+ findRequestScopedMultiContribution(token, visited) {
1269
+ const aliasChain = new Set();
1270
+ let currentToken = token;
1271
+ while (!aliasChain.has(currentToken)) {
1272
+ aliasChain.add(currentToken);
1273
+ for (const multiProvider of this.collectMultiProviders(currentToken)) {
1274
+ if (multiProvider.scope === Scope.REQUEST) {
1275
+ return multiProvider.provide;
1276
+ }
1277
+ const requestScopedToken = multiProvider.type === 'existing' && multiProvider.useExisting !== undefined ? this.findRequestScopedDependencyToken(multiProvider.useExisting, visited) : this.findRequestScopedDependency(multiProvider.inject, visited);
1278
+ if (requestScopedToken) {
1279
+ return requestScopedToken;
1280
+ }
1281
+ }
1282
+ const aliasProvider = this.lookupProvider(currentToken);
1283
+ if (aliasProvider?.type !== 'existing' || aliasProvider.useExisting === undefined) {
1284
+ return undefined;
1285
+ }
1286
+ currentToken = aliasProvider.useExisting;
1287
+ }
1288
+ return undefined;
1289
+ }
956
1290
  resolveEffectiveProvider(token, visited = new Set(), chain = []) {
957
1291
  const cacheable = visited.size === 0 && chain.length === 0;
958
1292
  if (cacheable) {
package/dist/errors.d.ts CHANGED
@@ -48,7 +48,8 @@ export declare class ScopeMismatchError extends FluoCodeError {
48
48
  *
49
49
  * @remarks
50
50
  * The formatted message includes the full dependency path plus a first-party hint that points callers toward
51
- * extracting shared logic or using `forwardRef()` for intentional cycle deferral.
51
+ * extracting shared logic, introducing a mediator, or moving the interaction to a later boundary.
52
+ * `forwardRef()` only defers declaration-time token lookup and cannot resolve a true constructor cycle.
52
53
  */
53
54
  export declare class CircularDependencyError extends FluoCodeError {
54
55
  constructor(chain: readonly unknown[], detail?: string);
@@ -1 +1 @@
1
- {"version":3,"file":"errors.d.ts","sourceRoot":"","sources":["../src/errors.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,aAAa,EAAmB,MAAM,cAAc,CAAC;AAE9D;;GAEG;AACH,MAAM,WAAW,cAAc;IAC7B,QAAQ,CAAC,KAAK,CAAC,EAAE,OAAO,CAAC;IACzB,QAAQ,CAAC,KAAK,CAAC,EAAE,MAAM,CAAC;IACxB,QAAQ,CAAC,MAAM,CAAC,EAAE,MAAM,CAAC;IACzB,QAAQ,CAAC,eAAe,CAAC,EAAE,SAAS,OAAO,EAAE,CAAC;IAC9C,QAAQ,CAAC,IAAI,CAAC,EAAE,MAAM,CAAC;CACxB;AAgCD;;;;;;GAMG;AACH,qBAAa,oBAAqB,SAAQ,aAAa;gBACzC,OAAO,EAAE,MAAM,EAAE,OAAO,CAAC,EAAE,cAAc;CAOtD;AAED;;;;;GAKG;AACH,qBAAa,wBAAyB,SAAQ,aAAa;gBAC7C,OAAO,EAAE,MAAM,EAAE,OAAO,CAAC,EAAE,cAAc;CAOtD;AAED;;;;;GAKG;AACH,qBAAa,2BAA4B,SAAQ,aAAa;gBAChD,OAAO,EAAE,MAAM,EAAE,OAAO,CAAC,EAAE,cAAc;CAOtD;AAED;;GAEG;AACH,qBAAa,kBAAmB,SAAQ,aAAa;gBACvC,OAAO,EAAE,MAAM,EAAE,OAAO,CAAC,EAAE,cAAc;CAOtD;AAED;;;;;;GAMG;AACH,qBAAa,uBAAwB,SAAQ,aAAa;gBAC5C,KAAK,EAAE,SAAS,OAAO,EAAE,EAAE,MAAM,CAAC,EAAE,MAAM;CAWvD;AAED;;GAEG;AACH,qBAAa,sBAAuB,SAAQ,aAAa;gBAC3C,KAAK,EAAE,OAAO;CAW3B"}
1
+ {"version":3,"file":"errors.d.ts","sourceRoot":"","sources":["../src/errors.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,aAAa,EAAmB,MAAM,cAAc,CAAC;AAE9D;;GAEG;AACH,MAAM,WAAW,cAAc;IAC7B,QAAQ,CAAC,KAAK,CAAC,EAAE,OAAO,CAAC;IACzB,QAAQ,CAAC,KAAK,CAAC,EAAE,MAAM,CAAC;IACxB,QAAQ,CAAC,MAAM,CAAC,EAAE,MAAM,CAAC;IACzB,QAAQ,CAAC,eAAe,CAAC,EAAE,SAAS,OAAO,EAAE,CAAC;IAC9C,QAAQ,CAAC,IAAI,CAAC,EAAE,MAAM,CAAC;CACxB;AAgCD;;;;;;GAMG;AACH,qBAAa,oBAAqB,SAAQ,aAAa;gBACzC,OAAO,EAAE,MAAM,EAAE,OAAO,CAAC,EAAE,cAAc;CAOtD;AAED;;;;;GAKG;AACH,qBAAa,wBAAyB,SAAQ,aAAa;gBAC7C,OAAO,EAAE,MAAM,EAAE,OAAO,CAAC,EAAE,cAAc;CAOtD;AAED;;;;;GAKG;AACH,qBAAa,2BAA4B,SAAQ,aAAa;gBAChD,OAAO,EAAE,MAAM,EAAE,OAAO,CAAC,EAAE,cAAc;CAOtD;AAED;;GAEG;AACH,qBAAa,kBAAmB,SAAQ,aAAa;gBACvC,OAAO,EAAE,MAAM,EAAE,OAAO,CAAC,EAAE,cAAc;CAOtD;AAED;;;;;;;GAOG;AACH,qBAAa,uBAAwB,SAAQ,aAAa;gBAC5C,KAAK,EAAE,SAAS,OAAO,EAAE,EAAE,MAAM,CAAC,EAAE,MAAM;CAWvD;AAED;;GAEG;AACH,qBAAa,sBAAuB,SAAQ,aAAa;gBAC3C,KAAK,EAAE,OAAO;CAW3B"}