@quanticjs/core 1.1.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/dist/bootstrap/bootstrapService.d.ts +8 -0
- package/dist/bootstrap/bootstrapService.js +58 -0
- package/dist/cqrs/PipelineExecutor.d.ts +31 -0
- package/dist/cqrs/PipelineExecutor.js +81 -0
- package/dist/cqrs/behaviors/CacheBehavior.d.ts +9 -0
- package/dist/cqrs/behaviors/CacheBehavior.js +68 -0
- package/dist/cqrs/behaviors/DistributedLockBehavior.d.ts +12 -0
- package/dist/cqrs/behaviors/DistributedLockBehavior.js +90 -0
- package/dist/cqrs/behaviors/FeatureFlagBehavior.d.ts +8 -0
- package/dist/cqrs/behaviors/FeatureFlagBehavior.js +58 -0
- package/dist/cqrs/behaviors/InvalidateCacheBehavior.d.ts +9 -0
- package/dist/cqrs/behaviors/InvalidateCacheBehavior.js +61 -0
- package/dist/cqrs/behaviors/LogBehavior.d.ts +9 -0
- package/dist/cqrs/behaviors/LogBehavior.js +125 -0
- package/dist/cqrs/behaviors/PerformanceBehavior.d.ts +12 -0
- package/dist/cqrs/behaviors/PerformanceBehavior.js +56 -0
- package/dist/cqrs/behaviors/TransactionalBehavior.d.ts +18 -0
- package/dist/cqrs/behaviors/TransactionalBehavior.js +77 -0
- package/dist/cqrs/behaviors/ValidationBehavior.d.ts +4 -0
- package/dist/cqrs/behaviors/ValidationBehavior.js +33 -0
- package/dist/cqrs/behaviors/WorkflowBehavior.d.ts +8 -0
- package/dist/cqrs/behaviors/WorkflowBehavior.js +61 -0
- package/dist/cqrs/constants.d.ts +2 -0
- package/dist/cqrs/constants.js +5 -0
- package/dist/cqrs/decorators/Cache.decorator.d.ts +13 -0
- package/dist/cqrs/decorators/Cache.decorator.js +18 -0
- package/dist/cqrs/decorators/DistributedLock.decorator.d.ts +15 -0
- package/dist/cqrs/decorators/DistributedLock.decorator.js +23 -0
- package/dist/cqrs/decorators/FeatureFlag.decorator.d.ts +9 -0
- package/dist/cqrs/decorators/FeatureFlag.decorator.js +14 -0
- package/dist/cqrs/decorators/InvalidateCache.decorator.d.ts +6 -0
- package/dist/cqrs/decorators/InvalidateCache.decorator.js +14 -0
- package/dist/cqrs/decorators/IsolatedTransaction.decorator.d.ts +14 -0
- package/dist/cqrs/decorators/IsolatedTransaction.decorator.js +25 -0
- package/dist/cqrs/decorators/Log.decorator.d.ts +11 -0
- package/dist/cqrs/decorators/Log.decorator.js +18 -0
- package/dist/cqrs/decorators/Validate.decorator.d.ts +24 -0
- package/dist/cqrs/decorators/Validate.decorator.js +37 -0
- package/dist/cqrs/decorators/Workflow.decorator.d.ts +8 -0
- package/dist/cqrs/decorators/Workflow.decorator.js +14 -0
- package/dist/cqrs/interfaces/WorkflowEngine.d.ts +14 -0
- package/dist/cqrs/interfaces/WorkflowEngine.js +4 -0
- package/dist/cqrs/pipeline/QuanticCommandBus.d.ts +37 -0
- package/dist/cqrs/pipeline/QuanticCommandBus.js +99 -0
- package/dist/cqrs/pipeline/QuanticQueryBus.d.ts +28 -0
- package/dist/cqrs/pipeline/QuanticQueryBus.js +78 -0
- package/dist/cqrs/pipeline/runPipeline.d.ts +3 -0
- package/dist/cqrs/pipeline/runPipeline.js +12 -0
- package/dist/cqrs/transaction/TransactionContext.d.ts +18 -0
- package/dist/cqrs/transaction/TransactionContext.js +26 -0
- package/dist/cqrs/transaction/getTransactionalRepo.d.ts +16 -0
- package/dist/cqrs/transaction/getTransactionalRepo.js +22 -0
- package/dist/cqrs/validation/ICommandValidator.d.ts +48 -0
- package/dist/cqrs/validation/ICommandValidator.js +21 -0
- package/dist/entities/BaseEntity.d.ts +5 -0
- package/dist/entities/BaseEntity.js +31 -0
- package/dist/entities/TenantBaseEntity.d.ts +4 -0
- package/dist/entities/TenantBaseEntity.js +22 -0
- package/dist/events/DomainEvent.d.ts +14 -0
- package/dist/events/DomainEvent.js +27 -0
- package/dist/events/OutboxEvent.entity.d.ts +18 -0
- package/dist/events/OutboxEvent.entity.js +87 -0
- package/dist/events/OutboxPublisherService.d.ts +14 -0
- package/dist/events/OutboxPublisherService.js +104 -0
- package/dist/events/RedisStreamConsumer.d.ts +43 -0
- package/dist/events/RedisStreamConsumer.js +158 -0
- package/dist/events/RedisStreamPublisher.d.ts +9 -0
- package/dist/events/RedisStreamPublisher.js +60 -0
- package/dist/filters/GlobalExceptionFilter.d.ts +11 -0
- package/dist/filters/GlobalExceptionFilter.js +102 -0
- package/dist/guards/JwtAuthGuard.d.ts +10 -0
- package/dist/guards/JwtAuthGuard.js +46 -0
- package/dist/guards/JwtStrategy.d.ts +22 -0
- package/dist/guards/JwtStrategy.js +47 -0
- package/dist/guards/RolesGuard.d.ts +8 -0
- package/dist/guards/RolesGuard.js +52 -0
- package/dist/index.d.ts +69 -0
- package/dist/index.js +146 -0
- package/dist/interceptors/ResultInterceptor.d.ts +7 -0
- package/dist/interceptors/ResultInterceptor.js +88 -0
- package/dist/lifecycle/GracefulShutdownService.d.ts +24 -0
- package/dist/lifecycle/GracefulShutdownService.js +93 -0
- package/dist/logging/pino-config.d.ts +35 -0
- package/dist/logging/pino-config.js +79 -0
- package/dist/metrics/MetricsController.d.ts +7 -0
- package/dist/metrics/MetricsController.js +42 -0
- package/dist/metrics/MetricsService.d.ts +13 -0
- package/dist/metrics/MetricsService.js +58 -0
- package/dist/middleware/CorrelationIdMiddleware.d.ts +4 -0
- package/dist/middleware/CorrelationIdMiddleware.js +33 -0
- package/dist/middleware/CorrelationStore.d.ts +11 -0
- package/dist/middleware/CorrelationStore.js +9 -0
- package/dist/middleware/TenantContextMiddleware.d.ts +7 -0
- package/dist/middleware/TenantContextMiddleware.js +27 -0
- package/dist/middleware/TenantStore.d.ts +9 -0
- package/dist/middleware/TenantStore.js +9 -0
- package/dist/redis/redis.module.d.ts +8 -0
- package/dist/redis/redis.module.js +49 -0
- package/dist/resilience/CircuitBreakerFactory.d.ts +12 -0
- package/dist/resilience/CircuitBreakerFactory.js +22 -0
- package/dist/result/Result.d.ts +26 -0
- package/dist/result/Result.js +62 -0
- package/dist/shared-kernel.module.d.ts +13 -0
- package/dist/shared-kernel.module.js +87 -0
- package/dist/subscribers/TenantSubscriber.d.ts +20 -0
- package/dist/subscribers/TenantSubscriber.js +52 -0
- package/dist/testing/TestingModuleFactory.d.ts +23 -0
- package/dist/testing/TestingModuleFactory.js +63 -0
- package/dist/testing/index.d.ts +2 -0
- package/dist/testing/index.js +7 -0
- package/dist/testing/mocks.d.ts +34 -0
- package/dist/testing/mocks.js +62 -0
- package/dist/unleash/initial-flags.d.ts +7 -0
- package/dist/unleash/initial-flags.js +9 -0
- package/dist/unleash/unleash.module.d.ts +9 -0
- package/dist/unleash/unleash.module.js +47 -0
- package/package.json +140 -0
- package/src/bootstrap/bootstrapService.ts +72 -0
- package/src/cqrs/behaviors/CacheBehavior.spec.ts +63 -0
- package/src/cqrs/behaviors/CacheBehavior.ts +54 -0
- package/src/cqrs/behaviors/DistributedLockBehavior.ts +88 -0
- package/src/cqrs/behaviors/FeatureFlagBehavior.ts +46 -0
- package/src/cqrs/behaviors/InvalidateCacheBehavior.spec.ts +89 -0
- package/src/cqrs/behaviors/InvalidateCacheBehavior.ts +50 -0
- package/src/cqrs/behaviors/LogBehavior.spec.ts +55 -0
- package/src/cqrs/behaviors/LogBehavior.ts +121 -0
- package/src/cqrs/behaviors/PerformanceBehavior.spec.ts +48 -0
- package/src/cqrs/behaviors/PerformanceBehavior.ts +43 -0
- package/src/cqrs/behaviors/TransactionalBehavior.ts +64 -0
- package/src/cqrs/behaviors/ValidationBehavior.spec.ts +114 -0
- package/src/cqrs/behaviors/ValidationBehavior.ts +29 -0
- package/src/cqrs/behaviors/WorkflowBehavior.spec.ts +97 -0
- package/src/cqrs/behaviors/WorkflowBehavior.ts +62 -0
- package/src/cqrs/constants.ts +2 -0
- package/src/cqrs/decorators/Cache.decorator.ts +24 -0
- package/src/cqrs/decorators/DistributedLock.decorator.ts +34 -0
- package/src/cqrs/decorators/FeatureFlag.decorator.ts +23 -0
- package/src/cqrs/decorators/InvalidateCache.decorator.spec.ts +20 -0
- package/src/cqrs/decorators/InvalidateCache.decorator.ts +17 -0
- package/src/cqrs/decorators/IsolatedTransaction.decorator.ts +24 -0
- package/src/cqrs/decorators/Log.decorator.ts +22 -0
- package/src/cqrs/decorators/Validate.decorator.ts +39 -0
- package/src/cqrs/decorators/Workflow.decorator.ts +22 -0
- package/src/cqrs/interfaces/WorkflowEngine.ts +19 -0
- package/src/cqrs/pipeline/QuanticCommandBus.ts +69 -0
- package/src/cqrs/pipeline/QuanticQueryBus.ts +56 -0
- package/src/cqrs/pipeline/runPipeline.ts +22 -0
- package/src/cqrs/transaction/TransactionContext.ts +26 -0
- package/src/cqrs/transaction/getTransactionalRepo.ts +23 -0
- package/src/cqrs/validation/ICommandValidator.ts +55 -0
- package/src/entities/BaseEntity.ts +16 -0
- package/src/entities/TenantBaseEntity.ts +7 -0
- package/src/events/DomainEvent.ts +27 -0
- package/src/events/OutboxEvent.entity.ts +56 -0
- package/src/events/OutboxPublisherService.ts +94 -0
- package/src/events/RedisStreamConsumer.ts +172 -0
- package/src/events/RedisStreamPublisher.ts +54 -0
- package/src/filters/GlobalExceptionFilter.ts +125 -0
- package/src/guards/JwtAuthGuard.ts +29 -0
- package/src/guards/JwtStrategy.ts +41 -0
- package/src/guards/RolesGuard.ts +39 -0
- package/src/index.ts +118 -0
- package/src/interceptors/ResultInterceptor.ts +93 -0
- package/src/lifecycle/GracefulShutdownService.ts +77 -0
- package/src/logging/pino-config.ts +80 -0
- package/src/metrics/MetricsController.ts +17 -0
- package/src/metrics/MetricsService.ts +55 -0
- package/src/middleware/CorrelationIdMiddleware.ts +27 -0
- package/src/middleware/CorrelationStore.ts +13 -0
- package/src/middleware/TenantContextMiddleware.ts +21 -0
- package/src/middleware/TenantStore.ts +11 -0
- package/src/redis/redis.module.ts +41 -0
- package/src/resilience/CircuitBreakerFactory.ts +33 -0
- package/src/result/Result.ts +66 -0
- package/src/shared-kernel.module.ts +87 -0
- package/src/subscribers/TenantSubscriber.ts +47 -0
- package/src/testing/TestingModuleFactory.ts +78 -0
- package/src/testing/index.ts +2 -0
- package/src/testing/mocks.ts +59 -0
- package/src/unleash/unleash.module.ts +45 -0
- package/tsconfig.json +22 -0
|
@@ -0,0 +1,16 @@
|
|
|
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
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
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
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
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
|
+
}
|
|
@@ -0,0 +1,94 @@
|
|
|
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
|
+
}
|
|
@@ -0,0 +1,172 @@
|
|
|
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
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
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
|
+
}
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
import {
|
|
2
|
+
ExceptionFilter,
|
|
3
|
+
Catch,
|
|
4
|
+
ArgumentsHost,
|
|
5
|
+
HttpException,
|
|
6
|
+
Logger,
|
|
7
|
+
} from '@nestjs/common';
|
|
8
|
+
import { ThrottlerException } from '@nestjs/throttler';
|
|
9
|
+
import { Request, Response } from 'express';
|
|
10
|
+
import { correlationStore } from '../middleware/CorrelationStore';
|
|
11
|
+
|
|
12
|
+
interface ProblemDetails {
|
|
13
|
+
type: string;
|
|
14
|
+
title: string;
|
|
15
|
+
status: number;
|
|
16
|
+
detail?: string;
|
|
17
|
+
instance?: string;
|
|
18
|
+
correlationId?: string;
|
|
19
|
+
retryAfter?: number;
|
|
20
|
+
errors?: Array<{ field?: string; message: string }>;
|
|
21
|
+
stack?: string;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Global exception filter for framework-level exceptions (guards, pipes, throttler).
|
|
26
|
+
* Result<T> errors are handled by ResultInterceptor — they never reach this filter.
|
|
27
|
+
*/
|
|
28
|
+
@Catch()
|
|
29
|
+
export class GlobalExceptionFilter implements ExceptionFilter {
|
|
30
|
+
private readonly logger = new Logger(GlobalExceptionFilter.name);
|
|
31
|
+
private readonly isProduction = process.env.NODE_ENV === 'production';
|
|
32
|
+
|
|
33
|
+
catch(exception: unknown, host: ArgumentsHost): void {
|
|
34
|
+
const ctx = host.switchToHttp();
|
|
35
|
+
const request = ctx.getRequest<Request>();
|
|
36
|
+
const response = ctx.getResponse<Response>();
|
|
37
|
+
const correlationId = correlationStore.getStore()?.correlationId;
|
|
38
|
+
|
|
39
|
+
if (response.headersSent) return;
|
|
40
|
+
|
|
41
|
+
if (correlationId) {
|
|
42
|
+
response.setHeader('X-Correlation-ID', correlationId);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const problem = this.toProblemDetails(exception, correlationId, request.originalUrl);
|
|
46
|
+
|
|
47
|
+
if (problem.retryAfter) {
|
|
48
|
+
response.setHeader('Retry-After', String(problem.retryAfter));
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
this.logger.error({
|
|
52
|
+
msg: `${problem.status} ${problem.title}`,
|
|
53
|
+
correlationId,
|
|
54
|
+
status: problem.status,
|
|
55
|
+
detail: problem.detail,
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
response
|
|
59
|
+
.status(problem.status)
|
|
60
|
+
.setHeader('Content-Type', 'application/problem+json')
|
|
61
|
+
.json(problem);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
private toProblemDetails(
|
|
65
|
+
exception: unknown,
|
|
66
|
+
correlationId?: string,
|
|
67
|
+
instance?: string,
|
|
68
|
+
): ProblemDetails {
|
|
69
|
+
// ThrottlerException → 429
|
|
70
|
+
if (exception instanceof ThrottlerException) {
|
|
71
|
+
return {
|
|
72
|
+
type: 'https://arex.dev/errors/RATE_LIMITED',
|
|
73
|
+
title: 'Too Many Requests',
|
|
74
|
+
status: 429,
|
|
75
|
+
detail: 'Rate limit exceeded. Please retry later.',
|
|
76
|
+
instance,
|
|
77
|
+
correlationId,
|
|
78
|
+
retryAfter: 60,
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// NestJS HttpException (guards, pipes, etc.)
|
|
83
|
+
if (exception instanceof HttpException) {
|
|
84
|
+
const status = exception.getStatus();
|
|
85
|
+
const exceptionResponse = exception.getResponse();
|
|
86
|
+
const detail =
|
|
87
|
+
typeof exceptionResponse === 'string'
|
|
88
|
+
? exceptionResponse
|
|
89
|
+
: (exceptionResponse as Record<string, unknown>).message;
|
|
90
|
+
|
|
91
|
+
if (status === 400 && Array.isArray(detail)) {
|
|
92
|
+
return {
|
|
93
|
+
type: 'https://arex.dev/errors/VALIDATION_ERROR',
|
|
94
|
+
title: 'Validation Error',
|
|
95
|
+
status: 400,
|
|
96
|
+
detail: 'One or more validation errors occurred.',
|
|
97
|
+
instance,
|
|
98
|
+
correlationId,
|
|
99
|
+
errors: (detail as string[]).map((msg) => ({ message: msg })),
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
return {
|
|
104
|
+
type: `https://arex.dev/errors/HTTP_${status}`,
|
|
105
|
+
title: exception.name,
|
|
106
|
+
status,
|
|
107
|
+
detail: typeof detail === 'string' ? detail : JSON.stringify(detail),
|
|
108
|
+
instance,
|
|
109
|
+
correlationId,
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// Unhandled errors (middleware crashes, etc.)
|
|
114
|
+
const error = exception instanceof Error ? exception : new Error(String(exception));
|
|
115
|
+
return {
|
|
116
|
+
type: 'https://arex.dev/errors/INTERNAL_ERROR',
|
|
117
|
+
title: 'Internal Server Error',
|
|
118
|
+
status: 500,
|
|
119
|
+
detail: this.isProduction ? 'An unexpected error occurred.' : error.message,
|
|
120
|
+
instance,
|
|
121
|
+
correlationId,
|
|
122
|
+
...(this.isProduction ? {} : { stack: error.stack }),
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { ExecutionContext, Injectable } from '@nestjs/common';
|
|
2
|
+
import { Reflector } from '@nestjs/core';
|
|
3
|
+
import { AuthGuard } from '@nestjs/passport';
|
|
4
|
+
|
|
5
|
+
const IS_PUBLIC_KEY = 'isPublic';
|
|
6
|
+
|
|
7
|
+
export const Public = () => (target: object, _key?: string | symbol, descriptor?: PropertyDescriptor) => {
|
|
8
|
+
if (descriptor) {
|
|
9
|
+
Reflect.defineMetadata(IS_PUBLIC_KEY, true, descriptor.value!);
|
|
10
|
+
} else {
|
|
11
|
+
Reflect.defineMetadata(IS_PUBLIC_KEY, true, target);
|
|
12
|
+
}
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
@Injectable()
|
|
16
|
+
export class JwtAuthGuard extends AuthGuard('jwt') {
|
|
17
|
+
constructor(private readonly reflector: Reflector) {
|
|
18
|
+
super();
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
canActivate(context: ExecutionContext) {
|
|
22
|
+
const isPublic = this.reflector.getAllAndOverride<boolean>(IS_PUBLIC_KEY, [
|
|
23
|
+
context.getHandler(),
|
|
24
|
+
context.getClass(),
|
|
25
|
+
]);
|
|
26
|
+
if (isPublic) return true;
|
|
27
|
+
return super.canActivate(context);
|
|
28
|
+
}
|
|
29
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { Injectable } from '@nestjs/common';
|
|
2
|
+
import { PassportStrategy } from '@nestjs/passport';
|
|
3
|
+
import { Strategy, ExtractJwt } from 'passport-jwt';
|
|
4
|
+
import { passportJwtSecret } from 'jwks-rsa';
|
|
5
|
+
|
|
6
|
+
export interface JwtPayload {
|
|
7
|
+
sub: string;
|
|
8
|
+
email: string;
|
|
9
|
+
realm_access?: { roles: string[] };
|
|
10
|
+
preferred_username?: string;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
@Injectable()
|
|
14
|
+
export class JwtStrategy extends PassportStrategy(Strategy) {
|
|
15
|
+
constructor() {
|
|
16
|
+
const internalUrl = process.env.KEYCLOAK_INTERNAL_URL || process.env.KEYCLOAK_URL || 'http://localhost:8080';
|
|
17
|
+
const publicUrl = process.env.KEYCLOAK_URL || 'http://localhost:8080';
|
|
18
|
+
const realm = process.env.KEYCLOAK_REALM || 'arex';
|
|
19
|
+
|
|
20
|
+
super({
|
|
21
|
+
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
|
|
22
|
+
secretOrKeyProvider: passportJwtSecret({
|
|
23
|
+
jwksUri: `${internalUrl}/realms/${realm}/protocol/openid-connect/certs`,
|
|
24
|
+
cache: true,
|
|
25
|
+
cacheMaxAge: 600_000,
|
|
26
|
+
rateLimit: true,
|
|
27
|
+
}),
|
|
28
|
+
issuer: `${publicUrl}/realms/${realm}`,
|
|
29
|
+
algorithms: ['RS256'],
|
|
30
|
+
});
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
validate(payload: JwtPayload) {
|
|
34
|
+
return {
|
|
35
|
+
keycloakId: payload.sub,
|
|
36
|
+
email: payload.email,
|
|
37
|
+
roles: payload.realm_access?.roles || [],
|
|
38
|
+
username: payload.preferred_username,
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { CanActivate, ExecutionContext, ForbiddenException, Injectable } from '@nestjs/common';
|
|
2
|
+
import { Reflector } from '@nestjs/core';
|
|
3
|
+
|
|
4
|
+
const ROLES_KEY = 'roles';
|
|
5
|
+
|
|
6
|
+
export const Roles = (...roles: string[]): ClassDecorator & MethodDecorator =>
|
|
7
|
+
(target: object, _key?: string | symbol, descriptor?: PropertyDescriptor) => {
|
|
8
|
+
if (descriptor) {
|
|
9
|
+
Reflect.defineMetadata(ROLES_KEY, roles, descriptor.value!);
|
|
10
|
+
} else {
|
|
11
|
+
Reflect.defineMetadata(ROLES_KEY, roles, target);
|
|
12
|
+
}
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
@Injectable()
|
|
16
|
+
export class RolesGuard implements CanActivate {
|
|
17
|
+
constructor(private readonly reflector: Reflector) {}
|
|
18
|
+
|
|
19
|
+
canActivate(context: ExecutionContext): boolean {
|
|
20
|
+
const requiredRoles = this.reflector.getAllAndOverride<string[]>(ROLES_KEY, [
|
|
21
|
+
context.getHandler(),
|
|
22
|
+
context.getClass(),
|
|
23
|
+
]);
|
|
24
|
+
|
|
25
|
+
if (!requiredRoles || requiredRoles.length === 0) return true;
|
|
26
|
+
|
|
27
|
+
const request = context.switchToHttp().getRequest();
|
|
28
|
+
const userRoles: string[] = request.user?.roles || [];
|
|
29
|
+
|
|
30
|
+
const hasRole = requiredRoles.some((role) => userRoles.includes(role));
|
|
31
|
+
if (!hasRole) {
|
|
32
|
+
throw new ForbiddenException({
|
|
33
|
+
error: { code: 'FORBIDDEN', message: 'Insufficient permissions' },
|
|
34
|
+
});
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
return true;
|
|
38
|
+
}
|
|
39
|
+
}
|