@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.
- package/CHANGELOG.md +145 -0
- package/README.md +196 -219
- package/docs/README.md +69 -11
- package/docs/api-reference.md +281 -117
- package/docs/container.md +220 -56
- package/docs/examples/request-scope-example.mts +2 -2
- package/docs/factory.md +3 -8
- package/docs/getting-started.md +37 -8
- package/docs/migration.md +318 -37
- package/docs/request-contexts.md +263 -175
- package/docs/scopes.md +79 -42
- package/lib/browser/index.d.mts +1577 -0
- package/lib/browser/index.d.mts.map +1 -0
- package/lib/browser/index.mjs +3013 -0
- package/lib/browser/index.mjs.map +1 -0
- package/lib/index-7jfWsiG4.d.mts +1211 -0
- package/lib/index-7jfWsiG4.d.mts.map +1 -0
- package/lib/index-DW3K5sOX.d.cts +1206 -0
- package/lib/index-DW3K5sOX.d.cts.map +1 -0
- package/lib/index.cjs +389 -0
- package/lib/index.cjs.map +1 -0
- package/lib/index.d.cts +376 -0
- package/lib/index.d.cts.map +1 -0
- package/lib/index.d.mts +371 -78
- package/lib/index.d.mts.map +1 -0
- package/lib/index.mjs +325 -63
- package/lib/index.mjs.map +1 -1
- package/lib/testing/index.cjs +9 -0
- package/lib/testing/index.d.cts +2 -0
- package/lib/testing/index.d.mts +2 -2
- package/lib/testing/index.mjs +2 -72
- package/lib/testing-BG_fa9TJ.mjs +2656 -0
- package/lib/testing-BG_fa9TJ.mjs.map +1 -0
- package/lib/testing-DIaIRiJz.cjs +2896 -0
- package/lib/testing-DIaIRiJz.cjs.map +1 -0
- package/package.json +29 -7
- package/project.json +2 -2
- package/src/__tests__/async-local-storage.browser.spec.mts +240 -0
- package/src/__tests__/async-local-storage.spec.mts +333 -0
- package/src/__tests__/container.spec.mts +30 -25
- package/src/__tests__/e2e.browser.spec.mts +790 -0
- package/src/__tests__/e2e.spec.mts +1222 -0
- package/src/__tests__/factory.spec.mts +1 -1
- package/src/__tests__/get-injectors.spec.mts +1 -1
- package/src/__tests__/injectable.spec.mts +1 -1
- package/src/__tests__/injection-token.spec.mts +1 -1
- package/src/__tests__/library-findings.spec.mts +563 -0
- package/src/__tests__/registry.spec.mts +2 -2
- package/src/__tests__/request-scope.spec.mts +266 -274
- package/src/__tests__/service-instantiator.spec.mts +18 -17
- package/src/__tests__/service-locator-event-bus.spec.mts +9 -9
- package/src/__tests__/service-locator-manager.spec.mts +15 -15
- package/src/__tests__/service-locator.spec.mts +167 -244
- package/src/__tests__/unified-api.spec.mts +27 -27
- package/src/__type-tests__/factory.spec-d.mts +2 -2
- package/src/__type-tests__/inject.spec-d.mts +2 -2
- package/src/__type-tests__/injectable.spec-d.mts +1 -1
- package/src/browser.mts +16 -0
- package/src/container/container.mts +319 -0
- package/src/container/index.mts +2 -0
- package/src/container/scoped-container.mts +350 -0
- package/src/decorators/factory.decorator.mts +4 -4
- package/src/decorators/injectable.decorator.mts +5 -5
- package/src/errors/di-error.mts +12 -5
- package/src/errors/index.mts +0 -8
- package/src/index.mts +156 -15
- package/src/interfaces/container.interface.mts +82 -0
- package/src/interfaces/factory.interface.mts +2 -2
- package/src/interfaces/index.mts +1 -0
- package/src/internal/context/async-local-storage.mts +120 -0
- package/src/internal/context/factory-context.mts +18 -0
- package/src/internal/context/index.mts +3 -0
- package/src/{request-context-holder.mts → internal/context/request-context.mts} +40 -27
- package/src/internal/context/resolution-context.mts +63 -0
- package/src/internal/context/sync-local-storage.mts +51 -0
- package/src/internal/core/index.mts +5 -0
- package/src/internal/core/instance-resolver.mts +641 -0
- package/src/{service-instantiator.mts → internal/core/instantiator.mts} +31 -27
- package/src/internal/core/invalidator.mts +437 -0
- package/src/internal/core/service-locator.mts +202 -0
- package/src/{token-processor.mts → internal/core/token-processor.mts} +79 -60
- package/src/{base-instance-holder-manager.mts → internal/holder/base-holder-manager.mts} +91 -21
- package/src/internal/holder/holder-manager.mts +85 -0
- package/src/internal/holder/holder-storage.interface.mts +116 -0
- package/src/internal/holder/index.mts +6 -0
- package/src/internal/holder/instance-holder.mts +109 -0
- package/src/internal/holder/request-storage.mts +134 -0
- package/src/internal/holder/singleton-storage.mts +105 -0
- package/src/internal/index.mts +4 -0
- package/src/internal/lifecycle/circular-detector.mts +77 -0
- package/src/internal/lifecycle/index.mts +2 -0
- package/src/{service-locator-event-bus.mts → internal/lifecycle/lifecycle-event-bus.mts} +11 -4
- package/src/testing/__tests__/test-container.spec.mts +2 -2
- package/src/testing/test-container.mts +4 -4
- package/src/token/index.mts +2 -0
- package/src/{injection-token.mts → token/injection-token.mts} +1 -1
- package/src/{registry.mts → token/registry.mts} +1 -1
- package/src/utils/get-injectable-token.mts +1 -1
- package/src/utils/get-injectors.mts +32 -15
- package/src/utils/types.mts +1 -1
- package/tsdown.config.mts +67 -0
- package/lib/_tsup-dts-rollup.d.mts +0 -1283
- package/lib/_tsup-dts-rollup.d.ts +0 -1283
- package/lib/chunk-2M576LCC.mjs +0 -2043
- package/lib/chunk-2M576LCC.mjs.map +0 -1
- package/lib/index.d.ts +0 -78
- package/lib/index.js +0 -2127
- package/lib/index.js.map +0 -1
- package/lib/testing/index.d.ts +0 -2
- package/lib/testing/index.js +0 -2060
- package/lib/testing/index.js.map +0 -1
- package/lib/testing/index.mjs.map +0 -1
- package/src/container.mts +0 -227
- package/src/factory-context.mts +0 -8
- package/src/instance-resolver.mts +0 -559
- package/src/request-context-manager.mts +0 -149
- package/src/service-invalidator.mts +0 -429
- package/src/service-locator-instance-holder.mts +0 -70
- package/src/service-locator-manager.mts +0 -85
- package/src/service-locator.mts +0 -246
- package/tsup.config.mts +0 -12
- /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
|
|
@@ -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 {}
|