@objectstack/core 0.6.1 → 0.7.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +15 -0
- package/ENHANCED_FEATURES.md +380 -0
- package/README.md +299 -12
- package/dist/contracts/data-engine.d.ts +39 -22
- package/dist/contracts/data-engine.d.ts.map +1 -1
- package/dist/contracts/logger.d.ts +63 -0
- package/dist/contracts/logger.d.ts.map +1 -0
- package/dist/contracts/logger.js +1 -0
- package/dist/enhanced-kernel.d.ts +103 -0
- package/dist/enhanced-kernel.d.ts.map +1 -0
- package/dist/enhanced-kernel.js +403 -0
- package/dist/enhanced-kernel.test.d.ts +2 -0
- package/dist/enhanced-kernel.test.d.ts.map +1 -0
- package/dist/enhanced-kernel.test.js +412 -0
- package/dist/index.d.ts +11 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +10 -2
- package/dist/kernel-base.d.ts +84 -0
- package/dist/kernel-base.d.ts.map +1 -0
- package/dist/kernel-base.js +219 -0
- package/dist/kernel.d.ts +11 -18
- package/dist/kernel.d.ts.map +1 -1
- package/dist/kernel.js +43 -114
- package/dist/kernel.test.d.ts +2 -0
- package/dist/kernel.test.d.ts.map +1 -0
- package/dist/kernel.test.js +161 -0
- package/dist/logger.d.ts +70 -0
- package/dist/logger.d.ts.map +1 -0
- package/dist/logger.js +268 -0
- package/dist/logger.test.d.ts +2 -0
- package/dist/logger.test.d.ts.map +1 -0
- package/dist/logger.test.js +92 -0
- package/dist/plugin-loader.d.ts +148 -0
- package/dist/plugin-loader.d.ts.map +1 -0
- package/dist/plugin-loader.js +287 -0
- package/dist/plugin-loader.test.d.ts +2 -0
- package/dist/plugin-loader.test.d.ts.map +1 -0
- package/dist/plugin-loader.test.js +339 -0
- package/dist/types.d.ts +2 -1
- package/dist/types.d.ts.map +1 -1
- package/examples/enhanced-kernel-example.ts +309 -0
- package/package.json +19 -4
- package/src/contracts/data-engine.ts +46 -24
- package/src/contracts/logger.ts +70 -0
- package/src/enhanced-kernel.test.ts +535 -0
- package/src/enhanced-kernel.ts +496 -0
- package/src/index.ts +23 -2
- package/src/kernel-base.ts +256 -0
- package/src/kernel.test.ts +200 -0
- package/src/kernel.ts +55 -129
- package/src/logger.test.ts +116 -0
- package/src/logger.ts +306 -0
- package/src/plugin-loader.test.ts +412 -0
- package/src/plugin-loader.ts +435 -0
- package/src/types.ts +2 -1
- package/vitest.config.ts +8 -0
|
@@ -0,0 +1,287 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Service Lifecycle Types
|
|
3
|
+
* Defines how services are instantiated and managed
|
|
4
|
+
*/
|
|
5
|
+
export var ServiceLifecycle;
|
|
6
|
+
(function (ServiceLifecycle) {
|
|
7
|
+
/** Single instance shared across all requests */
|
|
8
|
+
ServiceLifecycle["SINGLETON"] = "singleton";
|
|
9
|
+
/** New instance created for each request */
|
|
10
|
+
ServiceLifecycle["TRANSIENT"] = "transient";
|
|
11
|
+
/** New instance per scope (e.g., per HTTP request) */
|
|
12
|
+
ServiceLifecycle["SCOPED"] = "scoped";
|
|
13
|
+
})(ServiceLifecycle || (ServiceLifecycle = {}));
|
|
14
|
+
/**
|
|
15
|
+
* Enhanced Plugin Loader
|
|
16
|
+
* Provides advanced plugin loading capabilities with validation, security, and lifecycle management
|
|
17
|
+
*/
|
|
18
|
+
export class PluginLoader {
|
|
19
|
+
constructor(logger) {
|
|
20
|
+
this.loadedPlugins = new Map();
|
|
21
|
+
this.serviceFactories = new Map();
|
|
22
|
+
this.serviceInstances = new Map();
|
|
23
|
+
this.scopedServices = new Map();
|
|
24
|
+
this.logger = logger;
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* Load a plugin asynchronously with validation
|
|
28
|
+
*/
|
|
29
|
+
async loadPlugin(plugin) {
|
|
30
|
+
const startTime = Date.now();
|
|
31
|
+
try {
|
|
32
|
+
this.logger.info(`Loading plugin: ${plugin.name}`);
|
|
33
|
+
// Convert to PluginMetadata
|
|
34
|
+
const metadata = this.toPluginMetadata(plugin);
|
|
35
|
+
// Validate plugin structure
|
|
36
|
+
this.validatePluginStructure(metadata);
|
|
37
|
+
// Check version compatibility
|
|
38
|
+
const versionCheck = this.checkVersionCompatibility(metadata);
|
|
39
|
+
if (!versionCheck.compatible) {
|
|
40
|
+
throw new Error(`Version incompatible: ${versionCheck.message}`);
|
|
41
|
+
}
|
|
42
|
+
// Validate configuration if schema is provided
|
|
43
|
+
if (metadata.configSchema) {
|
|
44
|
+
this.validatePluginConfig(metadata);
|
|
45
|
+
}
|
|
46
|
+
// Verify signature if provided
|
|
47
|
+
if (metadata.signature) {
|
|
48
|
+
await this.verifyPluginSignature(metadata);
|
|
49
|
+
}
|
|
50
|
+
// Store loaded plugin
|
|
51
|
+
this.loadedPlugins.set(metadata.name, metadata);
|
|
52
|
+
const loadTime = Date.now() - startTime;
|
|
53
|
+
this.logger.info(`Plugin loaded: ${plugin.name} (${loadTime}ms)`);
|
|
54
|
+
return {
|
|
55
|
+
success: true,
|
|
56
|
+
plugin: metadata,
|
|
57
|
+
loadTime,
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
catch (error) {
|
|
61
|
+
this.logger.error(`Failed to load plugin: ${plugin.name}`, error);
|
|
62
|
+
return {
|
|
63
|
+
success: false,
|
|
64
|
+
error: error,
|
|
65
|
+
loadTime: Date.now() - startTime,
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
/**
|
|
70
|
+
* Register a service with factory function
|
|
71
|
+
*/
|
|
72
|
+
registerServiceFactory(registration) {
|
|
73
|
+
if (this.serviceFactories.has(registration.name)) {
|
|
74
|
+
throw new Error(`Service factory '${registration.name}' already registered`);
|
|
75
|
+
}
|
|
76
|
+
this.serviceFactories.set(registration.name, registration);
|
|
77
|
+
this.logger.debug(`Service factory registered: ${registration.name} (${registration.lifecycle})`);
|
|
78
|
+
}
|
|
79
|
+
/**
|
|
80
|
+
* Get or create a service instance based on lifecycle type
|
|
81
|
+
*/
|
|
82
|
+
async getService(name, scopeId) {
|
|
83
|
+
const registration = this.serviceFactories.get(name);
|
|
84
|
+
if (!registration) {
|
|
85
|
+
// Fall back to static service instances
|
|
86
|
+
const instance = this.serviceInstances.get(name);
|
|
87
|
+
if (!instance) {
|
|
88
|
+
throw new Error(`Service '${name}' not found`);
|
|
89
|
+
}
|
|
90
|
+
return instance;
|
|
91
|
+
}
|
|
92
|
+
switch (registration.lifecycle) {
|
|
93
|
+
case ServiceLifecycle.SINGLETON:
|
|
94
|
+
return await this.getSingletonService(registration);
|
|
95
|
+
case ServiceLifecycle.TRANSIENT:
|
|
96
|
+
return await this.createTransientService(registration);
|
|
97
|
+
case ServiceLifecycle.SCOPED:
|
|
98
|
+
if (!scopeId) {
|
|
99
|
+
throw new Error(`Scope ID required for scoped service '${name}'`);
|
|
100
|
+
}
|
|
101
|
+
return await this.getScopedService(registration, scopeId);
|
|
102
|
+
default:
|
|
103
|
+
throw new Error(`Unknown service lifecycle: ${registration.lifecycle}`);
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
/**
|
|
107
|
+
* Register a static service instance (legacy support)
|
|
108
|
+
*/
|
|
109
|
+
registerService(name, service) {
|
|
110
|
+
if (this.serviceInstances.has(name)) {
|
|
111
|
+
throw new Error(`Service '${name}' already registered`);
|
|
112
|
+
}
|
|
113
|
+
this.serviceInstances.set(name, service);
|
|
114
|
+
}
|
|
115
|
+
/**
|
|
116
|
+
* Detect circular dependencies in service factories
|
|
117
|
+
* Note: This only detects cycles in service dependencies, not plugin dependencies.
|
|
118
|
+
* Plugin dependency cycles are detected in the kernel's resolveDependencies method.
|
|
119
|
+
*/
|
|
120
|
+
detectCircularDependencies() {
|
|
121
|
+
const cycles = [];
|
|
122
|
+
const visited = new Set();
|
|
123
|
+
const visiting = new Set();
|
|
124
|
+
const visit = (serviceName, path = []) => {
|
|
125
|
+
if (visiting.has(serviceName)) {
|
|
126
|
+
const cycle = [...path, serviceName].join(' -> ');
|
|
127
|
+
cycles.push(cycle);
|
|
128
|
+
return;
|
|
129
|
+
}
|
|
130
|
+
if (visited.has(serviceName)) {
|
|
131
|
+
return;
|
|
132
|
+
}
|
|
133
|
+
visiting.add(serviceName);
|
|
134
|
+
const registration = this.serviceFactories.get(serviceName);
|
|
135
|
+
if (registration?.dependencies) {
|
|
136
|
+
for (const dep of registration.dependencies) {
|
|
137
|
+
visit(dep, [...path, serviceName]);
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
visiting.delete(serviceName);
|
|
141
|
+
visited.add(serviceName);
|
|
142
|
+
};
|
|
143
|
+
for (const serviceName of this.serviceFactories.keys()) {
|
|
144
|
+
visit(serviceName);
|
|
145
|
+
}
|
|
146
|
+
return cycles;
|
|
147
|
+
}
|
|
148
|
+
/**
|
|
149
|
+
* Check plugin health
|
|
150
|
+
*/
|
|
151
|
+
async checkPluginHealth(pluginName) {
|
|
152
|
+
const plugin = this.loadedPlugins.get(pluginName);
|
|
153
|
+
if (!plugin) {
|
|
154
|
+
return {
|
|
155
|
+
healthy: false,
|
|
156
|
+
message: 'Plugin not found',
|
|
157
|
+
lastCheck: new Date(),
|
|
158
|
+
};
|
|
159
|
+
}
|
|
160
|
+
if (!plugin.healthCheck) {
|
|
161
|
+
return {
|
|
162
|
+
healthy: true,
|
|
163
|
+
message: 'No health check defined',
|
|
164
|
+
lastCheck: new Date(),
|
|
165
|
+
};
|
|
166
|
+
}
|
|
167
|
+
try {
|
|
168
|
+
const status = await plugin.healthCheck();
|
|
169
|
+
return {
|
|
170
|
+
...status,
|
|
171
|
+
lastCheck: new Date(),
|
|
172
|
+
};
|
|
173
|
+
}
|
|
174
|
+
catch (error) {
|
|
175
|
+
return {
|
|
176
|
+
healthy: false,
|
|
177
|
+
message: `Health check failed: ${error.message}`,
|
|
178
|
+
lastCheck: new Date(),
|
|
179
|
+
};
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
/**
|
|
183
|
+
* Clear scoped services for a scope
|
|
184
|
+
*/
|
|
185
|
+
clearScope(scopeId) {
|
|
186
|
+
this.scopedServices.delete(scopeId);
|
|
187
|
+
this.logger.debug(`Cleared scope: ${scopeId}`);
|
|
188
|
+
}
|
|
189
|
+
/**
|
|
190
|
+
* Get all loaded plugins
|
|
191
|
+
*/
|
|
192
|
+
getLoadedPlugins() {
|
|
193
|
+
return new Map(this.loadedPlugins);
|
|
194
|
+
}
|
|
195
|
+
// Private helper methods
|
|
196
|
+
toPluginMetadata(plugin) {
|
|
197
|
+
return {
|
|
198
|
+
...plugin,
|
|
199
|
+
version: plugin.version || '0.0.0',
|
|
200
|
+
};
|
|
201
|
+
}
|
|
202
|
+
validatePluginStructure(plugin) {
|
|
203
|
+
if (!plugin.name) {
|
|
204
|
+
throw new Error('Plugin name is required');
|
|
205
|
+
}
|
|
206
|
+
if (!plugin.init) {
|
|
207
|
+
throw new Error('Plugin init function is required');
|
|
208
|
+
}
|
|
209
|
+
if (!this.isValidSemanticVersion(plugin.version)) {
|
|
210
|
+
throw new Error(`Invalid semantic version: ${plugin.version}`);
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
checkVersionCompatibility(plugin) {
|
|
214
|
+
// Basic semantic version compatibility check
|
|
215
|
+
// In a real implementation, this would check against kernel version
|
|
216
|
+
const version = plugin.version;
|
|
217
|
+
if (!this.isValidSemanticVersion(version)) {
|
|
218
|
+
return {
|
|
219
|
+
compatible: false,
|
|
220
|
+
pluginVersion: version,
|
|
221
|
+
message: 'Invalid semantic version format',
|
|
222
|
+
};
|
|
223
|
+
}
|
|
224
|
+
return {
|
|
225
|
+
compatible: true,
|
|
226
|
+
pluginVersion: version,
|
|
227
|
+
};
|
|
228
|
+
}
|
|
229
|
+
isValidSemanticVersion(version) {
|
|
230
|
+
const semverRegex = /^\d+\.\d+\.\d+(-[a-zA-Z0-9.-]+)?(\+[a-zA-Z0-9.-]+)?$/;
|
|
231
|
+
return semverRegex.test(version);
|
|
232
|
+
}
|
|
233
|
+
validatePluginConfig(plugin) {
|
|
234
|
+
if (!plugin.configSchema) {
|
|
235
|
+
return;
|
|
236
|
+
}
|
|
237
|
+
// TODO: Configuration validation implementation
|
|
238
|
+
// This requires plugin config to be passed during loading
|
|
239
|
+
// For now, just validate that the schema exists
|
|
240
|
+
this.logger.debug(`Plugin ${plugin.name} has configuration schema (validation not yet implemented)`);
|
|
241
|
+
}
|
|
242
|
+
async verifyPluginSignature(plugin) {
|
|
243
|
+
if (!plugin.signature) {
|
|
244
|
+
return;
|
|
245
|
+
}
|
|
246
|
+
// TODO: Plugin signature verification implementation
|
|
247
|
+
// In a real implementation:
|
|
248
|
+
// 1. Extract public key from trusted source
|
|
249
|
+
// 2. Verify signature against plugin code hash
|
|
250
|
+
// 3. Throw error if verification fails
|
|
251
|
+
this.logger.debug(`Plugin ${plugin.name} signature verification (not yet implemented)`);
|
|
252
|
+
}
|
|
253
|
+
async getSingletonService(registration) {
|
|
254
|
+
let instance = this.serviceInstances.get(registration.name);
|
|
255
|
+
if (!instance) {
|
|
256
|
+
// Create instance (would need context)
|
|
257
|
+
instance = await this.createServiceInstance(registration);
|
|
258
|
+
this.serviceInstances.set(registration.name, instance);
|
|
259
|
+
this.logger.debug(`Singleton service created: ${registration.name}`);
|
|
260
|
+
}
|
|
261
|
+
return instance;
|
|
262
|
+
}
|
|
263
|
+
async createTransientService(registration) {
|
|
264
|
+
const instance = await this.createServiceInstance(registration);
|
|
265
|
+
this.logger.debug(`Transient service created: ${registration.name}`);
|
|
266
|
+
return instance;
|
|
267
|
+
}
|
|
268
|
+
async getScopedService(registration, scopeId) {
|
|
269
|
+
if (!this.scopedServices.has(scopeId)) {
|
|
270
|
+
this.scopedServices.set(scopeId, new Map());
|
|
271
|
+
}
|
|
272
|
+
const scope = this.scopedServices.get(scopeId);
|
|
273
|
+
let instance = scope.get(registration.name);
|
|
274
|
+
if (!instance) {
|
|
275
|
+
instance = await this.createServiceInstance(registration);
|
|
276
|
+
scope.set(registration.name, instance);
|
|
277
|
+
this.logger.debug(`Scoped service created: ${registration.name} (scope: ${scopeId})`);
|
|
278
|
+
}
|
|
279
|
+
return instance;
|
|
280
|
+
}
|
|
281
|
+
async createServiceInstance(registration) {
|
|
282
|
+
// This is a simplified version - in real implementation,
|
|
283
|
+
// we would need to pass proper context with resolved dependencies
|
|
284
|
+
const mockContext = {};
|
|
285
|
+
return await registration.factory(mockContext);
|
|
286
|
+
}
|
|
287
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"plugin-loader.test.d.ts","sourceRoot":"","sources":["../src/plugin-loader.test.ts"],"names":[],"mappings":""}
|
|
@@ -0,0 +1,339 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach } from 'vitest';
|
|
2
|
+
import { PluginLoader, ServiceLifecycle } from './plugin-loader';
|
|
3
|
+
import { createLogger } from './logger';
|
|
4
|
+
describe('PluginLoader', () => {
|
|
5
|
+
let loader;
|
|
6
|
+
beforeEach(() => {
|
|
7
|
+
const logger = createLogger({ level: 'error' }); // Suppress logs in tests
|
|
8
|
+
loader = new PluginLoader(logger);
|
|
9
|
+
});
|
|
10
|
+
describe('Plugin Loading', () => {
|
|
11
|
+
it('should load a valid plugin', async () => {
|
|
12
|
+
const plugin = {
|
|
13
|
+
name: 'test-plugin',
|
|
14
|
+
version: '1.0.0',
|
|
15
|
+
init: async () => { },
|
|
16
|
+
};
|
|
17
|
+
const result = await loader.loadPlugin(plugin);
|
|
18
|
+
expect(result.success).toBe(true);
|
|
19
|
+
expect(result.plugin?.name).toBe('test-plugin');
|
|
20
|
+
expect(result.plugin?.version).toBe('1.0.0');
|
|
21
|
+
expect(result.loadTime).toBeGreaterThanOrEqual(0);
|
|
22
|
+
});
|
|
23
|
+
it('should reject plugin with invalid name', async () => {
|
|
24
|
+
const plugin = {
|
|
25
|
+
name: '',
|
|
26
|
+
init: async () => { },
|
|
27
|
+
};
|
|
28
|
+
const result = await loader.loadPlugin(plugin);
|
|
29
|
+
expect(result.success).toBe(false);
|
|
30
|
+
expect(result.error?.message).toContain('name is required');
|
|
31
|
+
});
|
|
32
|
+
it('should reject plugin without init function', async () => {
|
|
33
|
+
const plugin = {
|
|
34
|
+
name: 'invalid-plugin',
|
|
35
|
+
};
|
|
36
|
+
const result = await loader.loadPlugin(plugin);
|
|
37
|
+
expect(result.success).toBe(false);
|
|
38
|
+
expect(result.error?.message).toContain('init function is required');
|
|
39
|
+
});
|
|
40
|
+
it('should use default version 0.0.0 if not provided', async () => {
|
|
41
|
+
const plugin = {
|
|
42
|
+
name: 'no-version',
|
|
43
|
+
init: async () => { },
|
|
44
|
+
};
|
|
45
|
+
const result = await loader.loadPlugin(plugin);
|
|
46
|
+
expect(result.success).toBe(true);
|
|
47
|
+
expect(result.plugin?.version).toBe('0.0.0');
|
|
48
|
+
});
|
|
49
|
+
});
|
|
50
|
+
describe('Version Compatibility', () => {
|
|
51
|
+
it('should accept valid semantic versions', async () => {
|
|
52
|
+
const validVersions = ['1.0.0', '2.3.4', '0.0.1', '10.20.30'];
|
|
53
|
+
for (const version of validVersions) {
|
|
54
|
+
const plugin = {
|
|
55
|
+
name: `plugin-${version}`,
|
|
56
|
+
version,
|
|
57
|
+
init: async () => { },
|
|
58
|
+
};
|
|
59
|
+
const result = await loader.loadPlugin(plugin);
|
|
60
|
+
expect(result.success).toBe(true);
|
|
61
|
+
}
|
|
62
|
+
});
|
|
63
|
+
it('should accept versions with pre-release tags', async () => {
|
|
64
|
+
const plugin = {
|
|
65
|
+
name: 'prerelease',
|
|
66
|
+
version: '1.0.0-alpha.1',
|
|
67
|
+
init: async () => { },
|
|
68
|
+
};
|
|
69
|
+
const result = await loader.loadPlugin(plugin);
|
|
70
|
+
expect(result.success).toBe(true);
|
|
71
|
+
});
|
|
72
|
+
it('should accept versions with build metadata', async () => {
|
|
73
|
+
const plugin = {
|
|
74
|
+
name: 'build-meta',
|
|
75
|
+
version: '1.0.0+20230101',
|
|
76
|
+
init: async () => { },
|
|
77
|
+
};
|
|
78
|
+
const result = await loader.loadPlugin(plugin);
|
|
79
|
+
expect(result.success).toBe(true);
|
|
80
|
+
});
|
|
81
|
+
it('should reject invalid semantic versions', async () => {
|
|
82
|
+
const invalidVersions = ['1.0', 'v1.0.0', '1', 'invalid'];
|
|
83
|
+
for (const version of invalidVersions) {
|
|
84
|
+
const plugin = {
|
|
85
|
+
name: `invalid-${version}`,
|
|
86
|
+
version,
|
|
87
|
+
init: async () => { },
|
|
88
|
+
};
|
|
89
|
+
const result = await loader.loadPlugin(plugin);
|
|
90
|
+
expect(result.success).toBe(false);
|
|
91
|
+
}
|
|
92
|
+
});
|
|
93
|
+
});
|
|
94
|
+
describe('Service Factory Registration', () => {
|
|
95
|
+
it('should register a singleton service factory', () => {
|
|
96
|
+
let callCount = 0;
|
|
97
|
+
const factory = () => {
|
|
98
|
+
callCount++;
|
|
99
|
+
return { value: callCount };
|
|
100
|
+
};
|
|
101
|
+
loader.registerServiceFactory({
|
|
102
|
+
name: 'singleton-service',
|
|
103
|
+
factory,
|
|
104
|
+
lifecycle: ServiceLifecycle.SINGLETON,
|
|
105
|
+
});
|
|
106
|
+
expect(() => {
|
|
107
|
+
loader.registerServiceFactory({
|
|
108
|
+
name: 'singleton-service',
|
|
109
|
+
factory,
|
|
110
|
+
lifecycle: ServiceLifecycle.SINGLETON,
|
|
111
|
+
});
|
|
112
|
+
}).toThrow('already registered');
|
|
113
|
+
});
|
|
114
|
+
it('should register multiple service factories with different names', () => {
|
|
115
|
+
loader.registerServiceFactory({
|
|
116
|
+
name: 'service-1',
|
|
117
|
+
factory: () => ({ id: 1 }),
|
|
118
|
+
lifecycle: ServiceLifecycle.SINGLETON,
|
|
119
|
+
});
|
|
120
|
+
loader.registerServiceFactory({
|
|
121
|
+
name: 'service-2',
|
|
122
|
+
factory: () => ({ id: 2 }),
|
|
123
|
+
lifecycle: ServiceLifecycle.TRANSIENT,
|
|
124
|
+
});
|
|
125
|
+
// Should not throw
|
|
126
|
+
expect(true).toBe(true);
|
|
127
|
+
});
|
|
128
|
+
});
|
|
129
|
+
describe('Service Retrieval with Lifecycle', () => {
|
|
130
|
+
it('should create singleton service only once', async () => {
|
|
131
|
+
let callCount = 0;
|
|
132
|
+
loader.registerServiceFactory({
|
|
133
|
+
name: 'counter',
|
|
134
|
+
factory: () => {
|
|
135
|
+
callCount++;
|
|
136
|
+
return { count: callCount };
|
|
137
|
+
},
|
|
138
|
+
lifecycle: ServiceLifecycle.SINGLETON,
|
|
139
|
+
});
|
|
140
|
+
const service1 = await loader.getService('counter');
|
|
141
|
+
const service2 = await loader.getService('counter');
|
|
142
|
+
expect(callCount).toBe(1);
|
|
143
|
+
expect(service1).toBe(service2);
|
|
144
|
+
});
|
|
145
|
+
it('should create new transient service on each request', async () => {
|
|
146
|
+
let callCount = 0;
|
|
147
|
+
loader.registerServiceFactory({
|
|
148
|
+
name: 'transient',
|
|
149
|
+
factory: () => {
|
|
150
|
+
callCount++;
|
|
151
|
+
return { count: callCount };
|
|
152
|
+
},
|
|
153
|
+
lifecycle: ServiceLifecycle.TRANSIENT,
|
|
154
|
+
});
|
|
155
|
+
const service1 = await loader.getService('transient');
|
|
156
|
+
const service2 = await loader.getService('transient');
|
|
157
|
+
expect(callCount).toBe(2);
|
|
158
|
+
expect(service1).not.toBe(service2);
|
|
159
|
+
expect(service1.count).toBe(1);
|
|
160
|
+
expect(service2.count).toBe(2);
|
|
161
|
+
});
|
|
162
|
+
it('should create scoped service once per scope', async () => {
|
|
163
|
+
let callCount = 0;
|
|
164
|
+
loader.registerServiceFactory({
|
|
165
|
+
name: 'scoped',
|
|
166
|
+
factory: () => {
|
|
167
|
+
callCount++;
|
|
168
|
+
return { count: callCount };
|
|
169
|
+
},
|
|
170
|
+
lifecycle: ServiceLifecycle.SCOPED,
|
|
171
|
+
});
|
|
172
|
+
const scope1Service1 = await loader.getService('scoped', 'scope-1');
|
|
173
|
+
const scope1Service2 = await loader.getService('scoped', 'scope-1');
|
|
174
|
+
const scope2Service1 = await loader.getService('scoped', 'scope-2');
|
|
175
|
+
expect(callCount).toBe(2); // Once per scope
|
|
176
|
+
expect(scope1Service1).toBe(scope1Service2); // Same within scope
|
|
177
|
+
expect(scope1Service1).not.toBe(scope2Service1); // Different across scopes
|
|
178
|
+
});
|
|
179
|
+
it('should throw error for scoped service without scope ID', async () => {
|
|
180
|
+
loader.registerServiceFactory({
|
|
181
|
+
name: 'scoped-no-id',
|
|
182
|
+
factory: () => ({ value: 'test' }),
|
|
183
|
+
lifecycle: ServiceLifecycle.SCOPED,
|
|
184
|
+
});
|
|
185
|
+
await expect(async () => {
|
|
186
|
+
await loader.getService('scoped-no-id');
|
|
187
|
+
}).rejects.toThrow('Scope ID required');
|
|
188
|
+
});
|
|
189
|
+
it('should throw error for non-existent service', async () => {
|
|
190
|
+
await expect(async () => {
|
|
191
|
+
await loader.getService('non-existent');
|
|
192
|
+
}).rejects.toThrow('not found');
|
|
193
|
+
});
|
|
194
|
+
});
|
|
195
|
+
describe('Circular Dependency Detection', () => {
|
|
196
|
+
it('should detect simple circular dependency', () => {
|
|
197
|
+
loader.registerServiceFactory({
|
|
198
|
+
name: 'service-a',
|
|
199
|
+
factory: () => ({}),
|
|
200
|
+
lifecycle: ServiceLifecycle.SINGLETON,
|
|
201
|
+
dependencies: ['service-b'],
|
|
202
|
+
});
|
|
203
|
+
loader.registerServiceFactory({
|
|
204
|
+
name: 'service-b',
|
|
205
|
+
factory: () => ({}),
|
|
206
|
+
lifecycle: ServiceLifecycle.SINGLETON,
|
|
207
|
+
dependencies: ['service-a'],
|
|
208
|
+
});
|
|
209
|
+
const cycles = loader.detectCircularDependencies();
|
|
210
|
+
expect(cycles.length).toBeGreaterThan(0);
|
|
211
|
+
expect(cycles[0]).toContain('service-a');
|
|
212
|
+
expect(cycles[0]).toContain('service-b');
|
|
213
|
+
});
|
|
214
|
+
it('should detect complex circular dependency', () => {
|
|
215
|
+
loader.registerServiceFactory({
|
|
216
|
+
name: 'service-a',
|
|
217
|
+
factory: () => ({}),
|
|
218
|
+
lifecycle: ServiceLifecycle.SINGLETON,
|
|
219
|
+
dependencies: ['service-b'],
|
|
220
|
+
});
|
|
221
|
+
loader.registerServiceFactory({
|
|
222
|
+
name: 'service-b',
|
|
223
|
+
factory: () => ({}),
|
|
224
|
+
lifecycle: ServiceLifecycle.SINGLETON,
|
|
225
|
+
dependencies: ['service-c'],
|
|
226
|
+
});
|
|
227
|
+
loader.registerServiceFactory({
|
|
228
|
+
name: 'service-c',
|
|
229
|
+
factory: () => ({}),
|
|
230
|
+
lifecycle: ServiceLifecycle.SINGLETON,
|
|
231
|
+
dependencies: ['service-a'],
|
|
232
|
+
});
|
|
233
|
+
const cycles = loader.detectCircularDependencies();
|
|
234
|
+
expect(cycles.length).toBeGreaterThan(0);
|
|
235
|
+
});
|
|
236
|
+
it('should not report false positives for valid dependency chains', () => {
|
|
237
|
+
loader.registerServiceFactory({
|
|
238
|
+
name: 'service-a',
|
|
239
|
+
factory: () => ({}),
|
|
240
|
+
lifecycle: ServiceLifecycle.SINGLETON,
|
|
241
|
+
dependencies: ['service-b'],
|
|
242
|
+
});
|
|
243
|
+
loader.registerServiceFactory({
|
|
244
|
+
name: 'service-b',
|
|
245
|
+
factory: () => ({}),
|
|
246
|
+
lifecycle: ServiceLifecycle.SINGLETON,
|
|
247
|
+
dependencies: ['service-c'],
|
|
248
|
+
});
|
|
249
|
+
loader.registerServiceFactory({
|
|
250
|
+
name: 'service-c',
|
|
251
|
+
factory: () => ({}),
|
|
252
|
+
lifecycle: ServiceLifecycle.SINGLETON,
|
|
253
|
+
});
|
|
254
|
+
const cycles = loader.detectCircularDependencies();
|
|
255
|
+
expect(cycles.length).toBe(0);
|
|
256
|
+
});
|
|
257
|
+
});
|
|
258
|
+
describe('Plugin Health Checks', () => {
|
|
259
|
+
it('should return healthy for plugin without health check', async () => {
|
|
260
|
+
const plugin = {
|
|
261
|
+
name: 'no-health-check',
|
|
262
|
+
version: '1.0.0',
|
|
263
|
+
init: async () => { },
|
|
264
|
+
};
|
|
265
|
+
await loader.loadPlugin(plugin);
|
|
266
|
+
const health = await loader.checkPluginHealth('no-health-check');
|
|
267
|
+
expect(health.healthy).toBe(true);
|
|
268
|
+
expect(health.message).toContain('No health check');
|
|
269
|
+
});
|
|
270
|
+
it('should execute plugin health check', async () => {
|
|
271
|
+
const plugin = {
|
|
272
|
+
name: 'with-health-check',
|
|
273
|
+
version: '1.0.0',
|
|
274
|
+
init: async () => { },
|
|
275
|
+
healthCheck: async () => ({
|
|
276
|
+
healthy: true,
|
|
277
|
+
message: 'All systems operational',
|
|
278
|
+
}),
|
|
279
|
+
};
|
|
280
|
+
await loader.loadPlugin(plugin);
|
|
281
|
+
const health = await loader.checkPluginHealth('with-health-check');
|
|
282
|
+
expect(health.healthy).toBe(true);
|
|
283
|
+
expect(health.message).toBe('All systems operational');
|
|
284
|
+
expect(health.lastCheck).toBeInstanceOf(Date);
|
|
285
|
+
});
|
|
286
|
+
it('should handle failing health check', async () => {
|
|
287
|
+
const plugin = {
|
|
288
|
+
name: 'failing-health',
|
|
289
|
+
version: '1.0.0',
|
|
290
|
+
init: async () => { },
|
|
291
|
+
healthCheck: async () => {
|
|
292
|
+
throw new Error('Service unavailable');
|
|
293
|
+
},
|
|
294
|
+
};
|
|
295
|
+
await loader.loadPlugin(plugin);
|
|
296
|
+
const health = await loader.checkPluginHealth('failing-health');
|
|
297
|
+
expect(health.healthy).toBe(false);
|
|
298
|
+
expect(health.message).toContain('Health check failed');
|
|
299
|
+
});
|
|
300
|
+
it('should return not found for unknown plugin', async () => {
|
|
301
|
+
const health = await loader.checkPluginHealth('unknown-plugin');
|
|
302
|
+
expect(health.healthy).toBe(false);
|
|
303
|
+
expect(health.message).toContain('not found');
|
|
304
|
+
});
|
|
305
|
+
});
|
|
306
|
+
describe('Scope Management', () => {
|
|
307
|
+
it('should clear scoped services', async () => {
|
|
308
|
+
let callCount = 0;
|
|
309
|
+
loader.registerServiceFactory({
|
|
310
|
+
name: 'scoped-clear',
|
|
311
|
+
factory: () => {
|
|
312
|
+
callCount++;
|
|
313
|
+
return { count: callCount };
|
|
314
|
+
},
|
|
315
|
+
lifecycle: ServiceLifecycle.SCOPED,
|
|
316
|
+
});
|
|
317
|
+
const service1 = await loader.getService('scoped-clear', 'scope-1');
|
|
318
|
+
expect(service1.count).toBe(1);
|
|
319
|
+
loader.clearScope('scope-1');
|
|
320
|
+
const service2 = await loader.getService('scoped-clear', 'scope-1');
|
|
321
|
+
expect(service2.count).toBe(2); // New instance created
|
|
322
|
+
});
|
|
323
|
+
});
|
|
324
|
+
describe('Static Service Registration', () => {
|
|
325
|
+
it('should register static service instance', () => {
|
|
326
|
+
const service = { value: 'test' };
|
|
327
|
+
loader.registerService('static-service', service);
|
|
328
|
+
expect(() => {
|
|
329
|
+
loader.registerService('static-service', service);
|
|
330
|
+
}).toThrow('already registered');
|
|
331
|
+
});
|
|
332
|
+
it('should retrieve static service', async () => {
|
|
333
|
+
const service = { value: 'static' };
|
|
334
|
+
loader.registerService('static', service);
|
|
335
|
+
const retrieved = await loader.getService('static');
|
|
336
|
+
expect(retrieved).toBe(service);
|
|
337
|
+
});
|
|
338
|
+
});
|
|
339
|
+
});
|
package/dist/types.d.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { ObjectKernel } from './kernel.js';
|
|
2
|
+
import type { Logger } from '@objectstack/spec/contracts';
|
|
2
3
|
/**
|
|
3
4
|
* PluginContext - Runtime context available to plugins
|
|
4
5
|
*
|
|
@@ -41,7 +42,7 @@ export interface PluginContext {
|
|
|
41
42
|
/**
|
|
42
43
|
* Logger instance
|
|
43
44
|
*/
|
|
44
|
-
logger:
|
|
45
|
+
logger: Logger;
|
|
45
46
|
/**
|
|
46
47
|
* Get the kernel instance (for advanced use cases)
|
|
47
48
|
* @returns Kernel instance
|
package/dist/types.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,YAAY,EAAE,MAAM,aAAa,CAAC;
|
|
1
|
+
{"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,YAAY,EAAE,MAAM,aAAa,CAAC;AAC3C,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,6BAA6B,CAAC;AAE1D;;;;;;;;GAQG;AACH,MAAM,WAAW,aAAa;IAC1B;;;;OAIG;IACH,eAAe,CAAC,IAAI,EAAE,MAAM,EAAE,OAAO,EAAE,GAAG,GAAG,IAAI,CAAC;IAElD;;;;;OAKG;IACH,UAAU,CAAC,CAAC,EAAE,IAAI,EAAE,MAAM,GAAG,CAAC,CAAC;IAE/B;;OAEG;IACH,WAAW,IAAI,GAAG,CAAC,MAAM,EAAE,GAAG,CAAC,CAAC;IAEhC;;;;OAIG;IACH,IAAI,CAAC,IAAI,EAAE,MAAM,EAAE,OAAO,EAAE,CAAC,GAAG,IAAI,EAAE,GAAG,EAAE,KAAK,IAAI,GAAG,OAAO,CAAC,IAAI,CAAC,GAAG,IAAI,CAAC;IAE5E;;;;OAIG;IACH,OAAO,CAAC,IAAI,EAAE,MAAM,EAAE,GAAG,IAAI,EAAE,GAAG,EAAE,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;IAErD;;OAEG;IACH,MAAM,EAAE,MAAM,CAAC;IAEf;;;OAGG;IACH,SAAS,IAAI,YAAY,CAAC;CAC7B;AAED;;;;GAIG;AACH,MAAM,WAAW,MAAM;IACnB;;OAEG;IACH,IAAI,EAAE,MAAM,CAAC;IAEb;;OAEG;IACH,OAAO,CAAC,EAAE,MAAM,CAAC;IAEjB;;;OAGG;IACH,YAAY,CAAC,EAAE,MAAM,EAAE,CAAC;IAExB;;;;OAIG;IACH,IAAI,CAAC,GAAG,EAAE,aAAa,GAAG,OAAO,CAAC,IAAI,CAAC,GAAG,IAAI,CAAC;IAE/C;;;;OAIG;IACH,KAAK,CAAC,CAAC,GAAG,EAAE,aAAa,GAAG,OAAO,CAAC,IAAI,CAAC,GAAG,IAAI,CAAC;IAEjD;;;OAGG;IACH,OAAO,CAAC,IAAI,OAAO,CAAC,IAAI,CAAC,GAAG,IAAI,CAAC;CACpC"}
|