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,191 @@
import { Type } from '@nestjs/common';
import { EventBus, IEvent, IEventPublisher } from '@nestjs/cqrs';
import { EventStore } from '@task-stream/api/shared/application';
import {
BusinessRuleError,
ConcurrencyError,
SerializableEvent,
VersionedAggregateRoot,
} from '@task-stream/api/shared/domain';
import { IssueNumbersRepository } from '../lib/ports/issue-numbers.repository';
import { ProjectKeysRepository } from '../lib/ports/project-keys.repository';
import { ReleaseIssuesRepository } from '../lib/ports/release-issues.repository';
import { ReleaseNamesRepository } from '../lib/ports/release-names.repository';
import {
DirectoryUser,
UserDirectory,
UserRefs,
} from '../lib/ports/user-directory';
/** Event store double: JSON round-trip + UNIQUE(streamId, position) like Postgres. */
export class InMemoryEventStore extends EventStore {
readonly events: SerializableEvent[] = [];
async persist(
eventOrEvents: SerializableEvent | SerializableEvent[],
): Promise<void> {
const events = Array.isArray(eventOrEvents)
? eventOrEvents
: [eventOrEvents];
for (const e of events) {
if (
this.events.some(
(x) => x.streamId === e.streamId && x.position === e.position,
)
) {
throw new ConcurrencyError('Aggregate', e.streamId);
}
}
this.events.push(
...events.map((e) => ({
...e,
data: JSON.parse(JSON.stringify(e.data)),
})),
);
}
async getEventsByStreamId(streamId: string): Promise<SerializableEvent[]> {
return this.events
.filter((e) => e.streamId === streamId)
.map((e) => ({ ...e, data: this.instantiate(e) }));
}
/** Rebuilds every aggregate of a type (test assertions only). */
async all<T extends VersionedAggregateRoot>(
AggregateCls: Type<T>,
prefix: string,
): Promise<T[]> {
const ids = [
...new Set(
this.events
.filter((e) => e.type.startsWith(prefix))
.map((e) => e.streamId),
),
];
return Promise.all(
ids.map(async (id) => {
const aggregate = new AggregateCls(id);
aggregate.loadFromHistory(await this.getEventsByStreamId(id));
return aggregate;
}),
);
}
types(): string[] {
return this.events.map((e) => e.type);
}
private instantiate(e: SerializableEvent) {
const cls = this.registry.get(e.type);
return cls ? Object.assign(Object.create(cls.prototype), e.data) : e.data;
}
private readonly registry = new Map<string, Type>();
remember(event: object): void {
this.registry.set(event.constructor.name, event.constructor as Type);
}
}
/** Same contract as the real EventStorePublisher (serialize → persist → trackCommit). */
export class InMemoryEventStorePublisher implements IEventPublisher {
constructor(private readonly store: InMemoryEventStore) {}
static install(eventBus: EventBus, store: InMemoryEventStore): void {
eventBus.publisher = new InMemoryEventStorePublisher(store);
}
publish<T extends IEvent>(event: T, dispatcher: VersionedAggregateRoot) {
return this.publishAll([event], dispatcher);
}
publishAll<T extends IEvent>(
events: T[],
dispatcher: VersionedAggregateRoot,
) {
const serialized = events.map((event, index) => {
this.store.remember(event);
const position = dispatcher.version.value + index + 1;
return {
streamId: dispatcher.id,
type: event.constructor.name,
position,
data: {
...JSON.parse(JSON.stringify(event)),
aggregateVersion: position,
},
};
});
const persisted = this.store.persist(serialized);
dispatcher.trackCommit(persisted, serialized.length);
return persisted;
}
}
export class InMemoryProjectKeys extends ProjectKeysRepository {
private readonly keys = new Set<string>();
async reserve(key: string) {
if (this.keys.has(key))
throw new BusinessRuleError(`Project key ${key} is already taken`);
this.keys.add(key);
}
}
export class InMemoryIssueNumbers extends IssueNumbersRepository {
private readonly counters = new Map<string, number>();
async next(projectId: string) {
const next = (this.counters.get(projectId) ?? 0) + 1;
this.counters.set(projectId, next);
return next;
}
}
export class InMemoryReleaseNames extends ReleaseNamesRepository {
private readonly names = new Set<string>();
async reserve(projectId: string, name: string) {
if (this.names.has(`${projectId}/${name}`))
throw new BusinessRuleError(`Release ${name} already exists`);
this.names.add(`${projectId}/${name}`);
}
async free(projectId: string, name: string) {
this.names.delete(`${projectId}/${name}`);
}
}
export class FakeUserDirectory extends UserDirectory {
readonly users = new Map<string, DirectoryUser>();
add(id: string, name = `user ${id}`, isActive = true): this {
this.users.set(id, { id, name, isActive });
return this;
}
async findById(userId: string) {
return this.users.get(userId) ?? null;
}
async refs(): Promise<UserRefs> {
const of = (id: string) => ({ id, name: this.users.get(id)?.name ?? id });
return { of, ofNullable: (id) => (id ? of(id) : null) };
}
}
/** Same lookup as the Postgres adapter, over the in-memory events. */
export class InMemoryReleaseIssues extends ReleaseIssuesRepository {
constructor(private readonly store: InMemoryEventStore) {
super();
}
async findIssueIds(releaseId: string): Promise<string[]> {
return [
...new Set(
this.store.events
.filter(
(e) =>
e.type === 'IssueReleaseChangedEvent' &&
(e.data as { releaseId?: string }).releaseId === releaseId,
)
.map((e) => e.streamId),
),
];
}
}