@onebun/core 0.1.13 → 0.1.15

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@onebun/core",
3
- "version": "0.1.13",
3
+ "version": "0.1.15",
4
4
  "description": "Core package for OneBun framework - decorators, DI, modules, controllers",
5
5
  "license": "LGPL-3.0",
6
6
  "author": "RemRyahirev",
@@ -154,6 +154,94 @@ describe('OneBunModule', () => {
154
154
 
155
155
  expect(() => new OneBunModule(TestClass, mockLoggerLayer)).toThrow();
156
156
  });
157
+
158
+ test('should detect circular dependencies and provide detailed error message', () => {
159
+ const { registerDependencies } = require('../decorators/decorators');
160
+ const { Effect, Layer } = require('effect');
161
+ const { LoggerService } = require('@onebun/logger');
162
+
163
+ // Collect error messages
164
+ const errorMessages: string[] = [];
165
+
166
+ // Create mock logger that captures error messages
167
+ // Using Effect.sync to ensure the message is captured synchronously when Effect.runSync is called
168
+ const captureLogger = {
169
+ trace: () => Effect.sync(() => undefined),
170
+ debug: () => Effect.sync(() => undefined),
171
+ info: () => Effect.sync(() => undefined),
172
+ warn: () => Effect.sync(() => undefined),
173
+ error: (msg: string) =>
174
+ Effect.sync(() => {
175
+ errorMessages.push(msg);
176
+ }),
177
+ fatal: () => Effect.sync(() => undefined),
178
+ child: () => captureLogger,
179
+ };
180
+ const captureLoggerLayer = Layer.succeed(LoggerService, captureLogger);
181
+
182
+ // Create services - define all classes first
183
+ @Service()
184
+ class CircularServiceA {
185
+ getValue() {
186
+ return 'A';
187
+ }
188
+ }
189
+
190
+ @Service()
191
+ class CircularServiceB {
192
+ getValue() {
193
+ return 'B';
194
+ }
195
+ }
196
+
197
+ @Service()
198
+ class CircularServiceC {
199
+ getValue() {
200
+ return 'C';
201
+ }
202
+ }
203
+
204
+ // Now register circular dependencies manually: A -> B -> C -> A
205
+ registerDependencies(CircularServiceA, [CircularServiceC]);
206
+ registerDependencies(CircularServiceB, [CircularServiceA]);
207
+ registerDependencies(CircularServiceC, [CircularServiceB]);
208
+
209
+ @Module({
210
+ providers: [CircularServiceA, CircularServiceB, CircularServiceC],
211
+ })
212
+ class CircularModule {}
213
+
214
+ // Initialize module - should detect circular dependency
215
+ new OneBunModule(CircularModule, captureLoggerLayer);
216
+
217
+ // Verify error message was logged
218
+ expect(errorMessages.length).toBeGreaterThan(0);
219
+
220
+ // Find the circular dependency error message
221
+ const circularError = errorMessages.find((msg) =>
222
+ msg.includes('Circular dependency detected'),
223
+ );
224
+ expect(circularError).toBeDefined();
225
+
226
+ // Should contain module name
227
+ expect(circularError).toContain('CircularModule');
228
+
229
+ // Should contain "Unresolved services" section
230
+ expect(circularError).toContain('Unresolved services');
231
+
232
+ // Should contain at least one of the service names with their dependencies
233
+ const hasServiceInfo =
234
+ circularError!.includes('CircularServiceA') ||
235
+ circularError!.includes('CircularServiceB') ||
236
+ circularError!.includes('CircularServiceC');
237
+ expect(hasServiceInfo).toBe(true);
238
+
239
+ // Should contain "needs:" showing what dependencies are required
240
+ expect(circularError).toContain('needs:');
241
+
242
+ // Should contain "Dependency chain" showing the cycle
243
+ expect(circularError).toContain('Dependency chain');
244
+ });
157
245
  });
158
246
 
159
247
  describe('Module instance methods', () => {
@@ -68,6 +68,7 @@ export class OneBunModule implements ModuleInstance {
68
68
  private controllers: Function[] = [];
69
69
  private controllerInstances: Map<Function, Controller> = new Map();
70
70
  private serviceInstances: Map<Context.Tag<unknown, unknown>, unknown> = new Map();
71
+ private pendingAsyncInits: Array<{ name: string; init: () => Promise<void> }> = [];
71
72
  private logger: SyncLogger;
72
73
  private config: IConfig<OneBunAppConfig>;
73
74
 
@@ -228,6 +229,7 @@ export class OneBunModule implements ModuleInstance {
228
229
  // Create services in dependency order
229
230
  const pendingProviders = [...metadata.providers.filter((p) => typeof p === 'function')];
230
231
  const createdServices = new Set<string>();
232
+ const unresolvedDeps = new Map<string, string[]>(); // Track unresolved dependencies for error reporting
231
233
  let iterations = 0;
232
234
  const maxIterations = pendingProviders.length * 2; // Prevent infinite loops
233
235
 
@@ -244,8 +246,11 @@ export class OneBunModule implements ModuleInstance {
244
246
  continue;
245
247
  }
246
248
 
247
- // Use autoDetectDependencies to find dependencies from constructor
248
- const detectedDeps = autoDetectDependencies(provider, availableServiceClasses);
249
+ // Use getConstructorParamTypes first (for @Inject and TypeScript metadata),
250
+ // then fallback to autoDetectDependencies (for constructor source analysis)
251
+ const detectedDeps =
252
+ getConstructorParamTypes(provider) ||
253
+ autoDetectDependencies(provider, availableServiceClasses);
249
254
  const dependencies: unknown[] = [];
250
255
  let allDependenciesResolved = true;
251
256
 
@@ -258,6 +263,12 @@ export class OneBunModule implements ModuleInstance {
258
263
  // Check if it's a service that hasn't been created yet
259
264
  const isServiceInModule = availableServiceClasses.has(depType.name);
260
265
  if (isServiceInModule && !createdServices.has(depType.name)) {
266
+ // Track unresolved dependency for error reporting
267
+ const deps = unresolvedDeps.get(provider.name) || [];
268
+ if (!deps.includes(depType.name)) {
269
+ deps.push(depType.name);
270
+ unresolvedDeps.set(provider.name, deps);
271
+ }
261
272
  // This dependency will be created later, defer this service
262
273
  allDependenciesResolved = false;
263
274
  pendingProviders.push(provider);
@@ -292,6 +303,19 @@ export class OneBunModule implements ModuleInstance {
292
303
  .initializeService(this.logger, this.config);
293
304
  }
294
305
 
306
+ // Track services that need async initialization
307
+ if (
308
+ serviceInstance &&
309
+ typeof serviceInstance === 'object' &&
310
+ 'onAsyncInit' in serviceInstance &&
311
+ typeof (serviceInstance as { onAsyncInit: unknown }).onAsyncInit === 'function'
312
+ ) {
313
+ this.pendingAsyncInits.push({
314
+ name: provider.name,
315
+ init: () => (serviceInstance as { onAsyncInit: () => Promise<void> }).onAsyncInit(),
316
+ });
317
+ }
318
+
295
319
  this.serviceInstances.set(serviceMetadata.tag, serviceInstance);
296
320
  createdServices.add(provider.name);
297
321
  this.logger.debug(
@@ -303,7 +327,25 @@ export class OneBunModule implements ModuleInstance {
303
327
  }
304
328
 
305
329
  if (iterations >= maxIterations) {
306
- this.logger.error('Possible circular dependency detected in services');
330
+ const unresolvedServices = pendingProviders
331
+ .filter((p) => typeof p === 'function')
332
+ .map((p) => p.name);
333
+
334
+ const details = unresolvedServices
335
+ .map((serviceName) => {
336
+ const deps = unresolvedDeps.get(serviceName) || [];
337
+
338
+ return ` - ${serviceName} -> needs: [${deps.join(', ')}]`;
339
+ })
340
+ .join('\n');
341
+
342
+ const dependencyChain = this.buildDependencyChain(unresolvedDeps, unresolvedServices);
343
+
344
+ this.logger.error(
345
+ `Circular dependency detected in module ${this.moduleClass.name}!\n` +
346
+ `Unresolved services:\n${details}\n` +
347
+ `Dependency chain: ${dependencyChain}`,
348
+ );
307
349
  }
308
350
  }
309
351
 
@@ -340,14 +382,12 @@ export class OneBunModule implements ModuleInstance {
340
382
  * Create controller instances and inject services
341
383
  */
342
384
  createControllerInstances(): Effect.Effect<unknown, never, void> {
343
- const self = this;
344
-
345
- return Effect.gen(function* (_) {
385
+ return Effect.sync(() => {
346
386
  // Services are already created in initModule via createServicesWithDI
347
387
  // Just need to set up controllers with DI
348
388
 
349
389
  // Get module metadata to access providers for controller dependency registration
350
- const moduleMetadata = getModuleMetadata(self.moduleClass);
390
+ const moduleMetadata = getModuleMetadata(this.moduleClass);
351
391
  if (moduleMetadata && moduleMetadata.providers) {
352
392
  // Create map of available services for dependency resolution
353
393
  const availableServices = new Map<string, Function>();
@@ -360,7 +400,7 @@ export class OneBunModule implements ModuleInstance {
360
400
  }
361
401
 
362
402
  // Also add services from imported modules
363
- for (const childModule of self.childModules) {
403
+ for (const childModule of this.childModules) {
364
404
  const childMetadata = getModuleMetadata(childModule.moduleClass);
365
405
  if (childMetadata?.exports) {
366
406
  for (const exported of childMetadata.exports) {
@@ -379,13 +419,13 @@ export class OneBunModule implements ModuleInstance {
379
419
  }
380
420
 
381
421
  // Automatically analyze and register dependencies for all controllers
382
- for (const controllerClass of self.controllers) {
422
+ for (const controllerClass of this.controllers) {
383
423
  registerControllerDependencies(controllerClass, availableServices);
384
424
  }
385
425
  }
386
426
 
387
427
  // Now create controller instances with automatic dependency injection
388
- self.createControllersWithDI();
428
+ this.createControllersWithDI();
389
429
  }).pipe(Effect.provide(this.rootLayer));
390
430
  }
391
431
 
@@ -467,11 +507,95 @@ export class OneBunModule implements ModuleInstance {
467
507
  return serviceInstance;
468
508
  }
469
509
 
510
+ /**
511
+ * Build a human-readable dependency chain for circular dependency error reporting
512
+ * Traverses the dependency graph to find and display the cycle
513
+ */
514
+ private buildDependencyChain(
515
+ unresolvedDeps: Map<string, string[]>,
516
+ unresolvedServices: string[],
517
+ ): string {
518
+ // Find cycle by traversing dependencies
519
+ const visited = new Set<string>();
520
+ const chain: string[] = [];
521
+
522
+ const findCycle = (service: string): boolean => {
523
+ if (visited.has(service)) {
524
+ chain.push(service);
525
+
526
+ return true;
527
+ }
528
+ visited.add(service);
529
+ chain.push(service);
530
+
531
+ const deps = unresolvedDeps.get(service) || [];
532
+ for (const dep of deps) {
533
+ if (unresolvedServices.includes(dep)) {
534
+ if (findCycle(dep)) {
535
+ return true;
536
+ }
537
+ }
538
+ }
539
+ chain.pop();
540
+
541
+ return false;
542
+ };
543
+
544
+ for (const service of unresolvedServices) {
545
+ visited.clear();
546
+ chain.length = 0;
547
+ if (findCycle(service)) {
548
+ return chain.join(' -> ');
549
+ }
550
+ }
551
+
552
+ // If no cycle found, just show all unresolved services
553
+ return unresolvedServices.join(' <-> ');
554
+ }
555
+
470
556
  /**
471
557
  * Setup the module and its dependencies
472
558
  */
473
559
  setup(): Effect.Effect<unknown, never, void> {
474
- return this.createControllerInstances();
560
+ return this.runAsyncServiceInit().pipe(
561
+ // Also run async init for child modules
562
+ Effect.flatMap(() =>
563
+ Effect.forEach(this.childModules, (childModule) => childModule.runAsyncServiceInit(), {
564
+ discard: true,
565
+ }),
566
+ ),
567
+ // Then create controller instances
568
+ Effect.flatMap(() => this.createControllerInstances()),
569
+ );
570
+ }
571
+
572
+ /**
573
+ * Run async initialization for all services that need it
574
+ */
575
+ runAsyncServiceInit(): Effect.Effect<unknown, never, void> {
576
+ if (this.pendingAsyncInits.length === 0) {
577
+ return Effect.void;
578
+ }
579
+
580
+ this.logger.debug(`Running async initialization for ${this.pendingAsyncInits.length} service(s)`);
581
+
582
+ // Run all async inits in parallel
583
+ const initPromises = this.pendingAsyncInits.map(async ({ name, init }) => {
584
+ try {
585
+ await init();
586
+ this.logger.debug(`Service ${name} async initialization completed`);
587
+ } catch (error) {
588
+ this.logger.error(`Service ${name} async initialization failed: ${error}`);
589
+ throw error;
590
+ }
591
+ });
592
+
593
+ return Effect.promise(() => Promise.all(initPromises)).pipe(
594
+ Effect.map(() => {
595
+ // Clear the list after initialization
596
+ this.pendingAsyncInits = [];
597
+ }),
598
+ );
475
599
  }
476
600
 
477
601
  /**
@@ -99,6 +99,16 @@ export class BaseService {
99
99
  this.logger.debug(`Service ${className} initialized`);
100
100
  }
101
101
 
102
+ /**
103
+ * Async initialization hook - called by the framework after initializeService()
104
+ * Override in subclasses that need async initialization (e.g., database connections)
105
+ * The framework will await this method before making the service available
106
+ * @internal
107
+ */
108
+ async onAsyncInit(): Promise<void> {
109
+ // Default: no async init needed
110
+ }
111
+
102
112
  /**
103
113
  * Check if service is initialized
104
114
  * @internal