@objectstack/core 0.6.1 → 0.7.1
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 +7 -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,412 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach } from 'vitest';
|
|
2
|
+
import { PluginLoader, ServiceLifecycle, PluginMetadata } from './plugin-loader';
|
|
3
|
+
import { createLogger } from './logger';
|
|
4
|
+
import type { Plugin } from './types';
|
|
5
|
+
|
|
6
|
+
describe('PluginLoader', () => {
|
|
7
|
+
let loader: PluginLoader;
|
|
8
|
+
|
|
9
|
+
beforeEach(() => {
|
|
10
|
+
const logger = createLogger({ level: 'error' }); // Suppress logs in tests
|
|
11
|
+
loader = new PluginLoader(logger);
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
describe('Plugin Loading', () => {
|
|
15
|
+
it('should load a valid plugin', async () => {
|
|
16
|
+
const plugin: Plugin = {
|
|
17
|
+
name: 'test-plugin',
|
|
18
|
+
version: '1.0.0',
|
|
19
|
+
init: async () => {},
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
const result = await loader.loadPlugin(plugin);
|
|
23
|
+
|
|
24
|
+
expect(result.success).toBe(true);
|
|
25
|
+
expect(result.plugin?.name).toBe('test-plugin');
|
|
26
|
+
expect(result.plugin?.version).toBe('1.0.0');
|
|
27
|
+
expect(result.loadTime).toBeGreaterThanOrEqual(0);
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it('should reject plugin with invalid name', async () => {
|
|
31
|
+
const plugin: Plugin = {
|
|
32
|
+
name: '',
|
|
33
|
+
init: async () => {},
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
const result = await loader.loadPlugin(plugin);
|
|
37
|
+
|
|
38
|
+
expect(result.success).toBe(false);
|
|
39
|
+
expect(result.error?.message).toContain('name is required');
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it('should reject plugin without init function', async () => {
|
|
43
|
+
const plugin: any = {
|
|
44
|
+
name: 'invalid-plugin',
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
const result = await loader.loadPlugin(plugin);
|
|
48
|
+
|
|
49
|
+
expect(result.success).toBe(false);
|
|
50
|
+
expect(result.error?.message).toContain('init function is required');
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it('should use default version 0.0.0 if not provided', async () => {
|
|
54
|
+
const plugin: Plugin = {
|
|
55
|
+
name: 'no-version',
|
|
56
|
+
init: async () => {},
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
const result = await loader.loadPlugin(plugin);
|
|
60
|
+
|
|
61
|
+
expect(result.success).toBe(true);
|
|
62
|
+
expect(result.plugin?.version).toBe('0.0.0');
|
|
63
|
+
});
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
describe('Version Compatibility', () => {
|
|
67
|
+
it('should accept valid semantic versions', async () => {
|
|
68
|
+
const validVersions = ['1.0.0', '2.3.4', '0.0.1', '10.20.30'];
|
|
69
|
+
|
|
70
|
+
for (const version of validVersions) {
|
|
71
|
+
const plugin: Plugin = {
|
|
72
|
+
name: `plugin-${version}`,
|
|
73
|
+
version,
|
|
74
|
+
init: async () => {},
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
const result = await loader.loadPlugin(plugin);
|
|
78
|
+
expect(result.success).toBe(true);
|
|
79
|
+
}
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it('should accept versions with pre-release tags', async () => {
|
|
83
|
+
const plugin: Plugin = {
|
|
84
|
+
name: 'prerelease',
|
|
85
|
+
version: '1.0.0-alpha.1',
|
|
86
|
+
init: async () => {},
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
const result = await loader.loadPlugin(plugin);
|
|
90
|
+
expect(result.success).toBe(true);
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it('should accept versions with build metadata', async () => {
|
|
94
|
+
const plugin: Plugin = {
|
|
95
|
+
name: 'build-meta',
|
|
96
|
+
version: '1.0.0+20230101',
|
|
97
|
+
init: async () => {},
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
const result = await loader.loadPlugin(plugin);
|
|
101
|
+
expect(result.success).toBe(true);
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it('should reject invalid semantic versions', async () => {
|
|
105
|
+
const invalidVersions = ['1.0', 'v1.0.0', '1', 'invalid'];
|
|
106
|
+
|
|
107
|
+
for (const version of invalidVersions) {
|
|
108
|
+
const plugin: Plugin = {
|
|
109
|
+
name: `invalid-${version}`,
|
|
110
|
+
version,
|
|
111
|
+
init: async () => {},
|
|
112
|
+
};
|
|
113
|
+
|
|
114
|
+
const result = await loader.loadPlugin(plugin);
|
|
115
|
+
expect(result.success).toBe(false);
|
|
116
|
+
}
|
|
117
|
+
});
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
describe('Service Factory Registration', () => {
|
|
121
|
+
it('should register a singleton service factory', () => {
|
|
122
|
+
let callCount = 0;
|
|
123
|
+
const factory = () => {
|
|
124
|
+
callCount++;
|
|
125
|
+
return { value: callCount };
|
|
126
|
+
};
|
|
127
|
+
|
|
128
|
+
loader.registerServiceFactory({
|
|
129
|
+
name: 'singleton-service',
|
|
130
|
+
factory,
|
|
131
|
+
lifecycle: ServiceLifecycle.SINGLETON,
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
expect(() => {
|
|
135
|
+
loader.registerServiceFactory({
|
|
136
|
+
name: 'singleton-service',
|
|
137
|
+
factory,
|
|
138
|
+
lifecycle: ServiceLifecycle.SINGLETON,
|
|
139
|
+
});
|
|
140
|
+
}).toThrow('already registered');
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
it('should register multiple service factories with different names', () => {
|
|
144
|
+
loader.registerServiceFactory({
|
|
145
|
+
name: 'service-1',
|
|
146
|
+
factory: () => ({ id: 1 }),
|
|
147
|
+
lifecycle: ServiceLifecycle.SINGLETON,
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
loader.registerServiceFactory({
|
|
151
|
+
name: 'service-2',
|
|
152
|
+
factory: () => ({ id: 2 }),
|
|
153
|
+
lifecycle: ServiceLifecycle.TRANSIENT,
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
// Should not throw
|
|
157
|
+
expect(true).toBe(true);
|
|
158
|
+
});
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
describe('Service Retrieval with Lifecycle', () => {
|
|
162
|
+
it('should create singleton service only once', async () => {
|
|
163
|
+
let callCount = 0;
|
|
164
|
+
loader.registerServiceFactory({
|
|
165
|
+
name: 'counter',
|
|
166
|
+
factory: () => {
|
|
167
|
+
callCount++;
|
|
168
|
+
return { count: callCount };
|
|
169
|
+
},
|
|
170
|
+
lifecycle: ServiceLifecycle.SINGLETON,
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
const service1 = await loader.getService('counter');
|
|
174
|
+
const service2 = await loader.getService('counter');
|
|
175
|
+
|
|
176
|
+
expect(callCount).toBe(1);
|
|
177
|
+
expect(service1).toBe(service2);
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
it('should create new transient service on each request', async () => {
|
|
181
|
+
let callCount = 0;
|
|
182
|
+
loader.registerServiceFactory({
|
|
183
|
+
name: 'transient',
|
|
184
|
+
factory: () => {
|
|
185
|
+
callCount++;
|
|
186
|
+
return { count: callCount };
|
|
187
|
+
},
|
|
188
|
+
lifecycle: ServiceLifecycle.TRANSIENT,
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
const service1 = await loader.getService('transient');
|
|
192
|
+
const service2 = await loader.getService('transient');
|
|
193
|
+
|
|
194
|
+
expect(callCount).toBe(2);
|
|
195
|
+
expect(service1).not.toBe(service2);
|
|
196
|
+
expect((service1 as any).count).toBe(1);
|
|
197
|
+
expect((service2 as any).count).toBe(2);
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
it('should create scoped service once per scope', async () => {
|
|
201
|
+
let callCount = 0;
|
|
202
|
+
loader.registerServiceFactory({
|
|
203
|
+
name: 'scoped',
|
|
204
|
+
factory: () => {
|
|
205
|
+
callCount++;
|
|
206
|
+
return { count: callCount };
|
|
207
|
+
},
|
|
208
|
+
lifecycle: ServiceLifecycle.SCOPED,
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
const scope1Service1 = await loader.getService('scoped', 'scope-1');
|
|
212
|
+
const scope1Service2 = await loader.getService('scoped', 'scope-1');
|
|
213
|
+
const scope2Service1 = await loader.getService('scoped', 'scope-2');
|
|
214
|
+
|
|
215
|
+
expect(callCount).toBe(2); // Once per scope
|
|
216
|
+
expect(scope1Service1).toBe(scope1Service2); // Same within scope
|
|
217
|
+
expect(scope1Service1).not.toBe(scope2Service1); // Different across scopes
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
it('should throw error for scoped service without scope ID', async () => {
|
|
221
|
+
loader.registerServiceFactory({
|
|
222
|
+
name: 'scoped-no-id',
|
|
223
|
+
factory: () => ({ value: 'test' }),
|
|
224
|
+
lifecycle: ServiceLifecycle.SCOPED,
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
await expect(async () => {
|
|
228
|
+
await loader.getService('scoped-no-id');
|
|
229
|
+
}).rejects.toThrow('Scope ID required');
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
it('should throw error for non-existent service', async () => {
|
|
233
|
+
await expect(async () => {
|
|
234
|
+
await loader.getService('non-existent');
|
|
235
|
+
}).rejects.toThrow('not found');
|
|
236
|
+
});
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
describe('Circular Dependency Detection', () => {
|
|
240
|
+
it('should detect simple circular dependency', () => {
|
|
241
|
+
loader.registerServiceFactory({
|
|
242
|
+
name: 'service-a',
|
|
243
|
+
factory: () => ({}),
|
|
244
|
+
lifecycle: ServiceLifecycle.SINGLETON,
|
|
245
|
+
dependencies: ['service-b'],
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
loader.registerServiceFactory({
|
|
249
|
+
name: 'service-b',
|
|
250
|
+
factory: () => ({}),
|
|
251
|
+
lifecycle: ServiceLifecycle.SINGLETON,
|
|
252
|
+
dependencies: ['service-a'],
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
const cycles = loader.detectCircularDependencies();
|
|
256
|
+
expect(cycles.length).toBeGreaterThan(0);
|
|
257
|
+
expect(cycles[0]).toContain('service-a');
|
|
258
|
+
expect(cycles[0]).toContain('service-b');
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
it('should detect complex circular dependency', () => {
|
|
262
|
+
loader.registerServiceFactory({
|
|
263
|
+
name: 'service-a',
|
|
264
|
+
factory: () => ({}),
|
|
265
|
+
lifecycle: ServiceLifecycle.SINGLETON,
|
|
266
|
+
dependencies: ['service-b'],
|
|
267
|
+
});
|
|
268
|
+
|
|
269
|
+
loader.registerServiceFactory({
|
|
270
|
+
name: 'service-b',
|
|
271
|
+
factory: () => ({}),
|
|
272
|
+
lifecycle: ServiceLifecycle.SINGLETON,
|
|
273
|
+
dependencies: ['service-c'],
|
|
274
|
+
});
|
|
275
|
+
|
|
276
|
+
loader.registerServiceFactory({
|
|
277
|
+
name: 'service-c',
|
|
278
|
+
factory: () => ({}),
|
|
279
|
+
lifecycle: ServiceLifecycle.SINGLETON,
|
|
280
|
+
dependencies: ['service-a'],
|
|
281
|
+
});
|
|
282
|
+
|
|
283
|
+
const cycles = loader.detectCircularDependencies();
|
|
284
|
+
expect(cycles.length).toBeGreaterThan(0);
|
|
285
|
+
});
|
|
286
|
+
|
|
287
|
+
it('should not report false positives for valid dependency chains', () => {
|
|
288
|
+
loader.registerServiceFactory({
|
|
289
|
+
name: 'service-a',
|
|
290
|
+
factory: () => ({}),
|
|
291
|
+
lifecycle: ServiceLifecycle.SINGLETON,
|
|
292
|
+
dependencies: ['service-b'],
|
|
293
|
+
});
|
|
294
|
+
|
|
295
|
+
loader.registerServiceFactory({
|
|
296
|
+
name: 'service-b',
|
|
297
|
+
factory: () => ({}),
|
|
298
|
+
lifecycle: ServiceLifecycle.SINGLETON,
|
|
299
|
+
dependencies: ['service-c'],
|
|
300
|
+
});
|
|
301
|
+
|
|
302
|
+
loader.registerServiceFactory({
|
|
303
|
+
name: 'service-c',
|
|
304
|
+
factory: () => ({}),
|
|
305
|
+
lifecycle: ServiceLifecycle.SINGLETON,
|
|
306
|
+
});
|
|
307
|
+
|
|
308
|
+
const cycles = loader.detectCircularDependencies();
|
|
309
|
+
expect(cycles.length).toBe(0);
|
|
310
|
+
});
|
|
311
|
+
});
|
|
312
|
+
|
|
313
|
+
describe('Plugin Health Checks', () => {
|
|
314
|
+
it('should return healthy for plugin without health check', async () => {
|
|
315
|
+
const plugin: Plugin = {
|
|
316
|
+
name: 'no-health-check',
|
|
317
|
+
version: '1.0.0',
|
|
318
|
+
init: async () => {},
|
|
319
|
+
};
|
|
320
|
+
|
|
321
|
+
await loader.loadPlugin(plugin);
|
|
322
|
+
const health = await loader.checkPluginHealth('no-health-check');
|
|
323
|
+
|
|
324
|
+
expect(health.healthy).toBe(true);
|
|
325
|
+
expect(health.message).toContain('No health check');
|
|
326
|
+
});
|
|
327
|
+
|
|
328
|
+
it('should execute plugin health check', async () => {
|
|
329
|
+
const plugin: PluginMetadata = {
|
|
330
|
+
name: 'with-health-check',
|
|
331
|
+
version: '1.0.0',
|
|
332
|
+
init: async () => {},
|
|
333
|
+
healthCheck: async () => ({
|
|
334
|
+
healthy: true,
|
|
335
|
+
message: 'All systems operational',
|
|
336
|
+
}),
|
|
337
|
+
};
|
|
338
|
+
|
|
339
|
+
await loader.loadPlugin(plugin);
|
|
340
|
+
const health = await loader.checkPluginHealth('with-health-check');
|
|
341
|
+
|
|
342
|
+
expect(health.healthy).toBe(true);
|
|
343
|
+
expect(health.message).toBe('All systems operational');
|
|
344
|
+
expect(health.lastCheck).toBeInstanceOf(Date);
|
|
345
|
+
});
|
|
346
|
+
|
|
347
|
+
it('should handle failing health check', async () => {
|
|
348
|
+
const plugin: PluginMetadata = {
|
|
349
|
+
name: 'failing-health',
|
|
350
|
+
version: '1.0.0',
|
|
351
|
+
init: async () => {},
|
|
352
|
+
healthCheck: async () => {
|
|
353
|
+
throw new Error('Service unavailable');
|
|
354
|
+
},
|
|
355
|
+
};
|
|
356
|
+
|
|
357
|
+
await loader.loadPlugin(plugin);
|
|
358
|
+
const health = await loader.checkPluginHealth('failing-health');
|
|
359
|
+
|
|
360
|
+
expect(health.healthy).toBe(false);
|
|
361
|
+
expect(health.message).toContain('Health check failed');
|
|
362
|
+
});
|
|
363
|
+
|
|
364
|
+
it('should return not found for unknown plugin', async () => {
|
|
365
|
+
const health = await loader.checkPluginHealth('unknown-plugin');
|
|
366
|
+
|
|
367
|
+
expect(health.healthy).toBe(false);
|
|
368
|
+
expect(health.message).toContain('not found');
|
|
369
|
+
});
|
|
370
|
+
});
|
|
371
|
+
|
|
372
|
+
describe('Scope Management', () => {
|
|
373
|
+
it('should clear scoped services', async () => {
|
|
374
|
+
let callCount = 0;
|
|
375
|
+
loader.registerServiceFactory({
|
|
376
|
+
name: 'scoped-clear',
|
|
377
|
+
factory: () => {
|
|
378
|
+
callCount++;
|
|
379
|
+
return { count: callCount };
|
|
380
|
+
},
|
|
381
|
+
lifecycle: ServiceLifecycle.SCOPED,
|
|
382
|
+
});
|
|
383
|
+
|
|
384
|
+
const service1 = await loader.getService('scoped-clear', 'scope-1');
|
|
385
|
+
expect((service1 as any).count).toBe(1);
|
|
386
|
+
|
|
387
|
+
loader.clearScope('scope-1');
|
|
388
|
+
|
|
389
|
+
const service2 = await loader.getService('scoped-clear', 'scope-1');
|
|
390
|
+
expect((service2 as any).count).toBe(2); // New instance created
|
|
391
|
+
});
|
|
392
|
+
});
|
|
393
|
+
|
|
394
|
+
describe('Static Service Registration', () => {
|
|
395
|
+
it('should register static service instance', () => {
|
|
396
|
+
const service = { value: 'test' };
|
|
397
|
+
loader.registerService('static-service', service);
|
|
398
|
+
|
|
399
|
+
expect(() => {
|
|
400
|
+
loader.registerService('static-service', service);
|
|
401
|
+
}).toThrow('already registered');
|
|
402
|
+
});
|
|
403
|
+
|
|
404
|
+
it('should retrieve static service', async () => {
|
|
405
|
+
const service = { value: 'static' };
|
|
406
|
+
loader.registerService('static', service);
|
|
407
|
+
|
|
408
|
+
const retrieved = await loader.getService('static');
|
|
409
|
+
expect(retrieved).toBe(service);
|
|
410
|
+
});
|
|
411
|
+
});
|
|
412
|
+
});
|