@navios/di 0.5.1 → 0.6.1

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 (122) hide show
  1. package/CHANGELOG.md +145 -0
  2. package/README.md +196 -219
  3. package/docs/README.md +69 -11
  4. package/docs/api-reference.md +281 -117
  5. package/docs/container.md +220 -56
  6. package/docs/examples/request-scope-example.mts +2 -2
  7. package/docs/factory.md +3 -8
  8. package/docs/getting-started.md +37 -8
  9. package/docs/migration.md +318 -37
  10. package/docs/request-contexts.md +263 -175
  11. package/docs/scopes.md +79 -42
  12. package/lib/browser/index.d.mts +1577 -0
  13. package/lib/browser/index.d.mts.map +1 -0
  14. package/lib/browser/index.mjs +3013 -0
  15. package/lib/browser/index.mjs.map +1 -0
  16. package/lib/index-7jfWsiG4.d.mts +1211 -0
  17. package/lib/index-7jfWsiG4.d.mts.map +1 -0
  18. package/lib/index-DW3K5sOX.d.cts +1206 -0
  19. package/lib/index-DW3K5sOX.d.cts.map +1 -0
  20. package/lib/index.cjs +389 -0
  21. package/lib/index.cjs.map +1 -0
  22. package/lib/index.d.cts +376 -0
  23. package/lib/index.d.cts.map +1 -0
  24. package/lib/index.d.mts +371 -78
  25. package/lib/index.d.mts.map +1 -0
  26. package/lib/index.mjs +325 -63
  27. package/lib/index.mjs.map +1 -1
  28. package/lib/testing/index.cjs +9 -0
  29. package/lib/testing/index.d.cts +2 -0
  30. package/lib/testing/index.d.mts +2 -2
  31. package/lib/testing/index.mjs +2 -72
  32. package/lib/testing-BG_fa9TJ.mjs +2656 -0
  33. package/lib/testing-BG_fa9TJ.mjs.map +1 -0
  34. package/lib/testing-DIaIRiJz.cjs +2896 -0
  35. package/lib/testing-DIaIRiJz.cjs.map +1 -0
  36. package/package.json +29 -7
  37. package/project.json +2 -2
  38. package/src/__tests__/async-local-storage.browser.spec.mts +240 -0
  39. package/src/__tests__/async-local-storage.spec.mts +333 -0
  40. package/src/__tests__/container.spec.mts +30 -25
  41. package/src/__tests__/e2e.browser.spec.mts +790 -0
  42. package/src/__tests__/e2e.spec.mts +1222 -0
  43. package/src/__tests__/factory.spec.mts +1 -1
  44. package/src/__tests__/get-injectors.spec.mts +1 -1
  45. package/src/__tests__/injectable.spec.mts +1 -1
  46. package/src/__tests__/injection-token.spec.mts +1 -1
  47. package/src/__tests__/library-findings.spec.mts +563 -0
  48. package/src/__tests__/registry.spec.mts +2 -2
  49. package/src/__tests__/request-scope.spec.mts +266 -274
  50. package/src/__tests__/service-instantiator.spec.mts +18 -17
  51. package/src/__tests__/service-locator-event-bus.spec.mts +9 -9
  52. package/src/__tests__/service-locator-manager.spec.mts +15 -15
  53. package/src/__tests__/service-locator.spec.mts +167 -244
  54. package/src/__tests__/unified-api.spec.mts +27 -27
  55. package/src/__type-tests__/factory.spec-d.mts +2 -2
  56. package/src/__type-tests__/inject.spec-d.mts +2 -2
  57. package/src/__type-tests__/injectable.spec-d.mts +1 -1
  58. package/src/browser.mts +16 -0
  59. package/src/container/container.mts +319 -0
  60. package/src/container/index.mts +2 -0
  61. package/src/container/scoped-container.mts +350 -0
  62. package/src/decorators/factory.decorator.mts +4 -4
  63. package/src/decorators/injectable.decorator.mts +5 -5
  64. package/src/errors/di-error.mts +12 -5
  65. package/src/errors/index.mts +0 -8
  66. package/src/index.mts +156 -15
  67. package/src/interfaces/container.interface.mts +82 -0
  68. package/src/interfaces/factory.interface.mts +2 -2
  69. package/src/interfaces/index.mts +1 -0
  70. package/src/internal/context/async-local-storage.mts +120 -0
  71. package/src/internal/context/factory-context.mts +18 -0
  72. package/src/internal/context/index.mts +3 -0
  73. package/src/{request-context-holder.mts → internal/context/request-context.mts} +40 -27
  74. package/src/internal/context/resolution-context.mts +63 -0
  75. package/src/internal/context/sync-local-storage.mts +51 -0
  76. package/src/internal/core/index.mts +5 -0
  77. package/src/internal/core/instance-resolver.mts +641 -0
  78. package/src/{service-instantiator.mts → internal/core/instantiator.mts} +31 -27
  79. package/src/internal/core/invalidator.mts +437 -0
  80. package/src/internal/core/service-locator.mts +202 -0
  81. package/src/{token-processor.mts → internal/core/token-processor.mts} +79 -60
  82. package/src/{base-instance-holder-manager.mts → internal/holder/base-holder-manager.mts} +91 -21
  83. package/src/internal/holder/holder-manager.mts +85 -0
  84. package/src/internal/holder/holder-storage.interface.mts +116 -0
  85. package/src/internal/holder/index.mts +6 -0
  86. package/src/internal/holder/instance-holder.mts +109 -0
  87. package/src/internal/holder/request-storage.mts +134 -0
  88. package/src/internal/holder/singleton-storage.mts +105 -0
  89. package/src/internal/index.mts +4 -0
  90. package/src/internal/lifecycle/circular-detector.mts +77 -0
  91. package/src/internal/lifecycle/index.mts +2 -0
  92. package/src/{service-locator-event-bus.mts → internal/lifecycle/lifecycle-event-bus.mts} +11 -4
  93. package/src/testing/__tests__/test-container.spec.mts +2 -2
  94. package/src/testing/test-container.mts +4 -4
  95. package/src/token/index.mts +2 -0
  96. package/src/{injection-token.mts → token/injection-token.mts} +1 -1
  97. package/src/{registry.mts → token/registry.mts} +1 -1
  98. package/src/utils/get-injectable-token.mts +1 -1
  99. package/src/utils/get-injectors.mts +32 -15
  100. package/src/utils/types.mts +1 -1
  101. package/tsdown.config.mts +67 -0
  102. package/lib/_tsup-dts-rollup.d.mts +0 -1283
  103. package/lib/_tsup-dts-rollup.d.ts +0 -1283
  104. package/lib/chunk-2M576LCC.mjs +0 -2043
  105. package/lib/chunk-2M576LCC.mjs.map +0 -1
  106. package/lib/index.d.ts +0 -78
  107. package/lib/index.js +0 -2127
  108. package/lib/index.js.map +0 -1
  109. package/lib/testing/index.d.ts +0 -2
  110. package/lib/testing/index.js +0 -2060
  111. package/lib/testing/index.js.map +0 -1
  112. package/lib/testing/index.mjs.map +0 -1
  113. package/src/container.mts +0 -227
  114. package/src/factory-context.mts +0 -8
  115. package/src/instance-resolver.mts +0 -559
  116. package/src/request-context-manager.mts +0 -149
  117. package/src/service-invalidator.mts +0 -429
  118. package/src/service-locator-instance-holder.mts +0 -70
  119. package/src/service-locator-manager.mts +0 -85
  120. package/src/service-locator.mts +0 -246
  121. package/tsup.config.mts +0 -12
  122. /package/src/{injector.mts → injectors.mts} +0 -0
@@ -4,7 +4,7 @@ import { z } from 'zod/v4'
4
4
  import { Factory } from '../decorators/index.mjs'
5
5
  import { InjectableScope } from '../enums/index.mjs'
6
6
  import { Container, Registry } from '../index.mjs'
7
- import { InjectionToken } from '../injection-token.mjs'
7
+ import { InjectionToken } from '../token/injection-token.mjs'
8
8
 
9
9
  describe('Factory decorator', () => {
10
10
  let container: Container
@@ -59,7 +59,7 @@ describe('getInjectors', () => {
59
59
 
60
60
  const mockContext = {
61
61
  inject: injectors.asyncInject,
62
- locator: {} as any,
62
+ container: {} as any,
63
63
  addDestroyListener: () => {},
64
64
  }
65
65
 
@@ -9,7 +9,7 @@ import {
9
9
  InjectableScope,
10
10
  Registry,
11
11
  } from '../index.mjs'
12
- import { InjectionToken } from '../injection-token.mjs'
12
+ import { InjectionToken } from '../token/injection-token.mjs'
13
13
 
14
14
  describe('Injectable decorator', () => {
15
15
  let container: Container
@@ -5,7 +5,7 @@ import type { Factorable, FactorableWithArgs } from '../interfaces/index.mjs'
5
5
 
6
6
  import { Factory, Injectable } from '../decorators/index.mjs'
7
7
  import { Container } from '../index.mjs'
8
- import { InjectionToken } from '../injection-token.mjs'
8
+ import { InjectionToken } from '../token/injection-token.mjs'
9
9
 
10
10
  describe('InjectToken', () => {
11
11
  let container: Container
@@ -0,0 +1,563 @@
1
+ /**
2
+ * Library Findings - Issues to Investigate
3
+ *
4
+ * This file documents potential issues or edge cases found during e2e testing
5
+ * that require further investigation and potential fixes.
6
+ *
7
+ * Each test case is marked with `.skip` to prevent CI failures.
8
+ * When investigating, remove `.skip` to reproduce the issue.
9
+ */
10
+
11
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
12
+
13
+ import { Container } from '../container/container.mjs'
14
+ import { Injectable } from '../decorators/injectable.decorator.mjs'
15
+ import { InjectableScope } from '../enums/index.mjs'
16
+ import type { OnServiceDestroy } from '../interfaces/on-service-destroy.interface.mjs'
17
+ import { Registry } from '../token/registry.mjs'
18
+ import { getInjectors } from '../utils/get-injectors.mjs'
19
+
20
+ function createTestSetup() {
21
+ const registry = new Registry()
22
+ const injectors = getInjectors()
23
+ const container = new Container(registry, null, injectors)
24
+ return { registry, injectors, container }
25
+ }
26
+
27
+ // ============================================================================
28
+ // FINDING #1: Circular Dependencies (FIXED)
29
+ // ============================================================================
30
+
31
+ describe('FINDING #1: Circular Dependencies (FIXED)', () => {
32
+ let registry: Registry
33
+ let container: Container
34
+ let injectors: ReturnType<typeof getInjectors>
35
+
36
+ beforeEach(() => {
37
+ const setup = createTestSetup()
38
+ registry = setup.registry
39
+ container = setup.container
40
+ injectors = setup.injectors
41
+ })
42
+
43
+ afterEach(async () => {
44
+ try {
45
+ await container.dispose()
46
+ } catch {
47
+ // Ignore - test may have caused issues
48
+ }
49
+ })
50
+
51
+ /**
52
+ * FIXED: Circular dependencies are now detected and throw a clear error.
53
+ *
54
+ * SOLUTION IMPLEMENTED:
55
+ * - Added waitingFor: Set<string> to ServiceLocatorInstanceHolder to track waiting relationships
56
+ * - Created CircularDependencyDetector that uses BFS to detect cycles in the waitingFor graph
57
+ * - Used AsyncLocalStorage (resolution-context.mts) to track the current "waiter" across async boundaries
58
+ * - Before waiting on a "Creating" holder, check for cycles and throw CircularDependencyError if found
59
+ *
60
+ * The error message shows a clear cycle path like:
61
+ * "Circular dependency detected: ServiceA -> ServiceB -> ServiceA"
62
+ *
63
+ * NOTE: asyncInject() still works with circular dependencies because it returns a Promise
64
+ * immediately without blocking. The cycle detection only triggers when using inject()
65
+ * which collects dependency promises that are awaited before onServiceInit.
66
+ */
67
+ it('should detect and report circular dependencies instead of hanging', async () => {
68
+ @Injectable({ scope: InjectableScope.Singleton, registry })
69
+ class ServiceA {
70
+ private serviceB = injectors.inject(ServiceB)
71
+ name = 'ServiceA'
72
+
73
+ async getB() {
74
+ return this.serviceB
75
+ }
76
+ }
77
+
78
+ @Injectable({ scope: InjectableScope.Singleton, registry })
79
+ class ServiceB {
80
+ private serviceA = injectors.inject(ServiceA)
81
+ name = 'ServiceB'
82
+
83
+ async getA() {
84
+ return this.serviceA
85
+ }
86
+ }
87
+
88
+ // This should throw an error like:
89
+ // "Circular dependency detected: ServiceA -> ServiceB -> ServiceA"
90
+ // Instead, it hangs forever.
91
+ await expect(container.get(ServiceA)).rejects.toThrow(/circular/i)
92
+ }, 1000) // 1 second timeout to detect the hang
93
+
94
+ /**
95
+ * Related scenario: Self-referential dependency
96
+ */
97
+ it('should detect self-referential dependencies', async () => {
98
+ @Injectable({ scope: InjectableScope.Singleton, registry })
99
+ class SelfReferentialService {
100
+ private self = injectors.inject(SelfReferentialService)
101
+
102
+ async getSelf() {
103
+ return this.self
104
+ }
105
+ }
106
+
107
+ await expect(container.get(SelfReferentialService)).rejects.toThrow(
108
+ /circular/i,
109
+ )
110
+ }, 1000)
111
+
112
+ /**
113
+ * Related scenario: Three-way circular dependency
114
+ */
115
+ it('should detect three-way circular dependencies', async () => {
116
+ @Injectable({ scope: InjectableScope.Singleton, registry })
117
+ class ServiceX {
118
+ private y = injectors.inject(ServiceY)
119
+ async getY() {
120
+ return this.y
121
+ }
122
+ }
123
+
124
+ @Injectable({ scope: InjectableScope.Singleton, registry })
125
+ class ServiceY {
126
+ private z = injectors.inject(ServiceZ)
127
+ async getZ() {
128
+ return this.z
129
+ }
130
+ }
131
+
132
+ @Injectable({ scope: InjectableScope.Singleton, registry })
133
+ class ServiceZ {
134
+ private x = injectors.inject(ServiceX)
135
+ async getX() {
136
+ return this.x
137
+ }
138
+ }
139
+
140
+ await expect(container.get(ServiceX)).rejects.toThrow(/circular/i)
141
+ }, 1000)
142
+ })
143
+
144
+ // ============================================================================
145
+ // FINDING #2: Request-Scoped Service Behavior Investigation
146
+ // ============================================================================
147
+
148
+ describe('FINDING #2: Request-Scoped Edge Cases (FIXED)', () => {
149
+ let registry: Registry
150
+ let container: Container
151
+ let injectors: ReturnType<typeof getInjectors>
152
+
153
+ beforeEach(() => {
154
+ const setup = createTestSetup()
155
+ registry = setup.registry
156
+ container = setup.container
157
+ injectors = setup.injectors
158
+ })
159
+
160
+ afterEach(async () => {
161
+ try {
162
+ await container.dispose()
163
+ } catch {
164
+ // Ignore
165
+ }
166
+ })
167
+
168
+ /**
169
+ * FIXED: Singletons that depend on request-scoped services are now
170
+ * properly invalidated when the request ends.
171
+ *
172
+ * This prevents stale reference issues where a singleton would hold
173
+ * a reference to a destroyed request-scoped service.
174
+ */
175
+ it('singleton is invalidated when its request-scoped dependency is destroyed', async () => {
176
+ @Injectable({ scope: InjectableScope.Request, registry })
177
+ class RequestData3 {
178
+ id = Math.random().toString(36).slice(2)
179
+ data = 'initial'
180
+ }
181
+
182
+ @Injectable({ scope: InjectableScope.Singleton, registry })
183
+ class SingletonHolder3 {
184
+ singletonId = Math.random().toString(36).slice(2)
185
+ private requestData = injectors.inject(RequestData3)
186
+
187
+ async getData() {
188
+ const rd = await this.requestData
189
+ return rd
190
+ }
191
+ }
192
+
193
+ // Create first request
194
+ const scoped1 = container.beginRequest('request-1')
195
+ const holder1 = await scoped1.get(SingletonHolder3)
196
+ const originalSingletonId = holder1.singletonId
197
+ const data1 = await holder1.getData()
198
+ const originalRequestDataId = data1.id
199
+ data1.data = 'modified-in-request-1'
200
+
201
+ await scoped1.endRequest()
202
+
203
+ // Create second request
204
+ const scoped2 = container.beginRequest('request-2')
205
+
206
+ // FIXED: The singleton is now invalidated when request ends
207
+ // Getting the singleton again creates a NEW instance
208
+ const holder2 = await scoped2.get(SingletonHolder3)
209
+
210
+ // New singleton instance (different ID)
211
+ expect(holder2.singletonId).not.toBe(originalSingletonId)
212
+
213
+ // The new singleton gets fresh request data from request-2
214
+ const data2 = await holder2.getData()
215
+ expect(data2.id).not.toBe(originalRequestDataId) // New request data instance
216
+ expect(data2.data).toBe('initial') // Fresh data, not stale!
217
+
218
+ await scoped2.endRequest()
219
+ })
220
+ })
221
+
222
+ // ============================================================================
223
+ // FINDING #3: Error Recovery Investigation
224
+ // ============================================================================
225
+
226
+ describe('FINDING #3: Error Recovery', () => {
227
+ let registry: Registry
228
+ let container: Container
229
+ let injectors: ReturnType<typeof getInjectors>
230
+
231
+ beforeEach(() => {
232
+ const setup = createTestSetup()
233
+ registry = setup.registry
234
+ container = setup.container
235
+ injectors = setup.injectors
236
+ })
237
+
238
+ afterEach(async () => {
239
+ try {
240
+ await container.dispose()
241
+ } catch {
242
+ // Ignore
243
+ }
244
+ })
245
+
246
+ /**
247
+ * DOCUMENTED BEHAVIOR: Container caches constructor errors
248
+ *
249
+ * When a service constructor throws on first attempt, the container
250
+ * caches the error state and re-throws the same error on subsequent
251
+ * attempts. It does NOT retry the constructor.
252
+ *
253
+ * This is important to understand for services that might fail due to
254
+ * transient errors (network issues, resource unavailability, etc.).
255
+ *
256
+ * IMPLICATION: If you need retry logic for transient failures, implement
257
+ * it inside the service (e.g., in onServiceInit) or use a factory pattern.
258
+ */
259
+ it('caches constructor errors and re-throws on retry (documented behavior)', async () => {
260
+ let attemptCount = 0
261
+ const shouldFail = { value: true }
262
+
263
+ @Injectable({ scope: InjectableScope.Singleton, registry })
264
+ class FlakeyService {
265
+ constructor() {
266
+ attemptCount++
267
+ if (shouldFail.value) {
268
+ throw new Error('Transient failure')
269
+ }
270
+ }
271
+
272
+ getValue() {
273
+ return 'success'
274
+ }
275
+ }
276
+
277
+ // First attempt should fail
278
+ await expect(container.get(FlakeyService)).rejects.toThrow(
279
+ 'Transient failure',
280
+ )
281
+ expect(attemptCount).toBe(1)
282
+
283
+ // Allow success on retry
284
+ shouldFail.value = false
285
+
286
+ // Second attempt - container caches the error and re-throws
287
+ // The constructor is NOT called again
288
+ await expect(container.get(FlakeyService)).rejects.toThrow(
289
+ 'Transient failure',
290
+ )
291
+ expect(attemptCount).toBe(1) // Still 1, constructor was not retried
292
+ })
293
+
294
+ /**
295
+ * INVESTIGATION: What happens when onServiceInit throws?
296
+ * Is the holder left in a corrupted state?
297
+ */
298
+ it('should clean up properly when onServiceInit throws', async () => {
299
+ let initAttempts = 0
300
+ const shouldFail = { value: true }
301
+
302
+ @Injectable({ scope: InjectableScope.Singleton, registry })
303
+ class FailingInitService {
304
+ async onServiceInit() {
305
+ initAttempts++
306
+ if (shouldFail.value) {
307
+ throw new Error('Init failed')
308
+ }
309
+ }
310
+ }
311
+
312
+ // First attempt should fail
313
+ await expect(container.get(FailingInitService)).rejects.toThrow(
314
+ 'Init failed',
315
+ )
316
+
317
+ // Allow success on retry
318
+ shouldFail.value = false
319
+
320
+ // Can we get the service now?
321
+ // This documents the recovery behavior:
322
+ try {
323
+ await container.get(FailingInitService)
324
+ // Container allows retry after init failure
325
+ expect(initAttempts).toBe(2)
326
+ } catch {
327
+ // Container doesn't allow retry
328
+ // Check if it's returning cached error or something else
329
+ }
330
+ })
331
+ })
332
+
333
+ // ============================================================================
334
+ // FINDING #4: Concurrent Initialization Race Conditions
335
+ // ============================================================================
336
+
337
+ describe('FINDING #4: Concurrent Initialization', () => {
338
+ let registry: Registry
339
+ let container: Container
340
+ let injectors: ReturnType<typeof getInjectors>
341
+
342
+ beforeEach(() => {
343
+ const setup = createTestSetup()
344
+ registry = setup.registry
345
+ container = setup.container
346
+ injectors = setup.injectors
347
+ })
348
+
349
+ afterEach(async () => {
350
+ try {
351
+ await container.dispose()
352
+ } catch {
353
+ // Ignore
354
+ }
355
+ })
356
+
357
+ /**
358
+ * INVESTIGATION: When multiple concurrent requests try to create the same
359
+ * service that has a slow onServiceInit, does the container properly
360
+ * deduplicate and wait for the first initialization to complete?
361
+ */
362
+ it('should deduplicate slow singleton initialization', async () => {
363
+ let constructorCalls = 0
364
+ let initCalls = 0
365
+
366
+ @Injectable({ scope: InjectableScope.Singleton, registry })
367
+ class SlowService {
368
+ constructor() {
369
+ constructorCalls++
370
+ }
371
+
372
+ async onServiceInit() {
373
+ initCalls++
374
+ // Slow initialization
375
+ await new Promise((resolve) => setTimeout(resolve, 50))
376
+ }
377
+ }
378
+
379
+ // Try to get the same service 10 times concurrently
380
+ const results = await Promise.all(
381
+ Array.from({ length: 10 }, () => container.get(SlowService)),
382
+ )
383
+
384
+ // All results should be the same instance
385
+ const uniqueInstances = new Set(results)
386
+ expect(uniqueInstances.size).toBe(1)
387
+
388
+ // Constructor and init should only be called once
389
+ expect(constructorCalls).toBe(1)
390
+ expect(initCalls).toBe(1)
391
+ })
392
+ })
393
+
394
+ // ============================================================================
395
+ // FINDING #5: Cross-Storage Dependency Tracking
396
+ // ============================================================================
397
+
398
+ describe('FINDING #5: Cross-Storage Dependency Invalidation', () => {
399
+ let registry: Registry
400
+ let container: Container
401
+ let injectors: ReturnType<typeof getInjectors>
402
+
403
+ beforeEach(() => {
404
+ const setup = createTestSetup()
405
+ registry = setup.registry
406
+ container = setup.container
407
+ injectors = setup.injectors
408
+ })
409
+
410
+ afterEach(async () => {
411
+ try {
412
+ await container.dispose()
413
+ } catch {
414
+ // Ignore
415
+ }
416
+ })
417
+
418
+ /**
419
+ * FIXED: When a request-scoped service is invalidated/destroyed,
420
+ * singletons that depend on it ARE now properly invalidated.
421
+ *
422
+ * The fix involved TWO changes:
423
+ * 1. RequestHolderStorage.findDependents() now checks both request storage
424
+ * AND singleton storage for holders that depend on the request service
425
+ * 2. endRequest() now uses clearAllWithStorage() which properly cascades
426
+ * invalidation to dependent singletons
427
+ */
428
+ it('singleton IS invalidated when its request dependency ends (FIXED)', async () => {
429
+ const singletonDestroySpy = vi.fn()
430
+
431
+ @Injectable({ scope: InjectableScope.Request, registry })
432
+ class RequestData2 {
433
+ data = 'request-data'
434
+ }
435
+
436
+ // Use a unique ID generator that doesn't depend on counting
437
+ @Injectable({ scope: InjectableScope.Singleton, registry })
438
+ class SingletonConsumer2 implements OnServiceDestroy {
439
+ id = Math.random().toString(36).slice(2)
440
+ private requestData = injectors.inject(RequestData2)
441
+
442
+ async getData() {
443
+ const rd = await this.requestData
444
+ return rd.data
445
+ }
446
+
447
+ onServiceDestroy() {
448
+ singletonDestroySpy(this.id)
449
+ }
450
+ }
451
+
452
+ // Request 1: Create singleton and its request-scoped dependency
453
+ const scoped1 = container.beginRequest('request-1')
454
+ const singleton1 = await scoped1.get(SingletonConsumer2)
455
+ const originalId = singleton1.id
456
+ const data1 = await singleton1.getData()
457
+ expect(data1).toBe('request-data')
458
+
459
+ await scoped1.endRequest()
460
+
461
+ // FIXED BEHAVIOR: Singleton IS invalidated when request ends
462
+ // because it depends on a request-scoped service
463
+ expect(singletonDestroySpy).toHaveBeenCalledWith(originalId)
464
+
465
+ // Request 2: Get singleton again - should be a NEW instance
466
+ const scoped2 = container.beginRequest('request-2')
467
+ const singleton2 = await scoped2.get(SingletonConsumer2)
468
+
469
+ // FIXED BEHAVIOR: New singleton instance is created
470
+ expect(singleton2).not.toBe(singleton1)
471
+ expect(singleton2.id).not.toBe(originalId)
472
+
473
+ // The new singleton gets fresh request-scoped data from request-2
474
+ const data2 = await singleton2.getData()
475
+ expect(data2).toBe('request-data')
476
+
477
+ await scoped2.endRequest()
478
+ })
479
+
480
+ /**
481
+ * Test to verify the dependency is actually tracked
482
+ */
483
+ it('verifies dependency is tracked in singleton deps', async () => {
484
+ @Injectable({ scope: InjectableScope.Request, registry })
485
+ class RequestService {
486
+ value = 'from-request'
487
+ }
488
+
489
+ @Injectable({ scope: InjectableScope.Singleton, registry })
490
+ class SingletonWithDep {
491
+ private reqSvc = injectors.inject(RequestService)
492
+
493
+ async getValue() {
494
+ const svc = await this.reqSvc
495
+ return svc.value
496
+ }
497
+ }
498
+
499
+ const scoped = container.beginRequest('test-request')
500
+ const singleton = await scoped.get(SingletonWithDep)
501
+ await singleton.getValue() // Force resolution
502
+
503
+ // Check that the singleton's holder has the request service in deps
504
+ const manager = container.getServiceLocator().getManager()
505
+ const singletonHolders = Array.from(manager.filter((h) => h.scope === InjectableScope.Singleton).values())
506
+
507
+ // Find the SingletonWithDep holder
508
+ const singletonHolder = singletonHolders.find(h => h.name.includes('SingletonWithDep'))
509
+
510
+ if (singletonHolder) {
511
+ // The deps should contain the RequestService instance name
512
+ const hasRequestDep = Array.from(singletonHolder.deps).some(dep =>
513
+ dep.includes('RequestService')
514
+ )
515
+ expect(hasRequestDep).toBe(true)
516
+ }
517
+
518
+ await scoped.endRequest()
519
+ })
520
+ })
521
+
522
+ // ============================================================================
523
+ // SUMMARY OF FINDINGS
524
+ // ============================================================================
525
+
526
+ /**
527
+ * FIXED ISSUES:
528
+ * 1. Circular dependencies - FIXED
529
+ * - Root cause: waitForInstanceReady would wait indefinitely on holders in the resolution chain
530
+ * - Fix applied:
531
+ * a) Added CircularDependencyDetector that uses BFS to detect cycles in the waitingFor graph
532
+ * b) Added waitingFor: Set<string> to ServiceLocatorInstanceHolder for tracking
533
+ * c) Used AsyncLocalStorage (resolution-context.mts) to track the current waiter across async boundaries
534
+ * d) Before waiting on a "Creating" holder, check for cycles and throw CircularDependencyError if found
535
+ * - Error message shows clear cycle path: "ServiceA -> ServiceB -> ServiceA"
536
+ * - Note: asyncInject() still works with circular deps because it doesn't block on dependencies
537
+ *
538
+ * 5. Cross-storage dependency invalidation - FIXED
539
+ * - Root cause was: RequestHolderStorage.findDependents() only searched request storage
540
+ * - Also: ScopedContainer.endRequest() bypassed invalidation cascade
541
+ * - Fix applied:
542
+ * a) RequestHolderStorage.findDependents() now also checks singleton manager
543
+ * b) endRequest() now uses clearAllWithStorage() for proper cascade
544
+ *
545
+ * 2. Singleton holding stale request-scoped references - FIXED (via #5 fix)
546
+ * - Singletons that depend on request-scoped services are now properly
547
+ * invalidated when the request ends
548
+ *
549
+ * EDGE CASES (documented behavior):
550
+ * 3. Error recovery behavior - constructor errors are cached
551
+ * - Priority: Low
552
+ * - Impact: May prevent retry after transient failures
553
+ * - Documented: This is intentional, use onServiceInit for retry logic
554
+ *
555
+ * VERIFIED WORKING:
556
+ * - Circular dependency detection throws clear errors
557
+ * - Concurrent singleton initialization is properly deduplicated
558
+ * - Request isolation works correctly
559
+ * - Lifecycle methods are called in correct order
560
+ * - Invalidation cascades properly to dependents (across all storages)
561
+ * - Dependency tracking works (deps are recorded correctly)
562
+ * - Cross-storage invalidation works (singletons depending on request-scoped)
563
+ */
@@ -1,8 +1,8 @@
1
1
  import { beforeEach, describe, expect, it } from 'vitest'
2
2
 
3
3
  import { InjectableScope, InjectableType } from '../enums/index.mjs'
4
- import { InjectionToken } from '../injection-token.mjs'
5
- import { globalRegistry, Registry } from '../registry.mjs'
4
+ import { InjectionToken } from '../token/injection-token.mjs'
5
+ import { globalRegistry, Registry } from '../token/registry.mjs'
6
6
 
7
7
  class TestService {}
8
8
  class AnotherService {}