first commit
This commit is contained in:
21
libs/api/shared/application/README.md
Normal file
21
libs/api/shared/application/README.md
Normal file
@@ -0,0 +1,21 @@
|
||||
# api-shared-application
|
||||
|
||||
Contrato del event store y reconstrucción de agregados.
|
||||
|
||||
- **Import**: `@task-stream/api/shared/application`
|
||||
- **Capa**: `application` · **Tags**: `scope:api-shared`, `type:application`
|
||||
|
||||
## Contenido
|
||||
|
||||
- `EventStore` (puerto): `persist` y `getEventsByStreamId`.
|
||||
- `AggregateRehydrator`: carga los eventos de un stream y devuelve el agregado reconstruido, ya enlazado con el `EventPublisher`.
|
||||
- `SharedModule.withInfrastructure(...)`: módulo global que expone el event store y el rehydrator a todos los contextos.
|
||||
|
||||
## Comandos
|
||||
|
||||
```sh
|
||||
npx nx lint api-shared-application
|
||||
npx nx test api-shared-application # Jest
|
||||
```
|
||||
|
||||
Reglas de dependencia entre capas: [Arquitectura](../../../../docs/02-arquitectura.md#21-capas-hexagonales).
|
||||
3
libs/api/shared/application/eslint.config.mjs
Normal file
3
libs/api/shared/application/eslint.config.mjs
Normal file
@@ -0,0 +1,3 @@
|
||||
import baseConfig from '../../../../eslint.config.mjs';
|
||||
|
||||
export default [...baseConfig];
|
||||
11
libs/api/shared/application/jest.config.cts
Normal file
11
libs/api/shared/application/jest.config.cts
Normal file
@@ -0,0 +1,11 @@
|
||||
module.exports = {
|
||||
displayName: 'api-shared-application',
|
||||
preset: '../../../../jest.preset.js',
|
||||
testEnvironment: 'node',
|
||||
passWithNoTests: true,
|
||||
transform: {
|
||||
'^.+\\.[tj]s$': ['ts-jest', { tsconfig: '<rootDir>/tsconfig.spec.json' }],
|
||||
},
|
||||
moduleFileExtensions: ['ts', 'js', 'html'],
|
||||
coverageDirectory: '../../../../coverage/libs/api/shared/application',
|
||||
};
|
||||
12
libs/api/shared/application/project.json
Normal file
12
libs/api/shared/application/project.json
Normal file
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"name": "api-shared-application",
|
||||
"$schema": "../../../../node_modules/nx/schemas/project-schema.json",
|
||||
"sourceRoot": "libs/api/shared/application/src",
|
||||
"projectType": "library",
|
||||
"tags": [
|
||||
"scope:api-shared",
|
||||
"type:application"
|
||||
],
|
||||
"// targets": "to see all targets run: nx show project api-shared-application --web",
|
||||
"targets": {}
|
||||
}
|
||||
3
libs/api/shared/application/src/index.ts
Normal file
3
libs/api/shared/application/src/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export * from './lib/ports/event-store';
|
||||
export * from './lib/aggregate-rehydrator';
|
||||
export * from './lib/shared.module';
|
||||
32
libs/api/shared/application/src/lib/aggregate-rehydrator.ts
Normal file
32
libs/api/shared/application/src/lib/aggregate-rehydrator.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import { Injectable, Type } from '@nestjs/common';
|
||||
import { EventPublisher } from '@nestjs/cqrs';
|
||||
import {
|
||||
NotFoundError,
|
||||
VersionedAggregateRoot,
|
||||
} from '@task-stream/api/shared/domain';
|
||||
import { EventStore } from './ports/event-store';
|
||||
|
||||
@Injectable()
|
||||
export class AggregateRehydrator {
|
||||
constructor(
|
||||
private readonly eventStore: EventStore,
|
||||
private readonly eventPublisher: EventPublisher,
|
||||
) {}
|
||||
|
||||
async rehydrate<T extends VersionedAggregateRoot>(
|
||||
aggregateId: string,
|
||||
AggregateCls: Type<T>,
|
||||
): Promise<T> {
|
||||
const events = await this.eventStore.getEventsByStreamId(aggregateId);
|
||||
if (events.length === 0) {
|
||||
throw new NotFoundError(AggregateCls.name, aggregateId);
|
||||
}
|
||||
|
||||
const AggregateClsWithDispatcher =
|
||||
this.eventPublisher.mergeClassContext(AggregateCls);
|
||||
const aggregate = new AggregateClsWithDispatcher(aggregateId);
|
||||
|
||||
aggregate.loadFromHistory(events);
|
||||
return aggregate;
|
||||
}
|
||||
}
|
||||
10
libs/api/shared/application/src/lib/ports/event-store.ts
Normal file
10
libs/api/shared/application/src/lib/ports/event-store.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import { SerializableEvent } from '@task-stream/api/shared/domain';
|
||||
|
||||
/** Append-only store of domain events (the write model). */
|
||||
export abstract class EventStore {
|
||||
/** Fails with ConcurrencyError if another process appended to the stream first. */
|
||||
abstract persist(
|
||||
eventOrEvents: SerializableEvent | SerializableEvent[],
|
||||
): Promise<void>;
|
||||
abstract getEventsByStreamId(streamId: string): Promise<SerializableEvent[]>;
|
||||
}
|
||||
23
libs/api/shared/application/src/lib/shared.module.ts
Normal file
23
libs/api/shared/application/src/lib/shared.module.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import { DynamicModule, Global, Module, Type } from '@nestjs/common';
|
||||
import { CqrsModule } from '@nestjs/cqrs';
|
||||
import { AggregateRehydrator } from './aggregate-rehydrator';
|
||||
|
||||
/**
|
||||
* Shared building blocks for every bounded context: the event store (from
|
||||
* the infrastructure module plugged in by the composition root) and the
|
||||
* AggregateRehydrator.
|
||||
*/
|
||||
@Global()
|
||||
@Module({})
|
||||
export class SharedModule {
|
||||
static withInfrastructure(
|
||||
infrastructureModule: Type | DynamicModule,
|
||||
): DynamicModule {
|
||||
return {
|
||||
module: SharedModule,
|
||||
imports: [CqrsModule, infrastructureModule],
|
||||
providers: [AggregateRehydrator],
|
||||
exports: [infrastructureModule, AggregateRehydrator],
|
||||
};
|
||||
}
|
||||
}
|
||||
23
libs/api/shared/application/tsconfig.json
Normal file
23
libs/api/shared/application/tsconfig.json
Normal file
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"extends": "../../../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"module": "commonjs",
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"strict": true,
|
||||
"importHelpers": true,
|
||||
"noImplicitOverride": true,
|
||||
"noImplicitReturns": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"noPropertyAccessFromIndexSignature": true
|
||||
},
|
||||
"files": [],
|
||||
"include": [],
|
||||
"references": [
|
||||
{
|
||||
"path": "./tsconfig.lib.json"
|
||||
},
|
||||
{
|
||||
"path": "./tsconfig.spec.json"
|
||||
}
|
||||
]
|
||||
}
|
||||
23
libs/api/shared/application/tsconfig.lib.json
Normal file
23
libs/api/shared/application/tsconfig.lib.json
Normal file
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "../../../../dist/out-tsc",
|
||||
"declaration": true,
|
||||
"types": ["node"],
|
||||
"target": "es2021",
|
||||
"experimentalDecorators": true,
|
||||
"emitDecoratorMetadata": true,
|
||||
"strictNullChecks": true,
|
||||
"noImplicitAny": true,
|
||||
"strictBindCallApply": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"noFallthroughCasesInSwitch": true
|
||||
},
|
||||
"include": ["src/**/*.ts"],
|
||||
"exclude": [
|
||||
"jest.config.ts",
|
||||
"jest.config.cts",
|
||||
"src/**/*.spec.ts",
|
||||
"src/**/*.test.ts"
|
||||
]
|
||||
}
|
||||
16
libs/api/shared/application/tsconfig.spec.json
Normal file
16
libs/api/shared/application/tsconfig.spec.json
Normal file
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "../../../../dist/out-tsc",
|
||||
"module": "commonjs",
|
||||
"moduleResolution": "bundler",
|
||||
"types": ["jest", "node"]
|
||||
},
|
||||
"include": [
|
||||
"jest.config.ts",
|
||||
"jest.config.cts",
|
||||
"src/**/*.test.ts",
|
||||
"src/**/*.spec.ts",
|
||||
"src/**/*.d.ts"
|
||||
]
|
||||
}
|
||||
24
libs/api/shared/domain/README.md
Normal file
24
libs/api/shared/domain/README.md
Normal file
@@ -0,0 +1,24 @@
|
||||
# api-shared-domain
|
||||
|
||||
Piezas de dominio comunes a todos los contextos de la API.
|
||||
|
||||
- **Import**: `@task-stream/api/shared/domain`
|
||||
- **Capa**: `domain` · **Tags**: `scope:api-shared`, `type:domain`
|
||||
|
||||
## Contenido
|
||||
|
||||
- `VersionedAggregateRoot`: extiende el `AggregateRoot` de `@nestjs/cqrs` con la versión (`loadFromHistory`, `commitAndWait`, `trackCommit`).
|
||||
- `Version`, `SerializableEvent`, `DomainEvent` (actor, fecha y `aggregateVersion`), `newId()`.
|
||||
- `EventClsRegistry` y `@AutowiredEvent`: registro de las clases de evento para deserializarlas.
|
||||
- Errores de dominio: `NotFoundError`, `ForbiddenError`, `ValidationError`, `BusinessRuleError`, `ConcurrencyError` y `assertThat`.
|
||||
|
||||
Event sourcing: [Arquitectura](../../../../docs/02-arquitectura.md#escritura-event-sourcing-en-postgresql).
|
||||
|
||||
## Comandos
|
||||
|
||||
```sh
|
||||
npx nx lint api-shared-domain
|
||||
npx nx test api-shared-domain # Jest
|
||||
```
|
||||
|
||||
Reglas de dependencia entre capas: [Arquitectura](../../../../docs/02-arquitectura.md#21-capas-hexagonales).
|
||||
3
libs/api/shared/domain/eslint.config.mjs
Normal file
3
libs/api/shared/domain/eslint.config.mjs
Normal file
@@ -0,0 +1,3 @@
|
||||
import baseConfig from '../../../../eslint.config.mjs';
|
||||
|
||||
export default [...baseConfig];
|
||||
11
libs/api/shared/domain/jest.config.cts
Normal file
11
libs/api/shared/domain/jest.config.cts
Normal file
@@ -0,0 +1,11 @@
|
||||
module.exports = {
|
||||
displayName: 'api-shared-domain',
|
||||
preset: '../../../../jest.preset.js',
|
||||
testEnvironment: 'node',
|
||||
passWithNoTests: true,
|
||||
transform: {
|
||||
'^.+\\.[tj]s$': ['ts-jest', { tsconfig: '<rootDir>/tsconfig.spec.json' }],
|
||||
},
|
||||
moduleFileExtensions: ['ts', 'js', 'html'],
|
||||
coverageDirectory: '../../../../coverage/libs/api/shared/domain',
|
||||
};
|
||||
12
libs/api/shared/domain/project.json
Normal file
12
libs/api/shared/domain/project.json
Normal file
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"name": "api-shared-domain",
|
||||
"$schema": "../../../../node_modules/nx/schemas/project-schema.json",
|
||||
"sourceRoot": "libs/api/shared/domain/src",
|
||||
"projectType": "library",
|
||||
"tags": [
|
||||
"scope:api-shared",
|
||||
"type:domain"
|
||||
],
|
||||
"// targets": "to see all targets run: nx show project api-shared-domain --web",
|
||||
"targets": {}
|
||||
}
|
||||
6
libs/api/shared/domain/src/index.ts
Normal file
6
libs/api/shared/domain/src/index.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
export * from './lib/aggregate-root';
|
||||
export * from './lib/errors';
|
||||
export * from './lib/events/domain-event';
|
||||
export * from './lib/events/event-cls.registry';
|
||||
export * from './lib/interfaces/serializable-event';
|
||||
export * from './lib/value-objects/version';
|
||||
51
libs/api/shared/domain/src/lib/aggregate-root.ts
Normal file
51
libs/api/shared/domain/src/lib/aggregate-root.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
import { AggregateRoot } from '@nestjs/cqrs';
|
||||
import { SerializableEvent } from './interfaces/serializable-event';
|
||||
import { Version } from './value-objects/version';
|
||||
|
||||
const VERSION = Symbol('version');
|
||||
const PENDING_COMMIT = Symbol('pendingCommit');
|
||||
|
||||
export class VersionedAggregateRoot extends AggregateRoot {
|
||||
public id!: string;
|
||||
|
||||
private [VERSION] = new Version(0);
|
||||
private [PENDING_COMMIT]: Promise<unknown> = Promise.resolve();
|
||||
|
||||
get version(): Version {
|
||||
return this[VERSION];
|
||||
}
|
||||
|
||||
override loadFromHistory(history: SerializableEvent[]): void {
|
||||
const domainEvents = history.map((event) => event.data);
|
||||
super.loadFromHistory(domainEvents);
|
||||
|
||||
const lastEvent = history[history.length - 1];
|
||||
this.setVersion(new Version(lastEvent.position));
|
||||
}
|
||||
|
||||
/**
|
||||
* `commit()` from @nestjs/cqrs is fire-and-forget. This commits and waits
|
||||
* until the event store has persisted the events, so failures (e.g. a
|
||||
* concurrent modification) reach the command handler.
|
||||
*/
|
||||
async commitAndWait(): Promise<void> {
|
||||
this.commit();
|
||||
await this[PENDING_COMMIT];
|
||||
}
|
||||
|
||||
/** Called by the event-store publisher with the persistence of the committed events. */
|
||||
trackCommit(persisted: Promise<unknown>, eventCount: number): void {
|
||||
const from = this.version.value;
|
||||
const tracked = persisted.then(() =>
|
||||
this.setVersion(new Version(from + eventCount)),
|
||||
);
|
||||
// Marked as handled so a bare commit() never crashes the process;
|
||||
// commitAndWait() still receives the error.
|
||||
tracked.catch(() => undefined);
|
||||
this[PENDING_COMMIT] = tracked;
|
||||
}
|
||||
|
||||
private setVersion(version: Version): void {
|
||||
this[VERSION] = version;
|
||||
}
|
||||
}
|
||||
51
libs/api/shared/domain/src/lib/errors.ts
Normal file
51
libs/api/shared/domain/src/lib/errors.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
export type DomainErrorCode =
|
||||
| 'NOT_FOUND'
|
||||
| 'FORBIDDEN'
|
||||
| 'VALIDATION'
|
||||
| 'CONFLICT'
|
||||
| 'BUSINESS_RULE';
|
||||
|
||||
export abstract class DomainError extends Error {
|
||||
abstract readonly code: DomainErrorCode;
|
||||
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
this.name = new.target.name;
|
||||
}
|
||||
}
|
||||
|
||||
export class NotFoundError extends DomainError {
|
||||
readonly code = 'NOT_FOUND' as const;
|
||||
|
||||
constructor(entity: string, id: string) {
|
||||
super(`${entity} "${id}" not found`);
|
||||
}
|
||||
}
|
||||
|
||||
export class ForbiddenError extends DomainError {
|
||||
readonly code = 'FORBIDDEN' as const;
|
||||
}
|
||||
|
||||
export class ValidationError extends DomainError {
|
||||
readonly code = 'VALIDATION' as const;
|
||||
}
|
||||
|
||||
export class BusinessRuleError extends DomainError {
|
||||
readonly code = 'BUSINESS_RULE' as const;
|
||||
}
|
||||
|
||||
/** Optimistic-locking failure: someone else modified the aggregate first. */
|
||||
export class ConcurrencyError extends DomainError {
|
||||
readonly code = 'CONFLICT' as const;
|
||||
|
||||
constructor(entity: string, id: string) {
|
||||
super(`${entity} "${id}" was modified concurrently, retry the operation`);
|
||||
}
|
||||
}
|
||||
|
||||
export function assertThat(
|
||||
condition: unknown,
|
||||
message: string,
|
||||
): asserts condition {
|
||||
if (!condition) throw new ValidationError(message);
|
||||
}
|
||||
17
libs/api/shared/domain/src/lib/events/domain-event.ts
Normal file
17
libs/api/shared/domain/src/lib/events/domain-event.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import { randomUUID } from 'node:crypto';
|
||||
|
||||
/** Base class of every domain event: who did it and when. */
|
||||
export abstract class DomainEvent {
|
||||
readonly occurredAt: string = new Date().toISOString();
|
||||
/**
|
||||
* Position of the event in its aggregate stream. Set by the event store
|
||||
* when the event is persisted (undefined on freshly applied events).
|
||||
*/
|
||||
readonly aggregateVersion?: number;
|
||||
|
||||
constructor(readonly actorId: string) {}
|
||||
}
|
||||
|
||||
export function newId(): string {
|
||||
return randomUUID();
|
||||
}
|
||||
23
libs/api/shared/domain/src/lib/events/event-cls.registry.ts
Normal file
23
libs/api/shared/domain/src/lib/events/event-cls.registry.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import { Type } from '@nestjs/common';
|
||||
|
||||
/** Maps stored event type names back to their classes (for deserialization). */
|
||||
export class EventClsRegistry {
|
||||
private static readonly eventClsMap = new Map<string, Type>();
|
||||
|
||||
static add(eventCls: Type): void {
|
||||
this.eventClsMap.set(eventCls.name, eventCls);
|
||||
}
|
||||
|
||||
static get(eventClsName: string): Type {
|
||||
const eventCls = this.eventClsMap.get(eventClsName);
|
||||
if (!eventCls) {
|
||||
throw new Error(`Event class "${eventClsName}" not registered`);
|
||||
}
|
||||
return eventCls;
|
||||
}
|
||||
}
|
||||
|
||||
/** Registers a domain event class so it can be rebuilt from the event store. */
|
||||
export const AutowiredEvent: ClassDecorator = (target) => {
|
||||
EventClsRegistry.add(target as unknown as Type);
|
||||
};
|
||||
@@ -0,0 +1,20 @@
|
||||
/** Event payload as stored: class instances replaced by their JSON form. */
|
||||
export type SerializedEventPayload<T> = T extends object
|
||||
? {
|
||||
[K in keyof T]: T[K] extends { toJSON(): infer U }
|
||||
? U
|
||||
: SerializedEventPayload<T[K]>;
|
||||
}
|
||||
: T;
|
||||
|
||||
/** A domain event as persisted in the event store. */
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- payload type is open by design
|
||||
export interface SerializableEvent<T = any> {
|
||||
/** Aggregate id. */
|
||||
streamId: string;
|
||||
/** Event class name, e.g. `IssueCreatedEvent`. */
|
||||
type: string;
|
||||
/** 1-based position inside the stream = aggregate version after the event. */
|
||||
position: number;
|
||||
data: SerializedEventPayload<T>;
|
||||
}
|
||||
4
libs/api/shared/domain/src/lib/value-objects/version.ts
Normal file
4
libs/api/shared/domain/src/lib/value-objects/version.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
/** Position of the last event applied to an aggregate (0 = brand new). */
|
||||
export class Version {
|
||||
constructor(readonly value: number) {}
|
||||
}
|
||||
23
libs/api/shared/domain/tsconfig.json
Normal file
23
libs/api/shared/domain/tsconfig.json
Normal file
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"extends": "../../../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"module": "commonjs",
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"strict": true,
|
||||
"importHelpers": true,
|
||||
"noImplicitOverride": true,
|
||||
"noImplicitReturns": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"noPropertyAccessFromIndexSignature": true
|
||||
},
|
||||
"files": [],
|
||||
"include": [],
|
||||
"references": [
|
||||
{
|
||||
"path": "./tsconfig.lib.json"
|
||||
},
|
||||
{
|
||||
"path": "./tsconfig.spec.json"
|
||||
}
|
||||
]
|
||||
}
|
||||
15
libs/api/shared/domain/tsconfig.lib.json
Normal file
15
libs/api/shared/domain/tsconfig.lib.json
Normal file
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "../../../../dist/out-tsc",
|
||||
"declaration": true,
|
||||
"types": ["node"]
|
||||
},
|
||||
"include": ["src/**/*.ts"],
|
||||
"exclude": [
|
||||
"jest.config.ts",
|
||||
"jest.config.cts",
|
||||
"src/**/*.spec.ts",
|
||||
"src/**/*.test.ts"
|
||||
]
|
||||
}
|
||||
16
libs/api/shared/domain/tsconfig.spec.json
Normal file
16
libs/api/shared/domain/tsconfig.spec.json
Normal file
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "../../../../dist/out-tsc",
|
||||
"module": "commonjs",
|
||||
"moduleResolution": "bundler",
|
||||
"types": ["jest", "node"]
|
||||
},
|
||||
"include": [
|
||||
"jest.config.ts",
|
||||
"jest.config.cts",
|
||||
"src/**/*.test.ts",
|
||||
"src/**/*.spec.ts",
|
||||
"src/**/*.d.ts"
|
||||
]
|
||||
}
|
||||
25
libs/api/shared/infrastructure/README.md
Normal file
25
libs/api/shared/infrastructure/README.md
Normal file
@@ -0,0 +1,25 @@
|
||||
# api-shared-infrastructure
|
||||
|
||||
Event store en PostgreSQL y el camino de los eventos hasta la lectura.
|
||||
|
||||
- **Import**: `@task-stream/api/shared/infrastructure`
|
||||
- **Capa**: `infrastructure` · **Tags**: `scope:api-shared`, `type:infrastructure`
|
||||
|
||||
## Contenido
|
||||
|
||||
- `PostgresEventStore` (tabla `events`, `UNIQUE(stream_id, position)` → `ConcurrencyError`).
|
||||
- `EventSerializer` / `EventDeserializer` y `EventStorePublisher` (sustituye al publisher del `EventBus`: `commit()` persiste).
|
||||
- `EventStoreRelay`: copia los eventos nuevos al Redis Stream (`FOR UPDATE SKIP LOCKED`).
|
||||
- `EventsBridge`: consume el stream y entrega cada evento a sus `@EventsHandler`, en orden y esperando (ACK al final).
|
||||
- Conexiones Redis (`REDIS_CLIENT`, `GRAPHQL_PUB_SUB`) y `SharedInfrastructureModule.forRootAsync`.
|
||||
|
||||
Flujo completo: [Arquitectura](../../../../docs/02-arquitectura.md#22-flujo-completo-de-una-escritura).
|
||||
|
||||
## Comandos
|
||||
|
||||
```sh
|
||||
npx nx lint api-shared-infrastructure
|
||||
npx nx test api-shared-infrastructure # Jest
|
||||
```
|
||||
|
||||
Reglas de dependencia entre capas: [Arquitectura](../../../../docs/02-arquitectura.md#21-capas-hexagonales).
|
||||
3
libs/api/shared/infrastructure/eslint.config.mjs
Normal file
3
libs/api/shared/infrastructure/eslint.config.mjs
Normal file
@@ -0,0 +1,3 @@
|
||||
import baseConfig from '../../../../eslint.config.mjs';
|
||||
|
||||
export default [...baseConfig];
|
||||
11
libs/api/shared/infrastructure/jest.config.cts
Normal file
11
libs/api/shared/infrastructure/jest.config.cts
Normal file
@@ -0,0 +1,11 @@
|
||||
module.exports = {
|
||||
displayName: 'api-shared-infrastructure',
|
||||
preset: '../../../../jest.preset.js',
|
||||
testEnvironment: 'node',
|
||||
passWithNoTests: true,
|
||||
transform: {
|
||||
'^.+\\.[tj]s$': ['ts-jest', { tsconfig: '<rootDir>/tsconfig.spec.json' }],
|
||||
},
|
||||
moduleFileExtensions: ['ts', 'js', 'html'],
|
||||
coverageDirectory: '../../../../coverage/libs/api/shared/infrastructure',
|
||||
};
|
||||
12
libs/api/shared/infrastructure/project.json
Normal file
12
libs/api/shared/infrastructure/project.json
Normal file
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"name": "api-shared-infrastructure",
|
||||
"$schema": "../../../../node_modules/nx/schemas/project-schema.json",
|
||||
"sourceRoot": "libs/api/shared/infrastructure/src",
|
||||
"projectType": "library",
|
||||
"tags": [
|
||||
"scope:api-shared",
|
||||
"type:infrastructure"
|
||||
],
|
||||
"// targets": "to see all targets run: nx show project api-shared-infrastructure --web",
|
||||
"targets": {}
|
||||
}
|
||||
8
libs/api/shared/infrastructure/src/index.ts
Normal file
8
libs/api/shared/infrastructure/src/index.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
export * from './lib/shared-infrastructure.module';
|
||||
export * from './lib/redis/redis.tokens';
|
||||
export * from './lib/redis/redis-connection.factory';
|
||||
export * from './lib/event-stream/event-stream.options';
|
||||
export * from './lib/event-stream/event-stream.consumer';
|
||||
export * from './lib/event-store/entities/event.entity';
|
||||
export * from './lib/event-store/deserializers/event.deserializer';
|
||||
export * from './lib/event-store/serializers/event.serializer';
|
||||
@@ -0,0 +1,26 @@
|
||||
import { Injectable, Type } from '@nestjs/common';
|
||||
import {
|
||||
EventClsRegistry,
|
||||
SerializableEvent,
|
||||
} from '@task-stream/api/shared/domain';
|
||||
|
||||
@Injectable()
|
||||
export class EventDeserializer {
|
||||
deserialize<T>(event: SerializableEvent): SerializableEvent<T> {
|
||||
const eventCls = EventClsRegistry.get(event.type);
|
||||
return {
|
||||
streamId: event.streamId,
|
||||
type: event.type,
|
||||
position: event.position,
|
||||
data: this.instantiateSerializedEvent(eventCls, event.data),
|
||||
};
|
||||
}
|
||||
|
||||
/** Rebuilds the event with its class prototype, without running the constructor. */
|
||||
instantiateSerializedEvent<T extends Type>(
|
||||
eventCls: T,
|
||||
data: Record<string, unknown>,
|
||||
) {
|
||||
return Object.assign(Object.create(eventCls.prototype), data);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import {
|
||||
Column,
|
||||
CreateDateColumn,
|
||||
Entity,
|
||||
Index,
|
||||
PrimaryGeneratedColumn,
|
||||
Unique,
|
||||
} from 'typeorm';
|
||||
|
||||
/**
|
||||
* Event store (write model). Append-only: one row per domain event.
|
||||
* `UNIQUE(stream_id, position)` gives optimistic concurrency per aggregate.
|
||||
*/
|
||||
@Entity('events')
|
||||
@Unique('uq_events_stream_position', ['streamId', 'position'])
|
||||
@Index('idx_events_unpublished', ['id'], { where: '"published_at" IS NULL' })
|
||||
export class EventEntity {
|
||||
/** Global order of the store, used to relay events in sequence. */
|
||||
@PrimaryGeneratedColumn('increment', { type: 'bigint' })
|
||||
id!: string;
|
||||
|
||||
@Column({ name: 'stream_id', length: 64 })
|
||||
streamId!: string;
|
||||
|
||||
@Column({ type: 'int' })
|
||||
position!: number;
|
||||
|
||||
@Column()
|
||||
type!: string;
|
||||
|
||||
@Column({ type: 'jsonb' })
|
||||
data!: Record<string, unknown>;
|
||||
|
||||
@CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
|
||||
createdAt!: Date;
|
||||
|
||||
/** When the relay pushed it to the Redis stream. */
|
||||
@Column({ name: 'published_at', type: 'timestamptz', nullable: true })
|
||||
publishedAt!: Date | null;
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
import {
|
||||
Injectable,
|
||||
Logger,
|
||||
OnApplicationBootstrap,
|
||||
OnModuleDestroy,
|
||||
} from '@nestjs/common';
|
||||
import { SerializableEvent } from '@task-stream/api/shared/domain';
|
||||
import { Subscription, debounceTime, interval, merge } from 'rxjs';
|
||||
import { DataSource } from 'typeorm';
|
||||
import { EventStreamPublisher } from '../event-stream/event-stream.publisher';
|
||||
import { EventEntity } from './entities/event.entity';
|
||||
import { EventStoreSignal } from './event-store.signal';
|
||||
|
||||
const BATCH_SIZE = 200;
|
||||
const POLL_INTERVAL_MS = 1000;
|
||||
|
||||
/**
|
||||
* Forwards new events from the Postgres event store to the Redis stream, in
|
||||
* store order. `FOR UPDATE SKIP LOCKED` lets several instances relay without
|
||||
* duplicates; delivery is at-least-once.
|
||||
*/
|
||||
@Injectable()
|
||||
export class EventStoreRelay
|
||||
implements OnApplicationBootstrap, OnModuleDestroy
|
||||
{
|
||||
private readonly logger = new Logger(EventStoreRelay.name);
|
||||
private subscription?: Subscription;
|
||||
private draining?: Promise<void>;
|
||||
private again = false;
|
||||
|
||||
constructor(
|
||||
private readonly dataSource: DataSource,
|
||||
private readonly publisher: EventStreamPublisher,
|
||||
private readonly signal: EventStoreSignal,
|
||||
) {}
|
||||
|
||||
onApplicationBootstrap(): void {
|
||||
this.subscription = merge(
|
||||
this.signal.appended$.pipe(debounceTime(10)),
|
||||
interval(POLL_INTERVAL_MS),
|
||||
).subscribe(() => void this.drain());
|
||||
}
|
||||
|
||||
/** Runs before connections are closed: stop polling and let the current batch finish. */
|
||||
async onModuleDestroy(): Promise<void> {
|
||||
this.subscription?.unsubscribe();
|
||||
await this.draining;
|
||||
}
|
||||
|
||||
drain(): Promise<void> {
|
||||
if (this.draining) {
|
||||
this.again = true;
|
||||
return this.draining;
|
||||
}
|
||||
this.draining = this.drainLoop().finally(() => (this.draining = undefined));
|
||||
return this.draining;
|
||||
}
|
||||
|
||||
private async drainLoop(): Promise<void> {
|
||||
try {
|
||||
do {
|
||||
this.again = false;
|
||||
while ((await this.relayBatch()) === BATCH_SIZE);
|
||||
} while (this.again);
|
||||
} catch (error) {
|
||||
this.logger.error(`Event relay failed: ${(error as Error).message}`);
|
||||
}
|
||||
}
|
||||
|
||||
private relayBatch(): Promise<number> {
|
||||
return this.dataSource.transaction(async (manager) => {
|
||||
const rows = await manager
|
||||
.getRepository(EventEntity)
|
||||
.createQueryBuilder('e')
|
||||
.where('e.published_at IS NULL')
|
||||
.orderBy('e.id', 'ASC')
|
||||
.limit(BATCH_SIZE)
|
||||
.setLock('pessimistic_write')
|
||||
.setOnLocked('skip_locked')
|
||||
.getMany();
|
||||
if (rows.length === 0) return 0;
|
||||
|
||||
await this.publisher.publish(
|
||||
rows.map(
|
||||
(row): SerializableEvent => ({
|
||||
streamId: row.streamId,
|
||||
type: row.type,
|
||||
position: row.position,
|
||||
data: row.data,
|
||||
}),
|
||||
),
|
||||
);
|
||||
await manager
|
||||
.getRepository(EventEntity)
|
||||
.createQueryBuilder()
|
||||
.update()
|
||||
.set({ publishedAt: () => 'now()' })
|
||||
.whereInIds(rows.map((r) => r.id))
|
||||
.execute();
|
||||
return rows.length;
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { Subject } from 'rxjs';
|
||||
|
||||
/** In-process nudge so the relay forwards events right after they are stored. */
|
||||
@Injectable()
|
||||
export class EventStoreSignal {
|
||||
private readonly subject = new Subject<void>();
|
||||
readonly appended$ = this.subject.asObservable();
|
||||
|
||||
notify(): void {
|
||||
this.subject.next();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
import {
|
||||
Inject,
|
||||
Injectable,
|
||||
Logger,
|
||||
OnApplicationBootstrap,
|
||||
OnApplicationShutdown,
|
||||
Type,
|
||||
} from '@nestjs/common';
|
||||
import { DiscoveryService } from '@nestjs/core';
|
||||
import { IEventHandler } from '@nestjs/cqrs';
|
||||
import { SerializableEvent } from '@task-stream/api/shared/domain';
|
||||
import { hostname } from 'node:os';
|
||||
import { EventStreamConsumer } from '../event-stream/event-stream.consumer';
|
||||
import {
|
||||
EVENT_STREAM_OPTIONS,
|
||||
EventStreamOptions,
|
||||
} from '../event-stream/event-stream.options';
|
||||
import { RedisConnectionFactory } from '../redis/redis-connection.factory';
|
||||
import { EventDeserializer } from './deserializers/event.deserializer';
|
||||
|
||||
export const EVENTS_BRIDGE_ENABLED = Symbol('EVENTS_BRIDGE_ENABLED');
|
||||
|
||||
/** Metadata written by @EventsHandler(...events) (@nestjs/cqrs). */
|
||||
const EVENTS_HANDLER_METADATA = '__eventsHandler__';
|
||||
|
||||
/**
|
||||
* Redis stream → @EventsHandler. Plays the role of the event-store change
|
||||
* stream: every stored event is deserialized and handed to the event
|
||||
* handlers registered for its class (read-model projections, realtime...).
|
||||
*
|
||||
* Unlike `eventBus.subject$.next()`, handlers are awaited one event at a
|
||||
* time, so events of an aggregate are projected in order, and the entry is
|
||||
* acknowledged in Redis only once every handler succeeded (otherwise it
|
||||
* stays pending and is retried).
|
||||
*/
|
||||
@Injectable()
|
||||
export class EventsBridge
|
||||
implements OnApplicationBootstrap, OnApplicationShutdown
|
||||
{
|
||||
private readonly logger = new Logger(EventsBridge.name);
|
||||
private consumer?: EventStreamConsumer;
|
||||
private readonly handlers = new Map<Type, IEventHandler[]>();
|
||||
|
||||
constructor(
|
||||
private readonly connections: RedisConnectionFactory,
|
||||
@Inject(EVENT_STREAM_OPTIONS) private readonly stream: EventStreamOptions,
|
||||
@Inject(EVENTS_BRIDGE_ENABLED) private readonly enabled: boolean,
|
||||
private readonly eventDeserializer: EventDeserializer,
|
||||
private readonly discovery: DiscoveryService,
|
||||
) {}
|
||||
|
||||
async onApplicationBootstrap(): Promise<void> {
|
||||
if (!this.enabled) {
|
||||
this.logger.log('Events bridge disabled on this instance');
|
||||
return;
|
||||
}
|
||||
this.registerEventHandlers();
|
||||
this.consumer = new EventStreamConsumer(
|
||||
this.connections.create('events-bridge'),
|
||||
{
|
||||
streamKey: this.stream.streamKey,
|
||||
group: 'events-bridge',
|
||||
consumer: hostname(),
|
||||
},
|
||||
(event) => this.handleEvent(event),
|
||||
);
|
||||
await this.consumer.start();
|
||||
this.logger.log(
|
||||
`Bridging ${this.stream.streamKey} to ${this.handlers.size} event type(s)`,
|
||||
);
|
||||
}
|
||||
|
||||
async onApplicationShutdown(): Promise<void> {
|
||||
await this.consumer?.stop();
|
||||
}
|
||||
|
||||
private registerEventHandlers(): void {
|
||||
for (const wrapper of this.discovery.getProviders()) {
|
||||
if (!wrapper.metatype || !wrapper.instance) continue;
|
||||
const events: Type[] | undefined = Reflect.getMetadata(
|
||||
EVENTS_HANDLER_METADATA,
|
||||
wrapper.metatype,
|
||||
);
|
||||
for (const eventCls of events ?? []) {
|
||||
this.handlers.set(eventCls, [
|
||||
...(this.handlers.get(eventCls) ?? []),
|
||||
wrapper.instance as IEventHandler,
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async handleEvent(stored: SerializableEvent): Promise<void> {
|
||||
const { data: event } = this.eventDeserializer.deserialize(stored);
|
||||
const eventCls = Object.getPrototypeOf(event).constructor as Type;
|
||||
for (const handler of this.handlers.get(eventCls) ?? []) {
|
||||
await handler.handle(event);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { EventStore } from '@task-stream/api/shared/application';
|
||||
import {
|
||||
ConcurrencyError,
|
||||
SerializableEvent,
|
||||
} from '@task-stream/api/shared/domain';
|
||||
import { DataSource } from 'typeorm';
|
||||
import { EventEntity } from './entities/event.entity';
|
||||
import { EventDeserializer } from './deserializers/event.deserializer';
|
||||
import { EventStoreSignal } from './event-store.signal';
|
||||
|
||||
const UNIQUE_VIOLATION = '23505';
|
||||
|
||||
@Injectable()
|
||||
export class PostgresEventStore extends EventStore {
|
||||
private readonly logger = new Logger(PostgresEventStore.name);
|
||||
|
||||
constructor(
|
||||
private readonly dataSource: DataSource,
|
||||
private readonly deserializer: EventDeserializer,
|
||||
private readonly signal: EventStoreSignal,
|
||||
) {
|
||||
super();
|
||||
}
|
||||
|
||||
async persist(
|
||||
eventOrEvents: SerializableEvent | SerializableEvent[],
|
||||
): Promise<void> {
|
||||
const events = Array.isArray(eventOrEvents)
|
||||
? eventOrEvents
|
||||
: [eventOrEvents];
|
||||
if (events.length === 0) return;
|
||||
|
||||
try {
|
||||
await this.dataSource.transaction((manager) =>
|
||||
manager.save(
|
||||
EventEntity,
|
||||
events.map((e) =>
|
||||
manager.create(EventEntity, {
|
||||
streamId: e.streamId,
|
||||
position: e.position,
|
||||
type: e.type,
|
||||
data: e.data as Record<string, unknown>,
|
||||
}),
|
||||
),
|
||||
),
|
||||
);
|
||||
this.logger.debug(`Events inserted successfully to the event store`);
|
||||
} catch (error) {
|
||||
if ((error as { code?: string }).code === UNIQUE_VIOLATION) {
|
||||
this.logger.warn(
|
||||
`Events could not be persisted. Aggregate ${events[0].streamId} is stale.`,
|
||||
);
|
||||
throw new ConcurrencyError('Aggregate', events[0].streamId);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
this.signal.notify();
|
||||
}
|
||||
|
||||
async getEventsByStreamId(streamId: string): Promise<SerializableEvent[]> {
|
||||
const rows = await this.dataSource
|
||||
.getRepository(EventEntity)
|
||||
.find({ where: { streamId }, order: { position: 'ASC' } });
|
||||
return rows.map((row) =>
|
||||
this.deserializer.deserialize({
|
||||
streamId: row.streamId,
|
||||
type: row.type,
|
||||
position: row.position,
|
||||
data: row.data,
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import { Injectable, OnApplicationBootstrap } from '@nestjs/common';
|
||||
import { EventBus, IEvent, IEventPublisher } from '@nestjs/cqrs';
|
||||
import { EventStore } from '@task-stream/api/shared/application';
|
||||
import { VersionedAggregateRoot } from '@task-stream/api/shared/domain';
|
||||
import { EventSerializer } from '../serializers/event.serializer';
|
||||
|
||||
/**
|
||||
* Replaces the EventBus publisher: `aggregate.commit()` appends the events
|
||||
* to the event store instead of dispatching them in memory. Handlers see
|
||||
* them later through the EventsBridge.
|
||||
*/
|
||||
@Injectable()
|
||||
export class EventStorePublisher
|
||||
implements OnApplicationBootstrap, IEventPublisher
|
||||
{
|
||||
constructor(
|
||||
private readonly eventStore: EventStore,
|
||||
private readonly eventBus: EventBus,
|
||||
private readonly eventSerializer: EventSerializer,
|
||||
) {}
|
||||
|
||||
onApplicationBootstrap() {
|
||||
this.eventBus.publisher = this;
|
||||
}
|
||||
|
||||
publish<T extends IEvent = IEvent>(
|
||||
event: T,
|
||||
dispatcher: VersionedAggregateRoot,
|
||||
) {
|
||||
return this.publishAll([event], dispatcher);
|
||||
}
|
||||
|
||||
publishAll<T extends IEvent = IEvent>(
|
||||
events: T[],
|
||||
dispatcher: VersionedAggregateRoot,
|
||||
) {
|
||||
const serializableEvents = events
|
||||
.map((event) => this.eventSerializer.serialize(event, dispatcher))
|
||||
.map((serializableEvent, index) => {
|
||||
const position = dispatcher.version.value + index + 1;
|
||||
// The stored event knows its own position: handlers use it to version read models.
|
||||
return {
|
||||
...serializableEvent,
|
||||
position,
|
||||
data: { ...serializableEvent.data, aggregateVersion: position },
|
||||
};
|
||||
});
|
||||
|
||||
const persisted = this.eventStore.persist(serializableEvents);
|
||||
// Lets commitAndWait() await the write and advances the aggregate version.
|
||||
dispatcher.trackCommit(persisted, serializableEvents.length);
|
||||
return persisted;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import {
|
||||
SerializableEvent,
|
||||
VersionedAggregateRoot,
|
||||
} from '@task-stream/api/shared/domain';
|
||||
|
||||
@Injectable()
|
||||
export class EventSerializer {
|
||||
serialize<T>(
|
||||
event: T,
|
||||
dispatcher: VersionedAggregateRoot,
|
||||
): SerializableEvent<T> {
|
||||
const eventType = (event as object).constructor?.name;
|
||||
if (!eventType) {
|
||||
throw new Error('Incompatible event type');
|
||||
}
|
||||
|
||||
const aggregateId = dispatcher.id;
|
||||
return {
|
||||
streamId: aggregateId,
|
||||
position: dispatcher.version.value + 1,
|
||||
type: eventType,
|
||||
data: this.toJSON(event) as SerializableEvent<T>['data'],
|
||||
};
|
||||
}
|
||||
|
||||
private toJSON<T>(data: T): unknown {
|
||||
if (typeof data !== 'object' || data === null) {
|
||||
return data;
|
||||
}
|
||||
|
||||
if ('toJSON' in data && typeof data.toJSON === 'function') {
|
||||
return data.toJSON();
|
||||
}
|
||||
|
||||
if (Array.isArray(data)) {
|
||||
return data.map((item) => this.toJSON(item));
|
||||
}
|
||||
|
||||
return Object.entries(data).reduce<Record<string, unknown>>(
|
||||
(acc, [key, value]) => {
|
||||
acc[key] = this.toJSON(value);
|
||||
return acc;
|
||||
},
|
||||
{},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
import { Logger } from '@nestjs/common';
|
||||
import Redis from 'ioredis';
|
||||
import { SerializableEvent } from '@task-stream/api/shared/domain';
|
||||
|
||||
type StreamEntry = [id: string, fields: string[]];
|
||||
type StreamReply = [stream: string, entries: StreamEntry[]][] | null;
|
||||
|
||||
export interface EventStreamConsumerOptions {
|
||||
streamKey: string;
|
||||
group: string;
|
||||
consumer: string;
|
||||
batchSize?: number;
|
||||
blockMs?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads the event stream with a Redis consumer group. Entries are ACKed only
|
||||
* after the handler succeeds; failed ones stay pending and are retried on
|
||||
* the next start (or by another consumer of the group).
|
||||
*/
|
||||
export class EventStreamConsumer {
|
||||
private readonly logger: Logger;
|
||||
private running = false;
|
||||
private loop?: Promise<void>;
|
||||
|
||||
constructor(
|
||||
private readonly redis: Redis,
|
||||
private readonly options: EventStreamConsumerOptions,
|
||||
private readonly handle: (event: SerializableEvent) => Promise<void>,
|
||||
) {
|
||||
this.logger = new Logger(`EventStreamConsumer:${options.group}`);
|
||||
}
|
||||
|
||||
async start(): Promise<void> {
|
||||
await this.ensureGroup();
|
||||
this.running = true;
|
||||
this.loop = this.run();
|
||||
}
|
||||
|
||||
async stop(): Promise<void> {
|
||||
this.running = false;
|
||||
this.redis.disconnect();
|
||||
await this.loop?.catch(() => undefined);
|
||||
}
|
||||
|
||||
private async ensureGroup(): Promise<void> {
|
||||
try {
|
||||
await this.redis.xgroup(
|
||||
'CREATE',
|
||||
this.options.streamKey,
|
||||
this.options.group,
|
||||
'0',
|
||||
'MKSTREAM',
|
||||
);
|
||||
} catch (error) {
|
||||
if (!String(error).includes('BUSYGROUP')) throw error;
|
||||
}
|
||||
// Take over entries left pending by consumers that died (e.g. old pods).
|
||||
await this.redis.xautoclaim(
|
||||
this.options.streamKey,
|
||||
this.options.group,
|
||||
this.options.consumer,
|
||||
60_000,
|
||||
'0-0',
|
||||
'COUNT',
|
||||
1000,
|
||||
);
|
||||
}
|
||||
|
||||
private async run(): Promise<void> {
|
||||
// First drain our own pending entries (crash recovery), then new ones.
|
||||
let cursor = '0';
|
||||
while (this.running) {
|
||||
try {
|
||||
const reply = (await this.redis.xreadgroup(
|
||||
'GROUP',
|
||||
this.options.group,
|
||||
this.options.consumer,
|
||||
'COUNT',
|
||||
this.options.batchSize ?? 50,
|
||||
'BLOCK',
|
||||
this.options.blockMs ?? 5000,
|
||||
'STREAMS',
|
||||
this.options.streamKey,
|
||||
cursor,
|
||||
)) as StreamReply;
|
||||
|
||||
const entries = reply?.[0]?.[1] ?? [];
|
||||
if (cursor !== '>' && entries.length === 0) {
|
||||
cursor = '>';
|
||||
continue;
|
||||
}
|
||||
for (const [id, fields] of entries) {
|
||||
await this.process(id, fields);
|
||||
}
|
||||
} catch (error) {
|
||||
if (!this.running) break;
|
||||
this.logger.error(`Stream read failed: ${(error as Error).message}`);
|
||||
await new Promise((resolve) => setTimeout(resolve, 1000));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async process(id: string, fields: string[]): Promise<void> {
|
||||
const index = fields.indexOf('event');
|
||||
if (index === -1) {
|
||||
await this.redis.xack(this.options.streamKey, this.options.group, id);
|
||||
return;
|
||||
}
|
||||
const event = JSON.parse(fields[index + 1]) as SerializableEvent;
|
||||
try {
|
||||
await this.handle(event);
|
||||
await this.redis.xack(this.options.streamKey, this.options.group, id);
|
||||
} catch (error) {
|
||||
// Left pending: it will be retried after a restart.
|
||||
this.logger.error(
|
||||
`Failed to handle ${event.type} (${event.streamId}#${event.position}): ${(error as Error).message}`,
|
||||
(error as Error).stack,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
export interface EventStreamOptions {
|
||||
/** Redis stream key, e.g. `task-stream:events`. */
|
||||
streamKey: string;
|
||||
/** Approximate max length kept in the stream (older entries are trimmed). */
|
||||
maxLength: number;
|
||||
}
|
||||
|
||||
export const EVENT_STREAM_OPTIONS = Symbol('EVENT_STREAM_OPTIONS');
|
||||
@@ -0,0 +1,39 @@
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import Redis from 'ioredis';
|
||||
import { REDIS_CLIENT } from '../redis/redis.tokens';
|
||||
import {
|
||||
EVENT_STREAM_OPTIONS,
|
||||
EventStreamOptions,
|
||||
} from './event-stream.options';
|
||||
import { SerializableEvent } from '@task-stream/api/shared/domain';
|
||||
|
||||
@Injectable()
|
||||
export class EventStreamPublisher {
|
||||
constructor(
|
||||
@Inject(REDIS_CLIENT) private readonly redis: Redis,
|
||||
@Inject(EVENT_STREAM_OPTIONS) private readonly options: EventStreamOptions,
|
||||
) {}
|
||||
|
||||
async publish(events: SerializableEvent[]): Promise<void> {
|
||||
if (events.length === 0) return;
|
||||
const pipeline = this.redis.pipeline();
|
||||
for (const event of events) {
|
||||
pipeline.xadd(
|
||||
this.options.streamKey,
|
||||
'MAXLEN',
|
||||
'~',
|
||||
this.options.maxLength,
|
||||
'*',
|
||||
'type',
|
||||
event.type,
|
||||
'streamId',
|
||||
event.streamId,
|
||||
'event',
|
||||
JSON.stringify(event),
|
||||
);
|
||||
}
|
||||
const results = await pipeline.exec();
|
||||
const failed = results?.find(([error]) => error);
|
||||
if (failed?.[0]) throw failed[0];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import { Injectable, OnApplicationShutdown } from '@nestjs/common';
|
||||
import Redis from 'ioredis';
|
||||
|
||||
/**
|
||||
* Creates Redis connections and closes them on shutdown. Blocking commands
|
||||
* (XREADGROUP BLOCK) and pub/sub subscribers need dedicated connections.
|
||||
*/
|
||||
@Injectable()
|
||||
export class RedisConnectionFactory implements OnApplicationShutdown {
|
||||
private readonly connections: Redis[] = [];
|
||||
|
||||
constructor(private readonly url: string) {}
|
||||
|
||||
create(name: string): Redis {
|
||||
const connection = new Redis(this.url, {
|
||||
connectionName: `task-stream:${name}`,
|
||||
maxRetriesPerRequest: null,
|
||||
});
|
||||
this.connections.push(connection);
|
||||
return connection;
|
||||
}
|
||||
|
||||
async onApplicationShutdown(): Promise<void> {
|
||||
await Promise.allSettled(this.connections.map((c) => c.quit()));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export const REDIS_CLIENT = Symbol('REDIS_CLIENT');
|
||||
export const GRAPHQL_PUB_SUB = Symbol('GRAPHQL_PUB_SUB');
|
||||
@@ -0,0 +1,117 @@
|
||||
import { DynamicModule, Global, Module } from '@nestjs/common';
|
||||
import { DiscoveryModule } from '@nestjs/core';
|
||||
import { CqrsModule } from '@nestjs/cqrs';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { EventStore } from '@task-stream/api/shared/application';
|
||||
import { RedisPubSub } from 'graphql-redis-subscriptions';
|
||||
import { EventDeserializer } from './event-store/deserializers/event.deserializer';
|
||||
import { EventEntity } from './event-store/entities/event.entity';
|
||||
import { EventStoreRelay } from './event-store/event-store.relay';
|
||||
import { EventStoreSignal } from './event-store/event-store.signal';
|
||||
import {
|
||||
EVENTS_BRIDGE_ENABLED,
|
||||
EventsBridge,
|
||||
} from './event-store/events-bridge';
|
||||
import { PostgresEventStore } from './event-store/postgres-event-store';
|
||||
import { EventStorePublisher } from './event-store/publishers/event-store.publisher';
|
||||
import { EventSerializer } from './event-store/serializers/event.serializer';
|
||||
import {
|
||||
EVENT_STREAM_OPTIONS,
|
||||
EventStreamOptions,
|
||||
} from './event-stream/event-stream.options';
|
||||
import { EventStreamPublisher } from './event-stream/event-stream.publisher';
|
||||
import { RedisConnectionFactory } from './redis/redis-connection.factory';
|
||||
import { GRAPHQL_PUB_SUB, REDIS_CLIENT } from './redis/redis.tokens';
|
||||
|
||||
export interface SharedInfrastructureOptions extends EventStreamOptions {
|
||||
redisUrl: string;
|
||||
/** Run the Redis stream → @EventsHandler bridge on this instance. */
|
||||
eventsBridgeEnabled: boolean;
|
||||
}
|
||||
|
||||
const OPTIONS = Symbol('SHARED_INFRASTRUCTURE_OPTIONS');
|
||||
|
||||
/**
|
||||
* Event store (PostgreSQL), relay to the Redis stream, events bridge to the
|
||||
* read side, and Redis clients (GraphQL pub/sub, caches).
|
||||
*/
|
||||
@Global()
|
||||
@Module({})
|
||||
export class SharedInfrastructureModule {
|
||||
static forRootAsync(config: {
|
||||
imports?: DynamicModule['imports'];
|
||||
inject?: unknown[];
|
||||
useFactory: (...args: never[]) => SharedInfrastructureOptions;
|
||||
}): DynamicModule {
|
||||
return {
|
||||
module: SharedInfrastructureModule,
|
||||
imports: [
|
||||
...(config.imports ?? []),
|
||||
CqrsModule,
|
||||
DiscoveryModule,
|
||||
TypeOrmModule.forFeature([EventEntity]),
|
||||
],
|
||||
providers: [
|
||||
{
|
||||
provide: OPTIONS,
|
||||
useFactory: config.useFactory,
|
||||
inject: config.inject as never[],
|
||||
},
|
||||
{
|
||||
provide: RedisConnectionFactory,
|
||||
useFactory: (o: SharedInfrastructureOptions) =>
|
||||
new RedisConnectionFactory(o.redisUrl),
|
||||
inject: [OPTIONS],
|
||||
},
|
||||
{
|
||||
provide: REDIS_CLIENT,
|
||||
useFactory: (factory: RedisConnectionFactory) =>
|
||||
factory.create('main'),
|
||||
inject: [RedisConnectionFactory],
|
||||
},
|
||||
{
|
||||
// Redis-backed pub/sub so GraphQL subscriptions work across instances.
|
||||
provide: GRAPHQL_PUB_SUB,
|
||||
useFactory: (factory: RedisConnectionFactory) =>
|
||||
new RedisPubSub({
|
||||
publisher: factory.create('pubsub-publisher'),
|
||||
subscriber: factory.create('pubsub-subscriber'),
|
||||
}),
|
||||
inject: [RedisConnectionFactory],
|
||||
},
|
||||
{
|
||||
provide: EVENT_STREAM_OPTIONS,
|
||||
useFactory: (o: SharedInfrastructureOptions): EventStreamOptions => ({
|
||||
streamKey: o.streamKey,
|
||||
maxLength: o.maxLength,
|
||||
}),
|
||||
inject: [OPTIONS],
|
||||
},
|
||||
{
|
||||
provide: EVENTS_BRIDGE_ENABLED,
|
||||
useFactory: (o: SharedInfrastructureOptions) => o.eventsBridgeEnabled,
|
||||
inject: [OPTIONS],
|
||||
},
|
||||
// Event store
|
||||
EventSerializer,
|
||||
EventDeserializer,
|
||||
EventStoreSignal,
|
||||
PostgresEventStore,
|
||||
{ provide: EventStore, useExisting: PostgresEventStore },
|
||||
EventStorePublisher,
|
||||
// Write → read
|
||||
EventStreamPublisher,
|
||||
EventStoreRelay,
|
||||
EventsBridge,
|
||||
],
|
||||
exports: [
|
||||
EventStore,
|
||||
EventDeserializer,
|
||||
RedisConnectionFactory,
|
||||
REDIS_CLIENT,
|
||||
GRAPHQL_PUB_SUB,
|
||||
EVENT_STREAM_OPTIONS,
|
||||
],
|
||||
};
|
||||
}
|
||||
}
|
||||
23
libs/api/shared/infrastructure/tsconfig.json
Normal file
23
libs/api/shared/infrastructure/tsconfig.json
Normal file
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"extends": "../../../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"module": "commonjs",
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"strict": true,
|
||||
"importHelpers": true,
|
||||
"noImplicitOverride": true,
|
||||
"noImplicitReturns": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"noPropertyAccessFromIndexSignature": true
|
||||
},
|
||||
"files": [],
|
||||
"include": [],
|
||||
"references": [
|
||||
{
|
||||
"path": "./tsconfig.lib.json"
|
||||
},
|
||||
{
|
||||
"path": "./tsconfig.spec.json"
|
||||
}
|
||||
]
|
||||
}
|
||||
23
libs/api/shared/infrastructure/tsconfig.lib.json
Normal file
23
libs/api/shared/infrastructure/tsconfig.lib.json
Normal file
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "../../../../dist/out-tsc",
|
||||
"declaration": true,
|
||||
"types": ["node"],
|
||||
"target": "es2021",
|
||||
"experimentalDecorators": true,
|
||||
"emitDecoratorMetadata": true,
|
||||
"strictNullChecks": true,
|
||||
"noImplicitAny": true,
|
||||
"strictBindCallApply": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"noFallthroughCasesInSwitch": true
|
||||
},
|
||||
"include": ["src/**/*.ts"],
|
||||
"exclude": [
|
||||
"jest.config.ts",
|
||||
"jest.config.cts",
|
||||
"src/**/*.spec.ts",
|
||||
"src/**/*.test.ts"
|
||||
]
|
||||
}
|
||||
16
libs/api/shared/infrastructure/tsconfig.spec.json
Normal file
16
libs/api/shared/infrastructure/tsconfig.spec.json
Normal file
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "../../../../dist/out-tsc",
|
||||
"module": "commonjs",
|
||||
"moduleResolution": "bundler",
|
||||
"types": ["jest", "node"]
|
||||
},
|
||||
"include": [
|
||||
"jest.config.ts",
|
||||
"jest.config.cts",
|
||||
"src/**/*.test.ts",
|
||||
"src/**/*.spec.ts",
|
||||
"src/**/*.d.ts"
|
||||
]
|
||||
}
|
||||
19
libs/api/shared/presenters/README.md
Normal file
19
libs/api/shared/presenters/README.md
Normal file
@@ -0,0 +1,19 @@
|
||||
# api-shared-presenters
|
||||
|
||||
Adaptadores de entrada comunes.
|
||||
|
||||
- **Import**: `@task-stream/api/shared/presenters`
|
||||
- **Capa**: `presenters` · **Tags**: `scope:api-shared`, `type:presenters`
|
||||
|
||||
## Contenido
|
||||
|
||||
- `DomainErrorFilter`: convierte los errores de dominio en errores GraphQL (`extensions.code`) o respuestas HTTP.
|
||||
|
||||
## Comandos
|
||||
|
||||
```sh
|
||||
npx nx lint api-shared-presenters
|
||||
npx nx test api-shared-presenters # Jest
|
||||
```
|
||||
|
||||
Reglas de dependencia entre capas: [Arquitectura](../../../../docs/02-arquitectura.md#21-capas-hexagonales).
|
||||
3
libs/api/shared/presenters/eslint.config.mjs
Normal file
3
libs/api/shared/presenters/eslint.config.mjs
Normal file
@@ -0,0 +1,3 @@
|
||||
import baseConfig from '../../../../eslint.config.mjs';
|
||||
|
||||
export default [...baseConfig];
|
||||
11
libs/api/shared/presenters/jest.config.cts
Normal file
11
libs/api/shared/presenters/jest.config.cts
Normal file
@@ -0,0 +1,11 @@
|
||||
module.exports = {
|
||||
displayName: 'api-shared-presenters',
|
||||
preset: '../../../../jest.preset.js',
|
||||
testEnvironment: 'node',
|
||||
passWithNoTests: true,
|
||||
transform: {
|
||||
'^.+\\.[tj]s$': ['ts-jest', { tsconfig: '<rootDir>/tsconfig.spec.json' }],
|
||||
},
|
||||
moduleFileExtensions: ['ts', 'js', 'html'],
|
||||
coverageDirectory: '../../../../coverage/libs/api/shared/presenters',
|
||||
};
|
||||
12
libs/api/shared/presenters/project.json
Normal file
12
libs/api/shared/presenters/project.json
Normal file
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"name": "api-shared-presenters",
|
||||
"$schema": "../../../../node_modules/nx/schemas/project-schema.json",
|
||||
"sourceRoot": "libs/api/shared/presenters/src",
|
||||
"projectType": "library",
|
||||
"tags": [
|
||||
"scope:api-shared",
|
||||
"type:presenters"
|
||||
],
|
||||
"// targets": "to see all targets run: nx show project api-shared-presenters --web",
|
||||
"targets": {}
|
||||
}
|
||||
1
libs/api/shared/presenters/src/index.ts
Normal file
1
libs/api/shared/presenters/src/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from './lib/filters/domain-error.filter';
|
||||
@@ -0,0 +1,40 @@
|
||||
import {
|
||||
ArgumentsHost,
|
||||
Catch,
|
||||
ExceptionFilter,
|
||||
HttpStatus,
|
||||
Logger,
|
||||
} from '@nestjs/common';
|
||||
import { GqlContextType } from '@nestjs/graphql';
|
||||
import { DomainError, DomainErrorCode } from '@task-stream/api/shared/domain';
|
||||
import { Response } from 'express';
|
||||
import { GraphQLError } from 'graphql';
|
||||
|
||||
const HTTP_STATUS: Record<DomainErrorCode, HttpStatus> = {
|
||||
NOT_FOUND: HttpStatus.NOT_FOUND,
|
||||
FORBIDDEN: HttpStatus.FORBIDDEN,
|
||||
VALIDATION: HttpStatus.BAD_REQUEST,
|
||||
CONFLICT: HttpStatus.CONFLICT,
|
||||
BUSINESS_RULE: HttpStatus.UNPROCESSABLE_ENTITY,
|
||||
};
|
||||
|
||||
/** Translates domain errors into GraphQL errors (extensions.code) or HTTP responses. */
|
||||
@Catch(DomainError)
|
||||
export class DomainErrorFilter implements ExceptionFilter {
|
||||
private readonly logger = new Logger(DomainErrorFilter.name);
|
||||
|
||||
catch(error: DomainError, host: ArgumentsHost): unknown {
|
||||
this.logger.debug(`${error.name}: ${error.message}`);
|
||||
if (host.getType<GqlContextType>() === 'graphql') {
|
||||
return new GraphQLError(error.message, {
|
||||
extensions: { code: error.code },
|
||||
});
|
||||
}
|
||||
const response = host.switchToHttp().getResponse<Response>();
|
||||
return response.status(HTTP_STATUS[error.code]).json({
|
||||
statusCode: HTTP_STATUS[error.code],
|
||||
code: error.code,
|
||||
message: error.message,
|
||||
});
|
||||
}
|
||||
}
|
||||
23
libs/api/shared/presenters/tsconfig.json
Normal file
23
libs/api/shared/presenters/tsconfig.json
Normal file
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"extends": "../../../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"module": "commonjs",
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"strict": true,
|
||||
"importHelpers": true,
|
||||
"noImplicitOverride": true,
|
||||
"noImplicitReturns": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"noPropertyAccessFromIndexSignature": true
|
||||
},
|
||||
"files": [],
|
||||
"include": [],
|
||||
"references": [
|
||||
{
|
||||
"path": "./tsconfig.lib.json"
|
||||
},
|
||||
{
|
||||
"path": "./tsconfig.spec.json"
|
||||
}
|
||||
]
|
||||
}
|
||||
23
libs/api/shared/presenters/tsconfig.lib.json
Normal file
23
libs/api/shared/presenters/tsconfig.lib.json
Normal file
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "../../../../dist/out-tsc",
|
||||
"declaration": true,
|
||||
"types": ["node"],
|
||||
"target": "es2021",
|
||||
"experimentalDecorators": true,
|
||||
"emitDecoratorMetadata": true,
|
||||
"strictNullChecks": true,
|
||||
"noImplicitAny": true,
|
||||
"strictBindCallApply": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"noFallthroughCasesInSwitch": true
|
||||
},
|
||||
"include": ["src/**/*.ts"],
|
||||
"exclude": [
|
||||
"jest.config.ts",
|
||||
"jest.config.cts",
|
||||
"src/**/*.spec.ts",
|
||||
"src/**/*.test.ts"
|
||||
]
|
||||
}
|
||||
16
libs/api/shared/presenters/tsconfig.spec.json
Normal file
16
libs/api/shared/presenters/tsconfig.spec.json
Normal file
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "../../../../dist/out-tsc",
|
||||
"module": "commonjs",
|
||||
"moduleResolution": "bundler",
|
||||
"types": ["jest", "node"]
|
||||
},
|
||||
"include": [
|
||||
"jest.config.ts",
|
||||
"jest.config.cts",
|
||||
"src/**/*.test.ts",
|
||||
"src/**/*.spec.ts",
|
||||
"src/**/*.d.ts"
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user