@navios/di 0.4.2 → 0.5.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 (120) hide show
  1. package/README.md +211 -1
  2. package/coverage/clover.xml +1912 -1277
  3. package/coverage/coverage-final.json +37 -28
  4. package/coverage/docs/examples/basic-usage.mts.html +1 -1
  5. package/coverage/docs/examples/factory-pattern.mts.html +1 -1
  6. package/coverage/docs/examples/index.html +1 -1
  7. package/coverage/docs/examples/injection-tokens.mts.html +1 -1
  8. package/coverage/docs/examples/request-scope-example.mts.html +1 -1
  9. package/coverage/docs/examples/service-lifecycle.mts.html +1 -1
  10. package/coverage/index.html +71 -41
  11. package/coverage/lib/_tsup-dts-rollup.d.mts.html +682 -43
  12. package/coverage/lib/index.d.mts.html +7 -4
  13. package/coverage/lib/index.html +5 -5
  14. package/coverage/lib/testing/index.d.mts.html +91 -0
  15. package/coverage/lib/testing/index.html +116 -0
  16. package/coverage/src/base-instance-holder-manager.mts.html +589 -0
  17. package/coverage/src/container.mts.html +257 -74
  18. package/coverage/src/decorators/factory.decorator.mts.html +1 -1
  19. package/coverage/src/decorators/index.html +1 -1
  20. package/coverage/src/decorators/index.mts.html +1 -1
  21. package/coverage/src/decorators/injectable.decorator.mts.html +20 -20
  22. package/coverage/src/enums/index.html +1 -1
  23. package/coverage/src/enums/index.mts.html +1 -1
  24. package/coverage/src/enums/injectable-scope.enum.mts.html +1 -1
  25. package/coverage/src/enums/injectable-type.enum.mts.html +1 -1
  26. package/coverage/src/errors/di-error.mts.html +292 -0
  27. package/coverage/src/errors/errors.enum.mts.html +30 -21
  28. package/coverage/src/errors/factory-not-found.mts.html +31 -22
  29. package/coverage/src/errors/factory-token-not-resolved.mts.html +29 -26
  30. package/coverage/src/errors/index.html +56 -41
  31. package/coverage/src/errors/index.mts.html +15 -9
  32. package/coverage/src/errors/instance-destroying.mts.html +31 -22
  33. package/coverage/src/errors/instance-expired.mts.html +31 -22
  34. package/coverage/src/errors/instance-not-found.mts.html +31 -22
  35. package/coverage/src/errors/unknown-error.mts.html +31 -43
  36. package/coverage/src/event-emitter.mts.html +14 -14
  37. package/coverage/src/factory-context.mts.html +1 -1
  38. package/coverage/src/index.html +121 -46
  39. package/coverage/src/index.mts.html +7 -4
  40. package/coverage/src/injection-token.mts.html +28 -28
  41. package/coverage/src/injector.mts.html +1 -1
  42. package/coverage/src/instance-resolver.mts.html +1762 -0
  43. package/coverage/src/interfaces/factory.interface.mts.html +1 -1
  44. package/coverage/src/interfaces/index.html +1 -1
  45. package/coverage/src/interfaces/index.mts.html +1 -1
  46. package/coverage/src/interfaces/on-service-destroy.interface.mts.html +1 -1
  47. package/coverage/src/interfaces/on-service-init.interface.mts.html +1 -1
  48. package/coverage/src/registry.mts.html +28 -28
  49. package/coverage/src/request-context-holder.mts.html +183 -102
  50. package/coverage/src/request-context-manager.mts.html +532 -0
  51. package/coverage/src/service-instantiator.mts.html +49 -49
  52. package/coverage/src/service-invalidator.mts.html +1372 -0
  53. package/coverage/src/service-locator-event-bus.mts.html +48 -48
  54. package/coverage/src/service-locator-instance-holder.mts.html +2 -14
  55. package/coverage/src/service-locator-manager.mts.html +71 -335
  56. package/coverage/src/service-locator.mts.html +240 -2328
  57. package/coverage/src/symbols/index.html +1 -1
  58. package/coverage/src/symbols/index.mts.html +1 -1
  59. package/coverage/src/symbols/injectable-token.mts.html +1 -1
  60. package/coverage/src/testing/index.html +131 -0
  61. package/coverage/src/testing/index.mts.html +88 -0
  62. package/coverage/src/testing/test-container.mts.html +445 -0
  63. package/coverage/src/token-processor.mts.html +607 -0
  64. package/coverage/src/utils/defer.mts.html +28 -214
  65. package/coverage/src/utils/get-injectable-token.mts.html +7 -7
  66. package/coverage/src/utils/get-injectors.mts.html +99 -99
  67. package/coverage/src/utils/index.html +15 -15
  68. package/coverage/src/utils/index.mts.html +4 -7
  69. package/coverage/src/utils/types.mts.html +1 -1
  70. package/docs/injectable.md +51 -11
  71. package/docs/scopes.md +63 -29
  72. package/lib/_tsup-dts-rollup.d.mts +376 -213
  73. package/lib/_tsup-dts-rollup.d.ts +376 -213
  74. package/lib/{chunk-3NLYPYBY.mjs → chunk-44F3LXW5.mjs} +1021 -605
  75. package/lib/chunk-44F3LXW5.mjs.map +1 -0
  76. package/lib/index.d.mts +6 -4
  77. package/lib/index.d.ts +6 -4
  78. package/lib/index.js +1192 -776
  79. package/lib/index.js.map +1 -1
  80. package/lib/index.mjs +2 -2
  81. package/lib/testing/index.js +1258 -840
  82. package/lib/testing/index.js.map +1 -1
  83. package/lib/testing/index.mjs +1 -1
  84. package/package.json +1 -1
  85. package/src/__tests__/container.spec.mts +47 -13
  86. package/src/__tests__/errors.spec.mts +53 -27
  87. package/src/__tests__/injectable.spec.mts +73 -0
  88. package/src/__tests__/request-scope.spec.mts +0 -2
  89. package/src/__tests__/service-locator-manager.spec.mts +12 -82
  90. package/src/__tests__/service-locator.spec.mts +1009 -1
  91. package/src/__type-tests__/inject.spec-d.mts +30 -7
  92. package/src/__type-tests__/injectable.spec-d.mts +76 -37
  93. package/src/base-instance-holder-manager.mts +2 -9
  94. package/src/container.mts +61 -9
  95. package/src/decorators/injectable.decorator.mts +29 -5
  96. package/src/errors/di-error.mts +69 -0
  97. package/src/errors/index.mts +9 -7
  98. package/src/injection-token.mts +1 -0
  99. package/src/injector.mts +2 -0
  100. package/src/instance-resolver.mts +559 -0
  101. package/src/request-context-holder.mts +0 -2
  102. package/src/request-context-manager.mts +149 -0
  103. package/src/service-invalidator.mts +429 -0
  104. package/src/service-locator-instance-holder.mts +0 -4
  105. package/src/service-locator-manager.mts +10 -40
  106. package/src/service-locator.mts +86 -782
  107. package/src/token-processor.mts +174 -0
  108. package/src/utils/get-injectors.mts +161 -24
  109. package/src/utils/index.mts +0 -1
  110. package/src/utils/types.mts +12 -8
  111. package/lib/chunk-3NLYPYBY.mjs.map +0 -1
  112. package/src/__tests__/defer.spec.mts +0 -166
  113. package/src/errors/errors.enum.mts +0 -8
  114. package/src/errors/factory-not-found.mts +0 -8
  115. package/src/errors/factory-token-not-resolved.mts +0 -10
  116. package/src/errors/instance-destroying.mts +0 -8
  117. package/src/errors/instance-expired.mts +0 -8
  118. package/src/errors/instance-not-found.mts +0 -8
  119. package/src/errors/unknown-error.mts +0 -15
  120. package/src/utils/defer.mts +0 -73
@@ -1,6 +1,14 @@
1
- import { describe, expect, it } from 'vitest'
1
+ import { beforeEach, describe, expect, it, vi } from 'vitest'
2
+ import { z } from 'zod/v4'
2
3
 
4
+ import type { OnServiceDestroy } from '../index.mjs'
5
+
6
+ import { Injectable } from '../decorators/injectable.decorator.mjs'
7
+ import { InjectableScope } from '../enums/index.mjs'
8
+ import { getInjectableToken } from '../index.mjs'
3
9
  import { InjectionToken } from '../injection-token.mjs'
10
+ import { asyncInject, inject } from '../injector.mjs'
11
+ import { globalRegistry } from '../registry.mjs'
4
12
  import { ServiceLocator } from '../service-locator.mjs'
5
13
 
6
14
  describe('ServiceLocator', () => {
@@ -32,4 +40,1004 @@ describe('ServiceLocator', () => {
32
40
  expect(identifier).toBe(`test(${token.id}):test=fn_test(0)`)
33
41
  })
34
42
  })
43
+
44
+ describe('clearAll', () => {
45
+ let serviceLocator: ServiceLocator
46
+ let mockLogger: Console
47
+
48
+ beforeEach(() => {
49
+ mockLogger = {
50
+ log: vi.fn(),
51
+ warn: vi.fn(),
52
+ error: vi.fn(),
53
+ debug: vi.fn(),
54
+ trace: vi.fn(),
55
+ } as any
56
+
57
+ serviceLocator = new ServiceLocator(globalRegistry, mockLogger)
58
+ })
59
+
60
+ it('should clear all services gracefully', async () => {
61
+ // Create Injectable services
62
+ @Injectable({ scope: InjectableScope.Singleton })
63
+ class ServiceA {
64
+ name = 'ServiceA'
65
+ }
66
+
67
+ @Injectable({ scope: InjectableScope.Singleton })
68
+ class ServiceB {
69
+ name = 'ServiceB'
70
+ }
71
+
72
+ @Injectable({ scope: InjectableScope.Singleton })
73
+ class ServiceC {
74
+ name = 'ServiceC'
75
+ }
76
+
77
+ // Create instances
78
+ await serviceLocator.getInstance(ServiceA)
79
+ await serviceLocator.getInstance(ServiceB)
80
+ await serviceLocator.getInstance(ServiceC)
81
+
82
+ // Verify services exist
83
+ expect(serviceLocator.getManager().size()).toBe(3)
84
+
85
+ // Clear all services
86
+ await serviceLocator.clearAll()
87
+
88
+ // Verify all services are cleared
89
+ expect(serviceLocator.getManager().size()).toBe(0)
90
+ expect(mockLogger.log).toHaveBeenCalledWith(
91
+ '[ServiceInvalidator] Graceful clearing completed',
92
+ )
93
+ })
94
+
95
+ it('should handle empty service locator', async () => {
96
+ await serviceLocator.clearAll()
97
+
98
+ expect(mockLogger.log).toHaveBeenCalledWith(
99
+ '[ServiceInvalidator] No singleton services to clear',
100
+ )
101
+ })
102
+
103
+ it('should clear service from a request context', async () => {
104
+ @Injectable({ scope: InjectableScope.Singleton })
105
+ class ServiceA {
106
+ name = 'ServiceA'
107
+ }
108
+
109
+ @Injectable({ scope: InjectableScope.Request })
110
+ class ServiceB {
111
+ serviceA = inject(ServiceA)
112
+ name = 'ServiceB'
113
+ }
114
+
115
+ const requestId = 'test-request'
116
+ serviceLocator.beginRequest(requestId)
117
+ const [error, serviceB] = await serviceLocator.getInstance(ServiceB)
118
+ expect(error).toBeUndefined()
119
+ expect(serviceB).toBeDefined()
120
+
121
+ await serviceLocator.invalidate(getInjectableToken(ServiceA).toString())
122
+ expect(serviceLocator.getManager().size()).toBe(0)
123
+ await serviceLocator.clearAll()
124
+ })
125
+
126
+ it('should clear request contexts when requested', async () => {
127
+ // Create a request context
128
+ const requestId = 'test-request'
129
+ serviceLocator.beginRequest(requestId)
130
+
131
+ // Create Injectable service with request scope
132
+ @Injectable({ scope: InjectableScope.Request })
133
+ class TestService {
134
+ name = 'TestService'
135
+ }
136
+
137
+ await serviceLocator.getInstance(TestService)
138
+
139
+ // Verify request context exists before clearing
140
+ expect(serviceLocator.getCurrentRequestContext()).not.toBeNull()
141
+
142
+ // Clear all services including request contexts
143
+ await serviceLocator.clearAll({ clearRequestContexts: true })
144
+
145
+ // Verify request context is cleared
146
+ expect(serviceLocator.getCurrentRequestContext()).toBeNull()
147
+ })
148
+
149
+ it('should skip clearing request contexts when disabled', async () => {
150
+ // Create a request context
151
+ const requestId = 'test-request'
152
+ serviceLocator.beginRequest(requestId)
153
+
154
+ // Clear all services but skip request contexts
155
+ await serviceLocator.clearAll({ clearRequestContexts: false })
156
+
157
+ // Verify request context is still there
158
+ expect(serviceLocator.getCurrentRequestContext()).not.toBeNull()
159
+ })
160
+
161
+ it('should handle services with dependencies correctly', async () => {
162
+ // Create Injectable services
163
+ @Injectable({ scope: InjectableScope.Singleton })
164
+ class ServiceA {
165
+ name = 'ServiceA'
166
+ }
167
+
168
+ @Injectable({ scope: InjectableScope.Singleton })
169
+ class ServiceB {
170
+ serviceA = inject(ServiceA)
171
+ name = 'ServiceB'
172
+ }
173
+
174
+ // Create instances
175
+ await serviceLocator.getInstance(ServiceB)
176
+
177
+ // Clear all services
178
+ await serviceLocator.clearAll()
179
+
180
+ // Verify all services are cleared
181
+ expect(serviceLocator.getManager().size()).toBe(0)
182
+ })
183
+
184
+ it('should respect maxRounds option', async () => {
185
+ // Create Injectable service
186
+ @Injectable({ scope: InjectableScope.Singleton })
187
+ class TestService {
188
+ name = 'TestService'
189
+ }
190
+
191
+ await serviceLocator.getInstance(TestService)
192
+
193
+ // Clear with a very low maxRounds to test the limit
194
+ await serviceLocator.clearAll({ maxRounds: 1 })
195
+
196
+ // Should still clear the service
197
+ expect(serviceLocator.getManager().size()).toBe(0)
198
+ })
199
+
200
+ it('should clear services with dependencies in correct order', async () => {
201
+ // Create services with dependencies
202
+ @Injectable({ scope: InjectableScope.Singleton })
203
+ class DatabaseService {
204
+ name = 'DatabaseService'
205
+ }
206
+
207
+ @Injectable({ scope: InjectableScope.Singleton })
208
+ class UserService {
209
+ public database = inject(DatabaseService)
210
+ name = 'UserService'
211
+ }
212
+
213
+ @Injectable({ scope: InjectableScope.Singleton })
214
+ class AuthService {
215
+ public userService = inject(UserService)
216
+ name = 'AuthService'
217
+ }
218
+
219
+ // Create instances (this will establish dependencies)
220
+ await serviceLocator.getInstance(AuthService)
221
+ await serviceLocator.getInstance(UserService)
222
+ await serviceLocator.getInstance(DatabaseService)
223
+
224
+ // Verify services exist
225
+ expect(serviceLocator.getManager().size()).toBe(3)
226
+
227
+ // Clear all services - should clear in dependency order
228
+ await serviceLocator.clearAll()
229
+
230
+ // Verify all services are cleared
231
+ expect(serviceLocator.getManager().size()).toBe(0)
232
+ })
233
+
234
+ it('should handle services with destroy listeners', async () => {
235
+ let destroyCalled = false
236
+ @Injectable({ scope: InjectableScope.Singleton })
237
+ class TestService implements OnServiceDestroy {
238
+ name = 'TestService'
239
+
240
+ constructor() {
241
+ // Simulate a service that needs cleanup
242
+ }
243
+
244
+ async onServiceDestroy() {
245
+ destroyCalled = true
246
+ }
247
+ }
248
+
249
+ await serviceLocator.getInstance(TestService)
250
+
251
+ // Clear all services
252
+ await serviceLocator.clearAll()
253
+
254
+ // Verify all services are cleared
255
+ expect(serviceLocator.getManager().size()).toBe(0)
256
+ expect(destroyCalled).toBe(true)
257
+ })
258
+ })
259
+
260
+ describe('Mixed Scope Services', () => {
261
+ let serviceLocator: ServiceLocator
262
+ let mockLogger: Console
263
+
264
+ beforeEach(() => {
265
+ mockLogger = {
266
+ log: vi.fn(),
267
+ warn: vi.fn(),
268
+ error: vi.fn(),
269
+ debug: vi.fn(),
270
+ trace: vi.fn(),
271
+ } as any
272
+
273
+ serviceLocator = new ServiceLocator(globalRegistry, mockLogger)
274
+ })
275
+
276
+ describe('Services with dependencies across different scopes', () => {
277
+ it('should handle Singleton service depending on Transient service', async () => {
278
+ // Create Transient service
279
+ @Injectable({ scope: InjectableScope.Transient })
280
+ class TransientService {
281
+ id = Math.random().toString(36).substr(2, 9)
282
+ name = 'TransientService'
283
+ }
284
+
285
+ // Create Singleton service that depends on Transient service
286
+ @Injectable({ scope: InjectableScope.Singleton })
287
+ class SingletonService {
288
+ transientService = asyncInject(TransientService)
289
+ name = 'SingletonService'
290
+ }
291
+
292
+ // Get instances
293
+ const [error1, singleton1] =
294
+ await serviceLocator.getInstance(SingletonService)
295
+ const [error2, singleton2] =
296
+ await serviceLocator.getInstance(SingletonService)
297
+
298
+ expect(error1).toBeUndefined()
299
+ expect(error2).toBeUndefined()
300
+ expect(singleton1).toBe(singleton2) // Same singleton instance
301
+
302
+ // Get the actual transient service instances (asyncInject returns Promises)
303
+ const transient1 = await singleton1.transientService
304
+ const transient2 = await singleton2.transientService
305
+
306
+ // Note: Since Singleton is created once, both references point to the same Transient instance
307
+ // This is expected behavior - the Transient service is created once during Singleton instantiation
308
+ expect(transient1).toBe(transient2) // Same transient instance (created during singleton instantiation)
309
+ })
310
+
311
+ it('should handle Request service depending on Singleton service', async () => {
312
+ // Create Singleton service
313
+ @Injectable({ scope: InjectableScope.Singleton })
314
+ class SingletonService {
315
+ id = Math.random().toString(36).substr(2, 9)
316
+ name = 'SingletonService'
317
+ }
318
+
319
+ // Create Request service that depends on Singleton service
320
+ @Injectable({ scope: InjectableScope.Request })
321
+ class RequestService {
322
+ singletonService = inject(SingletonService)
323
+ name = 'RequestService'
324
+ }
325
+
326
+ // Begin request context
327
+ const requestId = 'test-request-1'
328
+ serviceLocator.beginRequest(requestId)
329
+
330
+ // Get instances within the same request
331
+ const [error1, request1] =
332
+ await serviceLocator.getInstance(RequestService)
333
+ const [error2, request2] =
334
+ await serviceLocator.getInstance(RequestService)
335
+
336
+ expect(error1).toBeUndefined()
337
+ expect(error2).toBeUndefined()
338
+ expect(request1).toBe(request2) // Same request-scoped instance
339
+ expect(request1.singletonService).toBe(request2.singletonService) // Same singleton instance
340
+
341
+ // End request and start new one
342
+ await serviceLocator.endRequest(requestId)
343
+ const newRequestId = 'test-request-2'
344
+ serviceLocator.beginRequest(newRequestId)
345
+
346
+ // Get instance in new request
347
+ const [error3, request3] =
348
+ await serviceLocator.getInstance(RequestService)
349
+
350
+ expect(error3).toBeUndefined()
351
+ expect(request1).not.toBe(request3) // Different request-scoped instances
352
+ expect(request1.singletonService).toBe(request3.singletonService) // Same singleton instance
353
+ })
354
+
355
+ it('should handle Transient service depending on Request service', async () => {
356
+ // Create Request service
357
+ @Injectable({ scope: InjectableScope.Request })
358
+ class RequestService {
359
+ id = Math.random().toString(36).substr(2, 9)
360
+ name = 'RequestService'
361
+ }
362
+
363
+ // Create Transient service that depends on Request service
364
+ @Injectable({ scope: InjectableScope.Transient })
365
+ class TransientService {
366
+ requestService = inject(RequestService)
367
+ name = 'TransientService'
368
+ }
369
+
370
+ // Begin request context
371
+ const requestId = 'test-request'
372
+ serviceLocator.beginRequest(requestId)
373
+
374
+ // Get multiple transient instances
375
+ const [error1, transient1] =
376
+ await serviceLocator.getInstance(TransientService)
377
+ const [error2, transient2] =
378
+ await serviceLocator.getInstance(TransientService)
379
+
380
+ expect(error1).toBeUndefined()
381
+ expect(error2).toBeUndefined()
382
+ expect(transient1).not.toBe(transient2) // Different transient instances
383
+
384
+ // Get the actual request service instances (asyncInject returns Promises)
385
+ const requestService1 = transient1.requestService
386
+ const requestService2 = transient2.requestService
387
+ expect(requestService1).toBe(requestService2) // Same request-scoped instance
388
+ })
389
+
390
+ it('should handle complex dependency chain across all scopes', async () => {
391
+ // Create services with different scopes
392
+ @Injectable({ scope: InjectableScope.Singleton })
393
+ class DatabaseService {
394
+ id = Math.random().toString(36).substr(2, 9)
395
+ name = 'DatabaseService'
396
+ }
397
+
398
+ @Injectable({ scope: InjectableScope.Request })
399
+ class UserSessionService {
400
+ database = inject(DatabaseService)
401
+ id = Math.random().toString(36).substr(2, 9)
402
+ name = 'UserSessionService'
403
+ }
404
+
405
+ @Injectable({ scope: InjectableScope.Transient })
406
+ class UserActionService {
407
+ session = inject(UserSessionService)
408
+ database = inject(DatabaseService)
409
+ id = Math.random().toString(36).substr(2, 9)
410
+ name = 'UserActionService'
411
+ }
412
+
413
+ @Injectable({ scope: InjectableScope.Singleton })
414
+ class UserManagerService {
415
+ database = inject(DatabaseService)
416
+ name = 'UserManagerService'
417
+ }
418
+
419
+ // Begin request context
420
+ const requestId = 'complex-request'
421
+ serviceLocator.beginRequest(requestId)
422
+
423
+ // Get instances
424
+ const [error1, action1] =
425
+ await serviceLocator.getInstance(UserActionService)
426
+ const [error2, action2] =
427
+ await serviceLocator.getInstance(UserActionService)
428
+ const [error3, manager] =
429
+ await serviceLocator.getInstance(UserManagerService)
430
+
431
+ expect(error1).toBeUndefined()
432
+ expect(error2).toBeUndefined()
433
+ expect(error3).toBeUndefined()
434
+
435
+ // Verify instances are created
436
+ expect(action1).toBeDefined()
437
+ expect(action2).toBeDefined()
438
+ expect(manager).toBeDefined()
439
+
440
+ // Verify scope behavior - check if dependencies are properly injected
441
+ expect(action1).not.toBe(action2) // Different transient instances
442
+
443
+ // Get the actual dependency instances (asyncInject returns Promises)
444
+ const action1Database = action1.database
445
+ const action2Database = action2.database
446
+ const action1Session = action1.session
447
+ const action2Session = action2.session
448
+
449
+ expect(action1Database).toBe(action2Database) // Same singleton instance
450
+ expect(action1Database).toBe(manager.database) // Same singleton instance
451
+
452
+ // Check if session dependency is properly injected
453
+ expect(action1Session).toBe(action2Session) // Same request-scoped instance
454
+ expect(action1Session.database).toBe(action1Database) // Same singleton instance
455
+
456
+ // End request and start new one
457
+ await serviceLocator.endRequest(requestId)
458
+ const newRequestId = 'complex-request-2'
459
+ serviceLocator.beginRequest(newRequestId)
460
+
461
+ // Get instances in new request
462
+ const [error4, action3] =
463
+ await serviceLocator.getInstance(UserActionService)
464
+ const [error5, manager2] =
465
+ await serviceLocator.getInstance(UserManagerService)
466
+
467
+ expect(error4).toBeUndefined()
468
+ expect(error5).toBeUndefined()
469
+
470
+ // Verify scope behavior across requests
471
+ expect(action1).not.toBe(action3) // Different transient instances
472
+
473
+ // Get the actual dependency instances for the new request
474
+ const action3Database = action3.database
475
+ const action3Session = action3.session
476
+
477
+ expect(action1Database).toBe(action3Database) // Same singleton instance
478
+ expect(manager).toBe(manager2) // Same singleton instance
479
+
480
+ // Check if session dependency is properly injected in new request
481
+ expect(action1Session).not.toBe(action3Session) // Different request-scoped instances
482
+ })
483
+ })
484
+
485
+ describe('Instance sharing and isolation', () => {
486
+ it('should isolate Request-scoped instances between different requests', async () => {
487
+ @Injectable({ scope: InjectableScope.Request })
488
+ class RequestService {
489
+ id = Math.random().toString(36).substr(2, 9)
490
+ name = 'RequestService'
491
+ }
492
+
493
+ // First request
494
+ const requestId1 = 'request-1'
495
+ serviceLocator.beginRequest(requestId1)
496
+ const [error1, service1] =
497
+ await serviceLocator.getInstance(RequestService)
498
+ await serviceLocator.endRequest(requestId1)
499
+
500
+ // Second request
501
+ const requestId2 = 'request-2'
502
+ serviceLocator.beginRequest(requestId2)
503
+ const [error2, service2] =
504
+ await serviceLocator.getInstance(RequestService)
505
+ await serviceLocator.endRequest(requestId2)
506
+
507
+ expect(error1).toBeUndefined()
508
+ expect(error2).toBeUndefined()
509
+ expect(service1).not.toBe(service2) // Different instances
510
+ expect(service1.id).not.toBe(service2.id) // Different IDs
511
+ })
512
+
513
+ it('should share Singleton instances across requests', async () => {
514
+ @Injectable({ scope: InjectableScope.Singleton })
515
+ class SingletonService {
516
+ id = Math.random().toString(36).substr(2, 9)
517
+ name = 'SingletonService'
518
+ }
519
+
520
+ // First request
521
+ const requestId1 = 'request-1'
522
+ serviceLocator.beginRequest(requestId1)
523
+ const [error1, service1] =
524
+ await serviceLocator.getInstance(SingletonService)
525
+ await serviceLocator.endRequest(requestId1)
526
+
527
+ // Second request
528
+ const requestId2 = 'request-2'
529
+ serviceLocator.beginRequest(requestId2)
530
+ const [error2, service2] =
531
+ await serviceLocator.getInstance(SingletonService)
532
+ await serviceLocator.endRequest(requestId2)
533
+
534
+ expect(error1).toBeUndefined()
535
+ expect(error2).toBeUndefined()
536
+ expect(service1).toBe(service2) // Same instance
537
+ expect(service1.id).toBe(service2.id) // Same ID
538
+ })
539
+
540
+ it('should create new Transient instances every time', async () => {
541
+ @Injectable({ scope: InjectableScope.Transient })
542
+ class TransientService {
543
+ id = Math.random().toString(36).substr(2, 9)
544
+ name = 'TransientService'
545
+ }
546
+
547
+ const requestId = 'request'
548
+ serviceLocator.beginRequest(requestId)
549
+
550
+ const [error1, service1] =
551
+ await serviceLocator.getInstance(TransientService)
552
+ const [error2, service2] =
553
+ await serviceLocator.getInstance(TransientService)
554
+ const [error3, service3] =
555
+ await serviceLocator.getInstance(TransientService)
556
+
557
+ expect(error1).toBeUndefined()
558
+ expect(error2).toBeUndefined()
559
+ expect(error3).toBeUndefined()
560
+ expect(service1).not.toBe(service2) // Different instances
561
+ expect(service1).not.toBe(service3) // Different instances
562
+ expect(service2).not.toBe(service3) // Different instances
563
+ expect(service1.id).not.toBe(service2.id) // Different IDs
564
+ expect(service1.id).not.toBe(service3.id) // Different IDs
565
+ expect(service2.id).not.toBe(service3.id) // Different IDs
566
+ })
567
+ })
568
+
569
+ describe('Request context management with mixed scopes', () => {
570
+ it('should properly clean up Request-scoped instances when ending request', async () => {
571
+ @Injectable({ scope: InjectableScope.Request })
572
+ class RequestService {
573
+ id = Math.random().toString(36).substr(2, 9)
574
+ name = 'RequestService'
575
+ }
576
+
577
+ @Injectable({ scope: InjectableScope.Singleton })
578
+ class SingletonService {
579
+ id = Math.random().toString(36).substr(2, 9)
580
+ name = 'SingletonService'
581
+ }
582
+
583
+ const requestId = 'cleanup-test'
584
+ serviceLocator.beginRequest(requestId)
585
+
586
+ // Create instances
587
+ const [error1, _requestService] =
588
+ await serviceLocator.getInstance(RequestService)
589
+ const [error2, singletonService] =
590
+ await serviceLocator.getInstance(SingletonService)
591
+
592
+ expect(error1).toBeUndefined()
593
+ expect(error2).toBeUndefined()
594
+
595
+ // Verify request context exists
596
+ expect(serviceLocator.getCurrentRequestContext()).not.toBeNull()
597
+ expect(serviceLocator.getCurrentRequestContext()?.requestId).toBe(
598
+ requestId,
599
+ )
600
+
601
+ // End request
602
+ await serviceLocator.endRequest(requestId)
603
+
604
+ // Verify request context is cleared
605
+ expect(serviceLocator.getCurrentRequestContext()).toBeNull()
606
+
607
+ // Singleton should still be available
608
+ const [error3, singletonService2] =
609
+ await serviceLocator.getInstance(SingletonService)
610
+ expect(error3).toBeUndefined()
611
+ expect(singletonService).toBe(singletonService2) // Same singleton instance
612
+
613
+ // Request service should not be available (no current request context)
614
+ const [error4] = await serviceLocator.getInstance(RequestService)
615
+ expect(error4).toBeDefined() // Should error because no request context
616
+ })
617
+
618
+ it('should handle nested request contexts with mixed scopes', async () => {
619
+ @Injectable({ scope: InjectableScope.Request })
620
+ class RequestService {
621
+ id = Math.random().toString(36).substr(2, 9)
622
+ name = 'RequestService'
623
+ }
624
+
625
+ @Injectable({ scope: InjectableScope.Singleton })
626
+ class SingletonService {
627
+ id = Math.random().toString(36).substr(2, 9)
628
+ name = 'SingletonService'
629
+ }
630
+
631
+ // First request
632
+ const requestId1 = 'outer-request'
633
+ serviceLocator.beginRequest(requestId1)
634
+ const [error1, requestService1] =
635
+ await serviceLocator.getInstance(RequestService)
636
+ const [error2, singletonService1] =
637
+ await serviceLocator.getInstance(SingletonService)
638
+
639
+ // Second request (nested)
640
+ const requestId2 = 'inner-request'
641
+ serviceLocator.beginRequest(requestId2)
642
+ const [error3, requestService2] =
643
+ await serviceLocator.getInstance(RequestService)
644
+ const [error4, singletonService2] =
645
+ await serviceLocator.getInstance(SingletonService)
646
+
647
+ expect(error1).toBeUndefined()
648
+ expect(error2).toBeUndefined()
649
+ expect(error3).toBeUndefined()
650
+ expect(error4).toBeUndefined()
651
+
652
+ // Verify instances
653
+ expect(requestService1).not.toBe(requestService2) // Different request instances
654
+ expect(singletonService1).toBe(singletonService2) // Same singleton instance
655
+
656
+ // End inner request
657
+ await serviceLocator.endRequest(requestId2)
658
+
659
+ // Verify current context is back to outer request
660
+ expect(serviceLocator.getCurrentRequestContext()?.requestId).toBe(
661
+ requestId1,
662
+ )
663
+
664
+ // End outer request
665
+ await serviceLocator.endRequest(requestId1)
666
+
667
+ // Verify no current context
668
+ expect(serviceLocator.getCurrentRequestContext()).toBeNull()
669
+ })
670
+
671
+ it('should handle concurrent requests with mixed scopes', async () => {
672
+ @Injectable({ scope: InjectableScope.Request })
673
+ class RequestService {
674
+ id = Math.random().toString(36).substr(2, 9)
675
+ name = 'RequestService'
676
+ }
677
+
678
+ @Injectable({ scope: InjectableScope.Singleton })
679
+ class SingletonService {
680
+ id = Math.random().toString(36).substr(2, 9)
681
+ name = 'SingletonService'
682
+ }
683
+
684
+ // Start multiple requests sequentially to avoid race conditions
685
+ const requestIds = ['req-1', 'req-2', 'req-3']
686
+ const results = []
687
+
688
+ for (const requestId of requestIds) {
689
+ serviceLocator.beginRequest(requestId)
690
+ const [_error1, requestService] =
691
+ await serviceLocator.getInstance(RequestService)
692
+ const [_error2, singletonService] =
693
+ await serviceLocator.getInstance(SingletonService)
694
+ await serviceLocator.endRequest(requestId)
695
+ results.push({ requestService, singletonService, requestId })
696
+ }
697
+
698
+ // Verify all requests completed successfully
699
+ results.forEach(({ requestService, singletonService }) => {
700
+ expect(requestService).toBeDefined()
701
+ expect(singletonService).toBeDefined()
702
+ })
703
+
704
+ // Verify request services are different
705
+ expect(results[0].requestService).not.toBe(results[1].requestService)
706
+ expect(results[0].requestService).not.toBe(results[2].requestService)
707
+ expect(results[1].requestService).not.toBe(results[2].requestService)
708
+
709
+ // Verify singleton services are the same
710
+ expect(results[0].singletonService).toBe(results[1].singletonService)
711
+ expect(results[0].singletonService).toBe(results[2].singletonService)
712
+ expect(results[1].singletonService).toBe(results[2].singletonService)
713
+ })
714
+ })
715
+
716
+ describe('Error handling with mixed scopes', () => {
717
+ it('should handle Request-scoped service without request context', async () => {
718
+ @Injectable({ scope: InjectableScope.Request })
719
+ class RequestService {
720
+ name = 'RequestService'
721
+ }
722
+
723
+ // Try to get Request-scoped service without request context
724
+ const [error] = await serviceLocator.getInstance(RequestService)
725
+
726
+ expect(error).toBeDefined()
727
+ expect(error?.code).toBe('UnknownError')
728
+ })
729
+
730
+ it('should handle service instantiation errors in mixed scope scenario', async () => {
731
+ @Injectable({ scope: InjectableScope.Singleton })
732
+ class SingletonService {
733
+ constructor() {
734
+ throw new Error('Singleton creation failed')
735
+ }
736
+ name = 'SingletonService'
737
+ }
738
+
739
+ @Injectable({ scope: InjectableScope.Request })
740
+ class RequestService {
741
+ singleton = inject(SingletonService)
742
+ name = 'RequestService'
743
+ }
744
+
745
+ const requestId = 'error-test'
746
+ serviceLocator.beginRequest(requestId)
747
+
748
+ // Try to get Request service that depends on failing Singleton
749
+ // The system should throw an error when the service cannot be instantiated
750
+ await expect(
751
+ serviceLocator.getInstance(RequestService),
752
+ ).rejects.toThrow('Singleton creation failed')
753
+ })
754
+ })
755
+ })
756
+
757
+ describe('Injectable with Schema', () => {
758
+ let serviceLocator: ServiceLocator
759
+
760
+ beforeEach(() => {
761
+ serviceLocator = new ServiceLocator(globalRegistry)
762
+ })
763
+
764
+ it('should work with simple schema definition', async () => {
765
+ const configSchema = z.object({
766
+ host: z.string(),
767
+ port: z.number(),
768
+ })
769
+
770
+ @Injectable({ schema: configSchema })
771
+ class DatabaseConfig {
772
+ constructor(public readonly config: z.output<typeof configSchema>) {}
773
+
774
+ getConnectionString() {
775
+ return `${this.config.host}:${this.config.port}`
776
+ }
777
+ }
778
+
779
+ const token = getInjectableToken(DatabaseConfig)
780
+ const [error, instance] = await serviceLocator.getInstance(
781
+ InjectionToken.bound(token, {
782
+ host: 'localhost',
783
+ port: 5432,
784
+ }),
785
+ )
786
+
787
+ expect(error).toBeUndefined()
788
+ expect(instance).toBeInstanceOf(DatabaseConfig)
789
+ expect(instance.config).toEqual({ host: 'localhost', port: 5432 })
790
+ expect(instance.getConnectionString()).toBe('localhost:5432')
791
+ })
792
+
793
+ it('should work with schema and singleton scope', async () => {
794
+ const apiSchema = z.object({
795
+ apiKey: z.string(),
796
+ baseUrl: z.string(),
797
+ })
798
+
799
+ @Injectable({ schema: apiSchema, scope: InjectableScope.Singleton })
800
+ class ApiClient {
801
+ constructor(public readonly config: z.output<typeof apiSchema>) {}
802
+
803
+ getApiKey() {
804
+ return this.config.apiKey
805
+ }
806
+ }
807
+
808
+ const [error1, instance1] = await serviceLocator.getInstance(ApiClient, {
809
+ apiKey: 'secret-key',
810
+ baseUrl: 'https://api.example.com',
811
+ })
812
+ const [error2, instance2] = await serviceLocator.getInstance(ApiClient, {
813
+ apiKey: 'secret-key',
814
+ baseUrl: 'https://api.example.com',
815
+ })
816
+
817
+ expect(error1).toBeUndefined()
818
+ expect(error2).toBeUndefined()
819
+ expect(instance1).toBe(instance2) // Same singleton instance
820
+ expect(instance1.getApiKey()).toBe('secret-key')
821
+ })
822
+
823
+ it('should work with schema and transient scope', async () => {
824
+ const loggerSchema = z.object({
825
+ level: z.enum(['debug', 'info', 'warn', 'error']),
826
+ prefix: z.string(),
827
+ })
828
+
829
+ @Injectable({ schema: loggerSchema, scope: InjectableScope.Transient })
830
+ class Logger {
831
+ constructor(public readonly config: z.output<typeof loggerSchema>) {}
832
+
833
+ log(message: string) {
834
+ return `[${this.config.prefix}] ${message}`
835
+ }
836
+ }
837
+
838
+ const [error1, instance1] = await serviceLocator.getInstance(Logger, {
839
+ level: 'info' as const,
840
+ prefix: 'APP',
841
+ })
842
+ const [error2, instance2] = await serviceLocator.getInstance(Logger, {
843
+ level: 'info' as const,
844
+ prefix: 'APP',
845
+ })
846
+
847
+ expect(error1).toBeUndefined()
848
+ expect(error2).toBeUndefined()
849
+ expect(instance1).not.toBe(instance2) // Different transient instances
850
+ expect(instance1.log('test')).toBe('[APP] test')
851
+ expect(instance2.log('test')).toBe('[APP] test')
852
+ })
853
+
854
+ it('should work with schema and dependency injection', async () => {
855
+ const dbConfigSchema = z.object({
856
+ connectionString: z.string(),
857
+ })
858
+
859
+ @Injectable({ schema: dbConfigSchema })
860
+ class DatabaseConfig {
861
+ constructor(public readonly config: z.output<typeof dbConfigSchema>) {}
862
+ }
863
+
864
+ @Injectable()
865
+ class DatabaseService {
866
+ private dbConfig = inject(DatabaseConfig, {
867
+ connectionString: 'postgres://localhost:5432/mydb',
868
+ })
869
+
870
+ getConnectionString() {
871
+ return this.dbConfig.config.connectionString
872
+ }
873
+ }
874
+
875
+ const [error, instance] =
876
+ await serviceLocator.getInstance(DatabaseService)
877
+
878
+ expect(error).toBeUndefined()
879
+ if (!instance) throw new Error('Instance is undefined')
880
+ expect(instance).toBeInstanceOf(DatabaseService)
881
+ expect(instance.getConnectionString()).toBe(
882
+ 'postgres://localhost:5432/mydb',
883
+ )
884
+ })
885
+
886
+ it('should work with schema and async dependency injection', async () => {
887
+ const cacheConfigSchema = z.object({
888
+ ttl: z.number(),
889
+ maxSize: z.number(),
890
+ })
891
+
892
+ @Injectable({ schema: cacheConfigSchema })
893
+ class CacheConfig {
894
+ constructor(
895
+ public readonly config: z.output<typeof cacheConfigSchema>,
896
+ ) {}
897
+ }
898
+
899
+ @Injectable()
900
+ class CacheService {
901
+ private cacheConfig = asyncInject(CacheConfig, {
902
+ ttl: 3600,
903
+ maxSize: 1000,
904
+ })
905
+
906
+ async getConfig() {
907
+ const config = await this.cacheConfig
908
+ return config.config
909
+ }
910
+ }
911
+
912
+ const [error, instance] = await serviceLocator.getInstance(CacheService)
913
+
914
+ expect(error).toBeUndefined()
915
+ if (!instance) throw new Error('Instance is undefined')
916
+ expect(instance).toBeInstanceOf(CacheService)
917
+ const config = await instance.getConfig()
918
+ expect(config).toEqual({ ttl: 3600, maxSize: 1000 })
919
+ })
920
+
921
+ it('should validate schema when using bound tokens', async () => {
922
+ const strictSchema = z.object({
923
+ required: z.string(),
924
+ optional: z.number().optional(),
925
+ })
926
+
927
+ @Injectable({ schema: strictSchema })
928
+ class StrictService {
929
+ constructor(public readonly config: z.output<typeof strictSchema>) {}
930
+ }
931
+
932
+ // Valid configuration
933
+ const [error1, instance1] = await serviceLocator.getInstance(
934
+ StrictService,
935
+ {
936
+ required: 'value',
937
+ optional: 42,
938
+ },
939
+ )
940
+
941
+ expect(error1).toBeUndefined()
942
+ expect(instance1).toBeInstanceOf(StrictService)
943
+ expect(instance1.config).toEqual({ required: 'value', optional: 42 })
944
+
945
+ // Valid with optional field missing
946
+ const [error2, instance2] = await serviceLocator.getInstance(
947
+ StrictService,
948
+ {
949
+ required: 'another value',
950
+ },
951
+ )
952
+
953
+ expect(error2).toBeUndefined()
954
+ expect(instance2).toBeInstanceOf(StrictService)
955
+ expect(instance2.config).toEqual({ required: 'another value' })
956
+ })
957
+
958
+ it('should work with complex nested schemas', async () => {
959
+ const nestedSchema = z.object({
960
+ database: z.object({
961
+ host: z.string(),
962
+ port: z.number(),
963
+ credentials: z.object({
964
+ username: z.string(),
965
+ password: z.string(),
966
+ }),
967
+ }),
968
+ cache: z.object({
969
+ enabled: z.boolean(),
970
+ ttl: z.number(),
971
+ }),
972
+ })
973
+
974
+ @Injectable({ schema: nestedSchema })
975
+ class AppConfig {
976
+ constructor(public readonly config: z.output<typeof nestedSchema>) {}
977
+
978
+ getDatabaseHost() {
979
+ return this.config.database.host
980
+ }
981
+
982
+ isCacheEnabled() {
983
+ return this.config.cache.enabled
984
+ }
985
+ }
986
+
987
+ const [error, instance] = await serviceLocator.getInstance(AppConfig, {
988
+ database: {
989
+ host: 'db.example.com',
990
+ port: 5432,
991
+ credentials: {
992
+ username: 'admin',
993
+ password: 'secret',
994
+ },
995
+ },
996
+ cache: {
997
+ enabled: true,
998
+ ttl: 300,
999
+ },
1000
+ })
1001
+
1002
+ expect(error).toBeUndefined()
1003
+ expect(instance).toBeInstanceOf(AppConfig)
1004
+ expect(instance.getDatabaseHost()).toBe('db.example.com')
1005
+ expect(instance.isCacheEnabled()).toBe(true)
1006
+ })
1007
+
1008
+ it('should work with schema in request-scoped services', async () => {
1009
+ const userContextSchema = z.object({
1010
+ userId: z.string(),
1011
+ sessionId: z.string(),
1012
+ })
1013
+
1014
+ @Injectable({
1015
+ schema: userContextSchema,
1016
+ scope: InjectableScope.Request,
1017
+ })
1018
+ class UserContext {
1019
+ constructor(
1020
+ public readonly context: z.output<typeof userContextSchema>,
1021
+ ) {}
1022
+
1023
+ getUserId() {
1024
+ return this.context.userId
1025
+ }
1026
+ }
1027
+
1028
+ const requestId = 'test-request'
1029
+ serviceLocator.beginRequest(requestId)
1030
+
1031
+ const [error, instance] = await serviceLocator.getInstance(UserContext, {
1032
+ userId: 'user-123',
1033
+ sessionId: 'session-456',
1034
+ })
1035
+
1036
+ expect(error).toBeUndefined()
1037
+ expect(instance).toBeInstanceOf(UserContext)
1038
+ expect(instance.getUserId()).toBe('user-123')
1039
+
1040
+ await serviceLocator.endRequest(requestId)
1041
+ })
1042
+ })
35
1043
  })