@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 +1 -1
- package/src/module/module.test.ts +88 -0
- package/src/module/module.ts +135 -11
- package/src/module/service.ts +10 -0
package/package.json
CHANGED
|
@@ -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', () => {
|
package/src/module/module.ts
CHANGED
|
@@ -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
|
|
248
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
|
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
|
|
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
|
-
|
|
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.
|
|
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
|
/**
|
package/src/module/service.ts
CHANGED
|
@@ -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
|