@quanticjs/core 1.1.1 → 2.1.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 (123) hide show
  1. package/dist/QuanticCoreModule.d.ts +4 -0
  2. package/dist/QuanticCoreModule.js +53 -0
  3. package/dist/cqrs/behaviors/LogBehavior.d.ts +4 -1
  4. package/dist/cqrs/behaviors/LogBehavior.js +3 -0
  5. package/dist/cqrs/behaviors/TransactionalBehavior.d.ts +4 -11
  6. package/dist/cqrs/behaviors/TransactionalBehavior.js +3 -12
  7. package/dist/cqrs/behaviors/ValidationBehavior.d.ts +4 -1
  8. package/dist/cqrs/behaviors/ValidationBehavior.js +3 -0
  9. package/dist/cqrs/pipeline/QuanticCommandBus.d.ts +3 -28
  10. package/dist/cqrs/pipeline/QuanticCommandBus.js +11 -59
  11. package/dist/cqrs/pipeline/QuanticQueryBus.d.ts +3 -19
  12. package/dist/cqrs/pipeline/QuanticQueryBus.js +11 -38
  13. package/dist/cqrs/pipeline/behavior-order.d.ts +11 -0
  14. package/dist/cqrs/pipeline/behavior-order.js +14 -0
  15. package/dist/cqrs/pipeline/types.d.ts +8 -0
  16. package/dist/cqrs/pipeline/types.js +4 -0
  17. package/dist/index.d.ts +6 -22
  18. package/dist/index.js +13 -44
  19. package/package.json +26 -80
  20. package/dist/cqrs/PipelineExecutor.d.ts +0 -31
  21. package/dist/cqrs/PipelineExecutor.js +0 -81
  22. package/dist/cqrs/behaviors/CacheBehavior.d.ts +0 -9
  23. package/dist/cqrs/behaviors/CacheBehavior.js +0 -68
  24. package/dist/cqrs/behaviors/DistributedLockBehavior.d.ts +0 -12
  25. package/dist/cqrs/behaviors/DistributedLockBehavior.js +0 -90
  26. package/dist/cqrs/behaviors/FeatureFlagBehavior.d.ts +0 -8
  27. package/dist/cqrs/behaviors/FeatureFlagBehavior.js +0 -58
  28. package/dist/cqrs/behaviors/InvalidateCacheBehavior.d.ts +0 -9
  29. package/dist/cqrs/behaviors/InvalidateCacheBehavior.js +0 -61
  30. package/dist/cqrs/behaviors/PerformanceBehavior.d.ts +0 -12
  31. package/dist/cqrs/behaviors/PerformanceBehavior.js +0 -56
  32. package/dist/cqrs/behaviors/WorkflowBehavior.d.ts +0 -8
  33. package/dist/cqrs/behaviors/WorkflowBehavior.js +0 -61
  34. package/dist/events/DomainEvent.d.ts +0 -14
  35. package/dist/events/DomainEvent.js +0 -27
  36. package/dist/events/OutboxEvent.entity.d.ts +0 -18
  37. package/dist/events/OutboxEvent.entity.js +0 -87
  38. package/dist/events/OutboxPublisherService.d.ts +0 -14
  39. package/dist/events/OutboxPublisherService.js +0 -104
  40. package/dist/events/RedisStreamConsumer.d.ts +0 -43
  41. package/dist/events/RedisStreamConsumer.js +0 -158
  42. package/dist/events/RedisStreamPublisher.d.ts +0 -9
  43. package/dist/events/RedisStreamPublisher.js +0 -60
  44. package/dist/metrics/MetricsController.d.ts +0 -7
  45. package/dist/metrics/MetricsController.js +0 -42
  46. package/dist/metrics/MetricsService.d.ts +0 -13
  47. package/dist/metrics/MetricsService.js +0 -58
  48. package/dist/redis/redis.module.d.ts +0 -8
  49. package/dist/redis/redis.module.js +0 -49
  50. package/dist/shared-kernel.module.d.ts +0 -13
  51. package/dist/shared-kernel.module.js +0 -87
  52. package/dist/testing/TestingModuleFactory.d.ts +0 -23
  53. package/dist/testing/TestingModuleFactory.js +0 -63
  54. package/dist/testing/index.d.ts +0 -2
  55. package/dist/testing/index.js +0 -7
  56. package/dist/unleash/initial-flags.d.ts +0 -7
  57. package/dist/unleash/initial-flags.js +0 -9
  58. package/dist/unleash/unleash.module.d.ts +0 -9
  59. package/dist/unleash/unleash.module.js +0 -47
  60. package/src/bootstrap/bootstrapService.ts +0 -72
  61. package/src/cqrs/behaviors/CacheBehavior.spec.ts +0 -63
  62. package/src/cqrs/behaviors/CacheBehavior.ts +0 -54
  63. package/src/cqrs/behaviors/DistributedLockBehavior.ts +0 -88
  64. package/src/cqrs/behaviors/FeatureFlagBehavior.ts +0 -46
  65. package/src/cqrs/behaviors/InvalidateCacheBehavior.spec.ts +0 -89
  66. package/src/cqrs/behaviors/InvalidateCacheBehavior.ts +0 -50
  67. package/src/cqrs/behaviors/LogBehavior.spec.ts +0 -55
  68. package/src/cqrs/behaviors/LogBehavior.ts +0 -121
  69. package/src/cqrs/behaviors/PerformanceBehavior.spec.ts +0 -48
  70. package/src/cqrs/behaviors/PerformanceBehavior.ts +0 -43
  71. package/src/cqrs/behaviors/TransactionalBehavior.ts +0 -64
  72. package/src/cqrs/behaviors/ValidationBehavior.spec.ts +0 -114
  73. package/src/cqrs/behaviors/ValidationBehavior.ts +0 -29
  74. package/src/cqrs/behaviors/WorkflowBehavior.spec.ts +0 -97
  75. package/src/cqrs/behaviors/WorkflowBehavior.ts +0 -62
  76. package/src/cqrs/constants.ts +0 -2
  77. package/src/cqrs/decorators/Cache.decorator.ts +0 -24
  78. package/src/cqrs/decorators/DistributedLock.decorator.ts +0 -34
  79. package/src/cqrs/decorators/FeatureFlag.decorator.ts +0 -23
  80. package/src/cqrs/decorators/InvalidateCache.decorator.spec.ts +0 -20
  81. package/src/cqrs/decorators/InvalidateCache.decorator.ts +0 -17
  82. package/src/cqrs/decorators/IsolatedTransaction.decorator.ts +0 -24
  83. package/src/cqrs/decorators/Log.decorator.ts +0 -22
  84. package/src/cqrs/decorators/Validate.decorator.ts +0 -39
  85. package/src/cqrs/decorators/Workflow.decorator.ts +0 -22
  86. package/src/cqrs/interfaces/WorkflowEngine.ts +0 -19
  87. package/src/cqrs/pipeline/QuanticCommandBus.ts +0 -69
  88. package/src/cqrs/pipeline/QuanticQueryBus.ts +0 -56
  89. package/src/cqrs/pipeline/runPipeline.ts +0 -22
  90. package/src/cqrs/transaction/TransactionContext.ts +0 -26
  91. package/src/cqrs/transaction/getTransactionalRepo.ts +0 -23
  92. package/src/cqrs/validation/ICommandValidator.ts +0 -55
  93. package/src/entities/BaseEntity.ts +0 -16
  94. package/src/entities/TenantBaseEntity.ts +0 -7
  95. package/src/events/DomainEvent.ts +0 -27
  96. package/src/events/OutboxEvent.entity.ts +0 -56
  97. package/src/events/OutboxPublisherService.ts +0 -94
  98. package/src/events/RedisStreamConsumer.ts +0 -172
  99. package/src/events/RedisStreamPublisher.ts +0 -54
  100. package/src/filters/GlobalExceptionFilter.ts +0 -125
  101. package/src/guards/JwtAuthGuard.ts +0 -29
  102. package/src/guards/JwtStrategy.ts +0 -41
  103. package/src/guards/RolesGuard.ts +0 -39
  104. package/src/index.ts +0 -118
  105. package/src/interceptors/ResultInterceptor.ts +0 -93
  106. package/src/lifecycle/GracefulShutdownService.ts +0 -77
  107. package/src/logging/pino-config.ts +0 -80
  108. package/src/metrics/MetricsController.ts +0 -17
  109. package/src/metrics/MetricsService.ts +0 -55
  110. package/src/middleware/CorrelationIdMiddleware.ts +0 -27
  111. package/src/middleware/CorrelationStore.ts +0 -13
  112. package/src/middleware/TenantContextMiddleware.ts +0 -21
  113. package/src/middleware/TenantStore.ts +0 -11
  114. package/src/redis/redis.module.ts +0 -41
  115. package/src/resilience/CircuitBreakerFactory.ts +0 -33
  116. package/src/result/Result.ts +0 -66
  117. package/src/shared-kernel.module.ts +0 -87
  118. package/src/subscribers/TenantSubscriber.ts +0 -47
  119. package/src/testing/TestingModuleFactory.ts +0 -78
  120. package/src/testing/index.ts +0 -2
  121. package/src/testing/mocks.ts +0 -59
  122. package/src/unleash/unleash.module.ts +0 -45
  123. package/tsconfig.json +0 -22
@@ -1,56 +0,0 @@
1
- import { Inject, Injectable, Logger, OnModuleInit, Optional } from '@nestjs/common';
2
- import { QueryBus } from '@nestjs/cqrs';
3
- import { Result } from '../../result/Result';
4
- import { LogBehavior } from '../behaviors/LogBehavior';
5
- import { FeatureFlagBehavior } from '../behaviors/FeatureFlagBehavior';
6
- import { ValidationBehavior } from '../behaviors/ValidationBehavior';
7
- import { CacheBehavior } from '../behaviors/CacheBehavior';
8
- import { PerformanceBehavior } from '../behaviors/PerformanceBehavior';
9
- import { BehaviorFn, runPipeline } from './runPipeline';
10
-
11
- /**
12
- * QuanticQueryBus — wraps the @nestjs/cqrs QueryBus with a behavior pipeline.
13
- *
14
- * Pipeline order:
15
- * Log → Performance → FeatureFlag → Validate → Cache → Handler
16
- *
17
- * Queries skip Workflow, DistributedLock, and Transactional — they are read-only.
18
- */
19
- @Injectable()
20
- export class QuanticQueryBus implements OnModuleInit {
21
- private readonly logger = new Logger(QuanticQueryBus.name);
22
- private behaviors: BehaviorFn[] = [];
23
- private originalExecute!: QueryBus['execute'];
24
-
25
- constructor(
26
- private readonly queryBus: QueryBus,
27
- private readonly logBehavior: LogBehavior,
28
- private readonly performanceBehavior: PerformanceBehavior,
29
- @Optional() @Inject(FeatureFlagBehavior) private readonly featureFlagBehavior: FeatureFlagBehavior | undefined,
30
- private readonly validationBehavior: ValidationBehavior,
31
- private readonly cacheBehavior: CacheBehavior,
32
- ) {}
33
-
34
- onModuleInit() {
35
- const optional = (behavior: { execute: BehaviorFn } | undefined): BehaviorFn[] =>
36
- behavior ? [(cmd, next) => behavior.execute(cmd, next)] : [];
37
-
38
- this.behaviors = [
39
- (cmd, next) => this.logBehavior.execute(cmd, next),
40
- (cmd, next) => this.performanceBehavior.execute(cmd, next),
41
- ...optional(this.featureFlagBehavior),
42
- (cmd, next) => this.validationBehavior.execute(cmd, next),
43
- (cmd, next) => this.cacheBehavior.execute(cmd, next),
44
- ];
45
-
46
- this.originalExecute = this.queryBus.execute.bind(this.queryBus);
47
-
48
- const self = this;
49
- this.queryBus.execute = <T>(query: object): Promise<T> => {
50
- const handler = () => self.originalExecute(query) as Promise<Result<T>>;
51
- return runPipeline(query, handler, self.behaviors) as Promise<T>;
52
- };
53
-
54
- this.logger.log(`Query pipeline initialized — ${this.behaviors.length} behaviors`);
55
- }
56
- }
@@ -1,22 +0,0 @@
1
- import { Result } from '../../result/Result';
2
-
3
- export type BehaviorFn = <T>(
4
- command: object,
5
- next: () => Promise<Result<T>>,
6
- ) => Promise<Result<T>>;
7
-
8
- export function runPipeline<T>(
9
- command: object,
10
- handler: () => Promise<Result<T>>,
11
- behaviors: BehaviorFn[],
12
- ): Promise<Result<T>> {
13
- let next = handler;
14
-
15
- for (let i = behaviors.length - 1; i >= 0; i--) {
16
- const behavior = behaviors[i]!;
17
- const currentNext = next;
18
- next = () => behavior(command, currentNext);
19
- }
20
-
21
- return next();
22
- }
@@ -1,26 +0,0 @@
1
- import { AsyncLocalStorage } from 'async_hooks';
2
- import type { QueryRunner } from 'typeorm';
3
-
4
- /**
5
- * Ambient transaction context using AsyncLocalStorage.
6
- *
7
- * Node.js is single-threaded — each async chain (HTTP request, Bull job,
8
- * event consumer) gets its own isolated storage. Two concurrent requests
9
- * never share a QueryRunner.
10
- *
11
- * The TransactionalBehavior creates the context for the outermost command;
12
- * nested commands join automatically.
13
- */
14
- export class TransactionContext {
15
- private static readonly storage = new AsyncLocalStorage<QueryRunner>();
16
-
17
- /** Returns the active QueryRunner for this async scope, or undefined. */
18
- static get(): QueryRunner | undefined {
19
- return this.storage.getStore();
20
- }
21
-
22
- /** Runs `fn` with `queryRunner` as the ambient transaction for all nested calls. */
23
- static run<T>(queryRunner: QueryRunner, fn: () => Promise<T>): Promise<T> {
24
- return this.storage.run(queryRunner, fn);
25
- }
26
- }
@@ -1,23 +0,0 @@
1
- import type { Repository, ObjectLiteral, EntityTarget } from 'typeorm';
2
- import { TransactionContext } from './TransactionContext';
3
-
4
- /**
5
- * Returns a repository bound to the ambient transaction's QueryRunner,
6
- * or the original repository if no transaction context exists.
7
- *
8
- * Usage in command handlers:
9
- * ```typescript
10
- * async execute(cmd: CreateUserCommand): Promise<Result<UserDto>> {
11
- * const repo = getTransactionalRepo(this.userRepo);
12
- * const user = repo.create({ email: cmd.email });
13
- * await repo.save(user);
14
- * return Result.success(toDto(user));
15
- * }
16
- * ```
17
- */
18
- export function getTransactionalRepo<T extends ObjectLiteral>(
19
- repo: Repository<T>,
20
- ): Repository<T> {
21
- const qr = TransactionContext.get();
22
- return qr ? qr.manager.getRepository(repo.target as EntityTarget<T>) : repo;
23
- }
@@ -1,55 +0,0 @@
1
- import { Result } from '../../result/Result';
2
-
3
- /**
4
- * Interface for Zod-based command/query validators.
5
- *
6
- * Each command that needs validation gets a separate validator class
7
- * with a Zod schema. The `@Validate(ValidatorClass)` decorator links
8
- * the command to its validator.
9
- *
10
- * Usage:
11
- * ```typescript
12
- * export class BlockUserValidator implements ICommandValidator<BlockUserCommand> {
13
- * schema = z.object({
14
- * blockerId: z.string().uuid(),
15
- * blockedUserId: z.string().uuid(),
16
- * }).refine(d => d.blockerId !== d.blockedUserId, 'Cannot block yourself');
17
- *
18
- * validate(command: BlockUserCommand): Result<void> {
19
- * return validateCommand(this.schema, command);
20
- * }
21
- * }
22
- * ```
23
- */
24
- export interface ICommandValidator<T = any> {
25
- validate(command: T): Result<void>;
26
- }
27
-
28
- /**
29
- * A schema that can safeParse — matches Zod's ZodType without importing it.
30
- * This avoids Zod v3/v4 compatibility issues across packages.
31
- */
32
- interface SafeParseable {
33
- safeParse(data: unknown): { success: true } | { success: false; error: { issues: Array<{ path: PropertyKey[]; message: string }> } };
34
- }
35
-
36
- /**
37
- * Helper: run a Zod schema against a command and return Result<void>.
38
- * Formats all Zod issues into a single error message.
39
- */
40
- export function validateCommand(schema: SafeParseable, command: unknown): Result<void> {
41
- const result = schema.safeParse(command);
42
-
43
- if (result.success) {
44
- return Result.success(undefined);
45
- }
46
-
47
- const messages = result.error.issues
48
- .map((issue) => {
49
- const path = issue.path.length > 0 ? `${issue.path.map(String).join('.')}: ` : '';
50
- return `${path}${issue.message}`;
51
- })
52
- .join('; ');
53
-
54
- return Result.validationError(messages);
55
- }
@@ -1,16 +0,0 @@
1
- import {
2
- PrimaryGeneratedColumn,
3
- CreateDateColumn,
4
- UpdateDateColumn,
5
- } from 'typeorm';
6
-
7
- export abstract class BaseEntity {
8
- @PrimaryGeneratedColumn('uuid')
9
- id!: string;
10
-
11
- @CreateDateColumn({ type: 'timestamptz' })
12
- createdAt!: Date;
13
-
14
- @UpdateDateColumn({ type: 'timestamptz' })
15
- updatedAt!: Date;
16
- }
@@ -1,7 +0,0 @@
1
- import { Column } from 'typeorm';
2
- import { BaseEntity } from './BaseEntity';
3
-
4
- export abstract class TenantBaseEntity extends BaseEntity {
5
- @Column({ type: 'uuid' })
6
- organizationId!: string;
7
- }
@@ -1,27 +0,0 @@
1
- import { v4 as uuidv4 } from 'uuid';
2
-
3
- export interface DomainEventPayload {
4
- [key: string]: unknown;
5
- }
6
-
7
- export class DomainEvent {
8
- public readonly eventId: string;
9
- public readonly occurredAt: Date;
10
-
11
- constructor(
12
- public readonly eventType: string,
13
- public readonly aggregateId: string,
14
- public readonly payload: DomainEventPayload,
15
- public readonly organizationId?: string,
16
- ) {
17
- this.eventId = uuidv4();
18
- this.occurredAt = new Date();
19
- }
20
-
21
- /** The Redis Stream key this event should be published to */
22
- get streamKey(): string {
23
- // e.g. 'build.completed' → 'arex:events:builds'
24
- const category = this.eventType.split('.')[0];
25
- return `arex:events:${category}s`;
26
- }
27
- }
@@ -1,56 +0,0 @@
1
- import {
2
- Column,
3
- CreateDateColumn,
4
- Entity,
5
- Index,
6
- PrimaryGeneratedColumn,
7
- } from 'typeorm';
8
-
9
- export enum OutboxEventStatus {
10
- Pending = 'Pending',
11
- Published = 'Published',
12
- Failed = 'Failed',
13
- }
14
-
15
- @Entity('outbox_events')
16
- @Index('idx_outbox_pending', ['status', 'createdAt'], {
17
- where: `"status" = 'Pending'`,
18
- })
19
- export class OutboxEvent {
20
- @PrimaryGeneratedColumn('uuid')
21
- id!: string;
22
-
23
- @Column({ type: 'varchar' })
24
- eventType!: string;
25
-
26
- @Column({ type: 'varchar' })
27
- aggregateId!: string;
28
-
29
- @Column({ type: 'varchar' })
30
- streamKey!: string;
31
-
32
- @Column({ type: 'jsonb' })
33
- payload!: Record<string, unknown>;
34
-
35
- @Column({ type: 'uuid', nullable: true })
36
- organizationId!: string | null;
37
-
38
- @Column({
39
- type: 'enum',
40
- enum: OutboxEventStatus,
41
- default: OutboxEventStatus.Pending,
42
- })
43
- status!: OutboxEventStatus;
44
-
45
- @Column({ type: 'int', default: 0 })
46
- publishAttempts!: number;
47
-
48
- @Column({ type: 'varchar', nullable: true })
49
- lastError!: string | null;
50
-
51
- @CreateDateColumn({ type: 'timestamptz' })
52
- createdAt!: Date;
53
-
54
- @Column({ type: 'timestamptz', nullable: true })
55
- publishedAt!: Date | null;
56
- }
@@ -1,94 +0,0 @@
1
- import { Injectable, Logger, OnModuleInit, OnModuleDestroy } from '@nestjs/common';
2
- import { DataSource } from 'typeorm';
3
- import { OutboxEvent, OutboxEventStatus } from './OutboxEvent.entity';
4
- import { RedisStreamPublisher } from './RedisStreamPublisher';
5
-
6
- const POLL_INTERVAL_MS = 100;
7
- const BATCH_SIZE = 50;
8
- const MAX_PUBLISH_ATTEMPTS = 5;
9
- const DLQ_STREAM = 'arex:events:dlq';
10
-
11
- @Injectable()
12
- export class OutboxPublisherService implements OnModuleInit, OnModuleDestroy {
13
- private readonly logger = new Logger(OutboxPublisherService.name);
14
- private timer: ReturnType<typeof setInterval> | null = null;
15
- private processing = false;
16
-
17
- constructor(
18
- private readonly dataSource: DataSource,
19
- private readonly redisPublisher: RedisStreamPublisher,
20
- ) {}
21
-
22
- onModuleInit() {
23
- this.timer = setInterval(() => this.pollAndPublish(), POLL_INTERVAL_MS);
24
- this.logger.log(`Outbox publisher started (${POLL_INTERVAL_MS}ms poll interval)`);
25
- }
26
-
27
- onModuleDestroy() {
28
- if (this.timer) {
29
- clearInterval(this.timer);
30
- this.timer = null;
31
- }
32
- this.logger.log('Outbox publisher stopped');
33
- }
34
-
35
- private async pollAndPublish(): Promise<void> {
36
- if (this.processing) return;
37
- this.processing = true;
38
-
39
- try {
40
- const repo = this.dataSource.getRepository(OutboxEvent);
41
-
42
- const events = await repo.find({
43
- where: { status: OutboxEventStatus.Pending },
44
- order: { createdAt: 'ASC' },
45
- take: BATCH_SIZE,
46
- });
47
-
48
- for (const event of events) {
49
- try {
50
- await this.redisPublisher.publishToStream(event.streamKey, {
51
- eventId: event.id,
52
- eventType: event.eventType,
53
- aggregateId: event.aggregateId,
54
- organizationId: event.organizationId || '',
55
- payload: JSON.stringify(event.payload),
56
- occurredAt: event.createdAt.toISOString(),
57
- });
58
-
59
- event.status = OutboxEventStatus.Published;
60
- event.publishedAt = new Date();
61
- await repo.save(event);
62
- } catch (error) {
63
- event.publishAttempts += 1;
64
- event.lastError = (error as Error).message;
65
-
66
- if (event.publishAttempts >= MAX_PUBLISH_ATTEMPTS) {
67
- event.status = OutboxEventStatus.Failed;
68
- this.logger.error(
69
- `Event ${event.id} exceeded max attempts, routing to DLQ`,
70
- );
71
-
72
- try {
73
- await this.redisPublisher.publishToStream(DLQ_STREAM, {
74
- eventId: event.id,
75
- eventType: event.eventType,
76
- aggregateId: event.aggregateId,
77
- error: event.lastError || 'unknown',
78
- payload: JSON.stringify(event.payload),
79
- });
80
- } catch {
81
- this.logger.error(`Failed to route event ${event.id} to DLQ`);
82
- }
83
- }
84
-
85
- await repo.save(event);
86
- }
87
- }
88
- } catch (error) {
89
- this.logger.error('Outbox poll cycle failed', (error as Error).stack);
90
- } finally {
91
- this.processing = false;
92
- }
93
- }
94
- }
@@ -1,172 +0,0 @@
1
- import { Logger, Inject, Optional, OnModuleInit, OnModuleDestroy } from '@nestjs/common';
2
- import { REDIS_CLIENT } from '../cqrs/constants';
3
- import type { Redis } from 'ioredis';
4
-
5
- /**
6
- * Abstract base class for Redis Stream consumer groups.
7
- *
8
- * Subclasses declare `streamKey`, `consumerGroup`, `consumerName`
9
- * and implement `handleMessage(fields)`.
10
- *
11
- * Lifecycle:
12
- * - OnModuleInit: creates consumer group (idempotent), starts read loop
13
- * - OnModuleDestroy: stops the read loop gracefully, disconnects dedicated client
14
- *
15
- * Connection strategy:
16
- * - Shared REDIS_CLIENT: used for non-blocking ops (XGROUP CREATE, XACK)
17
- * - Dedicated cloned connection: used ONLY for blocking XREADGROUP BLOCK calls
18
- * - This prevents blocking consumers from starving non-blocking callers
19
- * (cache, distributed locks, publishers) that share the main REDIS_CLIENT.
20
- *
21
- * Features:
22
- * - Auto-creates consumer group via XGROUP CREATE ... MKSTREAM
23
- * - Recovers pending (unacked) messages on startup before reading new ones
24
- * - XREADGROUP BLOCK for efficient new-message polling (on dedicated connection)
25
- * - XACK after successful processing (on shared connection)
26
- * - Graceful shutdown (finishes in-flight message, disconnects dedicated client)
27
- */
28
- export abstract class RedisStreamConsumer implements OnModuleInit, OnModuleDestroy {
29
- protected readonly logger = new Logger(this.constructor.name);
30
-
31
- abstract readonly streamKey: string;
32
- abstract readonly consumerGroup: string;
33
- abstract readonly consumerName: string;
34
-
35
- /** Override to filter by eventType. Return true to process. Default: all messages. */
36
- protected shouldHandle(_fields: Record<string, string>): boolean {
37
- return true;
38
- }
39
-
40
- abstract handleMessage(fields: Record<string, string>): Promise<void>;
41
-
42
- private running = false;
43
-
44
- /** Dedicated connection for blocking XREADGROUP — cloned from shared client on init */
45
- private blockingClient?: Redis;
46
-
47
- constructor(
48
- @Optional() @Inject(REDIS_CLIENT) protected readonly redis?: Redis,
49
- ) {}
50
-
51
- async onModuleInit(): Promise<void> {
52
- if (!this.redis) {
53
- this.logger.warn('Redis client not available — consumer disabled');
54
- return;
55
- }
56
-
57
- // Clone a dedicated connection for blocking reads.
58
- // This prevents XREADGROUP BLOCK from starving cache/lock/publish ops on the shared client.
59
- this.blockingClient = this.redis.duplicate();
60
- this.blockingClient.on('error', (err) =>
61
- this.logger.error(`Blocking client error on ${this.streamKey}: ${err.message}`),
62
- );
63
-
64
- // Create consumer group (idempotent) — uses shared client (non-blocking)
65
- try {
66
- await this.redis.xgroup(
67
- 'CREATE',
68
- this.streamKey,
69
- this.consumerGroup,
70
- '0',
71
- 'MKSTREAM',
72
- );
73
- this.logger.log(`Created consumer group "${this.consumerGroup}" on "${this.streamKey}"`);
74
- } catch (err: any) {
75
- if (!err.message?.includes('BUSYGROUP')) {
76
- throw err;
77
- }
78
- // Group already exists — fine
79
- }
80
-
81
- this.running = true;
82
-
83
- // Process pending messages first, then new ones
84
- // Run in background so NestJS startup isn't blocked
85
- void this.consumeLoop();
86
- }
87
-
88
- async onModuleDestroy(): Promise<void> {
89
- this.running = false;
90
-
91
- // Disconnect the dedicated blocking client
92
- if (this.blockingClient) {
93
- this.blockingClient.disconnect();
94
- this.blockingClient = undefined;
95
- }
96
- }
97
-
98
- private async consumeLoop(): Promise<void> {
99
- // Phase 1: recover pending (unacked) messages
100
- await this.readMessages('0');
101
-
102
- // Phase 2: read new messages
103
- while (this.running) {
104
- await this.readMessages('>');
105
- }
106
- }
107
-
108
- private async readMessages(id: '0' | '>'): Promise<void> {
109
- if (!this.redis || !this.blockingClient || !this.running) return;
110
-
111
- try {
112
- const blockMs = id === '>' ? 5000 : undefined;
113
- const args: (string | number)[] = [
114
- 'GROUP',
115
- this.consumerGroup,
116
- this.consumerName,
117
- 'COUNT',
118
- '10',
119
- ];
120
- if (blockMs !== undefined) {
121
- args.push('BLOCK', blockMs);
122
- }
123
- args.push('STREAMS', this.streamKey, id);
124
-
125
- // Use dedicated blocking client for XREADGROUP — never the shared REDIS_CLIENT
126
- const results = await (this.blockingClient as any).xreadgroup(...args) as
127
- Array<[string, Array<[string, string[]]>]> | null;
128
-
129
- if (!results || results.length === 0) {
130
- if (id === '0') return; // No pending — switch to new messages
131
- return;
132
- }
133
-
134
- const [, entries] = results[0];
135
-
136
- if (entries.length === 0 && id === '0') {
137
- return; // All pending recovered
138
- }
139
-
140
- for (const [messageId, fieldArray] of entries) {
141
- // Parse flat field array into Record
142
- const fields: Record<string, string> = {};
143
- for (let i = 0; i < fieldArray.length; i += 2) {
144
- fields[fieldArray[i]] = fieldArray[i + 1];
145
- }
146
-
147
- if (!this.shouldHandle(fields)) {
148
- // Ack and skip — uses shared client (non-blocking)
149
- await this.redis.xack(this.streamKey, this.consumerGroup, messageId);
150
- continue;
151
- }
152
-
153
- try {
154
- await this.handleMessage(fields);
155
- // XACK on shared client — non-blocking
156
- await this.redis.xack(this.streamKey, this.consumerGroup, messageId);
157
- } catch (err: any) {
158
- this.logger.error(
159
- `Failed to process message ${messageId} on ${this.streamKey}: ${err.message}`,
160
- err.stack,
161
- );
162
- // Don't ack — message stays pending for retry on next startup
163
- }
164
- }
165
- } catch (err: any) {
166
- if (!this.running) return; // Shutdown in progress
167
- this.logger.error(`Consumer read error on ${this.streamKey}: ${err.message}`, err.stack);
168
- // Back off before retrying
169
- await new Promise((r) => setTimeout(r, 2000));
170
- }
171
- }
172
- }
@@ -1,54 +0,0 @@
1
- import { Injectable, Logger, Inject, Optional } from '@nestjs/common';
2
- import { REDIS_CLIENT } from '../cqrs/constants';
3
- import type { Redis } from 'ioredis';
4
- import { DomainEvent } from './DomainEvent';
5
-
6
- const MAX_STREAM_LENGTH = 10000;
7
-
8
- @Injectable()
9
- export class RedisStreamPublisher {
10
- private readonly logger = new Logger(RedisStreamPublisher.name);
11
-
12
- constructor(
13
- @Optional() @Inject(REDIS_CLIENT) private readonly redis?: Redis,
14
- ) {}
15
-
16
- async publish(event: DomainEvent): Promise<void> {
17
- if (!this.redis) {
18
- this.logger.warn('Redis client not available, skipping event publish');
19
- return;
20
- }
21
-
22
- await this.publishToStream(event.streamKey, {
23
- eventId: event.eventId,
24
- eventType: event.eventType,
25
- aggregateId: event.aggregateId,
26
- organizationId: event.organizationId || '',
27
- payload: JSON.stringify(event.payload),
28
- occurredAt: event.occurredAt.toISOString(),
29
- });
30
- }
31
-
32
- async publishToStream(
33
- streamKey: string,
34
- fields: Record<string, string>,
35
- ): Promise<string | null> {
36
- if (!this.redis) return null;
37
-
38
- try {
39
- const messageId = await this.redis.xadd(
40
- streamKey,
41
- 'MAXLEN',
42
- '~',
43
- String(MAX_STREAM_LENGTH),
44
- '*',
45
- ...Object.entries(fields).flat(),
46
- );
47
- this.logger.debug(`Published to ${streamKey}: ${messageId}`);
48
- return messageId;
49
- } catch (error) {
50
- this.logger.error(`Failed to publish to ${streamKey}`, (error as Error).stack);
51
- throw error;
52
- }
53
- }
54
- }