@martel/calyx 1.11.0 → 1.13.0

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.
Files changed (47) hide show
  1. package/CHANGELOG.md +15 -0
  2. package/package.json +1 -1
  3. package/src/cache/cache.interceptor.ts +4 -2
  4. package/src/cache/decorators.ts +4 -0
  5. package/src/cache/index.ts +1 -0
  6. package/src/cli/index.ts +7 -1
  7. package/src/config/config.module.ts +16 -2
  8. package/src/config/config.service.ts +20 -6
  9. package/src/core/container.ts +559 -140
  10. package/src/core/index.ts +2 -0
  11. package/src/core/lazy-module-loader.ts +29 -0
  12. package/src/core/metadata.ts +6 -1
  13. package/src/core/testing-module.ts +123 -0
  14. package/src/cqrs/cqrs.ts +264 -0
  15. package/src/database/sequelize.module.ts +239 -0
  16. package/src/event-emitter/decorators.ts +2 -2
  17. package/src/event-emitter/event-emitter.ts +3 -0
  18. package/src/graphql/decorators.ts +16 -0
  19. package/src/graphql/graphql.module.ts +16 -0
  20. package/src/http/application.ts +261 -21
  21. package/src/http/decorators.ts +25 -1
  22. package/src/http/exceptions.ts +97 -0
  23. package/src/http/factory.ts +3 -0
  24. package/src/http/router.ts +27 -4
  25. package/src/index.ts +3 -0
  26. package/src/microservices/clients.module.ts +47 -0
  27. package/src/microservices/exceptions.ts +10 -0
  28. package/src/microservices/index.ts +2 -0
  29. package/src/microservices/microservice.ts +1 -1
  30. package/src/queue/queue.module.ts +73 -5
  31. package/src/schedule/decorators.ts +10 -6
  32. package/src/schedule/index.ts +1 -0
  33. package/src/schedule/schedule.module.ts +3 -2
  34. package/src/schedule/scheduler-registry.ts +50 -0
  35. package/src/security/index.ts +1 -0
  36. package/src/security/throttler.module.ts +108 -0
  37. package/src/terminus/terminus.ts +134 -0
  38. package/src/validation/compiler.ts +133 -10
  39. package/src/validation/decorators.ts +164 -2
  40. package/src/validation/http-pipes.ts +128 -0
  41. package/src/validation/index.ts +1 -0
  42. package/src/websockets/decorators.ts +12 -2
  43. package/src/websockets/exceptions.ts +10 -0
  44. package/src/websockets/index.ts +1 -0
  45. package/tests/circular-di.test.ts +151 -0
  46. package/tests/di.test.ts +10 -2
  47. package/tests/nestjs-parity.test.ts +527 -0
package/tests/di.test.ts CHANGED
@@ -167,15 +167,17 @@ describe('Dependency Injection System', () => {
167
167
  expect(() => container.bootstrap(RootModule)).toThrow(/Cannot resolve dependency/);
168
168
  });
169
169
 
170
- test('should detect circular dependencies and throw error', () => {
170
+ test('should resolve circular dependencies using lazy proxies', () => {
171
171
  @Injectable()
172
172
  class ServiceA {
173
173
  constructor(@Inject(forwardRef(() => ServiceB)) public b: any) {}
174
+ getValue() { return 'A'; }
174
175
  }
175
176
 
176
177
  @Injectable()
177
178
  class ServiceB {
178
179
  constructor(@Inject(forwardRef(() => ServiceA)) public a: any) {}
180
+ getValue() { return 'B'; }
179
181
  }
180
182
 
181
183
  @Module({
@@ -184,7 +186,13 @@ describe('Dependency Injection System', () => {
184
186
  class RootModule {}
185
187
 
186
188
  const container = new CalyxContainer();
187
- expect(() => container.bootstrap(RootModule)).toThrow(/Circular dependency detected/);
189
+ container.bootstrap(RootModule);
190
+
191
+ const a = container.getGlobalOrAnyInstance(ServiceA);
192
+ const b = container.getGlobalOrAnyInstance(ServiceB);
193
+
194
+ expect(a.b.getValue()).toBe('B');
195
+ expect(b.a.getValue()).toBe('A');
188
196
  });
189
197
 
190
198
  test('should isolate module scopes and resolve exported providers', () => {
@@ -0,0 +1,527 @@
1
+ import { expect, test, describe } from 'bun:test';
2
+ import { filter, map } from 'rxjs/operators';
3
+ import { Observable } from 'rxjs';
4
+ import {
5
+ Test,
6
+ LazyModuleLoader,
7
+ Injectable,
8
+ Module,
9
+ ParseIntPipe,
10
+ ParseBoolPipe,
11
+ CalyxResponse,
12
+ SchedulerRegistry,
13
+ ScheduleModule,
14
+ Cron,
15
+ Interval,
16
+ CacheKey,
17
+ CacheTTL,
18
+ Client,
19
+ ClientsModule,
20
+ HealthCheckService,
21
+ TerminusModule,
22
+ CqrsModule,
23
+ CommandBus,
24
+ QueryBus,
25
+ EventBus,
26
+ CommandHandler,
27
+ QueryHandler,
28
+ EventsHandler,
29
+ ICommand,
30
+ IQuery,
31
+ IEvent,
32
+ ICommandHandler,
33
+ IQueryHandler,
34
+ IEventHandler,
35
+ ConflictException,
36
+ ServiceUnavailableException,
37
+ UnprocessableEntityException,
38
+ WsException,
39
+ RpcException,
40
+ IsUUID,
41
+ Min,
42
+ Max,
43
+ IsBoolean,
44
+ Length,
45
+ ValidationCompiler,
46
+ registerAs,
47
+ ConfigModule,
48
+ SequelizeModule,
49
+ SequelizeModel,
50
+ BullModule,
51
+ UnhandledExceptionBus,
52
+ EventPublisher,
53
+ AggregateRoot,
54
+ Saga,
55
+ MemoryHealthIndicator,
56
+ TypeOrmHealthIndicator,
57
+ } from '../src/index.ts';
58
+
59
+ @Injectable()
60
+ class DummyService {
61
+ getValue() { return 'dummy'; }
62
+ }
63
+
64
+ @Injectable()
65
+ class AliasService {
66
+ constructor(public readonly dummy: DummyService) {}
67
+ }
68
+
69
+ @Module({
70
+ providers: [
71
+ DummyService,
72
+ {
73
+ provide: 'ALIAS_TOKEN',
74
+ useExisting: DummyService,
75
+ },
76
+ AliasService,
77
+ ],
78
+ })
79
+ class TestParityModule {}
80
+
81
+ describe('NestJS Parity Extensions', () => {
82
+
83
+ test('DI: useExisting alias provider resolution', async () => {
84
+ const moduleRef = await Test.createTestingModule({
85
+ imports: [TestParityModule],
86
+ }).compile();
87
+
88
+ const dummy = moduleRef.get(DummyService);
89
+ const alias = moduleRef.get('ALIAS_TOKEN');
90
+ expect(dummy).toBe(alias);
91
+ expect(dummy.getValue()).toBe('dummy');
92
+ await moduleRef.close();
93
+ });
94
+
95
+ test('DI: Test/TestingModule overrides', async () => {
96
+ const moduleRef = await Test.createTestingModule({
97
+ imports: [TestParityModule],
98
+ })
99
+ .overrideProvider(DummyService)
100
+ .useValue({ getValue: () => 'mocked' })
101
+ .compile();
102
+
103
+ const dummy = moduleRef.get(DummyService);
104
+ expect(dummy.getValue()).toBe('mocked');
105
+ await moduleRef.close();
106
+ });
107
+
108
+ test('DI: LazyModuleLoader', async () => {
109
+ const moduleRef = await Test.createTestingModule({
110
+ imports: [],
111
+ providers: [LazyModuleLoader],
112
+ }).compile();
113
+
114
+ const loader = moduleRef.get(LazyModuleLoader);
115
+ const lazyModuleRef = await loader.load(() => Promise.resolve(TestParityModule));
116
+ expect(lazyModuleRef).toBeDefined();
117
+
118
+ const dummy = lazyModuleRef.get(DummyService);
119
+ expect(dummy.getValue()).toBe('dummy');
120
+ await moduleRef.close();
121
+ });
122
+
123
+ test('Pipes: built-in parsing pipes', () => {
124
+ const intPipe = new ParseIntPipe();
125
+ expect(intPipe.transform('42', { type: 'query', data: 'id' })).toBe(42);
126
+ expect(() => intPipe.transform('abc', { type: 'query', data: 'id' })).toThrow();
127
+
128
+ const boolPipe = new ParseBoolPipe();
129
+ expect(boolPipe.transform('true', { type: 'query', data: 'flag' })).toBe(true);
130
+ expect(boolPipe.transform('false', { type: 'query', data: 'flag' })).toBe(false);
131
+ expect(() => boolPipe.transform('abc', { type: 'query', data: 'flag' })).toThrow();
132
+ });
133
+
134
+ test('CalyxResponse compatibility methods', () => {
135
+ const res = new CalyxResponse();
136
+ res.header('X-Test', 'value')
137
+ .type('text/html')
138
+ .cookie('cookie_name', 'cookie_val')
139
+ .append('X-Test', 'another');
140
+
141
+ expect(res.get('x-test')).toBe('value, another');
142
+ expect(res.get('content-type')).toBe('text/html');
143
+ });
144
+
145
+ test('SchedulerRegistry named dynamic tasks', async () => {
146
+ @Injectable()
147
+ class ScheduledTasks {
148
+ @Cron('* * * * * *', { name: 'my-cron' })
149
+ runCron() {}
150
+
151
+ @Interval('my-interval', 1000)
152
+ runInterval() {}
153
+ }
154
+
155
+ @Module({
156
+ imports: [ScheduleModule.forRoot()],
157
+ providers: [ScheduledTasks],
158
+ })
159
+ class RootScheduleModule {}
160
+
161
+ const moduleRef = await Test.createTestingModule({
162
+ imports: [RootScheduleModule],
163
+ }).compile();
164
+
165
+ const app = moduleRef.createCalyxApplication();
166
+ await app.init();
167
+
168
+ const registry = moduleRef.get(SchedulerRegistry);
169
+ expect(registry.getCronJob('my-cron')).toBeDefined();
170
+ expect(registry.getInterval('my-interval')).toBeDefined();
171
+
172
+ registry.deleteCronJob('my-cron');
173
+ registry.deleteInterval('my-interval');
174
+
175
+ expect(() => registry.getCronJob('my-cron')).toThrow();
176
+ expect(() => registry.getInterval('my-interval')).toThrow();
177
+
178
+ await app.close();
179
+ await moduleRef.close();
180
+ });
181
+
182
+ test('CacheInterceptor key/ttl retrieval', () => {
183
+ class Target {
184
+ @CacheKey('custom_key')
185
+ @CacheTTL(100)
186
+ handler() {}
187
+ }
188
+
189
+ const t = new Target();
190
+ const key = Reflect.getMetadata('cache_metadata_key', t.handler);
191
+ const ttl = Reflect.getMetadata('cache_metadata_ttl', t.handler);
192
+
193
+ expect(key).toBe('custom_key');
194
+ expect(ttl).toBe(100);
195
+ });
196
+
197
+ test('Microservice clients: @Client and ClientsModule', async () => {
198
+ class Target {
199
+ @Client({ options: { host: '127.0.0.1', port: 1234 } })
200
+ client: any;
201
+ }
202
+
203
+ const target = new Target();
204
+ expect(target.client).toBeDefined();
205
+
206
+ const moduleRef = await Test.createTestingModule({
207
+ imports: [
208
+ ClientsModule.register([
209
+ { name: 'TEST_SERVICE', options: { host: 'localhost', port: 5000 } },
210
+ ]),
211
+ ],
212
+ }).compile();
213
+
214
+ const service = moduleRef.get('TEST_SERVICE');
215
+ expect(service).toBeDefined();
216
+ await moduleRef.close();
217
+ });
218
+
219
+ test('CQRS command/query/event buses', async () => {
220
+ class MyCommand implements ICommand {}
221
+ class MyQuery implements IQuery {}
222
+ class MyEvent implements IEvent {}
223
+
224
+ let commandHandled = false;
225
+ let queryHandled = false;
226
+ let eventHandledCount = 0;
227
+
228
+ @CommandHandler(MyCommand)
229
+ class MyCommandHandler implements ICommandHandler<MyCommand> {
230
+ async execute(command: MyCommand) {
231
+ commandHandled = true;
232
+ return 'command-result';
233
+ }
234
+ }
235
+
236
+ @QueryHandler(MyQuery)
237
+ class MyQueryHandler implements IQueryHandler<MyQuery> {
238
+ async execute(query: MyQuery) {
239
+ queryHandled = true;
240
+ return 'query-result';
241
+ }
242
+ }
243
+
244
+ @EventsHandler(MyEvent)
245
+ class MyEventHandler implements IEventHandler<MyEvent> {
246
+ handle(event: MyEvent) {
247
+ eventHandledCount++;
248
+ }
249
+ }
250
+
251
+ const moduleRef = await Test.createTestingModule({
252
+ imports: [CqrsModule],
253
+ providers: [MyCommandHandler, MyQueryHandler, MyEventHandler],
254
+ }).compile();
255
+
256
+ const commandBus = moduleRef.get(CommandBus);
257
+ const queryBus = moduleRef.get(QueryBus);
258
+ const eventBus = moduleRef.get(EventBus);
259
+
260
+ const cqrsModule = moduleRef.get(CqrsModule);
261
+ (cqrsModule as any).onModuleInit();
262
+
263
+ const cmdRes = await commandBus.execute(new MyCommand());
264
+ expect(cmdRes).toBe('command-result');
265
+ expect(commandHandled).toBe(true);
266
+
267
+ const qryRes = await queryBus.execute(new MyQuery());
268
+ expect(qryRes).toBe('query-result');
269
+ expect(queryHandled).toBe(true);
270
+
271
+ eventBus.publish(new MyEvent());
272
+ expect(eventHandledCount).toBe(1);
273
+
274
+ await moduleRef.close();
275
+ });
276
+
277
+ test('Terminus health check service', async () => {
278
+ const moduleRef = await Test.createTestingModule({
279
+ imports: [TerminusModule],
280
+ }).compile();
281
+
282
+ const health = moduleRef.get(HealthCheckService);
283
+ const res = await health.check([
284
+ () => ({ db: { status: 'up' } }),
285
+ ]);
286
+
287
+ expect(res.status).toBe('ok');
288
+ expect(res.details.db.status).toBe('up');
289
+
290
+ expect(health.check([
291
+ () => { throw new Error('DB connection lost'); }
292
+ ])).rejects.toThrow();
293
+
294
+ await moduleRef.close();
295
+ });
296
+
297
+ test('HTTP Exceptions: check new status codes', () => {
298
+ const exc1 = new ConflictException();
299
+ expect(exc1.getStatus()).toBe(409);
300
+
301
+ const exc2 = new ServiceUnavailableException();
302
+ expect(exc2.getStatus()).toBe(503);
303
+
304
+ const exc3 = new UnprocessableEntityException();
305
+ expect(exc3.getStatus()).toBe(422);
306
+
307
+ const wsExc = new WsException('error');
308
+ expect(wsExc.getError()).toBe('error');
309
+
310
+ const rpcExc = new RpcException('error');
311
+ expect(rpcExc.getError()).toBe('error');
312
+ });
313
+
314
+ test('JIT Validation: check advanced decorators', () => {
315
+ class DTO {
316
+ @IsUUID()
317
+ uuid!: string;
318
+
319
+ @Min(10)
320
+ minVal!: number;
321
+
322
+ @Max(50)
323
+ maxVal!: number;
324
+
325
+ @IsBoolean()
326
+ boolVal!: boolean;
327
+
328
+ @Length(3, 8)
329
+ strLen!: string;
330
+ }
331
+
332
+ const validate = ValidationCompiler.compile(DTO);
333
+
334
+ const errs1 = validate({
335
+ uuid: 'abc',
336
+ minVal: 5,
337
+ maxVal: 60,
338
+ boolVal: 'not-bool' as any,
339
+ strLen: 'hi',
340
+ });
341
+ expect(errs1).not.toBeNull();
342
+ expect(errs1!.length).toBe(5);
343
+
344
+ const errs2 = validate({
345
+ uuid: '123e4567-e89b-12d3-a456-426614174000',
346
+ minVal: 15,
347
+ maxVal: 40,
348
+ boolVal: true,
349
+ strLen: 'hello',
350
+ });
351
+ expect(errs2).toBeNull();
352
+ });
353
+
354
+ test('ConfigNamespaces: registerAs and dot-notation', () => {
355
+ const dbConfig = registerAs('database', () => ({
356
+ host: 'my-host',
357
+ port: 5432,
358
+ }));
359
+
360
+ const module = ConfigModule.forRoot({
361
+ load: [dbConfig],
362
+ });
363
+
364
+ const service = (module.providers[0] as any).useValue;
365
+ expect(service.get('database.host')).toBe('my-host');
366
+ expect(service.get('database.port')).toBe(5432);
367
+ });
368
+
369
+ test('SequelizeModule: check database fallback model', async () => {
370
+ class User extends SequelizeModel {
371
+ name!: string;
372
+ }
373
+
374
+ const moduleRef = await Test.createTestingModule({
375
+ imports: [
376
+ SequelizeModule.forRoot({ storage: ':memory:' }),
377
+ SequelizeModule.forFeature([User]),
378
+ ],
379
+ }).compile();
380
+
381
+ const injectedModel = moduleRef.get('Sequelize_Model_User') as any;
382
+ expect(injectedModel).toBeDefined();
383
+
384
+ const user = await User.create({ name: 'Alice' });
385
+ expect(user.id).toBeDefined();
386
+ expect(user.name).toBe('Alice');
387
+
388
+ const found = await User.findByPk(user.id);
389
+ expect(found).not.toBeNull();
390
+ expect(found.name).toBe('Alice');
391
+
392
+ await user.update({ name: 'Bob' });
393
+ const found2 = await User.findByPk(user.id);
394
+ expect(found2.name).toBe('Bob');
395
+
396
+ await user.destroy();
397
+ const found3 = await User.findByPk(user.id);
398
+ expect(found3).toBeNull();
399
+
400
+ await moduleRef.close();
401
+ });
402
+
403
+ test('BullModule: check root registry and compatibility methods', async () => {
404
+ const moduleRef = await Test.createTestingModule({
405
+ imports: [
406
+ BullModule.forRoot({}),
407
+ BullModule.registerQueue({ name: 'audio' }),
408
+ ],
409
+ }).compile();
410
+
411
+ const queue = moduleRef.get('Queue_audio') as any;
412
+ expect(queue).toBeDefined();
413
+ expect(queue.pause).toBeDefined();
414
+ expect(queue.resume).toBeDefined();
415
+
416
+ await queue.pause();
417
+ expect(await queue.isPaused()).toBe(true);
418
+ await queue.resume();
419
+ expect(await queue.isPaused()).toBe(false);
420
+
421
+ const job = await queue.add('test-job', { foo: 'bar' });
422
+ expect(job.id).toBeDefined();
423
+ expect(await queue.count()).toBe(1);
424
+
425
+ await queue.drain();
426
+ expect(await queue.count()).toBe(0);
427
+
428
+ await moduleRef.close();
429
+ });
430
+
431
+ test('CQRS Saga & AggregateRoot & ExceptionBus', async () => {
432
+ class MyCommand implements ICommand {
433
+ constructor(public readonly val: string) {}
434
+ }
435
+ class MyEvent implements IEvent {
436
+ constructor(public readonly val: string) {}
437
+ }
438
+
439
+ let commandVal = '';
440
+
441
+ @CommandHandler(MyCommand)
442
+ class MyCommandHandler implements ICommandHandler<MyCommand> {
443
+ async execute(command: MyCommand) {
444
+ commandVal = command.val;
445
+ if (command.val === 'throw') {
446
+ throw new Error('command-failed');
447
+ }
448
+ return 'ok';
449
+ }
450
+ }
451
+
452
+ class OrderAggregate extends AggregateRoot {
453
+ createOrder(val: string) {
454
+ this.apply(new MyEvent(val));
455
+ }
456
+ }
457
+
458
+ @Injectable()
459
+ class OrderSagas {
460
+ @Saga()
461
+ orderCreated = (events$: Observable<any>): Observable<ICommand> => {
462
+ return events$.pipe(
463
+ filter((e: any) => e instanceof MyEvent),
464
+ map((e: any) => new MyCommand(e.val))
465
+ );
466
+ }
467
+ }
468
+
469
+ const moduleRef = await Test.createTestingModule({
470
+ imports: [CqrsModule],
471
+ providers: [MyCommandHandler, OrderSagas],
472
+ }).compile();
473
+
474
+ const eventBus = moduleRef.get(EventBus);
475
+ const exceptionBus = moduleRef.get(UnhandledExceptionBus);
476
+ const commandBus = moduleRef.get(CommandBus);
477
+
478
+ const cqrs = moduleRef.get(CqrsModule);
479
+ (cqrs as any).onModuleInit();
480
+
481
+ const agg = new OrderAggregate();
482
+ agg.createOrder('hello-saga');
483
+ expect(agg.getUncommittedEvents().length).toBe(1);
484
+
485
+ const publisher = moduleRef.get(EventPublisher);
486
+ const boundAgg = publisher.mergeObjectContext(agg);
487
+ boundAgg.commit();
488
+ expect(agg.getUncommittedEvents().length).toBe(0);
489
+
490
+ await new Promise((resolve) => setTimeout(resolve, 10));
491
+ expect(commandVal).toBe('hello-saga');
492
+
493
+ let caughtErr: any = null;
494
+ exceptionBus.subscribe((err) => {
495
+ caughtErr = err;
496
+ });
497
+
498
+ try {
499
+ await commandBus.execute(new MyCommand('throw'));
500
+ } catch {
501
+ // expected
502
+ }
503
+
504
+ expect(caughtErr).not.toBeNull();
505
+ expect(caughtErr.error.message).toBe('command-failed');
506
+
507
+ await moduleRef.close();
508
+ });
509
+
510
+ test('Terminus health check indicators', async () => {
511
+ const moduleRef = await Test.createTestingModule({
512
+ imports: [TerminusModule],
513
+ }).compile();
514
+
515
+ const memIndicator = moduleRef.get(MemoryHealthIndicator);
516
+ const dbIndicator = moduleRef.get(TypeOrmHealthIndicator);
517
+
518
+ const memRes = await memIndicator.checkHeap('heap', 1000 * 1024 * 1024);
519
+ expect(memRes.heap.status).toBe('up');
520
+
521
+ const dbRes = await dbIndicator.pingCheck('db');
522
+ expect(dbRes.db.status).toBe('up');
523
+
524
+ await moduleRef.close();
525
+ });
526
+ });
527
+