@onebun/core 0.1.21 → 0.1.23
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/docs-examples.test.ts +95 -1
- package/src/module/module.test.ts +282 -0
- package/src/module/module.ts +37 -22
- package/src/module/service.test.ts +105 -0
- package/src/module/service.ts +68 -5
package/package.json
CHANGED
|
@@ -816,7 +816,7 @@ describe('Services API Documentation Examples', () => {
|
|
|
816
816
|
@Service()
|
|
817
817
|
class UserService extends BaseService {
|
|
818
818
|
// Dependencies are auto-injected via constructor
|
|
819
|
-
// Logger and config are
|
|
819
|
+
// Logger and config are available immediately after super()
|
|
820
820
|
constructor(private repository: UserRepository) {
|
|
821
821
|
super();
|
|
822
822
|
}
|
|
@@ -1150,6 +1150,100 @@ describe('Lifecycle Hooks API Documentation Examples (docs/api/services.md)', ()
|
|
|
1150
1150
|
await callOnApplicationDestroy(emptyObj, 'SIGTERM');
|
|
1151
1151
|
});
|
|
1152
1152
|
});
|
|
1153
|
+
|
|
1154
|
+
describe('Standalone Service Pattern (docs/api/services.md)', () => {
|
|
1155
|
+
/**
|
|
1156
|
+
* @source docs/api/services.md#lifecycle-hooks
|
|
1157
|
+
* Standalone services (not injected anywhere) still have their
|
|
1158
|
+
* onModuleInit called. This is useful for background workers,
|
|
1159
|
+
* cron jobs, event listeners, etc.
|
|
1160
|
+
*/
|
|
1161
|
+
it('should call onModuleInit for standalone services not injected anywhere', async () => {
|
|
1162
|
+
const moduleMod = await import('./module/module');
|
|
1163
|
+
const testUtils = await import('./testing/test-utils');
|
|
1164
|
+
const effectLib = await import('effect');
|
|
1165
|
+
|
|
1166
|
+
let schedulerStarted = false;
|
|
1167
|
+
|
|
1168
|
+
// From docs: Standalone service pattern
|
|
1169
|
+
@Service()
|
|
1170
|
+
class TaskSchedulerService extends BaseService implements OnModuleInit {
|
|
1171
|
+
async onModuleInit(): Promise<void> {
|
|
1172
|
+
// Main work happens here — no need to be injected anywhere
|
|
1173
|
+
schedulerStarted = true;
|
|
1174
|
+
this.logger.info('Task scheduler started');
|
|
1175
|
+
}
|
|
1176
|
+
}
|
|
1177
|
+
|
|
1178
|
+
@Module({
|
|
1179
|
+
providers: [TaskSchedulerService],
|
|
1180
|
+
// No controllers use this service — it works on its own
|
|
1181
|
+
})
|
|
1182
|
+
class SchedulerModule {}
|
|
1183
|
+
|
|
1184
|
+
const mod = new moduleMod.OneBunModule(SchedulerModule, testUtils.makeMockLoggerLayer());
|
|
1185
|
+
await effectLib.Effect.runPromise(mod.setup() as import('effect').Effect.Effect<unknown, never, never>);
|
|
1186
|
+
|
|
1187
|
+
// Scheduler was started even though nothing injected it
|
|
1188
|
+
expect(schedulerStarted).toBe(true);
|
|
1189
|
+
});
|
|
1190
|
+
|
|
1191
|
+
/**
|
|
1192
|
+
* @source docs/api/services.md#lifecycle-hooks
|
|
1193
|
+
* onModuleInit is called sequentially in dependency order:
|
|
1194
|
+
* dependencies complete their init before dependents start theirs.
|
|
1195
|
+
*/
|
|
1196
|
+
it('should call onModuleInit in dependency order so dependencies are fully initialized', async () => {
|
|
1197
|
+
const moduleMod = await import('./module/module');
|
|
1198
|
+
const testUtils = await import('./testing/test-utils');
|
|
1199
|
+
const effectLib = await import('effect');
|
|
1200
|
+
const decorators = await import('./decorators/decorators');
|
|
1201
|
+
|
|
1202
|
+
const initOrder: string[] = [];
|
|
1203
|
+
|
|
1204
|
+
@Service()
|
|
1205
|
+
class DatabaseService extends BaseService implements OnModuleInit {
|
|
1206
|
+
private ready = false;
|
|
1207
|
+
|
|
1208
|
+
async onModuleInit(): Promise<void> {
|
|
1209
|
+
this.ready = true;
|
|
1210
|
+
initOrder.push('database');
|
|
1211
|
+
}
|
|
1212
|
+
|
|
1213
|
+
isReady(): boolean {
|
|
1214
|
+
return this.ready;
|
|
1215
|
+
}
|
|
1216
|
+
}
|
|
1217
|
+
|
|
1218
|
+
@Service()
|
|
1219
|
+
class CacheService extends BaseService implements OnModuleInit {
|
|
1220
|
+
private db: DatabaseService;
|
|
1221
|
+
|
|
1222
|
+
constructor(db: DatabaseService) {
|
|
1223
|
+
super();
|
|
1224
|
+
this.db = db;
|
|
1225
|
+
}
|
|
1226
|
+
|
|
1227
|
+
async onModuleInit(): Promise<void> {
|
|
1228
|
+
// At this point, DatabaseService.onModuleInit has already completed
|
|
1229
|
+
initOrder.push(`cache:db-ready=${this.db.isReady()}`);
|
|
1230
|
+
}
|
|
1231
|
+
}
|
|
1232
|
+
|
|
1233
|
+
decorators.registerDependencies(CacheService, [DatabaseService]);
|
|
1234
|
+
|
|
1235
|
+
@Module({
|
|
1236
|
+
providers: [DatabaseService, CacheService],
|
|
1237
|
+
})
|
|
1238
|
+
class AppModule {}
|
|
1239
|
+
|
|
1240
|
+
const mod = new moduleMod.OneBunModule(AppModule, testUtils.makeMockLoggerLayer());
|
|
1241
|
+
await effectLib.Effect.runPromise(mod.setup() as import('effect').Effect.Effect<unknown, never, never>);
|
|
1242
|
+
|
|
1243
|
+
// Database initialized first, then cache saw database was ready
|
|
1244
|
+
expect(initOrder).toEqual(['database', 'cache:db-ready=true']);
|
|
1245
|
+
});
|
|
1246
|
+
});
|
|
1153
1247
|
});
|
|
1154
1248
|
|
|
1155
1249
|
describe('getService API Documentation Examples (docs/api/core.md)', () => {
|
|
@@ -851,6 +851,288 @@ describe('OneBunModule', () => {
|
|
|
851
851
|
expect(apiService).toBeDefined();
|
|
852
852
|
expect((apiService as ApiService).getConnectionTimeout()).toBe(5000);
|
|
853
853
|
});
|
|
854
|
+
|
|
855
|
+
/**
|
|
856
|
+
* Test that this.config and this.logger are available in the service constructor
|
|
857
|
+
* when the service is created through the DI system (via ambient init context).
|
|
858
|
+
*/
|
|
859
|
+
test('should have this.config and this.logger available in constructor via DI', () => {
|
|
860
|
+
let configInConstructor: unknown = undefined;
|
|
861
|
+
let loggerInConstructor: unknown = undefined;
|
|
862
|
+
|
|
863
|
+
@Service()
|
|
864
|
+
class ConfigAwareService extends BaseServiceClass {
|
|
865
|
+
constructor() {
|
|
866
|
+
super();
|
|
867
|
+
configInConstructor = this.config;
|
|
868
|
+
loggerInConstructor = this.logger;
|
|
869
|
+
}
|
|
870
|
+
}
|
|
871
|
+
|
|
872
|
+
@ModuleDecorator({
|
|
873
|
+
providers: [ConfigAwareService],
|
|
874
|
+
})
|
|
875
|
+
class TestModule {}
|
|
876
|
+
|
|
877
|
+
// Initialize module — this triggers DI and service creation
|
|
878
|
+
new ModuleInstance(TestModule, mockLoggerLayer);
|
|
879
|
+
|
|
880
|
+
// config and logger should have been available in the constructor
|
|
881
|
+
expect(configInConstructor).toBeDefined();
|
|
882
|
+
expect(loggerInConstructor).toBeDefined();
|
|
883
|
+
});
|
|
884
|
+
|
|
885
|
+
/**
|
|
886
|
+
* Test that this.config.get() works in the constructor for services created via DI
|
|
887
|
+
*/
|
|
888
|
+
test('should allow config.get() in service constructor via DI', () => {
|
|
889
|
+
@Service()
|
|
890
|
+
class ServiceWithConfigInConstructor extends BaseServiceClass {
|
|
891
|
+
readonly configValue: unknown;
|
|
892
|
+
|
|
893
|
+
constructor() {
|
|
894
|
+
super();
|
|
895
|
+
// Config should be available here via init context
|
|
896
|
+
this.configValue = this.config;
|
|
897
|
+
}
|
|
898
|
+
|
|
899
|
+
getConfigValue() {
|
|
900
|
+
return this.configValue;
|
|
901
|
+
}
|
|
902
|
+
}
|
|
903
|
+
|
|
904
|
+
@ModuleDecorator({
|
|
905
|
+
providers: [ServiceWithConfigInConstructor],
|
|
906
|
+
})
|
|
907
|
+
class TestModule {}
|
|
908
|
+
|
|
909
|
+
const module = new ModuleInstance(TestModule, mockLoggerLayer);
|
|
910
|
+
|
|
911
|
+
const { getServiceTag } = require('./service');
|
|
912
|
+
const tag = getServiceTag(ServiceWithConfigInConstructor);
|
|
913
|
+
const service = module.getServiceInstance(tag) as ServiceWithConfigInConstructor;
|
|
914
|
+
|
|
915
|
+
expect(service).toBeDefined();
|
|
916
|
+
// configValue was captured in constructor — should not be undefined
|
|
917
|
+
expect(service.getConfigValue()).toBeDefined();
|
|
918
|
+
});
|
|
919
|
+
|
|
920
|
+
/**
|
|
921
|
+
* Test that this.config is available in constructor of a service with dependencies
|
|
922
|
+
*/
|
|
923
|
+
test('should have this.config in constructor of service with dependencies', () => {
|
|
924
|
+
let configAvailable = false;
|
|
925
|
+
|
|
926
|
+
@Service()
|
|
927
|
+
class DependencyService {
|
|
928
|
+
getValue() {
|
|
929
|
+
return 42;
|
|
930
|
+
}
|
|
931
|
+
}
|
|
932
|
+
|
|
933
|
+
@Service()
|
|
934
|
+
class MainService extends BaseServiceClass {
|
|
935
|
+
constructor(private dep: DependencyService) {
|
|
936
|
+
super();
|
|
937
|
+
configAvailable = this.config !== undefined;
|
|
938
|
+
}
|
|
939
|
+
|
|
940
|
+
getDep() {
|
|
941
|
+
return this.dep.getValue();
|
|
942
|
+
}
|
|
943
|
+
}
|
|
944
|
+
|
|
945
|
+
@ModuleDecorator({
|
|
946
|
+
providers: [DependencyService, MainService],
|
|
947
|
+
})
|
|
948
|
+
class TestModule {}
|
|
949
|
+
|
|
950
|
+
new ModuleInstance(TestModule, mockLoggerLayer);
|
|
951
|
+
|
|
952
|
+
expect(configAvailable).toBe(true);
|
|
953
|
+
});
|
|
954
|
+
});
|
|
955
|
+
|
|
956
|
+
describe('Lifecycle hooks', () => {
|
|
957
|
+
const { clearGlobalModules } = require('../decorators/decorators');
|
|
958
|
+
const { clearGlobalServicesRegistry: clearRegistry, OneBunModule: ModuleClass } = require('./module');
|
|
959
|
+
const { OnModuleInit } = require('./lifecycle');
|
|
960
|
+
|
|
961
|
+
beforeEach(() => {
|
|
962
|
+
clearGlobalModules();
|
|
963
|
+
clearRegistry();
|
|
964
|
+
});
|
|
965
|
+
|
|
966
|
+
afterEach(() => {
|
|
967
|
+
clearGlobalModules();
|
|
968
|
+
clearRegistry();
|
|
969
|
+
});
|
|
970
|
+
|
|
971
|
+
test('should call onModuleInit for a service that is not injected anywhere', async () => {
|
|
972
|
+
let initCalled = false;
|
|
973
|
+
|
|
974
|
+
@Service()
|
|
975
|
+
class StandaloneService {
|
|
976
|
+
async onModuleInit(): Promise<void> {
|
|
977
|
+
initCalled = true;
|
|
978
|
+
}
|
|
979
|
+
}
|
|
980
|
+
|
|
981
|
+
@Module({
|
|
982
|
+
providers: [StandaloneService],
|
|
983
|
+
// No controllers, no exports — this service is not injected anywhere
|
|
984
|
+
})
|
|
985
|
+
class TestModule {}
|
|
986
|
+
|
|
987
|
+
const module = new ModuleClass(TestModule, mockLoggerLayer);
|
|
988
|
+
await Effect.runPromise(module.setup() as Effect.Effect<unknown, never, never>);
|
|
989
|
+
|
|
990
|
+
expect(initCalled).toBe(true);
|
|
991
|
+
});
|
|
992
|
+
|
|
993
|
+
test('should call onModuleInit for multiple standalone services', async () => {
|
|
994
|
+
const initLog: string[] = [];
|
|
995
|
+
|
|
996
|
+
@Service()
|
|
997
|
+
class WorkerServiceA {
|
|
998
|
+
async onModuleInit(): Promise<void> {
|
|
999
|
+
initLog.push('A');
|
|
1000
|
+
}
|
|
1001
|
+
}
|
|
1002
|
+
|
|
1003
|
+
@Service()
|
|
1004
|
+
class WorkerServiceB {
|
|
1005
|
+
async onModuleInit(): Promise<void> {
|
|
1006
|
+
initLog.push('B');
|
|
1007
|
+
}
|
|
1008
|
+
}
|
|
1009
|
+
|
|
1010
|
+
@Service()
|
|
1011
|
+
class WorkerServiceC {
|
|
1012
|
+
async onModuleInit(): Promise<void> {
|
|
1013
|
+
initLog.push('C');
|
|
1014
|
+
}
|
|
1015
|
+
}
|
|
1016
|
+
|
|
1017
|
+
@Module({
|
|
1018
|
+
providers: [WorkerServiceA, WorkerServiceB, WorkerServiceC],
|
|
1019
|
+
})
|
|
1020
|
+
class TestModule {}
|
|
1021
|
+
|
|
1022
|
+
const module = new ModuleClass(TestModule, mockLoggerLayer);
|
|
1023
|
+
await Effect.runPromise(module.setup() as Effect.Effect<unknown, never, never>);
|
|
1024
|
+
|
|
1025
|
+
expect(initLog).toContain('A');
|
|
1026
|
+
expect(initLog).toContain('B');
|
|
1027
|
+
expect(initLog).toContain('C');
|
|
1028
|
+
expect(initLog.length).toBe(3);
|
|
1029
|
+
});
|
|
1030
|
+
|
|
1031
|
+
test('should call onModuleInit for standalone service in a child module', async () => {
|
|
1032
|
+
let childInitCalled = false;
|
|
1033
|
+
|
|
1034
|
+
@Service()
|
|
1035
|
+
class ChildStandaloneService {
|
|
1036
|
+
async onModuleInit(): Promise<void> {
|
|
1037
|
+
childInitCalled = true;
|
|
1038
|
+
}
|
|
1039
|
+
}
|
|
1040
|
+
|
|
1041
|
+
@Module({
|
|
1042
|
+
providers: [ChildStandaloneService],
|
|
1043
|
+
})
|
|
1044
|
+
class ChildModule {}
|
|
1045
|
+
|
|
1046
|
+
@Module({
|
|
1047
|
+
imports: [ChildModule],
|
|
1048
|
+
})
|
|
1049
|
+
class RootModule {}
|
|
1050
|
+
|
|
1051
|
+
const module = new ModuleClass(RootModule, mockLoggerLayer);
|
|
1052
|
+
await Effect.runPromise(module.setup() as Effect.Effect<unknown, never, never>);
|
|
1053
|
+
|
|
1054
|
+
expect(childInitCalled).toBe(true);
|
|
1055
|
+
});
|
|
1056
|
+
|
|
1057
|
+
test('should call onModuleInit sequentially in dependency order', async () => {
|
|
1058
|
+
const initLog: string[] = [];
|
|
1059
|
+
const { registerDependencies } = require('../decorators/decorators');
|
|
1060
|
+
|
|
1061
|
+
@Service()
|
|
1062
|
+
class DependencyService {
|
|
1063
|
+
async onModuleInit(): Promise<void> {
|
|
1064
|
+
// Simulate async work to make ordering matter
|
|
1065
|
+
await new Promise<void>((resolve) => {
|
|
1066
|
+
setTimeout(resolve, 5);
|
|
1067
|
+
});
|
|
1068
|
+
initLog.push('dependency-completed');
|
|
1069
|
+
}
|
|
1070
|
+
|
|
1071
|
+
getValue() {
|
|
1072
|
+
return 42;
|
|
1073
|
+
}
|
|
1074
|
+
}
|
|
1075
|
+
|
|
1076
|
+
@Service()
|
|
1077
|
+
class DependentService {
|
|
1078
|
+
async onModuleInit(): Promise<void> {
|
|
1079
|
+
initLog.push('dependent-started');
|
|
1080
|
+
}
|
|
1081
|
+
}
|
|
1082
|
+
|
|
1083
|
+
// Register DependentService -> DependencyService dependency
|
|
1084
|
+
registerDependencies(DependentService, [DependencyService]);
|
|
1085
|
+
|
|
1086
|
+
@Module({
|
|
1087
|
+
providers: [DependencyService, DependentService],
|
|
1088
|
+
})
|
|
1089
|
+
class TestModule {}
|
|
1090
|
+
|
|
1091
|
+
const module = new ModuleClass(TestModule, mockLoggerLayer);
|
|
1092
|
+
await Effect.runPromise(module.setup() as Effect.Effect<unknown, never, never>);
|
|
1093
|
+
|
|
1094
|
+
// DependencyService.onModuleInit must complete BEFORE DependentService.onModuleInit starts
|
|
1095
|
+
expect(initLog).toEqual(['dependency-completed', 'dependent-started']);
|
|
1096
|
+
});
|
|
1097
|
+
|
|
1098
|
+
test('should have dependencies already injected when onModuleInit is called', async () => {
|
|
1099
|
+
let depValueInInit: number | null = null;
|
|
1100
|
+
const { registerDependencies } = require('../decorators/decorators');
|
|
1101
|
+
|
|
1102
|
+
@Service()
|
|
1103
|
+
class ConfigService {
|
|
1104
|
+
getPort() {
|
|
1105
|
+
return 8080;
|
|
1106
|
+
}
|
|
1107
|
+
}
|
|
1108
|
+
|
|
1109
|
+
@Service()
|
|
1110
|
+
class ServerService {
|
|
1111
|
+
private configService: ConfigService;
|
|
1112
|
+
|
|
1113
|
+
constructor(configService: ConfigService) {
|
|
1114
|
+
this.configService = configService;
|
|
1115
|
+
}
|
|
1116
|
+
|
|
1117
|
+
async onModuleInit(): Promise<void> {
|
|
1118
|
+
// At this point configService should already be injected
|
|
1119
|
+
depValueInInit = this.configService.getPort();
|
|
1120
|
+
}
|
|
1121
|
+
}
|
|
1122
|
+
|
|
1123
|
+
registerDependencies(ServerService, [ConfigService]);
|
|
1124
|
+
|
|
1125
|
+
@Module({
|
|
1126
|
+
providers: [ConfigService, ServerService],
|
|
1127
|
+
})
|
|
1128
|
+
class TestModule {}
|
|
1129
|
+
|
|
1130
|
+
const module = new ModuleClass(TestModule, mockLoggerLayer);
|
|
1131
|
+
await Effect.runPromise(module.setup() as Effect.Effect<unknown, never, never>);
|
|
1132
|
+
|
|
1133
|
+
expect(depValueInInit).not.toBeNull();
|
|
1134
|
+
expect(depValueInInit as unknown as number).toBe(8080);
|
|
1135
|
+
});
|
|
854
1136
|
});
|
|
855
1137
|
|
|
856
1138
|
describe('Module DI scoping (exports only for cross-module)', () => {
|
package/src/module/module.ts
CHANGED
|
@@ -36,7 +36,11 @@ import {
|
|
|
36
36
|
hasBeforeApplicationDestroy,
|
|
37
37
|
hasOnApplicationDestroy,
|
|
38
38
|
} from './lifecycle';
|
|
39
|
-
import {
|
|
39
|
+
import {
|
|
40
|
+
BaseService,
|
|
41
|
+
getServiceMetadata,
|
|
42
|
+
getServiceTag,
|
|
43
|
+
} from './service';
|
|
40
44
|
|
|
41
45
|
/**
|
|
42
46
|
* Global services registry
|
|
@@ -290,13 +294,23 @@ export class OneBunModule implements ModuleInstance {
|
|
|
290
294
|
continue;
|
|
291
295
|
}
|
|
292
296
|
|
|
293
|
-
// Create service instance with resolved dependencies
|
|
297
|
+
// Create service instance with resolved dependencies.
|
|
298
|
+
// Set ambient init context so BaseService constructor can pick up logger/config,
|
|
299
|
+
// making them available immediately after super() in subclass constructors.
|
|
294
300
|
try {
|
|
295
301
|
const serviceConstructor = provider as new (...args: unknown[]) => unknown;
|
|
296
|
-
const serviceInstance = new serviceConstructor(...dependencies);
|
|
297
302
|
|
|
298
|
-
|
|
299
|
-
|
|
303
|
+
BaseService.setInitContext(this.logger, this.config);
|
|
304
|
+
let serviceInstance: unknown;
|
|
305
|
+
try {
|
|
306
|
+
serviceInstance = new serviceConstructor(...dependencies);
|
|
307
|
+
} finally {
|
|
308
|
+
BaseService.clearInitContext();
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
// Fallback: call initializeService for services that have it but were not
|
|
312
|
+
// initialized via the constructor (e.g., services not extending BaseService
|
|
313
|
+
// but implementing initializeService manually, or for backwards compatibility).
|
|
300
314
|
if (
|
|
301
315
|
serviceInstance &&
|
|
302
316
|
typeof serviceInstance === 'object' &&
|
|
@@ -555,7 +569,10 @@ export class OneBunModule implements ModuleInstance {
|
|
|
555
569
|
}
|
|
556
570
|
|
|
557
571
|
/**
|
|
558
|
-
* Call onModuleInit lifecycle hook for all services that implement it
|
|
572
|
+
* Call onModuleInit lifecycle hook for all services that implement it.
|
|
573
|
+
* Hooks are called sequentially in dependency order (dependencies first),
|
|
574
|
+
* so each service's onModuleInit completes before its dependents' onModuleInit starts.
|
|
575
|
+
* This is called for ALL services in providers, even if they are not injected anywhere.
|
|
559
576
|
*/
|
|
560
577
|
callServicesOnModuleInit(): Effect.Effect<unknown, never, void> {
|
|
561
578
|
if (this.pendingServiceInits.length === 0) {
|
|
@@ -564,25 +581,23 @@ export class OneBunModule implements ModuleInstance {
|
|
|
564
581
|
|
|
565
582
|
this.logger.debug(`Calling onModuleInit for ${this.pendingServiceInits.length} service(s)`);
|
|
566
583
|
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
584
|
+
return Effect.promise(async () => {
|
|
585
|
+
// Run onModuleInit hooks sequentially in dependency order
|
|
586
|
+
// (pendingServiceInits is already ordered: dependencies first)
|
|
587
|
+
for (const { name, instance } of this.pendingServiceInits) {
|
|
588
|
+
try {
|
|
589
|
+
if (hasOnModuleInit(instance)) {
|
|
590
|
+
await instance.onModuleInit();
|
|
591
|
+
}
|
|
592
|
+
this.logger.debug(`Service ${name} onModuleInit completed`);
|
|
593
|
+
} catch (error) {
|
|
594
|
+
this.logger.error(`Service ${name} onModuleInit failed: ${error}`);
|
|
595
|
+
throw error;
|
|
572
596
|
}
|
|
573
|
-
this.logger.debug(`Service ${name} onModuleInit completed`);
|
|
574
|
-
} catch (error) {
|
|
575
|
-
this.logger.error(`Service ${name} onModuleInit failed: ${error}`);
|
|
576
|
-
throw error;
|
|
577
597
|
}
|
|
598
|
+
// Clear the list after initialization
|
|
599
|
+
this.pendingServiceInits = [];
|
|
578
600
|
});
|
|
579
|
-
|
|
580
|
-
return Effect.promise(() => Promise.all(initPromises)).pipe(
|
|
581
|
-
Effect.map(() => {
|
|
582
|
-
// Clear the list after initialization
|
|
583
|
-
this.pendingServiceInits = [];
|
|
584
|
-
}),
|
|
585
|
-
);
|
|
586
601
|
}
|
|
587
602
|
|
|
588
603
|
/**
|
|
@@ -165,6 +165,111 @@ describe('BaseService', () => {
|
|
|
165
165
|
});
|
|
166
166
|
});
|
|
167
167
|
|
|
168
|
+
describe('Initialization via static init context (constructor)', () => {
|
|
169
|
+
test('should initialize service via static init context in constructor', () => {
|
|
170
|
+
class TestService extends BaseService {
|
|
171
|
+
configAvailableInConstructor = false;
|
|
172
|
+
loggerAvailableInConstructor = false;
|
|
173
|
+
|
|
174
|
+
constructor() {
|
|
175
|
+
super();
|
|
176
|
+
this.configAvailableInConstructor = this.config !== undefined;
|
|
177
|
+
this.loggerAvailableInConstructor = this.logger !== undefined;
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// Set init context before construction (as the framework does)
|
|
182
|
+
BaseService.setInitContext(mockLogger, mockConfig);
|
|
183
|
+
let service: TestService;
|
|
184
|
+
try {
|
|
185
|
+
service = new TestService();
|
|
186
|
+
} finally {
|
|
187
|
+
BaseService.clearInitContext();
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
expect(service.isInitialized).toBe(true);
|
|
191
|
+
expect(service.configAvailableInConstructor).toBe(true);
|
|
192
|
+
expect(service.loggerAvailableInConstructor).toBe(true);
|
|
193
|
+
expect((service as any).config).toBe(mockConfig);
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
test('should allow using config.get() in constructor when init context is set', () => {
|
|
197
|
+
class TestService extends BaseService {
|
|
198
|
+
readonly dbHost: string;
|
|
199
|
+
|
|
200
|
+
constructor() {
|
|
201
|
+
super();
|
|
202
|
+
this.dbHost = this.config.get('database.host') as string;
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
BaseService.setInitContext(mockLogger, mockConfig);
|
|
207
|
+
let service: TestService;
|
|
208
|
+
try {
|
|
209
|
+
service = new TestService();
|
|
210
|
+
} finally {
|
|
211
|
+
BaseService.clearInitContext();
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
expect(service.dbHost).toBe('localhost');
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
test('should create child logger with correct className in constructor', () => {
|
|
218
|
+
class MyCustomService extends BaseService {}
|
|
219
|
+
|
|
220
|
+
BaseService.setInitContext(mockLogger, mockConfig);
|
|
221
|
+
try {
|
|
222
|
+
new MyCustomService();
|
|
223
|
+
} finally {
|
|
224
|
+
BaseService.clearInitContext();
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
expect(mockLogger.child).toHaveBeenCalledWith({ className: 'MyCustomService' });
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
test('should not initialize if no init context is set', () => {
|
|
231
|
+
// Ensure context is clear
|
|
232
|
+
BaseService.clearInitContext();
|
|
233
|
+
|
|
234
|
+
class TestService extends BaseService {}
|
|
235
|
+
const service = new TestService();
|
|
236
|
+
|
|
237
|
+
expect(service.isInitialized).toBe(false);
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
test('initializeService should be a no-op if already initialized via init context', () => {
|
|
241
|
+
class TestService extends BaseService {}
|
|
242
|
+
|
|
243
|
+
BaseService.setInitContext(mockLogger, mockConfig);
|
|
244
|
+
let service: TestService;
|
|
245
|
+
try {
|
|
246
|
+
service = new TestService();
|
|
247
|
+
} finally {
|
|
248
|
+
BaseService.clearInitContext();
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
expect(service.isInitialized).toBe(true);
|
|
252
|
+
|
|
253
|
+
// Call initializeService again — should be a no-op
|
|
254
|
+
const otherLogger = { ...mockLogger, child: mock(() => ({ ...mockLogger })) };
|
|
255
|
+
const otherConfig = createMockConfig({ other: 'config' });
|
|
256
|
+
service.initializeService(otherLogger, otherConfig);
|
|
257
|
+
|
|
258
|
+
// Should still have original config (no reinit)
|
|
259
|
+
expect((service as any).config).toBe(mockConfig);
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
test('clearInitContext should prevent subsequent constructors from picking up context', () => {
|
|
263
|
+
BaseService.setInitContext(mockLogger, mockConfig);
|
|
264
|
+
BaseService.clearInitContext();
|
|
265
|
+
|
|
266
|
+
class TestService extends BaseService {}
|
|
267
|
+
const service = new TestService();
|
|
268
|
+
|
|
269
|
+
expect(service.isInitialized).toBe(false);
|
|
270
|
+
});
|
|
271
|
+
});
|
|
272
|
+
|
|
168
273
|
describe('Error handling edge cases', () => {
|
|
169
274
|
test('should handle complex effect scenarios', async () => {
|
|
170
275
|
class TestService extends BaseService {
|
package/src/module/service.ts
CHANGED
|
@@ -19,8 +19,9 @@ const META_SERVICES = new Map<
|
|
|
19
19
|
/**
|
|
20
20
|
* Service decorator
|
|
21
21
|
* Registers the class as a service with an optional Effect Context tag.
|
|
22
|
-
* Services extending BaseService will have logger and config
|
|
23
|
-
*
|
|
22
|
+
* Services extending BaseService will have logger and config available
|
|
23
|
+
* immediately after super() in the constructor (via ambient init context),
|
|
24
|
+
* as well as through the initializeService fallback method.
|
|
24
25
|
*
|
|
25
26
|
* @param tag - Optional Effect Context tag for the service
|
|
26
27
|
*/
|
|
@@ -62,7 +63,26 @@ export function getServiceTag<T>(serviceClass: new (...args: unknown[]) => T): C
|
|
|
62
63
|
}
|
|
63
64
|
|
|
64
65
|
/**
|
|
65
|
-
* Base service class that provides utility methods for working with Effect
|
|
66
|
+
* Base service class that provides utility methods for working with Effect.
|
|
67
|
+
*
|
|
68
|
+
* Services extending BaseService have `this.config` and `this.logger` available
|
|
69
|
+
* immediately after `super()` in the constructor when created through the framework DI.
|
|
70
|
+
* The framework sets an ambient init context before calling the constructor, and
|
|
71
|
+
* BaseService reads from it in `super()`.
|
|
72
|
+
*
|
|
73
|
+
* @example
|
|
74
|
+
* ```typescript
|
|
75
|
+
* @Service()
|
|
76
|
+
* class MyService extends BaseService {
|
|
77
|
+
* private readonly baseUrl: string;
|
|
78
|
+
*
|
|
79
|
+
* constructor(private dep: SomeDep) {
|
|
80
|
+
* super();
|
|
81
|
+
* // this.config and this.logger are available here!
|
|
82
|
+
* this.baseUrl = this.config.get('api.baseUrl');
|
|
83
|
+
* }
|
|
84
|
+
* }
|
|
85
|
+
* ```
|
|
66
86
|
*/
|
|
67
87
|
export class BaseService {
|
|
68
88
|
// Logger instance with service class name as context
|
|
@@ -73,12 +93,55 @@ export class BaseService {
|
|
|
73
93
|
private _initialized = false;
|
|
74
94
|
|
|
75
95
|
/**
|
|
76
|
-
*
|
|
96
|
+
* Ambient init context set by the framework before service construction.
|
|
97
|
+
* This allows BaseService constructor to pick up logger and config
|
|
98
|
+
* so they are available immediately after super() in subclass constructors.
|
|
99
|
+
* @internal
|
|
100
|
+
*/
|
|
101
|
+
private static _initContext: { logger: SyncLogger; config: IConfig<OneBunAppConfig> } | null =
|
|
102
|
+
null;
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Set the ambient init context before constructing a service.
|
|
106
|
+
* Called by the framework (OneBunModule) before `new ServiceClass(...)`.
|
|
107
|
+
* @internal
|
|
108
|
+
*/
|
|
109
|
+
static setInitContext(logger: SyncLogger, config: IConfig<OneBunAppConfig>): void {
|
|
110
|
+
BaseService._initContext = { logger, config };
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Clear the ambient init context after service construction.
|
|
115
|
+
* Called by the framework (OneBunModule) after `new ServiceClass(...)`.
|
|
116
|
+
* @internal
|
|
117
|
+
*/
|
|
118
|
+
static clearInitContext(): void {
|
|
119
|
+
BaseService._initContext = null;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
constructor() {
|
|
123
|
+
// Pick up logger and config from ambient init context if available.
|
|
124
|
+
// This makes this.config and this.logger available immediately after super()
|
|
125
|
+
// in subclass constructors.
|
|
126
|
+
if (BaseService._initContext) {
|
|
127
|
+
const { logger, config } = BaseService._initContext;
|
|
128
|
+
const className = this.constructor.name;
|
|
129
|
+
this.logger = logger.child({ className });
|
|
130
|
+
this.config = config;
|
|
131
|
+
this._initialized = true;
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Initialize service with logger and config (called by the framework).
|
|
137
|
+
* This is a fallback for services not constructed through the DI system
|
|
138
|
+
* (e.g., in tests or when created manually). If the service was already
|
|
139
|
+
* initialized via the constructor init context, this is a no-op.
|
|
77
140
|
* @internal
|
|
78
141
|
*/
|
|
79
142
|
initializeService(logger: SyncLogger, config: IConfig<OneBunAppConfig>): void {
|
|
80
143
|
if (this._initialized) {
|
|
81
|
-
return; // Already initialized
|
|
144
|
+
return; // Already initialized (via constructor or previous call)
|
|
82
145
|
}
|
|
83
146
|
|
|
84
147
|
const className = this.constructor.name;
|