first commit

This commit is contained in:
2026-10-03 13:40:26 +02:00
parent 732a5b5a16
commit 566f415fa7
555 changed files with 62704 additions and 0 deletions

View 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';

View 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;
}
}

View 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);
}

View 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();
}

View 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);
};

View File

@@ -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>;
}

View File

@@ -0,0 +1,4 @@
/** Position of the last event applied to an aggregate (0 = brand new). */
export class Version {
constructor(readonly value: number) {}
}