racing typeorm
This commit is contained in:
@@ -67,6 +67,10 @@ export class InMemoryLeagueRepository implements ILeagueRepository {
|
||||
}
|
||||
}
|
||||
|
||||
async countAll(): Promise<number> {
|
||||
return this.leagues.size;
|
||||
}
|
||||
|
||||
async create(league: League): Promise<League> {
|
||||
this.logger.debug(`Attempting to create league: ${league.id}.`);
|
||||
try {
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
import { Column, CreateDateColumn, Entity, PrimaryColumn } from 'typeorm';
|
||||
|
||||
import type { SerializedLeagueSettings } from '../serialized/RacingTypeOrmSerialized';
|
||||
|
||||
@Entity({ name: 'racing_leagues' })
|
||||
export class LeagueOrmEntity {
|
||||
@PrimaryColumn({ type: 'uuid' })
|
||||
id!: string;
|
||||
|
||||
@Column({ type: 'text' })
|
||||
name!: string;
|
||||
|
||||
@Column({ type: 'text' })
|
||||
description!: string;
|
||||
|
||||
@Column({ type: 'text' })
|
||||
ownerId!: string;
|
||||
|
||||
@Column({ type: 'jsonb' })
|
||||
settings!: SerializedLeagueSettings;
|
||||
|
||||
@CreateDateColumn({ type: 'timestamptz' })
|
||||
createdAt!: Date;
|
||||
|
||||
@Column({ type: 'int', default: 0 })
|
||||
participantCount!: number;
|
||||
|
||||
@Column({ type: 'text', nullable: true })
|
||||
discordUrl!: string | null;
|
||||
|
||||
@Column({ type: 'text', nullable: true })
|
||||
youtubeUrl!: string | null;
|
||||
|
||||
@Column({ type: 'text', nullable: true })
|
||||
websiteUrl!: string | null;
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import { Column, Entity, PrimaryColumn } from 'typeorm';
|
||||
|
||||
import type { SerializedChampionshipConfig } from '../mappers/ChampionshipConfigJsonMapper';
|
||||
|
||||
@Entity({ name: 'racing_league_scoring_configs' })
|
||||
export class LeagueScoringConfigOrmEntity {
|
||||
@PrimaryColumn({ type: 'text' })
|
||||
id!: string;
|
||||
|
||||
@Column({ type: 'uuid' })
|
||||
seasonId!: string;
|
||||
|
||||
@Column({ type: 'text', nullable: true })
|
||||
scoringPresetId!: string | null;
|
||||
|
||||
@Column({ type: 'jsonb' })
|
||||
championships!: SerializedChampionshipConfig[];
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import { Column, Entity, PrimaryColumn } from 'typeorm';
|
||||
|
||||
@Entity({ name: 'racing_races' })
|
||||
export class RaceOrmEntity {
|
||||
@PrimaryColumn({ type: 'text' })
|
||||
id!: string;
|
||||
|
||||
@Column({ type: 'uuid' })
|
||||
leagueId!: string;
|
||||
|
||||
@Column({ type: 'timestamptz' })
|
||||
scheduledAt!: Date;
|
||||
|
||||
@Column({ type: 'text' })
|
||||
track!: string;
|
||||
|
||||
@Column({ type: 'text', nullable: true })
|
||||
trackId!: string | null;
|
||||
|
||||
@Column({ type: 'text' })
|
||||
car!: string;
|
||||
|
||||
@Column({ type: 'text', nullable: true })
|
||||
carId!: string | null;
|
||||
|
||||
@Column({ type: 'text' })
|
||||
sessionType!: string;
|
||||
|
||||
@Column({ type: 'text' })
|
||||
status!: string;
|
||||
|
||||
@Column({ type: 'int', nullable: true })
|
||||
strengthOfField!: number | null;
|
||||
|
||||
@Column({ type: 'int', nullable: true })
|
||||
registeredCount!: number | null;
|
||||
|
||||
@Column({ type: 'int', nullable: true })
|
||||
maxParticipants!: number | null;
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import { Column, Entity, PrimaryColumn } from 'typeorm';
|
||||
|
||||
import type {
|
||||
SerializedSeasonDropPolicy,
|
||||
SerializedSeasonSchedule,
|
||||
SerializedSeasonScoringConfig,
|
||||
SerializedSeasonStewardingConfig,
|
||||
} from '../serialized/RacingTypeOrmSerialized';
|
||||
|
||||
@Entity({ name: 'racing_seasons' })
|
||||
export class SeasonOrmEntity {
|
||||
@PrimaryColumn({ type: 'uuid' })
|
||||
id!: string;
|
||||
|
||||
@Column({ type: 'uuid' })
|
||||
leagueId!: string;
|
||||
|
||||
@Column({ type: 'text' })
|
||||
gameId!: string;
|
||||
|
||||
@Column({ type: 'text' })
|
||||
name!: string;
|
||||
|
||||
@Column({ type: 'int', nullable: true })
|
||||
year!: number | null;
|
||||
|
||||
@Column({ type: 'int', nullable: true })
|
||||
order!: number | null;
|
||||
|
||||
@Column({ type: 'text' })
|
||||
status!: string;
|
||||
|
||||
@Column({ type: 'timestamptz', nullable: true })
|
||||
startDate!: Date | null;
|
||||
|
||||
@Column({ type: 'timestamptz', nullable: true })
|
||||
endDate!: Date | null;
|
||||
|
||||
@Column({ type: 'jsonb', nullable: true })
|
||||
schedule!: SerializedSeasonSchedule | null;
|
||||
|
||||
@Column({ type: 'boolean', default: false })
|
||||
schedulePublished!: boolean;
|
||||
|
||||
@Column({ type: 'jsonb', nullable: true })
|
||||
scoringConfig!: SerializedSeasonScoringConfig | null;
|
||||
|
||||
@Column({ type: 'jsonb', nullable: true })
|
||||
dropPolicy!: SerializedSeasonDropPolicy | null;
|
||||
|
||||
@Column({ type: 'jsonb', nullable: true })
|
||||
stewardingConfig!: SerializedSeasonStewardingConfig | null;
|
||||
|
||||
@Column({ type: 'int', nullable: true })
|
||||
maxDrivers!: number | null;
|
||||
|
||||
@Column({ type: 'int', default: 0 })
|
||||
participantCount!: number;
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
export class InvalidLeagueScoringConfigChampionshipsSchemaError extends Error {
|
||||
override readonly name = 'InvalidLeagueScoringConfigChampionshipsSchemaError';
|
||||
|
||||
constructor(message = 'Invalid LeagueScoringConfig.championships persisted schema') {
|
||||
super(message);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
export class InvalidLeagueSettingsSchemaError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
this.name = 'InvalidLeagueSettingsSchemaError';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
export class InvalidRaceSessionTypeSchemaError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
this.name = 'InvalidRaceSessionTypeSchemaError';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
export class InvalidRaceStatusSchemaError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
this.name = 'InvalidRaceStatusSchemaError';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
export class InvalidSeasonScheduleSchemaError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
this.name = 'InvalidSeasonScheduleSchemaError';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
export class InvalidSeasonStatusSchemaError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
this.name = 'InvalidSeasonStatusSchemaError';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import type { BonusRule } from '@core/racing/domain/types/BonusRule';
|
||||
import type { ChampionshipConfig } from '@core/racing/domain/types/ChampionshipConfig';
|
||||
import type { DropScorePolicy } from '@core/racing/domain/types/DropScorePolicy';
|
||||
import type { SessionType } from '@core/racing/domain/types/SessionType';
|
||||
import { PointsTableJsonMapper, type SerializedPointsTable } from './PointsTableJsonMapper';
|
||||
|
||||
export type SerializedChampionshipConfig = {
|
||||
id: string;
|
||||
name: string;
|
||||
type: ChampionshipConfig['type'];
|
||||
sessionTypes: SessionType[];
|
||||
pointsTableBySessionType: Record<SessionType, SerializedPointsTable>;
|
||||
bonusRulesBySessionType?: Record<SessionType, BonusRule[]>;
|
||||
dropScorePolicy: DropScorePolicy;
|
||||
};
|
||||
|
||||
export class ChampionshipConfigJsonMapper {
|
||||
constructor(private readonly pointsTableMapper: PointsTableJsonMapper) {}
|
||||
|
||||
toJson(config: ChampionshipConfig): SerializedChampionshipConfig {
|
||||
const pointsTableBySessionType = {} as Record<SessionType, SerializedPointsTable>;
|
||||
|
||||
for (const [sessionType, pointsTable] of Object.entries(config.pointsTableBySessionType) as Array<
|
||||
[SessionType, Parameters<PointsTableJsonMapper['toJson']>[0]]
|
||||
>) {
|
||||
pointsTableBySessionType[sessionType] = this.pointsTableMapper.toJson(pointsTable);
|
||||
}
|
||||
|
||||
return {
|
||||
id: config.id,
|
||||
name: config.name,
|
||||
type: config.type,
|
||||
sessionTypes: config.sessionTypes,
|
||||
pointsTableBySessionType,
|
||||
...(config.bonusRulesBySessionType ? { bonusRulesBySessionType: config.bonusRulesBySessionType } : {}),
|
||||
dropScorePolicy: config.dropScorePolicy,
|
||||
};
|
||||
}
|
||||
|
||||
fromJson(serialized: SerializedChampionshipConfig): ChampionshipConfig {
|
||||
const pointsTableBySessionType = {} as ChampionshipConfig['pointsTableBySessionType'];
|
||||
|
||||
for (const [sessionType, pointsTable] of Object.entries(serialized.pointsTableBySessionType) as Array<
|
||||
[SessionType, SerializedPointsTable]
|
||||
>) {
|
||||
pointsTableBySessionType[sessionType] = this.pointsTableMapper.fromJson(pointsTable);
|
||||
}
|
||||
|
||||
return {
|
||||
id: serialized.id,
|
||||
name: serialized.name,
|
||||
type: serialized.type,
|
||||
sessionTypes: serialized.sessionTypes,
|
||||
pointsTableBySessionType,
|
||||
...(serialized.bonusRulesBySessionType ? { bonusRulesBySessionType: serialized.bonusRulesBySessionType } : {}),
|
||||
dropScorePolicy: serialized.dropScorePolicy,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { League } from '@core/racing/domain/entities/League';
|
||||
|
||||
import { LeagueOrmEntity } from '../entities/LeagueOrmEntity';
|
||||
import { LeagueOrmMapper } from './LeagueOrmMapper';
|
||||
|
||||
describe('LeagueOrmMapper', () => {
|
||||
it('toDomain preserves persisted identity and uses rehydrate semantics (does not call create)', () => {
|
||||
const mapper = new LeagueOrmMapper();
|
||||
|
||||
const entity = new LeagueOrmEntity();
|
||||
entity.id = '00000000-0000-4000-8000-000000000001';
|
||||
entity.name = 'League Name';
|
||||
entity.description = 'League Description';
|
||||
entity.ownerId = '00000000-0000-4000-8000-000000000002';
|
||||
entity.settings = {
|
||||
pointsSystem: 'f1-2024',
|
||||
visibility: 'ranked',
|
||||
maxDrivers: 32,
|
||||
sessionDuration: 60,
|
||||
qualifyingFormat: 'open',
|
||||
stewarding: { decisionMode: 'admin_only' },
|
||||
};
|
||||
entity.createdAt = new Date('2025-01-01T00:00:00.000Z');
|
||||
entity.participantCount = 7;
|
||||
entity.discordUrl = null;
|
||||
entity.youtubeUrl = null;
|
||||
entity.websiteUrl = null;
|
||||
|
||||
if (typeof (League as any).rehydrate !== 'function') {
|
||||
throw new Error('rehydrate-missing');
|
||||
}
|
||||
|
||||
const rehydrateSpy = vi.spyOn(League as any, 'rehydrate');
|
||||
const createSpy = vi.spyOn(League, 'create').mockImplementation(() => {
|
||||
throw new Error('create-called');
|
||||
});
|
||||
|
||||
const domain = mapper.toDomain(entity);
|
||||
|
||||
expect(domain.id.toString()).toBe(entity.id);
|
||||
expect(createSpy).not.toHaveBeenCalled();
|
||||
expect(rehydrateSpy).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('toDomain validates persisted settings schema and throws adapter-scoped error type', () => {
|
||||
const mapper = new LeagueOrmMapper();
|
||||
|
||||
const entity = new LeagueOrmEntity();
|
||||
entity.id = '00000000-0000-4000-8000-000000000001';
|
||||
entity.name = 'League Name';
|
||||
entity.description = 'League Description';
|
||||
entity.ownerId = '00000000-0000-4000-8000-000000000002';
|
||||
entity.settings = 123 as unknown as never;
|
||||
entity.createdAt = new Date('2025-01-01T00:00:00.000Z');
|
||||
entity.participantCount = 0;
|
||||
entity.discordUrl = null;
|
||||
entity.youtubeUrl = null;
|
||||
entity.websiteUrl = null;
|
||||
|
||||
try {
|
||||
mapper.toDomain(entity);
|
||||
throw new Error('expected-to-throw');
|
||||
} catch (error) {
|
||||
expect(error).toMatchObject({ name: 'InvalidLeagueSettingsSchemaError' });
|
||||
}
|
||||
});
|
||||
});
|
||||
190
adapters/racing/persistence/typeorm/mappers/LeagueOrmMapper.ts
Normal file
190
adapters/racing/persistence/typeorm/mappers/LeagueOrmMapper.ts
Normal file
@@ -0,0 +1,190 @@
|
||||
import { League, type LeagueSettings } from '@core/racing/domain/entities/League';
|
||||
|
||||
import { LeagueOrmEntity } from '../entities/LeagueOrmEntity';
|
||||
import { InvalidLeagueSettingsSchemaError } from '../errors/InvalidLeagueSettingsSchemaError';
|
||||
import type { SerializedLeagueSettings } from '../serialized/RacingTypeOrmSerialized';
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
const VALID_POINTS_SYSTEMS = ['f1-2024', 'indycar', 'custom'] as const;
|
||||
|
||||
const VALID_VISIBILITY = ['ranked', 'unranked'] as const;
|
||||
|
||||
const VALID_DECISION_MODES = [
|
||||
'admin_only',
|
||||
'steward_decides',
|
||||
'steward_vote',
|
||||
'member_vote',
|
||||
'steward_veto',
|
||||
'member_veto',
|
||||
] as const;
|
||||
|
||||
function isOneOf<T extends string>(value: string, allowed: readonly T[]): value is T {
|
||||
return (allowed as readonly string[]).includes(value);
|
||||
}
|
||||
|
||||
function assertSerializedLeagueSettings(value: unknown): asserts value is SerializedLeagueSettings {
|
||||
if (!isRecord(value)) {
|
||||
throw new InvalidLeagueSettingsSchemaError('Invalid settings (expected object)');
|
||||
}
|
||||
|
||||
if (typeof value.pointsSystem !== 'string' || !isOneOf(value.pointsSystem, VALID_POINTS_SYSTEMS)) {
|
||||
throw new InvalidLeagueSettingsSchemaError('Invalid settings.pointsSystem');
|
||||
}
|
||||
|
||||
if (value.sessionDuration !== undefined && typeof value.sessionDuration !== 'number') {
|
||||
throw new InvalidLeagueSettingsSchemaError('Invalid settings.sessionDuration');
|
||||
}
|
||||
|
||||
if (value.qualifyingFormat !== undefined && typeof value.qualifyingFormat !== 'string') {
|
||||
throw new InvalidLeagueSettingsSchemaError('Invalid settings.qualifyingFormat');
|
||||
}
|
||||
|
||||
if (value.maxDrivers !== undefined && typeof value.maxDrivers !== 'number') {
|
||||
throw new InvalidLeagueSettingsSchemaError('Invalid settings.maxDrivers');
|
||||
}
|
||||
|
||||
if (value.visibility !== undefined) {
|
||||
if (typeof value.visibility !== 'string' || !isOneOf(value.visibility, VALID_VISIBILITY)) {
|
||||
throw new InvalidLeagueSettingsSchemaError('Invalid settings.visibility');
|
||||
}
|
||||
}
|
||||
|
||||
if (value.stewarding !== undefined) {
|
||||
if (!isRecord(value.stewarding)) {
|
||||
throw new InvalidLeagueSettingsSchemaError('Invalid settings.stewarding (expected object)');
|
||||
}
|
||||
|
||||
if (
|
||||
typeof value.stewarding.decisionMode !== 'string' ||
|
||||
!isOneOf(value.stewarding.decisionMode, VALID_DECISION_MODES)
|
||||
) {
|
||||
throw new InvalidLeagueSettingsSchemaError('Invalid settings.stewarding.decisionMode');
|
||||
}
|
||||
|
||||
if (value.stewarding.requiredVotes !== undefined && typeof value.stewarding.requiredVotes !== 'number') {
|
||||
throw new InvalidLeagueSettingsSchemaError('Invalid settings.stewarding.requiredVotes');
|
||||
}
|
||||
if (value.stewarding.requireDefense !== undefined && typeof value.stewarding.requireDefense !== 'boolean') {
|
||||
throw new InvalidLeagueSettingsSchemaError('Invalid settings.stewarding.requireDefense');
|
||||
}
|
||||
if (value.stewarding.defenseTimeLimit !== undefined && typeof value.stewarding.defenseTimeLimit !== 'number') {
|
||||
throw new InvalidLeagueSettingsSchemaError('Invalid settings.stewarding.defenseTimeLimit');
|
||||
}
|
||||
if (value.stewarding.voteTimeLimit !== undefined && typeof value.stewarding.voteTimeLimit !== 'number') {
|
||||
throw new InvalidLeagueSettingsSchemaError('Invalid settings.stewarding.voteTimeLimit');
|
||||
}
|
||||
if (value.stewarding.protestDeadlineHours !== undefined && typeof value.stewarding.protestDeadlineHours !== 'number') {
|
||||
throw new InvalidLeagueSettingsSchemaError('Invalid settings.stewarding.protestDeadlineHours');
|
||||
}
|
||||
if (
|
||||
value.stewarding.stewardingClosesHours !== undefined &&
|
||||
typeof value.stewarding.stewardingClosesHours !== 'number'
|
||||
) {
|
||||
throw new InvalidLeagueSettingsSchemaError('Invalid settings.stewarding.stewardingClosesHours');
|
||||
}
|
||||
if (
|
||||
value.stewarding.notifyAccusedOnProtest !== undefined &&
|
||||
typeof value.stewarding.notifyAccusedOnProtest !== 'boolean'
|
||||
) {
|
||||
throw new InvalidLeagueSettingsSchemaError('Invalid settings.stewarding.notifyAccusedOnProtest');
|
||||
}
|
||||
if (value.stewarding.notifyOnVoteRequired !== undefined && typeof value.stewarding.notifyOnVoteRequired !== 'boolean') {
|
||||
throw new InvalidLeagueSettingsSchemaError('Invalid settings.stewarding.notifyOnVoteRequired');
|
||||
}
|
||||
}
|
||||
|
||||
if (value.customPoints !== undefined) {
|
||||
if (!isRecord(value.customPoints)) {
|
||||
throw new InvalidLeagueSettingsSchemaError('Invalid settings.customPoints (expected object)');
|
||||
}
|
||||
|
||||
for (const [key, points] of Object.entries(value.customPoints)) {
|
||||
if (!Number.isInteger(Number(key))) {
|
||||
throw new InvalidLeagueSettingsSchemaError('Invalid settings.customPoints (expected numeric keys)');
|
||||
}
|
||||
if (typeof points !== 'number') {
|
||||
throw new InvalidLeagueSettingsSchemaError('Invalid settings.customPoints (expected number values)');
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function parseLeagueSettings(value: unknown): LeagueSettings {
|
||||
assertSerializedLeagueSettings(value);
|
||||
|
||||
const customPoints: Record<number, number> | undefined = value.customPoints
|
||||
? Object.fromEntries(Object.entries(value.customPoints).map(([position, points]) => [Number(position), points]))
|
||||
: undefined;
|
||||
|
||||
return {
|
||||
pointsSystem: value.pointsSystem,
|
||||
...(value.sessionDuration !== undefined ? { sessionDuration: value.sessionDuration } : {}),
|
||||
...(value.qualifyingFormat !== undefined ? { qualifyingFormat: value.qualifyingFormat } : {}),
|
||||
...(customPoints !== undefined ? { customPoints } : {}),
|
||||
...(value.maxDrivers !== undefined ? { maxDrivers: value.maxDrivers } : {}),
|
||||
...(value.stewarding !== undefined ? { stewarding: value.stewarding } : {}),
|
||||
...(value.visibility !== undefined ? { visibility: value.visibility } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
function serializeLeagueSettings(settings: LeagueSettings): SerializedLeagueSettings {
|
||||
const customPoints =
|
||||
settings.customPoints !== undefined
|
||||
? Object.fromEntries(
|
||||
Object.entries(settings.customPoints).map(([position, points]) => [String(position), points]),
|
||||
)
|
||||
: undefined;
|
||||
|
||||
return {
|
||||
pointsSystem: settings.pointsSystem,
|
||||
...(settings.sessionDuration !== undefined ? { sessionDuration: settings.sessionDuration } : {}),
|
||||
...(settings.qualifyingFormat !== undefined ? { qualifyingFormat: settings.qualifyingFormat } : {}),
|
||||
...(customPoints !== undefined ? { customPoints } : {}),
|
||||
...(settings.maxDrivers !== undefined ? { maxDrivers: settings.maxDrivers } : {}),
|
||||
...(settings.stewarding !== undefined ? { stewarding: settings.stewarding } : {}),
|
||||
...(settings.visibility !== undefined ? { visibility: settings.visibility } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
export class LeagueOrmMapper {
|
||||
toOrmEntity(domain: League): LeagueOrmEntity {
|
||||
const entity = new LeagueOrmEntity();
|
||||
entity.id = domain.id.toString();
|
||||
entity.name = domain.name.toString();
|
||||
entity.description = domain.description.toString();
|
||||
entity.ownerId = domain.ownerId.toString();
|
||||
entity.settings = serializeLeagueSettings(domain.settings);
|
||||
entity.createdAt = domain.createdAt.toDate();
|
||||
entity.participantCount = domain.getParticipantCount();
|
||||
entity.discordUrl = domain.socialLinks?.discordUrl ?? null;
|
||||
entity.youtubeUrl = domain.socialLinks?.youtubeUrl ?? null;
|
||||
entity.websiteUrl = domain.socialLinks?.websiteUrl ?? null;
|
||||
return entity;
|
||||
}
|
||||
|
||||
toDomain(entity: LeagueOrmEntity): League {
|
||||
const settings = parseLeagueSettings(entity.settings);
|
||||
|
||||
return League.rehydrate({
|
||||
id: entity.id,
|
||||
name: entity.name,
|
||||
description: entity.description,
|
||||
ownerId: entity.ownerId,
|
||||
settings,
|
||||
createdAt: entity.createdAt,
|
||||
participantCount: entity.participantCount,
|
||||
...(entity.discordUrl || entity.youtubeUrl || entity.websiteUrl
|
||||
? {
|
||||
socialLinks: {
|
||||
...(entity.discordUrl ? { discordUrl: entity.discordUrl } : {}),
|
||||
...(entity.youtubeUrl ? { youtubeUrl: entity.youtubeUrl } : {}),
|
||||
...(entity.websiteUrl ? { websiteUrl: entity.websiteUrl } : {}),
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { LeagueScoringConfig } from '@core/racing/domain/entities/LeagueScoringConfig';
|
||||
|
||||
import { LeagueScoringConfigOrmEntity } from '../entities/LeagueScoringConfigOrmEntity';
|
||||
import { LeagueScoringConfigOrmMapper } from './LeagueScoringConfigOrmMapper';
|
||||
import { ChampionshipConfigJsonMapper, type SerializedChampionshipConfig } from './ChampionshipConfigJsonMapper';
|
||||
import { PointsTableJsonMapper } from './PointsTableJsonMapper';
|
||||
|
||||
describe('LeagueScoringConfigOrmMapper', () => {
|
||||
it('toDomain uses rehydrate semantics (does not call create)', () => {
|
||||
const pointsTableMapper = new PointsTableJsonMapper();
|
||||
const championshipMapper = new ChampionshipConfigJsonMapper(pointsTableMapper);
|
||||
const mapper = new LeagueScoringConfigOrmMapper(championshipMapper);
|
||||
|
||||
const serializedChampionship: SerializedChampionshipConfig = {
|
||||
id: 'champ-1',
|
||||
name: 'Main Championship',
|
||||
type: 'driver',
|
||||
sessionTypes: ['main'],
|
||||
pointsTableBySessionType: {
|
||||
main: { pointsByPosition: { '1': 25, '2': 18 } },
|
||||
} as unknown as SerializedChampionshipConfig['pointsTableBySessionType'],
|
||||
dropScorePolicy: { strategy: 'none' },
|
||||
};
|
||||
|
||||
const entity = new LeagueScoringConfigOrmEntity();
|
||||
entity.id = 'scoring-config-season-1';
|
||||
entity.seasonId = 'season-1';
|
||||
entity.scoringPresetId = null;
|
||||
entity.championships = [serializedChampionship];
|
||||
|
||||
const createSpy = vi.spyOn(LeagueScoringConfig, 'create').mockImplementation(() => {
|
||||
throw new Error('create-called');
|
||||
});
|
||||
|
||||
expect(() => mapper.toDomain(entity)).not.toThrow();
|
||||
expect(createSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('toDomain validates schema: non-array championships yields adapter-scoped error type', () => {
|
||||
const pointsTableMapper = new PointsTableJsonMapper();
|
||||
const championshipMapper = new ChampionshipConfigJsonMapper(pointsTableMapper);
|
||||
const mapper = new LeagueScoringConfigOrmMapper(championshipMapper);
|
||||
|
||||
const entity = new LeagueScoringConfigOrmEntity();
|
||||
entity.id = 'scoring-config-season-1';
|
||||
entity.seasonId = 'season-1';
|
||||
entity.scoringPresetId = null;
|
||||
entity.championships = { not: 'an-array' } as unknown as never;
|
||||
|
||||
try {
|
||||
mapper.toDomain(entity);
|
||||
throw new Error('expected-to-throw');
|
||||
} catch (error) {
|
||||
expect(error).toMatchObject({ name: 'InvalidLeagueScoringConfigChampionshipsSchemaError' });
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,71 @@
|
||||
import { LeagueScoringConfig } from '@core/racing/domain/entities/LeagueScoringConfig';
|
||||
import { ChampionshipConfigJsonMapper, type SerializedChampionshipConfig } from './ChampionshipConfigJsonMapper';
|
||||
import { LeagueScoringConfigOrmEntity } from '../entities/LeagueScoringConfigOrmEntity';
|
||||
import { InvalidLeagueScoringConfigChampionshipsSchemaError } from '../errors/InvalidLeagueScoringConfigChampionshipsSchemaError';
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function assertSerializedChampionshipConfig(value: unknown, index: number): asserts value is SerializedChampionshipConfig {
|
||||
if (!isRecord(value)) {
|
||||
throw new InvalidLeagueScoringConfigChampionshipsSchemaError(`Invalid championships[${index}] (expected object)`);
|
||||
}
|
||||
|
||||
if (typeof value.id !== 'string' || value.id.trim().length === 0) {
|
||||
throw new InvalidLeagueScoringConfigChampionshipsSchemaError(`Invalid championships[${index}].id`);
|
||||
}
|
||||
|
||||
if (typeof value.name !== 'string' || value.name.trim().length === 0) {
|
||||
throw new InvalidLeagueScoringConfigChampionshipsSchemaError(`Invalid championships[${index}].name`);
|
||||
}
|
||||
|
||||
if (typeof value.type !== 'string' || value.type.trim().length === 0) {
|
||||
throw new InvalidLeagueScoringConfigChampionshipsSchemaError(`Invalid championships[${index}].type`);
|
||||
}
|
||||
|
||||
if (!Array.isArray(value.sessionTypes)) {
|
||||
throw new InvalidLeagueScoringConfigChampionshipsSchemaError(`Invalid championships[${index}].sessionTypes`);
|
||||
}
|
||||
|
||||
if (!isRecord(value.pointsTableBySessionType)) {
|
||||
throw new InvalidLeagueScoringConfigChampionshipsSchemaError(
|
||||
`Invalid championships[${index}].pointsTableBySessionType`,
|
||||
);
|
||||
}
|
||||
|
||||
if (!isRecord(value.dropScorePolicy) || typeof value.dropScorePolicy.strategy !== 'string') {
|
||||
throw new InvalidLeagueScoringConfigChampionshipsSchemaError(`Invalid championships[${index}].dropScorePolicy`);
|
||||
}
|
||||
}
|
||||
|
||||
export class LeagueScoringConfigOrmMapper {
|
||||
constructor(private readonly championshipConfigMapper: ChampionshipConfigJsonMapper) {}
|
||||
|
||||
toOrmEntity(domain: LeagueScoringConfig): LeagueScoringConfigOrmEntity {
|
||||
const entity = new LeagueScoringConfigOrmEntity();
|
||||
entity.id = domain.id.toString();
|
||||
entity.seasonId = domain.seasonId.toString();
|
||||
entity.scoringPresetId = domain.scoringPresetId ? domain.scoringPresetId.toString() : null;
|
||||
entity.championships = domain.championships.map((c) => this.championshipConfigMapper.toJson(c));
|
||||
return entity;
|
||||
}
|
||||
|
||||
toDomain(entity: LeagueScoringConfigOrmEntity): LeagueScoringConfig {
|
||||
if (!Array.isArray(entity.championships)) {
|
||||
throw new InvalidLeagueScoringConfigChampionshipsSchemaError('Invalid championships (expected array)');
|
||||
}
|
||||
|
||||
const championships = entity.championships.map((candidate, index) => {
|
||||
assertSerializedChampionshipConfig(candidate, index);
|
||||
return this.championshipConfigMapper.fromJson(candidate);
|
||||
});
|
||||
|
||||
return LeagueScoringConfig.rehydrate({
|
||||
id: entity.id,
|
||||
seasonId: entity.seasonId,
|
||||
...(entity.scoringPresetId ? { scoringPresetId: entity.scoringPresetId } : {}),
|
||||
championships,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import { PointsTable } from '@core/racing/domain/value-objects/PointsTable';
|
||||
|
||||
export type SerializedPointsTable = {
|
||||
pointsByPosition: Record<string, number>;
|
||||
};
|
||||
|
||||
export class PointsTableJsonMapper {
|
||||
toJson(pointsTable: PointsTable): SerializedPointsTable {
|
||||
const pointsByPosition: Record<string, number> = {};
|
||||
for (const [position, points] of pointsTable.props.pointsByPosition.entries()) {
|
||||
pointsByPosition[String(position)] = points;
|
||||
}
|
||||
return { pointsByPosition };
|
||||
}
|
||||
|
||||
fromJson(serialized: SerializedPointsTable): PointsTable {
|
||||
const record: Record<number, number> = {};
|
||||
for (const [position, points] of Object.entries(serialized.pointsByPosition)) {
|
||||
record[Number(position)] = points;
|
||||
}
|
||||
return new PointsTable(record);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { Race } from '@core/racing/domain/entities/Race';
|
||||
|
||||
import { RaceOrmEntity } from '../entities/RaceOrmEntity';
|
||||
import { RaceOrmMapper } from './RaceOrmMapper';
|
||||
|
||||
describe('RaceOrmMapper', () => {
|
||||
it('toDomain preserves persisted identity and uses rehydrate semantics (does not call create)', () => {
|
||||
const mapper = new RaceOrmMapper();
|
||||
|
||||
const entity = new RaceOrmEntity();
|
||||
entity.id = 'race-1';
|
||||
entity.leagueId = '00000000-0000-4000-8000-000000000001';
|
||||
entity.scheduledAt = new Date('2025-01-01T00:00:00.000Z');
|
||||
entity.track = 'Spa';
|
||||
entity.trackId = null;
|
||||
entity.car = 'Porsche';
|
||||
entity.carId = null;
|
||||
entity.sessionType = 'main';
|
||||
entity.status = 'scheduled';
|
||||
entity.strengthOfField = null;
|
||||
entity.registeredCount = null;
|
||||
entity.maxParticipants = null;
|
||||
|
||||
if (typeof (Race as any).rehydrate !== 'function') {
|
||||
throw new Error('rehydrate-missing');
|
||||
}
|
||||
|
||||
const rehydrateSpy = vi.spyOn(Race as any, 'rehydrate');
|
||||
const createSpy = vi.spyOn(Race, 'create').mockImplementation(() => {
|
||||
throw new Error('create-called');
|
||||
});
|
||||
|
||||
const domain = mapper.toDomain(entity);
|
||||
|
||||
expect(domain.id).toBe(entity.id);
|
||||
expect(createSpy).not.toHaveBeenCalled();
|
||||
expect(rehydrateSpy).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('toDomain validates persisted sessionType/status and throws adapter-scoped error type', () => {
|
||||
const mapper = new RaceOrmMapper();
|
||||
|
||||
const entity = new RaceOrmEntity();
|
||||
entity.id = 'race-1';
|
||||
entity.leagueId = '00000000-0000-4000-8000-000000000001';
|
||||
entity.scheduledAt = new Date('2025-01-01T00:00:00.000Z');
|
||||
entity.track = 'Spa';
|
||||
entity.trackId = null;
|
||||
entity.car = 'Porsche';
|
||||
entity.carId = null;
|
||||
entity.sessionType = 123 as unknown as string;
|
||||
entity.status = 'scheduled';
|
||||
entity.strengthOfField = null;
|
||||
entity.registeredCount = null;
|
||||
entity.maxParticipants = null;
|
||||
|
||||
try {
|
||||
mapper.toDomain(entity);
|
||||
throw new Error('expected-to-throw');
|
||||
} catch (error) {
|
||||
expect(error).toMatchObject({ name: 'InvalidRaceSessionTypeSchemaError' });
|
||||
}
|
||||
});
|
||||
});
|
||||
79
adapters/racing/persistence/typeorm/mappers/RaceOrmMapper.ts
Normal file
79
adapters/racing/persistence/typeorm/mappers/RaceOrmMapper.ts
Normal file
@@ -0,0 +1,79 @@
|
||||
import { Race } from '@core/racing/domain/entities/Race';
|
||||
import { RaceStatus, type RaceStatusValue } from '@core/racing/domain/value-objects/RaceStatus';
|
||||
import { SessionType, type SessionTypeValue } from '@core/racing/domain/value-objects/SessionType';
|
||||
|
||||
import { RaceOrmEntity } from '../entities/RaceOrmEntity';
|
||||
import { InvalidRaceSessionTypeSchemaError } from '../errors/InvalidRaceSessionTypeSchemaError';
|
||||
import { InvalidRaceStatusSchemaError } from '../errors/InvalidRaceStatusSchemaError';
|
||||
|
||||
const VALID_SESSION_TYPES: SessionTypeValue[] = [
|
||||
'practice',
|
||||
'qualifying',
|
||||
'q1',
|
||||
'q2',
|
||||
'q3',
|
||||
'sprint',
|
||||
'main',
|
||||
'timeTrial',
|
||||
];
|
||||
|
||||
const VALID_RACE_STATUSES: RaceStatusValue[] = [
|
||||
'scheduled',
|
||||
'running',
|
||||
'completed',
|
||||
'cancelled',
|
||||
];
|
||||
|
||||
function assertSessionTypeValue(value: unknown): asserts value is SessionTypeValue {
|
||||
if (typeof value !== 'string' || !VALID_SESSION_TYPES.includes(value as any)) {
|
||||
throw new InvalidRaceSessionTypeSchemaError('Invalid sessionType');
|
||||
}
|
||||
}
|
||||
|
||||
function assertRaceStatusValue(value: unknown): asserts value is RaceStatusValue {
|
||||
if (typeof value !== 'string' || !VALID_RACE_STATUSES.includes(value as any)) {
|
||||
throw new InvalidRaceStatusSchemaError('Invalid status');
|
||||
}
|
||||
}
|
||||
|
||||
export class RaceOrmMapper {
|
||||
toOrmEntity(domain: Race): RaceOrmEntity {
|
||||
const entity = new RaceOrmEntity();
|
||||
entity.id = domain.id;
|
||||
entity.leagueId = domain.leagueId;
|
||||
entity.scheduledAt = domain.scheduledAt;
|
||||
entity.track = domain.track;
|
||||
entity.trackId = domain.trackId ?? null;
|
||||
entity.car = domain.car;
|
||||
entity.carId = domain.carId ?? null;
|
||||
entity.sessionType = domain.sessionType.props;
|
||||
entity.status = domain.status.toString();
|
||||
entity.strengthOfField = domain.getStrengthOfField() ?? null;
|
||||
entity.registeredCount = domain.registeredCountNumber ?? null;
|
||||
entity.maxParticipants = domain.getMaxParticipants() ?? null;
|
||||
return entity;
|
||||
}
|
||||
|
||||
toDomain(entity: RaceOrmEntity): Race {
|
||||
assertSessionTypeValue(entity.sessionType);
|
||||
assertRaceStatusValue(entity.status);
|
||||
|
||||
const sessionType = new SessionType(entity.sessionType);
|
||||
const status = RaceStatus.create(entity.status);
|
||||
|
||||
return Race.rehydrate({
|
||||
id: entity.id,
|
||||
leagueId: entity.leagueId,
|
||||
scheduledAt: entity.scheduledAt,
|
||||
track: entity.track,
|
||||
...(entity.trackId !== null ? { trackId: entity.trackId } : {}),
|
||||
car: entity.car,
|
||||
...(entity.carId !== null ? { carId: entity.carId } : {}),
|
||||
sessionType,
|
||||
status,
|
||||
...(entity.strengthOfField !== null ? { strengthOfField: entity.strengthOfField } : {}),
|
||||
...(entity.registeredCount !== null ? { registeredCount: entity.registeredCount } : {}),
|
||||
...(entity.maxParticipants !== null ? { maxParticipants: entity.maxParticipants } : {}),
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { Season } from '@core/racing/domain/entities/season/Season';
|
||||
|
||||
import { SeasonOrmEntity } from '../entities/SeasonOrmEntity';
|
||||
import { SeasonOrmMapper } from './SeasonOrmMapper';
|
||||
|
||||
describe('SeasonOrmMapper', () => {
|
||||
it('toDomain preserves persisted identity and uses rehydrate semantics (does not call create)', () => {
|
||||
const mapper = new SeasonOrmMapper();
|
||||
|
||||
const entity = new SeasonOrmEntity();
|
||||
entity.id = '00000000-0000-4000-8000-000000000001';
|
||||
entity.leagueId = '00000000-0000-4000-8000-000000000002';
|
||||
entity.gameId = 'iracing';
|
||||
entity.name = 'Season 2025';
|
||||
entity.year = 2025;
|
||||
entity.order = 1;
|
||||
entity.status = 'planned';
|
||||
entity.startDate = null;
|
||||
entity.endDate = null;
|
||||
entity.schedule = null;
|
||||
entity.schedulePublished = false;
|
||||
entity.scoringConfig = null;
|
||||
entity.dropPolicy = null;
|
||||
entity.stewardingConfig = null;
|
||||
entity.maxDrivers = null;
|
||||
entity.participantCount = 3;
|
||||
|
||||
if (typeof (Season as any).rehydrate !== 'function') {
|
||||
throw new Error('rehydrate-missing');
|
||||
}
|
||||
|
||||
const rehydrateSpy = vi.spyOn(Season as any, 'rehydrate');
|
||||
const createSpy = vi.spyOn(Season, 'create').mockImplementation(() => {
|
||||
throw new Error('create-called');
|
||||
});
|
||||
|
||||
const domain = mapper.toDomain(entity);
|
||||
|
||||
expect(domain.id).toBe(entity.id);
|
||||
expect(createSpy).not.toHaveBeenCalled();
|
||||
expect(rehydrateSpy).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('toDomain validates persisted schedule schema and throws adapter-scoped error type', () => {
|
||||
const mapper = new SeasonOrmMapper();
|
||||
|
||||
const entity = new SeasonOrmEntity();
|
||||
entity.id = '00000000-0000-4000-8000-000000000001';
|
||||
entity.leagueId = '00000000-0000-4000-8000-000000000002';
|
||||
entity.gameId = 'iracing';
|
||||
entity.name = 'Season 2025';
|
||||
entity.year = null;
|
||||
entity.order = null;
|
||||
entity.status = 'planned';
|
||||
entity.startDate = null;
|
||||
entity.endDate = null;
|
||||
entity.schedule = 123 as unknown as never;
|
||||
entity.schedulePublished = false;
|
||||
entity.scoringConfig = null;
|
||||
entity.dropPolicy = null;
|
||||
entity.stewardingConfig = null;
|
||||
entity.maxDrivers = null;
|
||||
entity.participantCount = 0;
|
||||
|
||||
try {
|
||||
mapper.toDomain(entity);
|
||||
throw new Error('expected-to-throw');
|
||||
} catch (error) {
|
||||
expect(error).toMatchObject({ name: 'InvalidSeasonScheduleSchemaError' });
|
||||
}
|
||||
});
|
||||
});
|
||||
316
adapters/racing/persistence/typeorm/mappers/SeasonOrmMapper.ts
Normal file
316
adapters/racing/persistence/typeorm/mappers/SeasonOrmMapper.ts
Normal file
@@ -0,0 +1,316 @@
|
||||
import { Season } from '@core/racing/domain/entities/season/Season';
|
||||
import { ALL_WEEKDAYS, type Weekday } from '@core/racing/domain/types/Weekday';
|
||||
import { LeagueTimezone } from '@core/racing/domain/value-objects/LeagueTimezone';
|
||||
import { MonthlyRecurrencePattern } from '@core/racing/domain/value-objects/MonthlyRecurrencePattern';
|
||||
import { RaceTimeOfDay } from '@core/racing/domain/value-objects/RaceTimeOfDay';
|
||||
import { RecurrenceStrategy } from '@core/racing/domain/value-objects/RecurrenceStrategy';
|
||||
import { RecurrenceStrategyFactory } from '@core/racing/domain/value-objects/RecurrenceStrategyFactory';
|
||||
import { SeasonDropPolicy } from '@core/racing/domain/value-objects/SeasonDropPolicy';
|
||||
import { SeasonSchedule } from '@core/racing/domain/value-objects/SeasonSchedule';
|
||||
import { SeasonScoringConfig } from '@core/racing/domain/value-objects/SeasonScoringConfig';
|
||||
import { SeasonStatus, type SeasonStatusValue } from '@core/racing/domain/value-objects/SeasonStatus';
|
||||
import { SeasonStewardingConfig } from '@core/racing/domain/value-objects/SeasonStewardingConfig';
|
||||
import { WeekdaySet } from '@core/racing/domain/value-objects/WeekdaySet';
|
||||
|
||||
import { SeasonOrmEntity } from '../entities/SeasonOrmEntity';
|
||||
import { InvalidSeasonScheduleSchemaError } from '../errors/InvalidSeasonScheduleSchemaError';
|
||||
|
||||
import { InvalidSeasonStatusSchemaError } from '@adapters/racing/persistence/typeorm/errors/InvalidSeasonStatusSchemaError';
|
||||
import type {
|
||||
SerializedSeasonDropPolicy,
|
||||
SerializedSeasonEveryNWeeksRecurrence,
|
||||
SerializedSeasonMonthlyNthWeekdayRecurrence,
|
||||
SerializedSeasonRecurrence,
|
||||
SerializedSeasonSchedule,
|
||||
SerializedSeasonScoringConfig,
|
||||
SerializedSeasonStewardingConfig,
|
||||
SerializedSeasonWeeklyRecurrence,
|
||||
} from '../serialized/RacingTypeOrmSerialized';
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
const VALID_SEASON_STATUSES: SeasonStatusValue[] = [
|
||||
'planned',
|
||||
'active',
|
||||
'completed',
|
||||
'archived',
|
||||
'cancelled',
|
||||
];
|
||||
|
||||
function assertSeasonStatusValue(value: unknown): asserts value is SeasonStatusValue {
|
||||
if (typeof value !== 'string' || !VALID_SEASON_STATUSES.includes(value as SeasonStatusValue)) {
|
||||
throw new InvalidSeasonStatusSchemaError('Invalid status');
|
||||
}
|
||||
}
|
||||
|
||||
function assertSerializedSeasonRecurrence(value: unknown): asserts value is SerializedSeasonRecurrence {
|
||||
if (!isRecord(value) || typeof value.kind !== 'string') {
|
||||
throw new InvalidSeasonScheduleSchemaError('Invalid schedule.recurrence');
|
||||
}
|
||||
|
||||
if (value.kind === 'weekly') {
|
||||
const candidate = value as SerializedSeasonWeeklyRecurrence;
|
||||
if (!Array.isArray(candidate.weekdays)) {
|
||||
throw new InvalidSeasonScheduleSchemaError('Invalid schedule.recurrence.weekdays');
|
||||
}
|
||||
for (const day of candidate.weekdays) {
|
||||
if (typeof day !== 'string' || !ALL_WEEKDAYS.includes(day as Weekday)) {
|
||||
throw new InvalidSeasonScheduleSchemaError('Invalid schedule.recurrence.weekdays[]');
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (value.kind === 'everyNWeeks') {
|
||||
const candidate = value as SerializedSeasonEveryNWeeksRecurrence;
|
||||
if (!Number.isInteger(candidate.intervalWeeks) || candidate.intervalWeeks <= 0) {
|
||||
throw new InvalidSeasonScheduleSchemaError('Invalid schedule.recurrence.intervalWeeks');
|
||||
}
|
||||
if (!Array.isArray(candidate.weekdays)) {
|
||||
throw new InvalidSeasonScheduleSchemaError('Invalid schedule.recurrence.weekdays');
|
||||
}
|
||||
for (const day of candidate.weekdays) {
|
||||
if (typeof day !== 'string' || !ALL_WEEKDAYS.includes(day as Weekday)) {
|
||||
throw new InvalidSeasonScheduleSchemaError('Invalid schedule.recurrence.weekdays[]');
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (value.kind === 'monthlyNthWeekday') {
|
||||
const candidate = value as SerializedSeasonMonthlyNthWeekdayRecurrence;
|
||||
if (![1, 2, 3, 4].includes(candidate.ordinal)) {
|
||||
throw new InvalidSeasonScheduleSchemaError('Invalid schedule.recurrence.ordinal');
|
||||
}
|
||||
if (typeof candidate.weekday !== 'string' || !ALL_WEEKDAYS.includes(candidate.weekday as Weekday)) {
|
||||
throw new InvalidSeasonScheduleSchemaError('Invalid schedule.recurrence.weekday');
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
throw new InvalidSeasonScheduleSchemaError('Invalid schedule.recurrence.kind');
|
||||
}
|
||||
|
||||
function parseSeasonSchedule(value: unknown): SeasonSchedule {
|
||||
if (!isRecord(value)) {
|
||||
throw new InvalidSeasonScheduleSchemaError('Invalid schedule (expected object)');
|
||||
}
|
||||
|
||||
if (typeof value.startDate !== 'string') {
|
||||
throw new InvalidSeasonScheduleSchemaError('Invalid schedule.startDate');
|
||||
}
|
||||
const startDate = new Date(value.startDate);
|
||||
if (Number.isNaN(startDate.getTime())) {
|
||||
throw new InvalidSeasonScheduleSchemaError('Invalid schedule.startDate (invalid date)');
|
||||
}
|
||||
|
||||
if (typeof value.timeOfDay !== 'string') {
|
||||
throw new InvalidSeasonScheduleSchemaError('Invalid schedule.timeOfDay');
|
||||
}
|
||||
|
||||
if (typeof value.timezoneId !== 'string') {
|
||||
throw new InvalidSeasonScheduleSchemaError('Invalid schedule.timezoneId');
|
||||
}
|
||||
|
||||
const plannedRoundsCandidate = value.plannedRounds;
|
||||
if (typeof plannedRoundsCandidate !== 'number' || !Number.isInteger(plannedRoundsCandidate) || plannedRoundsCandidate <= 0) {
|
||||
throw new InvalidSeasonScheduleSchemaError('Invalid schedule.plannedRounds');
|
||||
}
|
||||
|
||||
const plannedRounds = plannedRoundsCandidate;
|
||||
|
||||
assertSerializedSeasonRecurrence(value.recurrence);
|
||||
|
||||
let timeOfDay: RaceTimeOfDay;
|
||||
try {
|
||||
timeOfDay = RaceTimeOfDay.fromString(value.timeOfDay);
|
||||
} catch {
|
||||
throw new InvalidSeasonScheduleSchemaError('Invalid schedule.timeOfDay (expected HH:MM)');
|
||||
}
|
||||
|
||||
let timezone: LeagueTimezone;
|
||||
try {
|
||||
timezone = LeagueTimezone.create(value.timezoneId);
|
||||
} catch {
|
||||
throw new InvalidSeasonScheduleSchemaError('Invalid schedule.timezoneId');
|
||||
}
|
||||
|
||||
let recurrence: RecurrenceStrategy;
|
||||
try {
|
||||
switch (value.recurrence.kind) {
|
||||
case 'weekly': {
|
||||
const weekdays = WeekdaySet.fromArray(value.recurrence.weekdays);
|
||||
recurrence = RecurrenceStrategyFactory.weekly(weekdays);
|
||||
break;
|
||||
}
|
||||
case 'everyNWeeks': {
|
||||
const weekdays = WeekdaySet.fromArray(value.recurrence.weekdays);
|
||||
recurrence = RecurrenceStrategyFactory.everyNWeeks(value.recurrence.intervalWeeks, weekdays);
|
||||
break;
|
||||
}
|
||||
case 'monthlyNthWeekday': {
|
||||
const pattern = MonthlyRecurrencePattern.create(value.recurrence.ordinal, value.recurrence.weekday);
|
||||
recurrence = RecurrenceStrategyFactory.monthlyNthWeekday(pattern);
|
||||
break;
|
||||
}
|
||||
default:
|
||||
throw new InvalidSeasonScheduleSchemaError('Invalid schedule.recurrence.kind');
|
||||
}
|
||||
} catch (error) {
|
||||
if (error instanceof InvalidSeasonScheduleSchemaError) {
|
||||
throw error;
|
||||
}
|
||||
throw new InvalidSeasonScheduleSchemaError('Invalid schedule.recurrence');
|
||||
}
|
||||
|
||||
return new SeasonSchedule({
|
||||
startDate,
|
||||
timeOfDay,
|
||||
timezone,
|
||||
recurrence,
|
||||
plannedRounds,
|
||||
});
|
||||
}
|
||||
|
||||
function serializeSeasonSchedule(schedule: SeasonSchedule): SerializedSeasonSchedule {
|
||||
const recurrence = (() => {
|
||||
const props = schedule.recurrence.props;
|
||||
switch (props.kind) {
|
||||
case 'weekly':
|
||||
return {
|
||||
kind: 'weekly',
|
||||
weekdays: props.weekdays.getAll(),
|
||||
} satisfies SerializedSeasonWeeklyRecurrence;
|
||||
case 'everyNWeeks':
|
||||
return {
|
||||
kind: 'everyNWeeks',
|
||||
intervalWeeks: props.intervalWeeks,
|
||||
weekdays: props.weekdays.getAll(),
|
||||
} satisfies SerializedSeasonEveryNWeeksRecurrence;
|
||||
case 'monthlyNthWeekday':
|
||||
return {
|
||||
kind: 'monthlyNthWeekday',
|
||||
ordinal: props.monthlyPattern.ordinal,
|
||||
weekday: props.monthlyPattern.weekday,
|
||||
} satisfies SerializedSeasonMonthlyNthWeekdayRecurrence;
|
||||
}
|
||||
})();
|
||||
|
||||
return {
|
||||
startDate: schedule.startDate.toISOString(),
|
||||
timeOfDay: schedule.timeOfDay.toString(),
|
||||
timezoneId: schedule.timezone.toString(),
|
||||
recurrence,
|
||||
plannedRounds: schedule.plannedRounds,
|
||||
};
|
||||
}
|
||||
|
||||
function serializeSeasonScoringConfig(config: SeasonScoringConfig): SerializedSeasonScoringConfig {
|
||||
const props = config.props;
|
||||
return {
|
||||
scoringPresetId: props.scoringPresetId,
|
||||
...(props.customScoringEnabled ? { customScoringEnabled: props.customScoringEnabled } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
function serializeSeasonDropPolicy(policy: SeasonDropPolicy): SerializedSeasonDropPolicy {
|
||||
const props = policy.props;
|
||||
return {
|
||||
strategy: props.strategy,
|
||||
...(props.n !== undefined ? { n: props.n } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
function serializeSeasonStewardingConfig(config: SeasonStewardingConfig): SerializedSeasonStewardingConfig {
|
||||
const props = config.props;
|
||||
return {
|
||||
decisionMode: props.decisionMode,
|
||||
...(props.requiredVotes !== undefined ? { requiredVotes: props.requiredVotes } : {}),
|
||||
requireDefense: props.requireDefense,
|
||||
defenseTimeLimit: props.defenseTimeLimit,
|
||||
voteTimeLimit: props.voteTimeLimit,
|
||||
protestDeadlineHours: props.protestDeadlineHours,
|
||||
stewardingClosesHours: props.stewardingClosesHours,
|
||||
notifyAccusedOnProtest: props.notifyAccusedOnProtest,
|
||||
notifyOnVoteRequired: props.notifyOnVoteRequired,
|
||||
};
|
||||
}
|
||||
|
||||
export class SeasonOrmMapper {
|
||||
toOrmEntity(domain: Season): SeasonOrmEntity {
|
||||
const entity = new SeasonOrmEntity();
|
||||
entity.id = domain.id;
|
||||
entity.leagueId = domain.leagueId;
|
||||
entity.gameId = domain.gameId;
|
||||
entity.name = domain.name;
|
||||
entity.year = domain.year ?? null;
|
||||
entity.order = domain.order ?? null;
|
||||
entity.status = domain.status.toString();
|
||||
entity.startDate = domain.startDate ?? null;
|
||||
entity.endDate = domain.endDate ?? null;
|
||||
entity.schedule = domain.schedule ? serializeSeasonSchedule(domain.schedule) : null;
|
||||
entity.schedulePublished = domain.schedulePublished;
|
||||
entity.scoringConfig = domain.scoringConfig ? serializeSeasonScoringConfig(domain.scoringConfig) : null;
|
||||
entity.dropPolicy = domain.dropPolicy ? serializeSeasonDropPolicy(domain.dropPolicy) : null;
|
||||
entity.stewardingConfig = domain.stewardingConfig ? serializeSeasonStewardingConfig(domain.stewardingConfig) : null;
|
||||
entity.maxDrivers = domain.maxDrivers ?? null;
|
||||
entity.participantCount = domain.getParticipantCount();
|
||||
return entity;
|
||||
}
|
||||
|
||||
toDomain(entity: SeasonOrmEntity): Season {
|
||||
assertSeasonStatusValue(entity.status);
|
||||
|
||||
const status = SeasonStatus.create(entity.status);
|
||||
|
||||
const schedule = entity.schedule !== null ? parseSeasonSchedule(entity.schedule) : undefined;
|
||||
|
||||
let scoringConfig: SeasonScoringConfig | undefined;
|
||||
if (entity.scoringConfig !== null) {
|
||||
try {
|
||||
scoringConfig = new SeasonScoringConfig(entity.scoringConfig);
|
||||
} catch {
|
||||
throw new InvalidSeasonScheduleSchemaError('Invalid scoringConfig');
|
||||
}
|
||||
}
|
||||
|
||||
let dropPolicy: SeasonDropPolicy | undefined;
|
||||
if (entity.dropPolicy !== null) {
|
||||
try {
|
||||
dropPolicy = new SeasonDropPolicy(entity.dropPolicy);
|
||||
} catch {
|
||||
throw new InvalidSeasonScheduleSchemaError('Invalid dropPolicy');
|
||||
}
|
||||
}
|
||||
|
||||
let stewardingConfig: SeasonStewardingConfig | undefined;
|
||||
if (entity.stewardingConfig !== null) {
|
||||
try {
|
||||
stewardingConfig = new SeasonStewardingConfig(entity.stewardingConfig);
|
||||
} catch {
|
||||
throw new InvalidSeasonScheduleSchemaError('Invalid stewardingConfig');
|
||||
}
|
||||
}
|
||||
|
||||
return Season.rehydrate({
|
||||
id: entity.id,
|
||||
leagueId: entity.leagueId,
|
||||
gameId: entity.gameId,
|
||||
name: entity.name,
|
||||
...(entity.year !== null ? { year: entity.year } : {}),
|
||||
...(entity.order !== null ? { order: entity.order } : {}),
|
||||
status,
|
||||
...(entity.startDate !== null ? { startDate: entity.startDate } : {}),
|
||||
...(entity.endDate !== null ? { endDate: entity.endDate } : {}),
|
||||
...(schedule !== undefined ? { schedule } : {}),
|
||||
schedulePublished: entity.schedulePublished,
|
||||
...(scoringConfig !== undefined ? { scoringConfig } : {}),
|
||||
...(dropPolicy !== undefined ? { dropPolicy } : {}),
|
||||
...(stewardingConfig !== undefined ? { stewardingConfig } : {}),
|
||||
...(entity.maxDrivers !== null ? { maxDrivers: entity.maxDrivers } : {}),
|
||||
participantCount: entity.participantCount,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import * as fs from 'node:fs';
|
||||
import * as path from 'node:path';
|
||||
|
||||
import { TypeOrmLeagueRepository } from './TypeOrmLeagueRepository';
|
||||
|
||||
describe('TypeOrmLeagueRepository', () => {
|
||||
it('does not construct its own mapper dependencies', () => {
|
||||
const sourcePath = path.resolve(__dirname, 'TypeOrmLeagueRepository.ts');
|
||||
const source = fs.readFileSync(sourcePath, 'utf8');
|
||||
|
||||
expect(source).not.toMatch(/new\s+LeagueOrmMapper\s*\(/);
|
||||
expect(source).not.toMatch(/=\s*new\s+LeagueOrmMapper\s*\(/);
|
||||
});
|
||||
|
||||
it('requires mapper injection via constructor (no default mapper)', () => {
|
||||
expect(TypeOrmLeagueRepository.length).toBe(2);
|
||||
});
|
||||
|
||||
it('uses the injected mapper at runtime (DB-free)', async () => {
|
||||
const ormRepo = {
|
||||
findOne: vi.fn().mockResolvedValue({ id: 'l1' }),
|
||||
};
|
||||
|
||||
const dataSource = {
|
||||
getRepository: vi.fn().mockReturnValue(ormRepo),
|
||||
};
|
||||
|
||||
const mapper = {
|
||||
toDomain: vi.fn().mockReturnValue({ id: 'domain' }),
|
||||
toOrmEntity: vi.fn(),
|
||||
};
|
||||
|
||||
const repo = new TypeOrmLeagueRepository(dataSource as any, mapper as any);
|
||||
|
||||
const league = await repo.findById('l1');
|
||||
|
||||
expect(dataSource.getRepository).toHaveBeenCalledTimes(1);
|
||||
expect(ormRepo.findOne).toHaveBeenCalledTimes(1);
|
||||
expect(mapper.toDomain).toHaveBeenCalledTimes(1);
|
||||
expect(league).toEqual({ id: 'domain' });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,72 @@
|
||||
import type { DataSource } from 'typeorm';
|
||||
|
||||
import type { ILeagueRepository } from '@core/racing/domain/repositories/ILeagueRepository';
|
||||
import { League } from '@core/racing/domain/entities/League';
|
||||
|
||||
import { LeagueOrmEntity } from '../entities/LeagueOrmEntity';
|
||||
import { LeagueOrmMapper } from '../mappers/LeagueOrmMapper';
|
||||
|
||||
export class TypeOrmLeagueRepository implements ILeagueRepository {
|
||||
constructor(
|
||||
private readonly dataSource: DataSource,
|
||||
private readonly mapper: LeagueOrmMapper,
|
||||
) {}
|
||||
|
||||
async findById(id: string): Promise<League | null> {
|
||||
const repo = this.dataSource.getRepository(LeagueOrmEntity);
|
||||
const entity = await repo.findOne({ where: { id } });
|
||||
return entity ? this.mapper.toDomain(entity) : null;
|
||||
}
|
||||
|
||||
async findAll(): Promise<League[]> {
|
||||
const repo = this.dataSource.getRepository(LeagueOrmEntity);
|
||||
const entities = await repo.find();
|
||||
return entities.map((e) => this.mapper.toDomain(e));
|
||||
}
|
||||
|
||||
async countAll(): Promise<number> {
|
||||
const repo = this.dataSource.getRepository(LeagueOrmEntity);
|
||||
return repo.count();
|
||||
}
|
||||
|
||||
async findByOwnerId(ownerId: string): Promise<League[]> {
|
||||
const repo = this.dataSource.getRepository(LeagueOrmEntity);
|
||||
const entities = await repo.find({ where: { ownerId } });
|
||||
return entities.map((e) => this.mapper.toDomain(e));
|
||||
}
|
||||
|
||||
async create(league: League): Promise<League> {
|
||||
const repo = this.dataSource.getRepository(LeagueOrmEntity);
|
||||
const entity = this.mapper.toOrmEntity(league);
|
||||
await repo.save(entity);
|
||||
return league;
|
||||
}
|
||||
|
||||
async update(league: League): Promise<League> {
|
||||
const repo = this.dataSource.getRepository(LeagueOrmEntity);
|
||||
const entity = this.mapper.toOrmEntity(league);
|
||||
await repo.save(entity);
|
||||
return league;
|
||||
}
|
||||
|
||||
async delete(id: string): Promise<void> {
|
||||
const repo = this.dataSource.getRepository(LeagueOrmEntity);
|
||||
await repo.delete({ id });
|
||||
}
|
||||
|
||||
async exists(id: string): Promise<boolean> {
|
||||
const repo = this.dataSource.getRepository(LeagueOrmEntity);
|
||||
const count = await repo.count({ where: { id } });
|
||||
return count > 0;
|
||||
}
|
||||
|
||||
async searchByName(query: string): Promise<League[]> {
|
||||
const repo = this.dataSource.getRepository(LeagueOrmEntity);
|
||||
const entities = await repo
|
||||
.createQueryBuilder('league')
|
||||
.where('league.name ILIKE :q', { q: `%${query}%` })
|
||||
.getMany();
|
||||
|
||||
return entities.map((e) => this.mapper.toDomain(e));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
// Hoisted mocks: if the repository constructor still "news" these classes internally,
|
||||
// the mocked constructors will be called and this test will fail.
|
||||
vi.mock('../mappers/PointsTableJsonMapper', () => ({
|
||||
PointsTableJsonMapper: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../mappers/ChampionshipConfigJsonMapper', () => ({
|
||||
ChampionshipConfigJsonMapper: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../mappers/LeagueScoringConfigOrmMapper', () => ({
|
||||
LeagueScoringConfigOrmMapper: vi.fn(),
|
||||
}));
|
||||
|
||||
describe('TypeOrmLeagueScoringConfigRepository', () => {
|
||||
it('constructor must not new sub-mappers internally (mapper graph must be injected)', async () => {
|
||||
const { PointsTableJsonMapper } = await import('../mappers/PointsTableJsonMapper');
|
||||
const { ChampionshipConfigJsonMapper } = await import('../mappers/ChampionshipConfigJsonMapper');
|
||||
const { LeagueScoringConfigOrmMapper } = await import('../mappers/LeagueScoringConfigOrmMapper');
|
||||
const { TypeOrmLeagueScoringConfigRepository } = await import('./TypeOrmLeagueScoringConfigRepository');
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const dataSourceStub: any = { getRepository: vi.fn() };
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const injectedMapper: any = { toDomain: vi.fn(), toOrmEntity: vi.fn() };
|
||||
|
||||
// If repo is still doing `new PointsTableJsonMapper()` etc. internally, the mocked constructors would be called.
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-call
|
||||
new (TypeOrmLeagueScoringConfigRepository as unknown as new (...args: unknown[]) => unknown)(
|
||||
dataSourceStub,
|
||||
injectedMapper,
|
||||
);
|
||||
|
||||
expect(PointsTableJsonMapper).not.toHaveBeenCalled();
|
||||
expect(ChampionshipConfigJsonMapper).not.toHaveBeenCalled();
|
||||
expect(LeagueScoringConfigOrmMapper).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,26 @@
|
||||
import type { DataSource } from 'typeorm';
|
||||
|
||||
import type { ILeagueScoringConfigRepository } from '@core/racing/domain/repositories/ILeagueScoringConfigRepository';
|
||||
import { LeagueScoringConfig } from '@core/racing/domain/entities/LeagueScoringConfig';
|
||||
|
||||
import { LeagueScoringConfigOrmEntity } from '../entities/LeagueScoringConfigOrmEntity';
|
||||
import { LeagueScoringConfigOrmMapper } from '../mappers/LeagueScoringConfigOrmMapper';
|
||||
|
||||
export class TypeOrmLeagueScoringConfigRepository implements ILeagueScoringConfigRepository {
|
||||
constructor(
|
||||
private readonly dataSource: DataSource,
|
||||
private readonly mapper: LeagueScoringConfigOrmMapper,
|
||||
) {}
|
||||
|
||||
async findBySeasonId(seasonId: string): Promise<LeagueScoringConfig | null> {
|
||||
const repo = this.dataSource.getRepository(LeagueScoringConfigOrmEntity);
|
||||
const entity = await repo.findOne({ where: { seasonId } });
|
||||
return entity ? this.mapper.toDomain(entity) : null;
|
||||
}
|
||||
|
||||
async save(config: LeagueScoringConfig): Promise<LeagueScoringConfig> {
|
||||
const repo = this.dataSource.getRepository(LeagueScoringConfigOrmEntity);
|
||||
await repo.save(this.mapper.toOrmEntity(config));
|
||||
return config;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import * as fs from 'node:fs';
|
||||
import * as path from 'node:path';
|
||||
|
||||
import { TypeOrmRaceRepository } from './TypeOrmRaceRepository';
|
||||
|
||||
describe('TypeOrmRaceRepository', () => {
|
||||
it('does not construct its own mapper dependencies', () => {
|
||||
const sourcePath = path.resolve(__dirname, 'TypeOrmRaceRepository.ts');
|
||||
const source = fs.readFileSync(sourcePath, 'utf8');
|
||||
|
||||
expect(source).not.toMatch(/new\s+RaceOrmMapper\s*\(/);
|
||||
expect(source).not.toMatch(/=\s*new\s+RaceOrmMapper\s*\(/);
|
||||
});
|
||||
|
||||
it('requires mapper injection via constructor (no default mapper)', () => {
|
||||
expect(TypeOrmRaceRepository.length).toBe(2);
|
||||
});
|
||||
|
||||
it('uses the injected mapper at runtime (DB-free)', async () => {
|
||||
const ormRepo = {
|
||||
findOne: vi.fn().mockResolvedValue({ id: 'r1' }),
|
||||
};
|
||||
|
||||
const dataSource = {
|
||||
getRepository: vi.fn().mockReturnValue(ormRepo),
|
||||
};
|
||||
|
||||
const mapper = {
|
||||
toDomain: vi.fn().mockReturnValue({ id: 'domain' }),
|
||||
toOrmEntity: vi.fn(),
|
||||
};
|
||||
|
||||
const repo = new TypeOrmRaceRepository(dataSource as any, mapper as any);
|
||||
|
||||
const race = await repo.findById('r1');
|
||||
|
||||
expect(dataSource.getRepository).toHaveBeenCalledTimes(1);
|
||||
expect(ormRepo.findOne).toHaveBeenCalledTimes(1);
|
||||
expect(mapper.toDomain).toHaveBeenCalledTimes(1);
|
||||
expect(race).toEqual({ id: 'domain' });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,88 @@
|
||||
import type { DataSource } from 'typeorm';
|
||||
|
||||
import type { IRaceRepository } from '@core/racing/domain/repositories/IRaceRepository';
|
||||
import { Race, type RaceStatusValue } from '@core/racing/domain/entities/Race';
|
||||
|
||||
import { RaceOrmEntity } from '../entities/RaceOrmEntity';
|
||||
import { RaceOrmMapper } from '../mappers/RaceOrmMapper';
|
||||
|
||||
export class TypeOrmRaceRepository implements IRaceRepository {
|
||||
constructor(
|
||||
private readonly dataSource: DataSource,
|
||||
private readonly mapper: RaceOrmMapper,
|
||||
) {}
|
||||
|
||||
async findById(id: string): Promise<Race | null> {
|
||||
const repo = this.dataSource.getRepository(RaceOrmEntity);
|
||||
const entity = await repo.findOne({ where: { id } });
|
||||
return entity ? this.mapper.toDomain(entity) : null;
|
||||
}
|
||||
|
||||
async findAll(): Promise<Race[]> {
|
||||
const repo = this.dataSource.getRepository(RaceOrmEntity);
|
||||
const entities = await repo.find();
|
||||
return entities.map((e) => this.mapper.toDomain(e));
|
||||
}
|
||||
|
||||
async findByLeagueId(leagueId: string): Promise<Race[]> {
|
||||
const repo = this.dataSource.getRepository(RaceOrmEntity);
|
||||
const entities = await repo.find({ where: { leagueId } });
|
||||
return entities.map((e) => this.mapper.toDomain(e));
|
||||
}
|
||||
|
||||
async findUpcomingByLeagueId(leagueId: string): Promise<Race[]> {
|
||||
const repo = this.dataSource.getRepository(RaceOrmEntity);
|
||||
const entities = await repo
|
||||
.createQueryBuilder('race')
|
||||
.where('race.leagueId = :leagueId', { leagueId })
|
||||
.andWhere('race.status = :status', { status: 'scheduled' })
|
||||
.andWhere('race.scheduledAt >= NOW()')
|
||||
.getMany();
|
||||
return entities.map((e) => this.mapper.toDomain(e));
|
||||
}
|
||||
|
||||
async findCompletedByLeagueId(leagueId: string): Promise<Race[]> {
|
||||
const repo = this.dataSource.getRepository(RaceOrmEntity);
|
||||
const entities = await repo.find({ where: { leagueId, status: 'completed' } });
|
||||
return entities.map((e) => this.mapper.toDomain(e));
|
||||
}
|
||||
|
||||
async findByStatus(status: RaceStatusValue): Promise<Race[]> {
|
||||
const repo = this.dataSource.getRepository(RaceOrmEntity);
|
||||
const entities = await repo.find({ where: { status } });
|
||||
return entities.map((e) => this.mapper.toDomain(e));
|
||||
}
|
||||
|
||||
async findByDateRange(startDate: Date, endDate: Date): Promise<Race[]> {
|
||||
const repo = this.dataSource.getRepository(RaceOrmEntity);
|
||||
const entities = await repo
|
||||
.createQueryBuilder('race')
|
||||
.where('race.scheduledAt >= :startDate', { startDate })
|
||||
.andWhere('race.scheduledAt <= :endDate', { endDate })
|
||||
.getMany();
|
||||
return entities.map((e) => this.mapper.toDomain(e));
|
||||
}
|
||||
|
||||
async create(race: Race): Promise<Race> {
|
||||
const repo = this.dataSource.getRepository(RaceOrmEntity);
|
||||
await repo.save(this.mapper.toOrmEntity(race));
|
||||
return race;
|
||||
}
|
||||
|
||||
async update(race: Race): Promise<Race> {
|
||||
const repo = this.dataSource.getRepository(RaceOrmEntity);
|
||||
await repo.save(this.mapper.toOrmEntity(race));
|
||||
return race;
|
||||
}
|
||||
|
||||
async delete(id: string): Promise<void> {
|
||||
const repo = this.dataSource.getRepository(RaceOrmEntity);
|
||||
await repo.delete({ id });
|
||||
}
|
||||
|
||||
async exists(id: string): Promise<boolean> {
|
||||
const repo = this.dataSource.getRepository(RaceOrmEntity);
|
||||
const count = await repo.count({ where: { id } });
|
||||
return count > 0;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import * as fs from 'node:fs';
|
||||
import * as path from 'node:path';
|
||||
|
||||
import { TypeOrmSeasonRepository } from './TypeOrmSeasonRepository';
|
||||
|
||||
describe('TypeOrmSeasonRepository', () => {
|
||||
it('does not construct its own mapper dependencies', () => {
|
||||
const sourcePath = path.resolve(__dirname, 'TypeOrmSeasonRepository.ts');
|
||||
const source = fs.readFileSync(sourcePath, 'utf8');
|
||||
|
||||
expect(source).not.toMatch(/new\s+SeasonOrmMapper\s*\(/);
|
||||
expect(source).not.toMatch(/=\s*new\s+SeasonOrmMapper\s*\(/);
|
||||
});
|
||||
|
||||
it('requires mapper injection via constructor (no default mapper)', () => {
|
||||
expect(TypeOrmSeasonRepository.length).toBe(2);
|
||||
});
|
||||
|
||||
it('uses the injected mapper at runtime (DB-free)', async () => {
|
||||
const ormRepo = {
|
||||
findOne: vi.fn().mockResolvedValue({ id: 's1' }),
|
||||
};
|
||||
|
||||
const dataSource = {
|
||||
getRepository: vi.fn().mockReturnValue(ormRepo),
|
||||
};
|
||||
|
||||
const mapper = {
|
||||
toDomain: vi.fn().mockReturnValue({ id: 'domain' }),
|
||||
toOrmEntity: vi.fn(),
|
||||
};
|
||||
|
||||
const repo = new TypeOrmSeasonRepository(dataSource as any, mapper as any);
|
||||
|
||||
const season = await repo.findById('s1');
|
||||
|
||||
expect(dataSource.getRepository).toHaveBeenCalledTimes(1);
|
||||
expect(ormRepo.findOne).toHaveBeenCalledTimes(1);
|
||||
expect(mapper.toDomain).toHaveBeenCalledTimes(1);
|
||||
expect(season).toEqual({ id: 'domain' });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,52 @@
|
||||
import type { DataSource } from 'typeorm';
|
||||
|
||||
import type { ISeasonRepository } from '@core/racing/domain/repositories/ISeasonRepository';
|
||||
import { Season } from '@core/racing/domain/entities/season/Season';
|
||||
|
||||
import { SeasonOrmEntity } from '../entities/SeasonOrmEntity';
|
||||
import { SeasonOrmMapper } from '../mappers/SeasonOrmMapper';
|
||||
|
||||
export class TypeOrmSeasonRepository implements ISeasonRepository {
|
||||
constructor(
|
||||
private readonly dataSource: DataSource,
|
||||
private readonly mapper: SeasonOrmMapper,
|
||||
) {}
|
||||
|
||||
async findById(id: string): Promise<Season | null> {
|
||||
const repo = this.dataSource.getRepository(SeasonOrmEntity);
|
||||
const entity = await repo.findOne({ where: { id } });
|
||||
return entity ? this.mapper.toDomain(entity) : null;
|
||||
}
|
||||
|
||||
async findByLeagueId(leagueId: string): Promise<Season[]> {
|
||||
const repo = this.dataSource.getRepository(SeasonOrmEntity);
|
||||
const entities = await repo.find({ where: { leagueId } });
|
||||
return entities.map((e) => this.mapper.toDomain(e));
|
||||
}
|
||||
|
||||
async create(season: Season): Promise<Season> {
|
||||
const repo = this.dataSource.getRepository(SeasonOrmEntity);
|
||||
await repo.save(this.mapper.toOrmEntity(season));
|
||||
return season;
|
||||
}
|
||||
|
||||
async add(season: Season): Promise<void> {
|
||||
const repo = this.dataSource.getRepository(SeasonOrmEntity);
|
||||
await repo.save(this.mapper.toOrmEntity(season));
|
||||
}
|
||||
|
||||
async update(season: Season): Promise<void> {
|
||||
const repo = this.dataSource.getRepository(SeasonOrmEntity);
|
||||
await repo.save(this.mapper.toOrmEntity(season));
|
||||
}
|
||||
|
||||
async listByLeague(leagueId: string): Promise<Season[]> {
|
||||
return this.findByLeagueId(leagueId);
|
||||
}
|
||||
|
||||
async listActiveByLeague(leagueId: string): Promise<Season[]> {
|
||||
const repo = this.dataSource.getRepository(SeasonOrmEntity);
|
||||
const entities = await repo.find({ where: { leagueId, status: 'active' } });
|
||||
return entities.map((e) => this.mapper.toDomain(e));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
import type { Weekday } from '@core/racing/domain/types/Weekday';
|
||||
|
||||
export type SerializedLeagueVisibility = 'ranked' | 'unranked';
|
||||
|
||||
export type SerializedLeagueStewardingDecisionMode =
|
||||
| 'admin_only'
|
||||
| 'steward_decides'
|
||||
| 'steward_vote'
|
||||
| 'member_vote'
|
||||
| 'steward_veto'
|
||||
| 'member_veto';
|
||||
|
||||
export type SerializedLeagueStewardingSettings = {
|
||||
decisionMode: SerializedLeagueStewardingDecisionMode;
|
||||
requiredVotes?: number;
|
||||
requireDefense?: boolean;
|
||||
defenseTimeLimit?: number;
|
||||
voteTimeLimit?: number;
|
||||
protestDeadlineHours?: number;
|
||||
stewardingClosesHours?: number;
|
||||
notifyAccusedOnProtest?: boolean;
|
||||
notifyOnVoteRequired?: boolean;
|
||||
};
|
||||
|
||||
export type SerializedLeagueCustomPoints = Record<string, number>;
|
||||
|
||||
export type SerializedLeagueSettings = {
|
||||
pointsSystem: 'f1-2024' | 'indycar' | 'custom';
|
||||
sessionDuration?: number;
|
||||
qualifyingFormat?: 'single-lap' | 'open';
|
||||
customPoints?: SerializedLeagueCustomPoints;
|
||||
maxDrivers?: number;
|
||||
stewarding?: SerializedLeagueStewardingSettings;
|
||||
visibility?: SerializedLeagueVisibility;
|
||||
};
|
||||
|
||||
export type SerializedSeasonWeeklyRecurrence = {
|
||||
kind: 'weekly';
|
||||
weekdays: Weekday[];
|
||||
};
|
||||
|
||||
export type SerializedSeasonEveryNWeeksRecurrence = {
|
||||
kind: 'everyNWeeks';
|
||||
intervalWeeks: number;
|
||||
weekdays: Weekday[];
|
||||
};
|
||||
|
||||
export type SerializedSeasonMonthlyNthWeekdayRecurrence = {
|
||||
kind: 'monthlyNthWeekday';
|
||||
ordinal: 1 | 2 | 3 | 4;
|
||||
weekday: Weekday;
|
||||
};
|
||||
|
||||
export type SerializedSeasonRecurrence =
|
||||
| SerializedSeasonWeeklyRecurrence
|
||||
| SerializedSeasonEveryNWeeksRecurrence
|
||||
| SerializedSeasonMonthlyNthWeekdayRecurrence;
|
||||
|
||||
export type SerializedSeasonSchedule = {
|
||||
startDate: string;
|
||||
timeOfDay: string;
|
||||
timezoneId: string;
|
||||
recurrence: SerializedSeasonRecurrence;
|
||||
plannedRounds: number;
|
||||
};
|
||||
|
||||
export type SerializedSeasonScoringConfig = {
|
||||
scoringPresetId: string;
|
||||
customScoringEnabled?: boolean;
|
||||
};
|
||||
|
||||
export type SerializedSeasonDropPolicy = {
|
||||
strategy: 'none' | 'bestNResults' | 'dropWorstN';
|
||||
n?: number;
|
||||
};
|
||||
|
||||
export type SerializedSeasonStewardingConfig = {
|
||||
decisionMode: SerializedLeagueStewardingDecisionMode;
|
||||
requiredVotes?: number;
|
||||
requireDefense: boolean;
|
||||
defenseTimeLimit: number;
|
||||
voteTimeLimit: number;
|
||||
protestDeadlineHours: number;
|
||||
stewardingClosesHours: number;
|
||||
notifyAccusedOnProtest: boolean;
|
||||
notifyOnVoteRequired: boolean;
|
||||
};
|
||||
@@ -0,0 +1,136 @@
|
||||
import 'reflect-metadata';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
type SetupOptions = {
|
||||
persistence: 'postgres' | 'inmemory';
|
||||
nodeEnv: string | undefined;
|
||||
bootstrapEnabled: boolean;
|
||||
leaguesCount: number;
|
||||
};
|
||||
|
||||
describe('BootstrapModule Postgres racing seed gating (unit)', () => {
|
||||
const originalEnv = { ...process.env };
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetModules();
|
||||
process.env = { ...originalEnv };
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
process.env = originalEnv;
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
async function setup({
|
||||
persistence,
|
||||
nodeEnv,
|
||||
bootstrapEnabled,
|
||||
leaguesCount,
|
||||
}: SetupOptions): Promise<{
|
||||
seedExecute: ReturnType<typeof vi.fn>;
|
||||
ensureExecute: ReturnType<typeof vi.fn>;
|
||||
leagueCountAll: ReturnType<typeof vi.fn>;
|
||||
}> {
|
||||
process.env.NODE_ENV = nodeEnv;
|
||||
|
||||
vi.doMock('../../env', async () => {
|
||||
const actual = await vi.importActual<typeof import('../../env')>('../../env');
|
||||
return {
|
||||
...actual,
|
||||
getApiPersistence: () => persistence,
|
||||
getEnableBootstrap: () => bootstrapEnabled,
|
||||
};
|
||||
});
|
||||
|
||||
const seedExecute = vi.fn(async () => undefined);
|
||||
vi.doMock('../../../../../adapters/bootstrap/SeedRacingData', () => {
|
||||
class SeedRacingData {
|
||||
execute = seedExecute;
|
||||
}
|
||||
return { SeedRacingData };
|
||||
});
|
||||
|
||||
const { BootstrapModule } = await import('./BootstrapModule');
|
||||
|
||||
const ensureExecute = vi.fn(async () => undefined);
|
||||
|
||||
const leagueCountAll = vi.fn(async () => leaguesCount);
|
||||
|
||||
const bootstrapModule = new BootstrapModule(
|
||||
{ execute: ensureExecute } as any,
|
||||
{ debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn() } as any,
|
||||
{
|
||||
leagueRepository: { countAll: leagueCountAll },
|
||||
} as any,
|
||||
);
|
||||
|
||||
await bootstrapModule.onModuleInit();
|
||||
|
||||
return { seedExecute, ensureExecute, leagueCountAll };
|
||||
}
|
||||
|
||||
it('seeds when inmemory + bootstrap enabled (existing behavior)', async () => {
|
||||
const { seedExecute, ensureExecute, leagueCountAll } = await setup({
|
||||
persistence: 'inmemory',
|
||||
nodeEnv: 'test',
|
||||
bootstrapEnabled: true,
|
||||
leaguesCount: 123,
|
||||
});
|
||||
|
||||
expect(ensureExecute).toHaveBeenCalledTimes(1);
|
||||
expect(leagueCountAll).toHaveBeenCalledTimes(0);
|
||||
expect(seedExecute).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('seeds when postgres + non-production + bootstrap enabled + empty', async () => {
|
||||
const { seedExecute, ensureExecute, leagueCountAll } = await setup({
|
||||
persistence: 'postgres',
|
||||
nodeEnv: 'development',
|
||||
bootstrapEnabled: true,
|
||||
leaguesCount: 0,
|
||||
});
|
||||
|
||||
expect(ensureExecute).toHaveBeenCalledTimes(1);
|
||||
expect(leagueCountAll).toHaveBeenCalledTimes(1);
|
||||
expect(seedExecute).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('does not seed when postgres + non-production + bootstrap enabled + not empty', async () => {
|
||||
const { seedExecute, ensureExecute, leagueCountAll } = await setup({
|
||||
persistence: 'postgres',
|
||||
nodeEnv: 'development',
|
||||
bootstrapEnabled: true,
|
||||
leaguesCount: 1,
|
||||
});
|
||||
|
||||
expect(ensureExecute).toHaveBeenCalledTimes(1);
|
||||
expect(leagueCountAll).toHaveBeenCalledTimes(1);
|
||||
expect(seedExecute).toHaveBeenCalledTimes(0);
|
||||
});
|
||||
|
||||
it('does not seed when postgres + production (even if empty)', async () => {
|
||||
const { seedExecute, ensureExecute, leagueCountAll } = await setup({
|
||||
persistence: 'postgres',
|
||||
nodeEnv: 'production',
|
||||
bootstrapEnabled: true,
|
||||
leaguesCount: 0,
|
||||
});
|
||||
|
||||
expect(ensureExecute).toHaveBeenCalledTimes(1);
|
||||
expect(leagueCountAll).toHaveBeenCalledTimes(0);
|
||||
expect(seedExecute).toHaveBeenCalledTimes(0);
|
||||
});
|
||||
|
||||
it('does not seed when postgres + non-production + bootstrap disabled (even if empty)', async () => {
|
||||
const { seedExecute, ensureExecute, leagueCountAll } = await setup({
|
||||
persistence: 'postgres',
|
||||
nodeEnv: 'development',
|
||||
bootstrapEnabled: false,
|
||||
leaguesCount: 0,
|
||||
});
|
||||
|
||||
expect(ensureExecute).toHaveBeenCalledTimes(0);
|
||||
expect(leagueCountAll).toHaveBeenCalledTimes(0);
|
||||
expect(seedExecute).toHaveBeenCalledTimes(0);
|
||||
});
|
||||
});
|
||||
@@ -2,13 +2,13 @@ import type { Logger } from '@core/shared/application';
|
||||
import type { EnsureInitialData } from '../../../../../adapters/bootstrap/EnsureInitialData';
|
||||
import { SeedRacingData, type RacingSeedDependencies } from '../../../../../adapters/bootstrap/SeedRacingData';
|
||||
import { Inject, Module, OnModuleInit } from '@nestjs/common';
|
||||
import { getApiPersistence } from '../../env';
|
||||
import { InMemoryRacingPersistenceModule } from '../../persistence/inmemory/InMemoryRacingPersistenceModule';
|
||||
import { getApiPersistence, getEnableBootstrap } from '../../env';
|
||||
import { RacingPersistenceModule } from '../../persistence/racing/RacingPersistenceModule';
|
||||
import { InMemorySocialPersistenceModule } from '../../persistence/inmemory/InMemorySocialPersistenceModule';
|
||||
import { BootstrapProviders, ENSURE_INITIAL_DATA_TOKEN } from './BootstrapProviders';
|
||||
|
||||
@Module({
|
||||
imports: [InMemoryRacingPersistenceModule, InMemorySocialPersistenceModule],
|
||||
imports: [RacingPersistenceModule, InMemorySocialPersistenceModule],
|
||||
providers: BootstrapProviders,
|
||||
})
|
||||
export class BootstrapModule implements OnModuleInit {
|
||||
@@ -21,9 +21,14 @@ export class BootstrapModule implements OnModuleInit {
|
||||
async onModuleInit() {
|
||||
console.log('[Bootstrap] Initializing application data...');
|
||||
try {
|
||||
if (!getEnableBootstrap()) {
|
||||
this.logger.info('[Bootstrap] Bootstrap disabled via GRIDPILOT_API_BOOTSTRAP; skipping initialization');
|
||||
return;
|
||||
}
|
||||
|
||||
await this.ensureInitialData.execute();
|
||||
|
||||
if (this.shouldSeedRacingData()) {
|
||||
if (await this.shouldSeedRacingData()) {
|
||||
await new SeedRacingData(this.logger, this.seedDeps).execute();
|
||||
}
|
||||
|
||||
@@ -34,7 +39,21 @@ export class BootstrapModule implements OnModuleInit {
|
||||
}
|
||||
}
|
||||
|
||||
private shouldSeedRacingData(): boolean {
|
||||
return getApiPersistence() === 'inmemory';
|
||||
private async shouldSeedRacingData(): Promise<boolean> {
|
||||
const persistence = getApiPersistence();
|
||||
|
||||
if (persistence === 'inmemory') return true;
|
||||
if (persistence !== 'postgres') return false;
|
||||
if (process.env.NODE_ENV === 'production') return false;
|
||||
|
||||
return this.isRacingDatabaseEmpty();
|
||||
}
|
||||
|
||||
private async isRacingDatabaseEmpty(): Promise<boolean> {
|
||||
const count = await this.seedDeps.leagueRepository.countAll?.();
|
||||
if (typeof count === 'number') return count === 0;
|
||||
|
||||
const leagues = await this.seedDeps.leagueRepository.findAll();
|
||||
return leagues.length === 0;
|
||||
}
|
||||
}
|
||||
@@ -1,12 +1,12 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { InMemoryRacingPersistenceModule } from '../../persistence/inmemory/InMemoryRacingPersistenceModule';
|
||||
import { RacingPersistenceModule } from '../../persistence/racing/RacingPersistenceModule';
|
||||
import { InMemorySocialPersistenceModule } from '../../persistence/inmemory/InMemorySocialPersistenceModule';
|
||||
import { DashboardService } from './DashboardService';
|
||||
import { DashboardController } from './DashboardController';
|
||||
import { DashboardProviders } from './DashboardProviders';
|
||||
|
||||
@Module({
|
||||
imports: [InMemoryRacingPersistenceModule, InMemorySocialPersistenceModule],
|
||||
imports: [RacingPersistenceModule, InMemorySocialPersistenceModule],
|
||||
controllers: [DashboardController],
|
||||
providers: [DashboardService, ...DashboardProviders],
|
||||
exports: [DashboardService],
|
||||
|
||||
@@ -14,7 +14,7 @@ import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
password: process.env.DATABASE_PASSWORD || 'password',
|
||||
database: process.env.DATABASE_NAME || 'gridpilot',
|
||||
}),
|
||||
// entities: [AnalyticsSnapshotOrmEntity, EngagementOrmEntity],
|
||||
autoLoadEntities: true,
|
||||
synchronize: process.env.NODE_ENV !== 'production',
|
||||
}),
|
||||
],
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { InMemoryRacingPersistenceModule } from '../../persistence/inmemory/InMemoryRacingPersistenceModule';
|
||||
import { RacingPersistenceModule } from '../../persistence/racing/RacingPersistenceModule';
|
||||
import { InMemorySocialPersistenceModule } from '../../persistence/inmemory/InMemorySocialPersistenceModule';
|
||||
import { DriverService } from './DriverService';
|
||||
import { DriverController } from './DriverController';
|
||||
import { DriverProviders } from './DriverProviders';
|
||||
|
||||
@Module({
|
||||
imports: [InMemoryRacingPersistenceModule, InMemorySocialPersistenceModule],
|
||||
imports: [RacingPersistenceModule, InMemorySocialPersistenceModule],
|
||||
controllers: [DriverController],
|
||||
providers: [DriverService, ...DriverProviders],
|
||||
exports: [DriverService],
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { InMemoryRacingPersistenceModule } from '../../persistence/inmemory/InMemoryRacingPersistenceModule';
|
||||
import { RacingPersistenceModule } from '../../persistence/racing/RacingPersistenceModule';
|
||||
import { LeagueService } from './LeagueService';
|
||||
import { LeagueController } from './LeagueController';
|
||||
import { LeagueProviders } from './LeagueProviders';
|
||||
|
||||
@Module({
|
||||
imports: [InMemoryRacingPersistenceModule],
|
||||
imports: [RacingPersistenceModule],
|
||||
controllers: [LeagueController],
|
||||
providers: LeagueProviders,
|
||||
exports: [LeagueService],
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { InMemoryRacingPersistenceModule } from '../../persistence/inmemory/InMemoryRacingPersistenceModule';
|
||||
import { RacingPersistenceModule } from '../../persistence/racing/RacingPersistenceModule';
|
||||
import { ProtestsController } from './ProtestsController';
|
||||
import { ProtestsService } from './ProtestsService';
|
||||
import { ProtestsProviders } from './ProtestsProviders';
|
||||
|
||||
@Module({
|
||||
imports: [InMemoryRacingPersistenceModule],
|
||||
imports: [RacingPersistenceModule],
|
||||
providers: [ProtestsService, ...ProtestsProviders],
|
||||
controllers: [ProtestsController],
|
||||
})
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { InMemoryRacingPersistenceModule } from '../../persistence/inmemory/InMemoryRacingPersistenceModule';
|
||||
import { RacingPersistenceModule } from '../../persistence/racing/RacingPersistenceModule';
|
||||
import { RaceService } from './RaceService';
|
||||
import { RaceController } from './RaceController';
|
||||
import { RaceProviders } from './RaceProviders';
|
||||
|
||||
@Module({
|
||||
imports: [InMemoryRacingPersistenceModule],
|
||||
imports: [RacingPersistenceModule],
|
||||
controllers: [RaceController],
|
||||
providers: [RaceService, ...RaceProviders],
|
||||
exports: [RaceService],
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { InMemoryRacingPersistenceModule } from '../../persistence/inmemory/InMemoryRacingPersistenceModule';
|
||||
import { RacingPersistenceModule } from '../../persistence/racing/RacingPersistenceModule';
|
||||
import { AuthModule } from '../auth/AuthModule';
|
||||
import { PolicyModule } from '../policy/PolicyModule';
|
||||
import { SponsorService } from './SponsorService';
|
||||
@@ -7,7 +7,7 @@ import { SponsorController } from './SponsorController';
|
||||
import { SponsorProviders } from './SponsorProviders';
|
||||
|
||||
@Module({
|
||||
imports: [InMemoryRacingPersistenceModule, AuthModule, PolicyModule],
|
||||
imports: [RacingPersistenceModule, AuthModule, PolicyModule],
|
||||
controllers: [SponsorController],
|
||||
providers: SponsorProviders,
|
||||
exports: [SponsorService],
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { InMemoryRacingPersistenceModule } from '../../persistence/inmemory/InMemoryRacingPersistenceModule';
|
||||
import { RacingPersistenceModule } from '../../persistence/racing/RacingPersistenceModule';
|
||||
import { TeamService } from './TeamService';
|
||||
import { TeamController } from './TeamController';
|
||||
import { TeamProviders } from './TeamProviders';
|
||||
|
||||
@Module({
|
||||
imports: [InMemoryRacingPersistenceModule],
|
||||
imports: [RacingPersistenceModule],
|
||||
controllers: [TeamController],
|
||||
providers: [TeamService, ...TeamProviders],
|
||||
exports: [TeamService],
|
||||
|
||||
@@ -0,0 +1,201 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { TypeOrmModule, getDataSourceToken } from '@nestjs/typeorm';
|
||||
import type { DataSource } from 'typeorm';
|
||||
|
||||
import { LoggingModule } from '../../domain/logging/LoggingModule';
|
||||
|
||||
import {
|
||||
DRIVER_REPOSITORY_TOKEN,
|
||||
GAME_REPOSITORY_TOKEN,
|
||||
LEAGUE_MEMBERSHIP_REPOSITORY_TOKEN,
|
||||
LEAGUE_REPOSITORY_TOKEN,
|
||||
LEAGUE_SCORING_CONFIG_REPOSITORY_TOKEN,
|
||||
LEAGUE_WALLET_REPOSITORY_TOKEN,
|
||||
PENALTY_REPOSITORY_TOKEN,
|
||||
PROTEST_REPOSITORY_TOKEN,
|
||||
RACE_REGISTRATION_REPOSITORY_TOKEN,
|
||||
RACE_REPOSITORY_TOKEN,
|
||||
RESULT_REPOSITORY_TOKEN,
|
||||
SEASON_REPOSITORY_TOKEN,
|
||||
SEASON_SPONSORSHIP_REPOSITORY_TOKEN,
|
||||
SPONSOR_REPOSITORY_TOKEN,
|
||||
SPONSORSHIP_PRICING_REPOSITORY_TOKEN,
|
||||
SPONSORSHIP_REQUEST_REPOSITORY_TOKEN,
|
||||
STANDING_REPOSITORY_TOKEN,
|
||||
TEAM_MEMBERSHIP_REPOSITORY_TOKEN,
|
||||
TEAM_REPOSITORY_TOKEN,
|
||||
TRANSACTION_REPOSITORY_TOKEN,
|
||||
} from '../inmemory/InMemoryRacingPersistenceModule';
|
||||
|
||||
import { LeagueOrmEntity } from '@adapters/racing/persistence/typeorm/entities/LeagueOrmEntity';
|
||||
import { LeagueScoringConfigOrmEntity } from '@adapters/racing/persistence/typeorm/entities/LeagueScoringConfigOrmEntity';
|
||||
import { RaceOrmEntity } from '@adapters/racing/persistence/typeorm/entities/RaceOrmEntity';
|
||||
import { SeasonOrmEntity } from '@adapters/racing/persistence/typeorm/entities/SeasonOrmEntity';
|
||||
|
||||
import { TypeOrmLeagueRepository } from '@adapters/racing/persistence/typeorm/repositories/TypeOrmLeagueRepository';
|
||||
import { TypeOrmLeagueScoringConfigRepository } from '@adapters/racing/persistence/typeorm/repositories/TypeOrmLeagueScoringConfigRepository';
|
||||
import { TypeOrmRaceRepository } from '@adapters/racing/persistence/typeorm/repositories/TypeOrmRaceRepository';
|
||||
import { TypeOrmSeasonRepository } from '@adapters/racing/persistence/typeorm/repositories/TypeOrmSeasonRepository';
|
||||
import { LeagueOrmMapper } from '@adapters/racing/persistence/typeorm/mappers/LeagueOrmMapper';
|
||||
import { RaceOrmMapper } from '@adapters/racing/persistence/typeorm/mappers/RaceOrmMapper';
|
||||
import { SeasonOrmMapper } from '@adapters/racing/persistence/typeorm/mappers/SeasonOrmMapper';
|
||||
import { PointsTableJsonMapper } from '@adapters/racing/persistence/typeorm/mappers/PointsTableJsonMapper';
|
||||
import { ChampionshipConfigJsonMapper } from '@adapters/racing/persistence/typeorm/mappers/ChampionshipConfigJsonMapper';
|
||||
import { LeagueScoringConfigOrmMapper } from '@adapters/racing/persistence/typeorm/mappers/LeagueScoringConfigOrmMapper';
|
||||
|
||||
function makePlaceholder(token: string): unknown {
|
||||
return Object.freeze({
|
||||
__token: token,
|
||||
__kind: 'postgres-placeholder',
|
||||
__notImplemented(): never {
|
||||
throw new Error(`[PostgresRacingPersistenceModule] Placeholder provider "${token}" is not implemented yet`);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const typeOrmFeatureImports = [
|
||||
TypeOrmModule.forFeature([LeagueOrmEntity, SeasonOrmEntity, RaceOrmEntity, LeagueScoringConfigOrmEntity]),
|
||||
];
|
||||
|
||||
@Module({
|
||||
imports: [LoggingModule, ...typeOrmFeatureImports],
|
||||
providers: [
|
||||
{
|
||||
provide: DRIVER_REPOSITORY_TOKEN,
|
||||
useFactory: () => makePlaceholder(DRIVER_REPOSITORY_TOKEN),
|
||||
inject: ['Logger'],
|
||||
},
|
||||
{
|
||||
provide: LEAGUE_REPOSITORY_TOKEN,
|
||||
useFactory: (dataSource: DataSource) => {
|
||||
const leagueMapper = new LeagueOrmMapper();
|
||||
return new TypeOrmLeagueRepository(dataSource, leagueMapper);
|
||||
},
|
||||
inject: [getDataSourceToken()],
|
||||
},
|
||||
{
|
||||
provide: RACE_REPOSITORY_TOKEN,
|
||||
useFactory: (dataSource: DataSource) => {
|
||||
const raceMapper = new RaceOrmMapper();
|
||||
return new TypeOrmRaceRepository(dataSource, raceMapper);
|
||||
},
|
||||
inject: [getDataSourceToken()],
|
||||
},
|
||||
{
|
||||
provide: RESULT_REPOSITORY_TOKEN,
|
||||
useFactory: () => makePlaceholder(RESULT_REPOSITORY_TOKEN),
|
||||
inject: ['Logger'],
|
||||
},
|
||||
{
|
||||
provide: STANDING_REPOSITORY_TOKEN,
|
||||
useFactory: () => makePlaceholder(STANDING_REPOSITORY_TOKEN),
|
||||
inject: ['Logger'],
|
||||
},
|
||||
{
|
||||
provide: LEAGUE_MEMBERSHIP_REPOSITORY_TOKEN,
|
||||
useFactory: () => makePlaceholder(LEAGUE_MEMBERSHIP_REPOSITORY_TOKEN),
|
||||
inject: ['Logger'],
|
||||
},
|
||||
{
|
||||
provide: RACE_REGISTRATION_REPOSITORY_TOKEN,
|
||||
useFactory: () => makePlaceholder(RACE_REGISTRATION_REPOSITORY_TOKEN),
|
||||
inject: ['Logger'],
|
||||
},
|
||||
{
|
||||
provide: TEAM_REPOSITORY_TOKEN,
|
||||
useFactory: () => makePlaceholder(TEAM_REPOSITORY_TOKEN),
|
||||
inject: ['Logger'],
|
||||
},
|
||||
{
|
||||
provide: TEAM_MEMBERSHIP_REPOSITORY_TOKEN,
|
||||
useFactory: () => makePlaceholder(TEAM_MEMBERSHIP_REPOSITORY_TOKEN),
|
||||
inject: ['Logger'],
|
||||
},
|
||||
{
|
||||
provide: PENALTY_REPOSITORY_TOKEN,
|
||||
useFactory: () => makePlaceholder(PENALTY_REPOSITORY_TOKEN),
|
||||
inject: ['Logger'],
|
||||
},
|
||||
{
|
||||
provide: PROTEST_REPOSITORY_TOKEN,
|
||||
useFactory: () => makePlaceholder(PROTEST_REPOSITORY_TOKEN),
|
||||
inject: ['Logger'],
|
||||
},
|
||||
{
|
||||
provide: SEASON_REPOSITORY_TOKEN,
|
||||
useFactory: (dataSource: DataSource) => {
|
||||
const seasonMapper = new SeasonOrmMapper();
|
||||
return new TypeOrmSeasonRepository(dataSource, seasonMapper);
|
||||
},
|
||||
inject: [getDataSourceToken()],
|
||||
},
|
||||
{
|
||||
provide: SEASON_SPONSORSHIP_REPOSITORY_TOKEN,
|
||||
useFactory: () => makePlaceholder(SEASON_SPONSORSHIP_REPOSITORY_TOKEN),
|
||||
inject: ['Logger'],
|
||||
},
|
||||
{
|
||||
provide: LEAGUE_SCORING_CONFIG_REPOSITORY_TOKEN,
|
||||
useFactory: (dataSource: DataSource) => {
|
||||
const pointsTableMapper = new PointsTableJsonMapper();
|
||||
const championshipMapper = new ChampionshipConfigJsonMapper(pointsTableMapper);
|
||||
const scoringConfigMapper = new LeagueScoringConfigOrmMapper(championshipMapper);
|
||||
return new TypeOrmLeagueScoringConfigRepository(dataSource, scoringConfigMapper);
|
||||
},
|
||||
inject: [getDataSourceToken()],
|
||||
},
|
||||
{
|
||||
provide: GAME_REPOSITORY_TOKEN,
|
||||
useFactory: () => makePlaceholder(GAME_REPOSITORY_TOKEN),
|
||||
inject: ['Logger'],
|
||||
},
|
||||
{
|
||||
provide: LEAGUE_WALLET_REPOSITORY_TOKEN,
|
||||
useFactory: () => makePlaceholder(LEAGUE_WALLET_REPOSITORY_TOKEN),
|
||||
inject: ['Logger'],
|
||||
},
|
||||
{
|
||||
provide: TRANSACTION_REPOSITORY_TOKEN,
|
||||
useFactory: () => makePlaceholder(TRANSACTION_REPOSITORY_TOKEN),
|
||||
inject: ['Logger'],
|
||||
},
|
||||
{
|
||||
provide: SPONSOR_REPOSITORY_TOKEN,
|
||||
useFactory: () => makePlaceholder(SPONSOR_REPOSITORY_TOKEN),
|
||||
inject: ['Logger'],
|
||||
},
|
||||
{
|
||||
provide: SPONSORSHIP_PRICING_REPOSITORY_TOKEN,
|
||||
useFactory: () => makePlaceholder(SPONSORSHIP_PRICING_REPOSITORY_TOKEN),
|
||||
inject: ['Logger'],
|
||||
},
|
||||
{
|
||||
provide: SPONSORSHIP_REQUEST_REPOSITORY_TOKEN,
|
||||
useFactory: () => makePlaceholder(SPONSORSHIP_REQUEST_REPOSITORY_TOKEN),
|
||||
inject: ['Logger'],
|
||||
},
|
||||
],
|
||||
exports: [
|
||||
DRIVER_REPOSITORY_TOKEN,
|
||||
LEAGUE_REPOSITORY_TOKEN,
|
||||
RACE_REPOSITORY_TOKEN,
|
||||
RESULT_REPOSITORY_TOKEN,
|
||||
STANDING_REPOSITORY_TOKEN,
|
||||
LEAGUE_MEMBERSHIP_REPOSITORY_TOKEN,
|
||||
RACE_REGISTRATION_REPOSITORY_TOKEN,
|
||||
TEAM_REPOSITORY_TOKEN,
|
||||
TEAM_MEMBERSHIP_REPOSITORY_TOKEN,
|
||||
PENALTY_REPOSITORY_TOKEN,
|
||||
PROTEST_REPOSITORY_TOKEN,
|
||||
SEASON_REPOSITORY_TOKEN,
|
||||
SEASON_SPONSORSHIP_REPOSITORY_TOKEN,
|
||||
LEAGUE_SCORING_CONFIG_REPOSITORY_TOKEN,
|
||||
GAME_REPOSITORY_TOKEN,
|
||||
LEAGUE_WALLET_REPOSITORY_TOKEN,
|
||||
TRANSACTION_REPOSITORY_TOKEN,
|
||||
SPONSOR_REPOSITORY_TOKEN,
|
||||
SPONSORSHIP_PRICING_REPOSITORY_TOKEN,
|
||||
SPONSORSHIP_REQUEST_REPOSITORY_TOKEN,
|
||||
],
|
||||
})
|
||||
export class PostgresRacingPersistenceModule {}
|
||||
@@ -0,0 +1,158 @@
|
||||
import 'reflect-metadata';
|
||||
|
||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
||||
import { DataSource } from 'typeorm';
|
||||
|
||||
import { League } from '@core/racing/domain/entities/League';
|
||||
import { LeagueScoringConfig } from '@core/racing/domain/entities/LeagueScoringConfig';
|
||||
import { Race } from '@core/racing/domain/entities/Race';
|
||||
import { Season } from '@core/racing/domain/entities/season/Season';
|
||||
import { PointsTable } from '@core/racing/domain/value-objects/PointsTable';
|
||||
|
||||
import type { ChampionshipConfig } from '@core/racing/domain/types/ChampionshipConfig';
|
||||
import type { SessionType } from '@core/racing/domain/types/SessionType';
|
||||
|
||||
import { LeagueOrmEntity } from '../../../../../../adapters/racing/persistence/typeorm/entities/LeagueOrmEntity';
|
||||
import { LeagueScoringConfigOrmEntity } from '../../../../../../adapters/racing/persistence/typeorm/entities/LeagueScoringConfigOrmEntity';
|
||||
import { RaceOrmEntity } from '../../../../../../adapters/racing/persistence/typeorm/entities/RaceOrmEntity';
|
||||
import { SeasonOrmEntity } from '../../../../../../adapters/racing/persistence/typeorm/entities/SeasonOrmEntity';
|
||||
|
||||
import { TypeOrmLeagueRepository } from '../../../../../../adapters/racing/persistence/typeorm/repositories/TypeOrmLeagueRepository';
|
||||
import { TypeOrmLeagueScoringConfigRepository } from '../../../../../../adapters/racing/persistence/typeorm/repositories/TypeOrmLeagueScoringConfigRepository';
|
||||
import { TypeOrmRaceRepository } from '../../../../../../adapters/racing/persistence/typeorm/repositories/TypeOrmRaceRepository';
|
||||
import { TypeOrmSeasonRepository } from '../../../../../../adapters/racing/persistence/typeorm/repositories/TypeOrmSeasonRepository';
|
||||
import { LeagueOrmMapper } from '../../../../../../adapters/racing/persistence/typeorm/mappers/LeagueOrmMapper';
|
||||
import { RaceOrmMapper } from '../../../../../../adapters/racing/persistence/typeorm/mappers/RaceOrmMapper';
|
||||
import { SeasonOrmMapper } from '../../../../../../adapters/racing/persistence/typeorm/mappers/SeasonOrmMapper';
|
||||
import { PointsTableJsonMapper } from '../../../../../../adapters/racing/persistence/typeorm/mappers/PointsTableJsonMapper';
|
||||
import { ChampionshipConfigJsonMapper } from '../../../../../../adapters/racing/persistence/typeorm/mappers/ChampionshipConfigJsonMapper';
|
||||
import { LeagueScoringConfigOrmMapper } from '../../../../../../adapters/racing/persistence/typeorm/mappers/LeagueScoringConfigOrmMapper';
|
||||
|
||||
const databaseUrl = process.env.DATABASE_URL;
|
||||
const describeIfDatabase = databaseUrl ? describe : describe.skip;
|
||||
|
||||
describeIfDatabase('TypeORM Racing repositories (postgres slice)', () => {
|
||||
let dataSource: DataSource;
|
||||
|
||||
beforeAll(async () => {
|
||||
if (!databaseUrl) {
|
||||
throw new Error('DATABASE_URL is required to run postgres integration tests');
|
||||
}
|
||||
|
||||
dataSource = new DataSource({
|
||||
type: 'postgres',
|
||||
url: databaseUrl,
|
||||
entities: [LeagueOrmEntity, SeasonOrmEntity, RaceOrmEntity, LeagueScoringConfigOrmEntity],
|
||||
synchronize: true,
|
||||
});
|
||||
|
||||
await dataSource.initialize();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
if (dataSource?.isInitialized) {
|
||||
await dataSource.destroy();
|
||||
}
|
||||
});
|
||||
|
||||
it('supports: create league + create season + save scoring config + fetch schedule', async () => {
|
||||
const leagueRepo = new TypeOrmLeagueRepository(dataSource, new LeagueOrmMapper());
|
||||
const seasonRepo = new TypeOrmSeasonRepository(dataSource, new SeasonOrmMapper());
|
||||
const raceRepo = new TypeOrmRaceRepository(dataSource, new RaceOrmMapper());
|
||||
|
||||
const pointsTableMapper = new PointsTableJsonMapper();
|
||||
const championshipMapper = new ChampionshipConfigJsonMapper(pointsTableMapper);
|
||||
const scoringConfigMapper = new LeagueScoringConfigOrmMapper(championshipMapper);
|
||||
const scoringRepo = new TypeOrmLeagueScoringConfigRepository(dataSource, scoringConfigMapper);
|
||||
|
||||
const league = League.create({
|
||||
id: 'league-it-1',
|
||||
name: 'Integration League',
|
||||
description: 'For integration testing',
|
||||
ownerId: 'driver-it-1',
|
||||
settings: { pointsSystem: 'custom', visibility: 'unranked', maxDrivers: 32 },
|
||||
participantCount: 0,
|
||||
});
|
||||
|
||||
await leagueRepo.create(league);
|
||||
|
||||
const season = Season.create({
|
||||
id: 'season-it-1',
|
||||
leagueId: league.id.toString(),
|
||||
gameId: 'iracing',
|
||||
name: 'Integration Season',
|
||||
year: 2025,
|
||||
order: 1,
|
||||
status: 'active',
|
||||
startDate: new Date('2025-01-01T00:00:00.000Z'),
|
||||
endDate: new Date('2025-12-31T00:00:00.000Z'),
|
||||
schedulePublished: false,
|
||||
});
|
||||
|
||||
await seasonRepo.create(season);
|
||||
|
||||
const pointsTableBySessionType: Record<SessionType, PointsTable> = {
|
||||
practice: new PointsTable({}),
|
||||
qualifying: new PointsTable({}),
|
||||
q1: new PointsTable({}),
|
||||
q2: new PointsTable({}),
|
||||
q3: new PointsTable({}),
|
||||
sprint: new PointsTable({}),
|
||||
main: new PointsTable({ 1: 25 }),
|
||||
timeTrial: new PointsTable({}),
|
||||
};
|
||||
|
||||
const bonusRulesBySessionType = {
|
||||
practice: [],
|
||||
qualifying: [],
|
||||
q1: [],
|
||||
q2: [],
|
||||
q3: [],
|
||||
sprint: [],
|
||||
main: [],
|
||||
timeTrial: [],
|
||||
};
|
||||
|
||||
const championship: ChampionshipConfig = {
|
||||
id: 'champ-it-1',
|
||||
name: 'Driver Championship',
|
||||
type: 'driver',
|
||||
sessionTypes: ['main' as SessionType],
|
||||
pointsTableBySessionType,
|
||||
bonusRulesBySessionType,
|
||||
dropScorePolicy: { strategy: 'none' },
|
||||
};
|
||||
|
||||
const scoring = LeagueScoringConfig.create({
|
||||
id: 'lsc-it-1',
|
||||
seasonId: season.id,
|
||||
scoringPresetId: 'club-default',
|
||||
championships: [championship],
|
||||
});
|
||||
|
||||
await scoringRepo.save(scoring);
|
||||
|
||||
const race = Race.create({
|
||||
id: 'race-it-1',
|
||||
leagueId: league.id.toString(),
|
||||
scheduledAt: new Date('2025-03-01T12:00:00.000Z'),
|
||||
track: 'Spa',
|
||||
car: 'GT3',
|
||||
status: 'scheduled',
|
||||
});
|
||||
|
||||
await raceRepo.create(race);
|
||||
|
||||
const persistedLeague = await leagueRepo.findById(league.id.toString());
|
||||
expect(persistedLeague?.name.toString()).toBe('Integration League');
|
||||
|
||||
const seasons = await seasonRepo.findByLeagueId(league.id.toString());
|
||||
expect(seasons.map((s: Season) => s.id)).toContain('season-it-1');
|
||||
|
||||
const races = await raceRepo.findByLeagueId(league.id.toString());
|
||||
expect(races.map((r: Race) => r.id)).toContain('race-it-1');
|
||||
|
||||
const persistedScoring = await scoringRepo.findBySeasonId(season.id);
|
||||
expect(persistedScoring?.id.toString()).toBe('lsc-it-1');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,166 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { League } from '@core/racing/domain/entities/League';
|
||||
import { LeagueScoringConfig } from '@core/racing/domain/entities/LeagueScoringConfig';
|
||||
import { Race } from '@core/racing/domain/entities/Race';
|
||||
import { Season } from '@core/racing/domain/entities/season/Season';
|
||||
import { PointsTable } from '@core/racing/domain/value-objects/PointsTable';
|
||||
import { SessionType as RaceSessionType } from '@core/racing/domain/value-objects/SessionType';
|
||||
|
||||
import type { ChampionshipConfig } from '@core/racing/domain/types/ChampionshipConfig';
|
||||
import type { SessionType } from '@core/racing/domain/types/SessionType';
|
||||
|
||||
import { ChampionshipConfigJsonMapper } from '@adapters/racing/persistence/typeorm/mappers/ChampionshipConfigJsonMapper';
|
||||
import { LeagueOrmMapper } from '@adapters/racing/persistence/typeorm/mappers/LeagueOrmMapper';
|
||||
import { LeagueScoringConfigOrmMapper } from '@adapters/racing/persistence/typeorm/mappers/LeagueScoringConfigOrmMapper';
|
||||
import { PointsTableJsonMapper } from '@adapters/racing/persistence/typeorm/mappers/PointsTableJsonMapper';
|
||||
import { RaceOrmMapper } from '@adapters/racing/persistence/typeorm/mappers/RaceOrmMapper';
|
||||
import { SeasonOrmMapper } from '@adapters/racing/persistence/typeorm/mappers/SeasonOrmMapper';
|
||||
|
||||
describe('RacingOrmMappers', () => {
|
||||
it('maps League domain <-> orm', () => {
|
||||
const league = League.create({
|
||||
id: 'league-1',
|
||||
name: 'My League',
|
||||
description: 'A description',
|
||||
ownerId: 'driver-1',
|
||||
settings: {
|
||||
pointsSystem: 'custom',
|
||||
maxDrivers: 48,
|
||||
visibility: 'unranked',
|
||||
},
|
||||
socialLinks: {
|
||||
discordUrl: 'https://discord.gg/example',
|
||||
},
|
||||
participantCount: 12,
|
||||
createdAt: new Date('2025-01-01T00:00:00.000Z'),
|
||||
});
|
||||
|
||||
const mapper = new LeagueOrmMapper();
|
||||
const orm = mapper.toOrmEntity(league);
|
||||
const rehydrated = mapper.toDomain(orm);
|
||||
|
||||
expect(rehydrated.id.toString()).toBe('league-1');
|
||||
expect(rehydrated.name.toString()).toBe('My League');
|
||||
expect(rehydrated.description.toString()).toBe('A description');
|
||||
expect(rehydrated.ownerId.toString()).toBe('driver-1');
|
||||
expect(rehydrated.settings.maxDrivers).toBe(48);
|
||||
expect(rehydrated.settings.visibility).toBe('unranked');
|
||||
expect(rehydrated.getParticipantCount()).toBe(12);
|
||||
expect(rehydrated.socialLinks?.discordUrl).toBe('https://discord.gg/example');
|
||||
});
|
||||
|
||||
it('maps Season domain <-> orm', () => {
|
||||
const season = Season.create({
|
||||
id: 'season-1',
|
||||
leagueId: 'league-1',
|
||||
gameId: 'iracing',
|
||||
name: 'Season 1',
|
||||
year: 2025,
|
||||
order: 1,
|
||||
status: 'active',
|
||||
startDate: new Date('2025-01-01T00:00:00.000Z'),
|
||||
endDate: new Date('2025-02-01T00:00:00.000Z'),
|
||||
schedulePublished: true,
|
||||
participantCount: 7,
|
||||
maxDrivers: 32,
|
||||
});
|
||||
|
||||
const mapper = new SeasonOrmMapper();
|
||||
const orm = mapper.toOrmEntity(season);
|
||||
const rehydrated = mapper.toDomain(orm);
|
||||
|
||||
expect(rehydrated.id).toBe('season-1');
|
||||
expect(rehydrated.leagueId).toBe('league-1');
|
||||
expect(rehydrated.gameId).toBe('iracing');
|
||||
expect(rehydrated.name).toBe('Season 1');
|
||||
expect(rehydrated.status.toString()).toBe('active');
|
||||
expect(rehydrated.schedulePublished).toBe(true);
|
||||
expect(rehydrated.getParticipantCount()).toBe(7);
|
||||
});
|
||||
|
||||
it('maps Race domain <-> orm', () => {
|
||||
const race = Race.create({
|
||||
id: 'race-1',
|
||||
leagueId: 'league-1',
|
||||
scheduledAt: new Date('2025-01-10T12:00:00.000Z'),
|
||||
track: 'Spa',
|
||||
car: 'GT3',
|
||||
sessionType: RaceSessionType.main(),
|
||||
status: 'scheduled',
|
||||
registeredCount: 3,
|
||||
maxParticipants: 50,
|
||||
});
|
||||
|
||||
const mapper = new RaceOrmMapper();
|
||||
const orm = mapper.toOrmEntity(race);
|
||||
const rehydrated = mapper.toDomain(orm);
|
||||
|
||||
expect(rehydrated.id).toBe('race-1');
|
||||
expect(rehydrated.leagueId).toBe('league-1');
|
||||
expect(rehydrated.track).toBe('Spa');
|
||||
expect(rehydrated.car).toBe('GT3');
|
||||
expect(rehydrated.sessionType.props).toBe('main');
|
||||
expect(rehydrated.status.toString()).toBe('scheduled');
|
||||
expect(rehydrated.getRegisteredCount()).toBe(3);
|
||||
expect(rehydrated.getMaxParticipants()).toBe(50);
|
||||
});
|
||||
|
||||
it('maps LeagueScoringConfig domain <-> orm (including PointsTable)', () => {
|
||||
const pointsTableBySessionType: Record<SessionType, PointsTable> = {
|
||||
practice: new PointsTable({}),
|
||||
qualifying: new PointsTable({}),
|
||||
q1: new PointsTable({}),
|
||||
q2: new PointsTable({}),
|
||||
q3: new PointsTable({}),
|
||||
sprint: new PointsTable({}),
|
||||
main: new PointsTable({ 1: 25, 2: 18 }),
|
||||
timeTrial: new PointsTable({}),
|
||||
};
|
||||
|
||||
const bonusRulesBySessionType = {
|
||||
practice: [],
|
||||
qualifying: [],
|
||||
q1: [],
|
||||
q2: [],
|
||||
q3: [],
|
||||
sprint: [],
|
||||
main: [],
|
||||
timeTrial: [],
|
||||
};
|
||||
|
||||
const championship: ChampionshipConfig = {
|
||||
id: 'champ-1',
|
||||
name: 'Driver Championship',
|
||||
type: 'driver',
|
||||
sessionTypes: ['main' as SessionType],
|
||||
pointsTableBySessionType,
|
||||
bonusRulesBySessionType,
|
||||
dropScorePolicy: { strategy: 'none' },
|
||||
};
|
||||
|
||||
const config = LeagueScoringConfig.create({
|
||||
id: 'lsc-season-1',
|
||||
seasonId: 'season-1',
|
||||
scoringPresetId: 'club-default',
|
||||
championships: [championship],
|
||||
});
|
||||
|
||||
const pointsTableMapper = new PointsTableJsonMapper();
|
||||
const championshipMapper = new ChampionshipConfigJsonMapper(pointsTableMapper);
|
||||
const mapper = new LeagueScoringConfigOrmMapper(championshipMapper);
|
||||
|
||||
const orm = mapper.toOrmEntity(config);
|
||||
const rehydrated = mapper.toDomain(orm);
|
||||
|
||||
expect(rehydrated.id.toString()).toBe('lsc-season-1');
|
||||
expect(rehydrated.seasonId.toString()).toBe('season-1');
|
||||
expect(rehydrated.scoringPresetId?.toString()).toBe('club-default');
|
||||
expect(rehydrated.championships).toHaveLength(1);
|
||||
|
||||
const mainPointsTable =
|
||||
rehydrated.championships[0]?.pointsTableBySessionType['main' as SessionType];
|
||||
expect(mainPointsTable).toBeDefined();
|
||||
expect(mainPointsTable!.getPointsForPosition(1)).toBe(25);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,49 @@
|
||||
import 'reflect-metadata';
|
||||
|
||||
import { MODULE_METADATA } from '@nestjs/common/constants';
|
||||
import { Test } from '@nestjs/testing';
|
||||
import type { TestingModule } from '@nestjs/testing';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { LEAGUE_REPOSITORY_TOKEN } from '../inmemory/InMemoryRacingPersistenceModule';
|
||||
|
||||
describe('RacingPersistenceModule', () => {
|
||||
const originalEnv = { ...process.env };
|
||||
|
||||
afterEach(() => {
|
||||
process.env = originalEnv;
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('uses inmemory providers when GRIDPILOT_API_PERSISTENCE=inmemory', async () => {
|
||||
vi.resetModules();
|
||||
|
||||
process.env.GRIDPILOT_API_PERSISTENCE = 'inmemory';
|
||||
delete process.env.DATABASE_URL;
|
||||
|
||||
const { RacingPersistenceModule } = await import('./RacingPersistenceModule');
|
||||
const { InMemoryLeagueRepository } = await import('@adapters/racing/persistence/inmemory/InMemoryLeagueRepository');
|
||||
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
imports: [RacingPersistenceModule],
|
||||
}).compile();
|
||||
|
||||
const leagueRepo = module.get(LEAGUE_REPOSITORY_TOKEN);
|
||||
expect(leagueRepo).toBeInstanceOf(InMemoryLeagueRepository);
|
||||
|
||||
await module.close();
|
||||
});
|
||||
|
||||
it('uses postgres module when GRIDPILOT_API_PERSISTENCE=postgres', async () => {
|
||||
vi.resetModules();
|
||||
|
||||
process.env.GRIDPILOT_API_PERSISTENCE = 'postgres';
|
||||
delete process.env.DATABASE_URL;
|
||||
|
||||
const { RacingPersistenceModule } = await import('./RacingPersistenceModule');
|
||||
const { PostgresRacingPersistenceModule } = await import('../postgres/PostgresRacingPersistenceModule');
|
||||
|
||||
const imports = Reflect.getMetadata(MODULE_METADATA.IMPORTS, RacingPersistenceModule) as unknown[];
|
||||
expect(imports).toContain(PostgresRacingPersistenceModule);
|
||||
});
|
||||
});
|
||||
14
apps/api/src/persistence/racing/RacingPersistenceModule.ts
Normal file
14
apps/api/src/persistence/racing/RacingPersistenceModule.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
|
||||
import { getApiPersistence } from '../../env';
|
||||
import { InMemoryRacingPersistenceModule } from '../inmemory/InMemoryRacingPersistenceModule';
|
||||
import { PostgresRacingPersistenceModule } from '../postgres/PostgresRacingPersistenceModule';
|
||||
|
||||
const selectedPersistenceModule =
|
||||
getApiPersistence() === 'postgres' ? PostgresRacingPersistenceModule : InMemoryRacingPersistenceModule;
|
||||
|
||||
@Module({
|
||||
imports: [selectedPersistenceModule],
|
||||
exports: [selectedPersistenceModule],
|
||||
})
|
||||
export class RacingPersistenceModule {}
|
||||
@@ -252,6 +252,47 @@ export class League implements IEntity<LeagueId> {
|
||||
});
|
||||
}
|
||||
|
||||
static rehydrate(props: {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
ownerId: string;
|
||||
settings: LeagueSettings;
|
||||
createdAt: Date;
|
||||
participantCount: number;
|
||||
socialLinks?: {
|
||||
discordUrl?: string;
|
||||
youtubeUrl?: string;
|
||||
websiteUrl?: string;
|
||||
};
|
||||
}): League {
|
||||
const id = LeagueId.create(props.id);
|
||||
const name = LeagueName.create(props.name);
|
||||
const description = LeagueDescription.create(props.description);
|
||||
const ownerId = LeagueOwnerId.create(props.ownerId);
|
||||
const createdAt = LeagueCreatedAt.create(props.createdAt);
|
||||
|
||||
const visibilityType = props.settings.visibility ?? 'ranked';
|
||||
const visibility = LeagueVisibility.fromString(visibilityType);
|
||||
|
||||
const participantCount = ParticipantCount.create(props.participantCount);
|
||||
const socialLinks = props.socialLinks
|
||||
? LeagueSocialLinks.create(props.socialLinks)
|
||||
: undefined;
|
||||
|
||||
return new League({
|
||||
id,
|
||||
name,
|
||||
description,
|
||||
ownerId,
|
||||
settings: props.settings,
|
||||
createdAt,
|
||||
...(socialLinks !== undefined ? { socialLinks } : {}),
|
||||
participantCount,
|
||||
visibility,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate stewarding settings configuration
|
||||
*/
|
||||
|
||||
@@ -18,6 +18,13 @@ export interface LeagueScoringConfigProps {
|
||||
championships: ChampionshipConfig[];
|
||||
}
|
||||
|
||||
export interface LeagueScoringConfigRehydrateProps {
|
||||
id: string;
|
||||
seasonId: string;
|
||||
scoringPresetId?: string;
|
||||
championships: ChampionshipConfig[];
|
||||
}
|
||||
|
||||
export class LeagueScoringConfig implements IEntity<LeagueScoringConfigId> {
|
||||
readonly id: LeagueScoringConfigId;
|
||||
readonly seasonId: SeasonId;
|
||||
@@ -53,6 +60,25 @@ export class LeagueScoringConfig implements IEntity<LeagueScoringConfigId> {
|
||||
});
|
||||
}
|
||||
|
||||
static rehydrate(props: LeagueScoringConfigRehydrateProps): LeagueScoringConfig {
|
||||
this.validate(props);
|
||||
|
||||
if (!props.id || props.id.trim().length === 0) {
|
||||
throw new RacingDomainValidationError('Scoring config ID is required');
|
||||
}
|
||||
|
||||
const id = LeagueScoringConfigId.create(props.id);
|
||||
const seasonId = SeasonId.create(props.seasonId);
|
||||
const scoringPresetId = props.scoringPresetId ? ScoringPresetId.create(props.scoringPresetId) : undefined;
|
||||
|
||||
return new LeagueScoringConfig({
|
||||
id,
|
||||
seasonId,
|
||||
...(scoringPresetId ? { scoringPresetId } : {}),
|
||||
championships: props.championships,
|
||||
});
|
||||
}
|
||||
|
||||
private static validate(props: LeagueScoringConfigProps): void {
|
||||
if (!props.seasonId || props.seasonId.trim().length === 0) {
|
||||
throw new RacingDomainValidationError('Season ID is required');
|
||||
|
||||
@@ -157,6 +157,58 @@ export class Race implements IEntity<string> {
|
||||
});
|
||||
}
|
||||
|
||||
static rehydrate(props: {
|
||||
id: string;
|
||||
leagueId: string;
|
||||
scheduledAt: Date;
|
||||
track: string;
|
||||
trackId?: string;
|
||||
car: string;
|
||||
carId?: string;
|
||||
sessionType: SessionType;
|
||||
status: RaceStatus;
|
||||
strengthOfField?: number;
|
||||
registeredCount?: number;
|
||||
maxParticipants?: number;
|
||||
}): Race {
|
||||
let registeredCount: ParticipantCount | undefined;
|
||||
let maxParticipants: MaxParticipants | undefined;
|
||||
|
||||
if (props.registeredCount !== undefined) {
|
||||
registeredCount = ParticipantCount.create(props.registeredCount);
|
||||
}
|
||||
|
||||
if (props.maxParticipants !== undefined) {
|
||||
maxParticipants = MaxParticipants.create(props.maxParticipants);
|
||||
|
||||
if (registeredCount && !maxParticipants.canAccommodate(registeredCount.toNumber())) {
|
||||
throw new RacingDomainValidationError(
|
||||
`Registered count (${registeredCount.toNumber()}) exceeds max participants (${maxParticipants.toNumber()})`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
let strengthOfField: StrengthOfField | undefined;
|
||||
if (props.strengthOfField !== undefined) {
|
||||
strengthOfField = StrengthOfField.create(props.strengthOfField);
|
||||
}
|
||||
|
||||
return new Race({
|
||||
id: props.id,
|
||||
leagueId: props.leagueId,
|
||||
scheduledAt: props.scheduledAt,
|
||||
track: props.track,
|
||||
...(props.trackId !== undefined ? { trackId: props.trackId } : {}),
|
||||
car: props.car,
|
||||
...(props.carId !== undefined ? { carId: props.carId } : {}),
|
||||
sessionType: props.sessionType,
|
||||
status: props.status,
|
||||
...(strengthOfField !== undefined ? { strengthOfField } : {}),
|
||||
...(registeredCount !== undefined ? { registeredCount } : {}),
|
||||
...(maxParticipants !== undefined ? { maxParticipants } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Start the race (move from scheduled to running)
|
||||
*/
|
||||
|
||||
@@ -175,6 +175,55 @@ export class Season implements IEntity<string> {
|
||||
});
|
||||
}
|
||||
|
||||
static rehydrate(props: {
|
||||
id: string;
|
||||
leagueId: string;
|
||||
gameId: string;
|
||||
name: string;
|
||||
year?: number;
|
||||
order?: number;
|
||||
status: SeasonStatus;
|
||||
startDate?: Date;
|
||||
endDate?: Date;
|
||||
schedule?: SeasonSchedule;
|
||||
schedulePublished: boolean;
|
||||
scoringConfig?: SeasonScoringConfig;
|
||||
dropPolicy?: SeasonDropPolicy;
|
||||
stewardingConfig?: SeasonStewardingConfig;
|
||||
maxDrivers?: number;
|
||||
participantCount: number;
|
||||
}): Season {
|
||||
const participantCount = ParticipantCount.create(props.participantCount);
|
||||
|
||||
if (props.maxDrivers !== undefined) {
|
||||
const maxParticipants = MaxParticipants.create(props.maxDrivers);
|
||||
if (!maxParticipants.canAccommodate(participantCount.toNumber())) {
|
||||
throw new RacingDomainValidationError(
|
||||
`Participant count (${participantCount.toNumber()}) exceeds season capacity (${maxParticipants.toNumber()})`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return new Season({
|
||||
id: props.id,
|
||||
leagueId: props.leagueId,
|
||||
gameId: props.gameId,
|
||||
name: props.name,
|
||||
...(props.year !== undefined ? { year: props.year } : {}),
|
||||
...(props.order !== undefined ? { order: props.order } : {}),
|
||||
status: props.status,
|
||||
...(props.startDate !== undefined ? { startDate: props.startDate } : {}),
|
||||
...(props.endDate !== undefined ? { endDate: props.endDate } : {}),
|
||||
...(props.schedule !== undefined ? { schedule: props.schedule } : {}),
|
||||
schedulePublished: props.schedulePublished,
|
||||
...(props.scoringConfig !== undefined ? { scoringConfig: props.scoringConfig } : {}),
|
||||
...(props.dropPolicy !== undefined ? { dropPolicy: props.dropPolicy } : {}),
|
||||
...(props.stewardingConfig !== undefined ? { stewardingConfig: props.stewardingConfig } : {}),
|
||||
...(props.maxDrivers !== undefined ? { maxDrivers: props.maxDrivers } : {}),
|
||||
participantCount,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate stewarding configuration
|
||||
*/
|
||||
|
||||
@@ -18,6 +18,13 @@ export interface ILeagueRepository {
|
||||
*/
|
||||
findAll(): Promise<League[]>;
|
||||
|
||||
/**
|
||||
* Count all leagues.
|
||||
*
|
||||
* Optional to avoid forcing all existing test doubles to implement it.
|
||||
*/
|
||||
countAll?(): Promise<number>;
|
||||
|
||||
/**
|
||||
* Find leagues by owner ID
|
||||
*/
|
||||
|
||||
@@ -33,7 +33,7 @@ services:
|
||||
- .env.development
|
||||
environment:
|
||||
- NODE_ENV=development
|
||||
- GRIDPILOT_API_PERSISTENCE=inmemory
|
||||
- GRIDPILOT_API_PERSISTENCE=${GRIDPILOT_API_PERSISTENCE:-}
|
||||
ports:
|
||||
- "3001:3000"
|
||||
- "9229:9229"
|
||||
|
||||
50
docs/SCHEMA_STRATEGY.md
Normal file
50
docs/SCHEMA_STRATEGY.md
Normal file
@@ -0,0 +1,50 @@
|
||||
# Schema strategy (dev) and persistence switching
|
||||
## Goal
|
||||
Keep the core domain independent from persistence details, while still providing a fast dev loop. Persistence and schema behavior are configured at the application boundary (the API app), not inside the domain.
|
||||
|
||||
## Persistence modes (API runtime)
|
||||
The API supports two persistence modes, controlled by [`ProcessEnv.GRIDPILOT_API_PERSISTENCE`](apps/api/src/env.d.ts:7):
|
||||
|
||||
- **`inmemory`**: no database required; the API runs with in-memory adapters.
|
||||
- **`postgres`**: the API uses Postgres via TypeORM.
|
||||
|
||||
### Default inference (dev ergonomics)
|
||||
If `GRIDPILOT_API_PERSISTENCE` is **unset**, the API infers persistence from `DATABASE_URL` via [`getApiPersistence()`](apps/api/src/env.ts:33):
|
||||
|
||||
- If `DATABASE_URL` is set → `postgres`
|
||||
- Otherwise → `inmemory`
|
||||
|
||||
This is why dev compose should not hard-code `GRIDPILOT_API_PERSISTENCE`; it should allow inference unless explicitly overridden.
|
||||
|
||||
## Schema strategy in development (TypeORM `synchronize`)
|
||||
When the API runs in Postgres mode, it loads [`DatabaseModule`](apps/api/src/domain/database/DatabaseModule.ts:1), which configures TypeORM with:
|
||||
|
||||
- `synchronize: process.env.NODE_ENV !== 'production'` in [`TypeOrmModule.forRoot()`](apps/api/src/domain/database/DatabaseModule.ts:6)
|
||||
|
||||
Practical meaning:
|
||||
- **Development/test:** TypeORM `synchronize` is enabled to keep schema aligned automatically during iteration.
|
||||
- **Production:** TypeORM `synchronize` is disabled.
|
||||
|
||||
### Migrations (deferred)
|
||||
Migrations are intentionally deferred for now: local dev relies on `synchronize` for speed, while production-grade migrations will be introduced later at the infrastructure boundary (without changing the domain).
|
||||
|
||||
## Switching modes locally
|
||||
### Docker dev (recommended)
|
||||
In [`docker-compose.dev.yml`](docker-compose.dev.yml:1), `GRIDPILOT_API_PERSISTENCE` should be optional so the default inference works.
|
||||
|
||||
To force a mode, set one of:
|
||||
- `GRIDPILOT_API_PERSISTENCE=inmemory`
|
||||
- `GRIDPILOT_API_PERSISTENCE=postgres`
|
||||
|
||||
Example environment reference (dev): [`DATABASE_URL`](.env.development.example:20) and optional persistence override (commented) in [`.env.development.example`](.env.development.example:17).
|
||||
|
||||
## Dev seeding behavior (high level)
|
||||
Seeding/bootstrap runs at API startup via [`BootstrapModule`](apps/api/src/domain/bootstrap/BootstrapModule.ts:1), unless disabled with `GRIDPILOT_API_BOOTSTRAP=0` (parsed by [`getEnableBootstrap()`](apps/api/src/env.ts:49)).
|
||||
|
||||
At startup:
|
||||
- Always runs `EnsureInitialData.execute()` in [`BootstrapModule.onModuleInit()`](apps/api/src/domain/bootstrap/BootstrapModule.ts:21).
|
||||
- Seeds racing data via `SeedRacingData` when:
|
||||
- persistence is `inmemory`, or
|
||||
- persistence is `postgres` AND `NODE_ENV !== 'production'` AND the racing DB appears empty (checked via the league repository) in [`BootstrapModule.shouldSeedRacingData()`](apps/api/src/domain/bootstrap/BootstrapModule.ts:42).
|
||||
|
||||
This keeps dev environments usable without requiring manual seed steps, while avoiding unexpected reseeding in production.
|
||||
@@ -0,0 +1,125 @@
|
||||
# Plan: Switch API persistence from InMemory to Postgres (TypeORM), keep InMemory for tests
|
||||
|
||||
Timestamp: 2025-12-28T20:17:49Z
|
||||
Scope: **Racing bounded context first**, dev switchable between InMemory and Postgres, tests keep forcing InMemory.
|
||||
|
||||
## Goals
|
||||
- Make it **easy to switch** API persistence in dev via [`getApiPersistence()`](apps/api/src/env.ts:33) + [`process.env.GRIDPILOT_API_PERSISTENCE`](apps/api/src/env.d.ts:7).
|
||||
- Default dev flow supports Postgres via Docker, but does **not** force it.
|
||||
- Keep InMemory persistence intact and default for tests (already used in tests like [`process.env.GRIDPILOT_API_PERSISTENCE = 'inmemory'`](apps/api/src/domain/bootstrap/BootstrapSeed.http.test.ts:17)).
|
||||
- Implement Postgres/TypeORM persistence for **racing repositories** currently provided by [`InMemoryRacingPersistenceModule`](apps/api/src/persistence/inmemory/InMemoryRacingPersistenceModule.ts:72).
|
||||
- Provide **minimal idempotent seed** for Postgres so UI works (analogous to [`SeedRacingData`](apps/api/src/domain/bootstrap/BootstrapModule.ts:27)).
|
||||
|
||||
## Current state (what’s wired today)
|
||||
- Env toggle exists: [`getApiPersistence()`](apps/api/src/env.ts:33).
|
||||
- Postgres wiring exists but incomplete: [`TypeOrmModule.forRoot()`](apps/api/src/domain/database/DatabaseModule.ts:6) with `entities` commented and `synchronize` enabled outside production ([`synchronize`](apps/api/src/domain/database/DatabaseModule.ts:18)).
|
||||
- Feature modules still hard-import in-memory racing persistence:
|
||||
- [`LeagueModule`](apps/api/src/domain/league/LeagueModule.ts:8)
|
||||
- [`RaceModule`](apps/api/src/domain/race/RaceModule.ts:8)
|
||||
- [`DashboardModule`](apps/api/src/domain/dashboard/DashboardModule.ts:2)
|
||||
- [`DriverModule`](apps/api/src/domain/driver/DriverModule.ts:2)
|
||||
- [`ProtestsModule`](apps/api/src/domain/protests/ProtestsModule.ts:2)
|
||||
- [`TeamModule`](apps/api/src/domain/team/TeamModule.ts:2)
|
||||
- [`SponsorModule`](apps/api/src/domain/sponsor/SponsorModule.ts:2)
|
||||
- [`BootstrapModule`](apps/api/src/domain/bootstrap/BootstrapModule.ts:11)
|
||||
- Dev compose currently forces InMemory even when `.env` provides `DATABASE_URL`:
|
||||
- Forced: [`GRIDPILOT_API_PERSISTENCE=inmemory`](docker-compose.dev.yml:36)
|
||||
- `.env` hints inference from `DATABASE_URL`: [`DATABASE_URL=postgres://...`](.env.development.example:20)
|
||||
|
||||
## High-level approach
|
||||
1. Introduce a **persistence boundary module** for racing that selects implementation based on [`getApiPersistence()`](apps/api/src/env.ts:33).
|
||||
2. Implement a Postgres/TypeORM module for racing repos (same tokens as in-memory).
|
||||
3. Update racing-dependent API feature modules to import the boundary module (not the in-memory module).
|
||||
4. Add minimal Postgres seed (dev-only, idempotent).
|
||||
5. Fix dev compose to not hard-force InMemory.
|
||||
6. Verify with lint/types/tests.
|
||||
|
||||
## Milestones (execution order)
|
||||
### M1 — Create racing persistence boundary (switch point)
|
||||
- Add [`RacingPersistenceModule`](apps/api/src/persistence/racing/RacingPersistenceModule.ts:1)
|
||||
- `imports`: choose one of:
|
||||
- [`InMemoryRacingPersistenceModule`](apps/api/src/persistence/inmemory/InMemoryRacingPersistenceModule.ts:72)
|
||||
- New [`PostgresRacingPersistenceModule`](apps/api/src/persistence/postgres/PostgresRacingPersistenceModule.ts:1)
|
||||
- `exports`: re-export the chosen module’s tokens, so downstream modules remain unchanged.
|
||||
|
||||
Acceptance:
|
||||
- No feature module directly imports [`InMemoryRacingPersistenceModule`](apps/api/src/persistence/inmemory/InMemoryRacingPersistenceModule.ts:72) anymore.
|
||||
|
||||
### M2 — Add Postgres racing persistence module (skeleton)
|
||||
- Add [`PostgresRacingPersistenceModule`](apps/api/src/persistence/postgres/PostgresRacingPersistenceModule.ts:1)
|
||||
- Provides **the same tokens** defined in [`InMemoryRacingPersistenceModule`](apps/api/src/persistence/inmemory/InMemoryRacingPersistenceModule.ts:51):
|
||||
- [`LEAGUE_REPOSITORY_TOKEN`](apps/api/src/persistence/inmemory/InMemoryRacingPersistenceModule.ts:52), [`RACE_REPOSITORY_TOKEN`](apps/api/src/persistence/inmemory/InMemoryRacingPersistenceModule.ts:53), etc.
|
||||
- Uses `TypeOrmModule.forFeature([...entities])`.
|
||||
- Must be compatible with DB root config in [`DatabaseModule`](apps/api/src/domain/database/DatabaseModule.ts:6).
|
||||
|
||||
Acceptance:
|
||||
- API can start with `GRIDPILOT_API_PERSISTENCE=postgres` and resolve racing repository providers (even if repo methods are initially stubbed during iteration).
|
||||
|
||||
### M3 — Implement first working slice (League + Season + Membership + Race as needed)
|
||||
- Implement ORM entities + mappers (ORM entities are not domain objects; follow clean architecture boundary from [`DOMAIN_OBJECTS.md`](docs/architecture/DOMAIN_OBJECTS.md:16)).
|
||||
- Implement TypeORM repositories for the minimal feature set used by:
|
||||
- [`LeagueModule`](apps/api/src/domain/league/LeagueModule.ts:7)
|
||||
- [`RaceModule`](apps/api/src/domain/race/RaceModule.ts:7)
|
||||
|
||||
Strategy:
|
||||
- Start from the endpoints exercised by existing HTTP tests that currently force InMemory (e.g. league schedule/roster tests), but run them in InMemory first; then add a small Postgres-specific smoke test later if needed.
|
||||
|
||||
Acceptance:
|
||||
- Core use cases depending on racing repos function against Postgres in dev.
|
||||
|
||||
### M4 — Rewire feature modules to boundary module
|
||||
Replace imports of [`InMemoryRacingPersistenceModule`](apps/api/src/persistence/inmemory/InMemoryRacingPersistenceModule.ts:72) with [`RacingPersistenceModule`](apps/api/src/persistence/racing/RacingPersistenceModule.ts:1) in:
|
||||
- [`LeagueModule`](apps/api/src/domain/league/LeagueModule.ts:1)
|
||||
- [`RaceModule`](apps/api/src/domain/race/RaceModule.ts:1)
|
||||
- [`DashboardModule`](apps/api/src/domain/dashboard/DashboardModule.ts:1)
|
||||
- [`DriverModule`](apps/api/src/domain/driver/DriverModule.ts:1)
|
||||
- [`ProtestsModule`](apps/api/src/domain/protests/ProtestsModule.ts:1)
|
||||
- [`TeamModule`](apps/api/src/domain/team/TeamModule.ts:1)
|
||||
- [`SponsorModule`](apps/api/src/domain/sponsor/SponsorModule.ts:1)
|
||||
- plus adjust [`BootstrapModule`](apps/api/src/domain/bootstrap/BootstrapModule.ts:10) (see M5).
|
||||
|
||||
Acceptance:
|
||||
- Switching env var changes racing persistence without touching module imports.
|
||||
|
||||
### M5 — Minimal idempotent Postgres seed (dev UX)
|
||||
- Extend bootstrap so Postgres mode can seed minimal data when DB is empty.
|
||||
- Current bootstrap behavior only seeds racing data for InMemory: [`shouldSeedRacingData()`](apps/api/src/domain/bootstrap/BootstrapModule.ts:37).
|
||||
- Update logic to also seed for Postgres when:
|
||||
- dev mode (non-prod), and
|
||||
- tables empty (e.g., count leagues/drivers), and
|
||||
- bootstrap enabled via [`getEnableBootstrap()`](apps/api/src/env.ts:49).
|
||||
|
||||
Implementation note:
|
||||
- Seed code should remain adapter-level (reuse [`SeedRacingData`](apps/api/src/domain/bootstrap/BootstrapModule.ts:27)) but use repos from the active persistence module.
|
||||
|
||||
Acceptance:
|
||||
- `docker compose -f docker-compose.dev.yml up` + `GRIDPILOT_API_PERSISTENCE=postgres` results in a usable UI without manual DB setup.
|
||||
|
||||
### M6 — Dev compose/env ergonomics
|
||||
- Remove hard-coded forcing of InMemory in dev compose:
|
||||
- Change/remove [`GRIDPILOT_API_PERSISTENCE=inmemory`](docker-compose.dev.yml:36)
|
||||
- Prefer `.env.development` control, consistent with `.env example` guidance ([`DATABASE_URL`](.env.development.example:20)).
|
||||
|
||||
Acceptance:
|
||||
- Devs can switch by editing `.env.development` or setting env override.
|
||||
|
||||
### M7 — Verification gate
|
||||
Run:
|
||||
- `eslint`
|
||||
- `tsc`
|
||||
- tests
|
||||
|
||||
Commands live in workspace scripts; start from package-level scripts as applicable (e.g. API tests via [`npm run test`](apps/api/package.json:10)).
|
||||
|
||||
Acceptance:
|
||||
- No lint errors, no TypeScript errors, green tests (with default tests still using InMemory).
|
||||
|
||||
## Out of scope (this pass)
|
||||
- Social persistence (stays InMemory for now).
|
||||
- Full migration system (placeholder remains, e.g. [`up()`](adapters/persistence/migrations/001_initial_schema.ts:5)).
|
||||
- Production-ready DB lifecycle (migrations, RLS, etc.).
|
||||
|
||||
## Risks / watchouts
|
||||
- Provider scoping: racing repos are exported tokens; boundary module must avoid creating competing instances.
|
||||
- Entity design: ORM entities must not leak into core (enforce boundary per [`DOMAIN_OBJECTS.md`](docs/architecture/DOMAIN_OBJECTS.md:16)).
|
||||
- Bootstrap: ensure Postgres seed is idempotent and doesn’t run in production (align with `NODE_ENV` usage in [`DatabaseModule`](apps/api/src/domain/database/DatabaseModule.ts:18)).
|
||||
@@ -0,0 +1,419 @@
|
||||
# Plan: Switch Racing persistence from InMemory to Postgres (TypeORM) while keeping InMemory for tests
|
||||
|
||||
Timestamp: 2025-12-28T20:46:00Z
|
||||
Scope: Racing bounded context persistence only (no Social/Identity/Media/Payments persistence changes)
|
||||
|
||||
This plan is intentionally implementation-ready (what files to add, what to wire, what tests to write first) while keeping scope controlled: a minimal vertical slice that makes one meaningful League/Race workflow work in dev Postgres, but does not attempt to implement every Racing repository at once.
|
||||
|
||||
---
|
||||
|
||||
## 0) Context (current state, must preserve)
|
||||
|
||||
- Persistence toggle is already defined at [`getApiPersistence()`](apps/api/src/env.ts:33) and typed in [`ProcessEnv`](apps/api/src/env.d.ts:3).
|
||||
- DB bootstrap exists as Nest module: [`DatabaseModule`](apps/api/src/domain/database/DatabaseModule.ts:1), with non-prod schema sync enabled via [`synchronize`](apps/api/src/domain/database/DatabaseModule.ts:18).
|
||||
- Racing repository tokens are currently defined in [`InMemoryRacingPersistenceModule`](apps/api/src/persistence/inmemory/InMemoryRacingPersistenceModule.ts:51) and are used as Nest provider tokens.
|
||||
- Persistence boundary already exists and selects between in-memory and Postgres: [`RacingPersistenceModule`](apps/api/src/persistence/racing/RacingPersistenceModule.ts:1).
|
||||
- Postgres wiring for Racing is currently placeholder-only: [`PostgresRacingPersistenceModule`](apps/api/src/persistence/postgres/PostgresRacingPersistenceModule.ts:51).
|
||||
- Clean Architecture rules to enforce (no ORM leakage into Core):
|
||||
- [`DATA_FLOW.md`](docs/architecture/DATA_FLOW.md:1)
|
||||
- [`DOMAIN_OBJECTS.md`](docs/architecture/DOMAIN_OBJECTS.md:1)
|
||||
- File placement rules via [`FILE_STRUCTURE.md`](docs/architecture/FILE_STRUCTURE.md:1)
|
||||
|
||||
---
|
||||
|
||||
## 1) Goal and non-goals
|
||||
|
||||
### Goal
|
||||
Enable Racing persistence via Postgres/TypeORM for development runtime (selected via [`getApiPersistence()`](apps/api/src/env.ts:33)), while keeping default test runs using in-memory persistence (to keep CI fast and deterministic).
|
||||
|
||||
### Non-goals (explicit scope control)
|
||||
- No migration framework rollout, no production migration story beyond the existing non-prod `synchronize` behavior in [`DatabaseModule`](apps/api/src/domain/database/DatabaseModule.ts:18).
|
||||
- No broad refactors to use cases, DTOs, controllers, or domain modeling.
|
||||
- No implementation of non-Racing bounded contexts (Social/Identity/Media/etc).
|
||||
- No attempt to “finish all Racing repositories” in first pass; we will do a minimal vertical slice first.
|
||||
|
||||
---
|
||||
|
||||
## 2) Proposed adapter folder/file layout (TypeORM under `adapters/`)
|
||||
|
||||
This follows the existing adapter organization shown in [`FILE_STRUCTURE.md`](docs/architecture/FILE_STRUCTURE.md:41) and the repo’s existing Racing adapter grouping under `adapters/racing/persistence/inmemory`.
|
||||
|
||||
Create a parallel `typeorm` tree for Racing persistence:
|
||||
|
||||
- `adapters/racing/persistence/typeorm/README.md`
|
||||
- `adapters/racing/persistence/typeorm/entities/`
|
||||
- `LeagueOrmEntity.ts`
|
||||
- `SeasonOrmEntity.ts`
|
||||
- `LeagueScoringConfigOrmEntity.ts`
|
||||
- `RaceOrmEntity.ts`
|
||||
- `LeagueMembershipOrmEntity.ts`
|
||||
- `adapters/racing/persistence/typeorm/mappers/`
|
||||
- `LeagueOrmMapper.ts`
|
||||
- `SeasonOrmMapper.ts`
|
||||
- `LeagueScoringConfigOrmMapper.ts`
|
||||
- `RaceOrmMapper.ts`
|
||||
- `LeagueMembershipOrmMapper.ts`
|
||||
- `adapters/racing/persistence/typeorm/repositories/`
|
||||
- `TypeOrmLeagueRepository.ts`
|
||||
- `TypeOrmSeasonRepository.ts`
|
||||
- `TypeOrmLeagueScoringConfigRepository.ts`
|
||||
- `TypeOrmRaceRepository.ts`
|
||||
- `TypeOrmLeagueMembershipRepository.ts`
|
||||
- `adapters/racing/persistence/typeorm/testing/` (test-only helpers; never imported by prod code)
|
||||
- `createTypeOrmTestDataSource.ts`
|
||||
- `truncateRacingTables.ts`
|
||||
|
||||
Notes:
|
||||
- Do not add any `index.ts` barrel files due to the lint restriction in [`.eslintrc.json`](.eslintrc.json:36).
|
||||
- All mapping logic must live in adapters (never in Core), per [`DATA_FLOW.md`](docs/architecture/DATA_FLOW.md:24).
|
||||
|
||||
---
|
||||
|
||||
## 3) Minimal vertical slice (useful and controlled)
|
||||
|
||||
### 3.1 “Meaningful workflow” target
|
||||
Implement the smallest set that supports this end-to-end workflow in Postgres dev:
|
||||
|
||||
1) Create a League (creates Season + scoring config) via the API call handled by the service method [`LeagueService.createLeague()`](apps/api/src/domain/league/LeagueService.ts:773) which uses [`CreateLeagueWithSeasonAndScoringUseCase`](core/racing/application/use-cases/CreateLeagueWithSeasonAndScoringUseCase.ts:56).
|
||||
2) Fetch that League’s schedule (or races list) via [`GetLeagueScheduleUseCase`](core/racing/application/use-cases/GetLeagueScheduleUseCase.ts:33), used by [`LeagueService.getLeagueSchedule()`](apps/api/src/domain/league/LeagueService.ts:614).
|
||||
|
||||
This is a practical vertical slice: it enables the admin UI to create a league and see a schedule scaffold.
|
||||
|
||||
### 3.2 Repos/tokens INCLUDED in slice 1
|
||||
Implement Postgres/TypeORM repositories for the following tokens from [`InMemoryRacingPersistenceModule`](apps/api/src/persistence/inmemory/InMemoryRacingPersistenceModule.ts:51):
|
||||
|
||||
- [`LEAGUE_REPOSITORY_TOKEN`](apps/api/src/persistence/inmemory/InMemoryRacingPersistenceModule.ts:52) → `TypeOrmLeagueRepository`
|
||||
- [`SEASON_REPOSITORY_TOKEN`](apps/api/src/persistence/inmemory/InMemoryRacingPersistenceModule.ts:62) → `TypeOrmSeasonRepository`
|
||||
- [`LEAGUE_SCORING_CONFIG_REPOSITORY_TOKEN`](apps/api/src/persistence/inmemory/InMemoryRacingPersistenceModule.ts:64) → `TypeOrmLeagueScoringConfigRepository`
|
||||
- [`RACE_REPOSITORY_TOKEN`](apps/api/src/persistence/inmemory/InMemoryRacingPersistenceModule.ts:53) → `TypeOrmRaceRepository`
|
||||
- [`LEAGUE_MEMBERSHIP_REPOSITORY_TOKEN`](apps/api/src/persistence/inmemory/InMemoryRacingPersistenceModule.ts:56) → `TypeOrmLeagueMembershipRepository`
|
||||
|
||||
Rationale:
|
||||
- Creation flow requires the first three via [`CreateLeagueWithSeasonAndScoringUseCase`](core/racing/application/use-cases/CreateLeagueWithSeasonAndScoringUseCase.ts:56).
|
||||
- Schedule flow requires `league`, `season`, `race` repos via [`GetLeagueScheduleUseCase`](core/racing/application/use-cases/GetLeagueScheduleUseCase.ts:33).
|
||||
- Capacity listing and “join league” depend on membership repo via [`GetAllLeaguesWithCapacityUseCase`](core/racing/application/use-cases/GetAllLeaguesWithCapacityUseCase.ts:25) and [`JoinLeagueUseCase`](core/racing/application/use-cases/JoinLeagueUseCase.ts:18).
|
||||
|
||||
### 3.3 Repos/tokens DEFERRED (slice 2+)
|
||||
Explicitly defer these tokens from [`InMemoryRacingPersistenceModule`](apps/api/src/persistence/inmemory/InMemoryRacingPersistenceModule.ts:51) to later phases:
|
||||
|
||||
- [`DRIVER_REPOSITORY_TOKEN`](apps/api/src/persistence/inmemory/InMemoryRacingPersistenceModule.ts:51)
|
||||
- [`RESULT_REPOSITORY_TOKEN`](apps/api/src/persistence/inmemory/InMemoryRacingPersistenceModule.ts:54)
|
||||
- [`STANDING_REPOSITORY_TOKEN`](apps/api/src/persistence/inmemory/InMemoryRacingPersistenceModule.ts:55)
|
||||
- [`RACE_REGISTRATION_REPOSITORY_TOKEN`](apps/api/src/persistence/inmemory/InMemoryRacingPersistenceModule.ts:57)
|
||||
- [`TEAM_REPOSITORY_TOKEN`](apps/api/src/persistence/inmemory/InMemoryRacingPersistenceModule.ts:58)
|
||||
- [`TEAM_MEMBERSHIP_REPOSITORY_TOKEN`](apps/api/src/persistence/inmemory/InMemoryRacingPersistenceModule.ts:59)
|
||||
- Anything sponsorship/wallet-related in Racing persistence (tokens near [`LEAGUE_WALLET_REPOSITORY_TOKEN`](apps/api/src/persistence/inmemory/InMemoryRacingPersistenceModule.ts:66))
|
||||
|
||||
This keeps slice 1 focused and prevents exploding schema surface area.
|
||||
|
||||
---
|
||||
|
||||
## 4) Mapping strategy (ORM entities vs domain entities)
|
||||
|
||||
### 4.1 Boundary rule (non-negotiable)
|
||||
Core domain entities (example [`League`](core/racing/domain/entities/League.ts:93), [`Race`](core/racing/domain/entities/Race.ts:18)) MUST NOT import or refer to ORM entities, repositories, decorators, or TypeORM types, per [`DOMAIN_OBJECTS.md`](docs/architecture/DOMAIN_OBJECTS.md:16) and [`DATA_FLOW.md`](docs/architecture/DATA_FLOW.md:24).
|
||||
|
||||
### 4.2 ORM entity design principles
|
||||
- ORM entities are persistence models optimized for storage/querying and can use primitives (string, number, Date) and JSON columns.
|
||||
- Domain entities use rich value objects and invariants and should be built using existing factories like [`League.create()`](core/racing/domain/entities/League.ts:132) and [`Race.create()`](core/racing/domain/entities/Race.ts:81).
|
||||
|
||||
### 4.3 Mapping responsibilities
|
||||
Mapping lives in `adapters/racing/persistence/typeorm/mappers/*` and is responsible for:
|
||||
|
||||
- `toDomain(orm)`:
|
||||
- Convert primitive columns/JSON back into domain props.
|
||||
- Call domain factory methods (`create`) with validated values.
|
||||
- Handle optional fields and backward-compatibility defaults (e.g., `League.settings` in [`League`](core/racing/domain/entities/League.ts:72)).
|
||||
- `toOrm(domain)`:
|
||||
- Convert domain value objects to primitives suitable for columns.
|
||||
- Define canonical serialization for nested structures (e.g., store `League.settings` as JSONB).
|
||||
|
||||
### 4.4 Proposed per-entity mapping notes (slice 1)
|
||||
|
||||
#### League
|
||||
- Persist fields:
|
||||
- `id` string (PK)
|
||||
- `name` string
|
||||
- `description` string
|
||||
- `ownerId` string
|
||||
- `createdAt` Date
|
||||
- `settings` JSONB (store `LeagueSettings` from [`LeagueSettings`](core/racing/domain/entities/League.ts:72))
|
||||
- `socialLinks` JSONB nullable
|
||||
- `participantCount` integer (if needed; domain tracks via internal `_participantCount` in [`League`](core/racing/domain/entities/League.ts:103))
|
||||
- `visibility` string (redundant to settings.visibility, but may be useful for querying; keep either:
|
||||
- Option A: derive from settings only and do not store separate column
|
||||
- Option B: store both and enforce consistency in mapper (preferred for query ergonomics)
|
||||
|
||||
#### Season
|
||||
- Keep Season as its own ORM entity with FK to leagueId (string).
|
||||
- Use JSONB for schedule (if schedule is a complex object), and scalar columns for status, year, order, start/end.
|
||||
|
||||
#### LeagueScoringConfig
|
||||
- Store `seasonId` string as unique FK.
|
||||
- Store scoring config payload (championships, points tables, bonus rules) as JSONB.
|
||||
|
||||
#### Race
|
||||
- Persist scalar fields corresponding to [`Race`](core/racing/domain/entities/Race.ts:18):
|
||||
- `id` string (PK)
|
||||
- `leagueId` string (indexed)
|
||||
- `scheduledAt` timestamptz
|
||||
- `track`, `trackId`, `car`, `carId` strings
|
||||
- `sessionType` string
|
||||
- `status` string (from [`RaceStatus`](core/racing/domain/entities/Race.ts:11))
|
||||
- `strengthOfField`, `registeredCount`, `maxParticipants` integers nullable
|
||||
- Queries required by Core ports (examples in [`IRaceRepository`](core/racing/domain/repositories/IRaceRepository.ts:10)):
|
||||
- find by leagueId
|
||||
- upcoming/completed filtering (status + scheduledAt)
|
||||
|
||||
#### LeagueMembership
|
||||
- Persist fields corresponding to [`LeagueMembership`](core/racing/domain/entities/LeagueMembership.ts:25):
|
||||
- `id` string (domain uses default `${leagueId}:${driverId}` in [`LeagueMembership.create()`](core/racing/domain/entities/LeagueMembership.ts:49))
|
||||
- `leagueId` string (indexed)
|
||||
- `driverId` string (indexed)
|
||||
- `role` string
|
||||
- `status` string
|
||||
- `joinedAt` timestamptz
|
||||
- This enables membership queries required by [`ILeagueMembershipRepository`](core/racing/domain/repositories/ILeagueMembershipRepository.ts:13).
|
||||
|
||||
---
|
||||
|
||||
## 5) TypeORM + Nest wiring specifics
|
||||
|
||||
### 5.1 Database root config
|
||||
Current `DatabaseModule` uses [`TypeOrmModule.forRoot()`](apps/api/src/domain/database/DatabaseModule.ts:6) and does not register entities.
|
||||
|
||||
Plan change (minimal and controlled):
|
||||
- Update [`DatabaseModule`](apps/api/src/domain/database/DatabaseModule.ts:1) to support loading Racing entities when Postgres persistence is enabled:
|
||||
- Add `autoLoadEntities: true` in the `forRoot` options so entities registered via feature modules are discovered.
|
||||
- Keep [`synchronize`](apps/api/src/domain/database/DatabaseModule.ts:18) behavior as-is for non-prod for now (explicitly acknowledged technical debt).
|
||||
|
||||
Why:
|
||||
- We want Racing persistence to be modular (entities registered only when the Postgres Racing module is imported) without a global “list every entity in the world” change.
|
||||
|
||||
### 5.2 Postgres Racing module structure
|
||||
Replace placeholder providers in [`PostgresRacingPersistenceModule`](apps/api/src/persistence/postgres/PostgresRacingPersistenceModule.ts:51) with real wiring:
|
||||
|
||||
- `imports`:
|
||||
- [`LoggingModule`](apps/api/src/persistence/postgres/PostgresRacingPersistenceModule.ts:3) (already present)
|
||||
- Nest TypeORM feature registration for the slice 1 entities:
|
||||
- `TypeOrmModule.forFeature([LeagueOrmEntity, SeasonOrmEntity, LeagueScoringConfigOrmEntity, RaceOrmEntity, LeagueMembershipOrmEntity])`
|
||||
- Mentioned as a method name; `TypeOrmModule` itself is already in use at [`TypeOrmModule.forRoot()`](apps/api/src/domain/database/DatabaseModule.ts:6).
|
||||
|
||||
- `providers`:
|
||||
- Register each repository implementation class under the existing tokens, matching the in-memory token names exactly (examples in [`InMemoryRacingPersistenceModule`](apps/api/src/persistence/inmemory/InMemoryRacingPersistenceModule.ts:75)):
|
||||
- Provide [`LEAGUE_REPOSITORY_TOKEN`](apps/api/src/persistence/inmemory/InMemoryRacingPersistenceModule.ts:52) → `TypeOrmLeagueRepository`
|
||||
- Provide [`SEASON_REPOSITORY_TOKEN`](apps/api/src/persistence/inmemory/InMemoryRacingPersistenceModule.ts:62) → `TypeOrmSeasonRepository`
|
||||
- Provide [`LEAGUE_SCORING_CONFIG_REPOSITORY_TOKEN`](apps/api/src/persistence/inmemory/InMemoryRacingPersistenceModule.ts:64) → `TypeOrmLeagueScoringConfigRepository`
|
||||
- Provide [`RACE_REPOSITORY_TOKEN`](apps/api/src/persistence/inmemory/InMemoryRacingPersistenceModule.ts:53) → `TypeOrmRaceRepository`
|
||||
- Provide [`LEAGUE_MEMBERSHIP_REPOSITORY_TOKEN`](apps/api/src/persistence/inmemory/InMemoryRacingPersistenceModule.ts:56) → `TypeOrmLeagueMembershipRepository`
|
||||
|
||||
- `exports`:
|
||||
- Export the same tokens so downstream modules remain unchanged, mirroring the export list pattern in [`InMemoryRacingPersistenceModule`](apps/api/src/persistence/inmemory/InMemoryRacingPersistenceModule.ts:182).
|
||||
|
||||
### 5.3 Repository implementation style
|
||||
Each `TypeOrm*Repository`:
|
||||
- Implements the relevant Core repository interface, e.g. [`ILeagueRepository`](core/racing/domain/repositories/ILeagueRepository.ts:10).
|
||||
- Depends only on:
|
||||
- TypeORM repository/DataSource types (adapter-layer OK)
|
||||
- Mappers in adapters
|
||||
- Domain entities/ports (core-layer OK)
|
||||
- Does not expose ORM entities outside adapters.
|
||||
|
||||
### 5.4 Persistence boundary selection remains the same
|
||||
Do not change selection semantics in [`RacingPersistenceModule`](apps/api/src/persistence/racing/RacingPersistenceModule.ts:7). This module already selects Postgres vs in-memory using [`getApiPersistence()`](apps/api/src/env.ts:33).
|
||||
|
||||
---
|
||||
|
||||
## 6) TDD-first phased rollout (tests first, controlled scope)
|
||||
|
||||
### 6.1 Testing goals
|
||||
- Add confidence that TypeORM repositories satisfy Core port contracts.
|
||||
- Keep default test runs fast and in-memory by default.
|
||||
- Add Postgres-backed integration tests that are opt-in (run only when explicitly enabled).
|
||||
|
||||
### 6.2 What tests already exist and should remain green
|
||||
- Persistence module selection test: [`RacingPersistenceModule.test.ts`](apps/api/src/persistence/racing/RacingPersistenceModule.test.ts:9) (currently asserts placeholder instance in Postgres mode). This will need updating once placeholders are replaced, but the intent remains valid.
|
||||
- Existing in-memory repository unit tests under `adapters/racing/persistence/inmemory/*.test.ts` (example: [`InMemoryLeagueRepository.test.ts`](adapters/racing/persistence/inmemory/InMemoryLeagueRepository.test.ts)) must remain untouched and continue to run by default.
|
||||
|
||||
### 6.3 New tests to write first (TDD sequence)
|
||||
|
||||
#### Phase A: Contract-style repo tests for TypeORM (integration tests, opt-in)
|
||||
Create a new test suite for each TypeORM repository in:
|
||||
- `adapters/racing/persistence/typeorm/repositories/*Repository.integration.test.ts`
|
||||
|
||||
Test approach:
|
||||
- Use a real Postgres database (not mocks) and TypeORM DataSource configured similarly to runtime config in [`DatabaseModule`](apps/api/src/domain/database/DatabaseModule.ts:6).
|
||||
- Keep these tests skipped unless a flag is set, e.g. `RUN_RACING_PG_TESTS=1` (exact naming to be decided in implementation mode).
|
||||
- Use a dedicated DB name or schema per run and truncate tables between tests.
|
||||
|
||||
Example test cases (for slice 1):
|
||||
- `TypeOrmLeagueRepository` should satisfy basic operations defined in [`ILeagueRepository`](core/racing/domain/repositories/ILeagueRepository.ts:10):
|
||||
- create + findById roundtrip
|
||||
- findAll returns inserted
|
||||
- update roundtrip
|
||||
- exists works
|
||||
- `TypeOrmSeasonRepository` should satisfy [`ISeasonRepository`](core/racing/domain/repositories/ISeasonRepository.ts:3):
|
||||
- create + findById
|
||||
- findByLeagueId
|
||||
- `TypeOrmRaceRepository` should satisfy [`IRaceRepository`](core/racing/domain/repositories/IRaceRepository.ts:10):
|
||||
- create + findById
|
||||
- findByLeagueId
|
||||
- findUpcomingByLeagueId and findCompletedByLeagueId behavior (status + date)
|
||||
- `TypeOrmLeagueMembershipRepository` should satisfy [`ILeagueMembershipRepository`](core/racing/domain/repositories/ILeagueMembershipRepository.ts:13):
|
||||
- saveMembership + getMembership
|
||||
- getLeagueMembers filtering (active vs pending) must match whatever domain expects (start with minimal “returns all stored” behavior, then align with use cases)
|
||||
- `TypeOrmLeagueScoringConfigRepository` should satisfy [`ILeagueScoringConfigRepository`](core/racing/domain/repositories/ILeagueScoringConfigRepository.ts:3):
|
||||
- save + findBySeasonId
|
||||
|
||||
Why integration tests first:
|
||||
- It forces us to design the ORM schema + mapping in a way that matches the Core port contracts immediately.
|
||||
|
||||
#### Phase B: Update module selection test (unit test, always-on)
|
||||
Update [`RacingPersistenceModule.test.ts`](apps/api/src/persistence/racing/RacingPersistenceModule.test.ts:9) to assert that in Postgres mode the provider resolves to the real TypeORM repo class (instead of the placeholder in [`PostgresRacingPersistenceModule`](apps/api/src/persistence/postgres/PostgresRacingPersistenceModule.ts:30)).
|
||||
|
||||
This remains a fast unit test: it only checks Nest DI wiring, not DB behavior.
|
||||
|
||||
### 6.4 What to mock vs run “for real”
|
||||
- Mock nothing for repository integration tests (they should hit Postgres).
|
||||
- Keep Core use case tests (if any exist) running with in-memory repos or test doubles by default.
|
||||
- Do not switch existing HTTP tests to Postgres by default (many explicitly set in-memory via env in files like those discovered under `apps/api/src/domain/league/*.http.test.ts` via earlier repo search).
|
||||
|
||||
### 6.5 Keeping default tests in in-memory mode
|
||||
- Preserve the current default behavior where tests set [`GRIDPILOT_API_PERSISTENCE`](apps/api/src/env.d.ts:7) to `inmemory` (example in [`RacingPersistenceModule.test.ts`](apps/api/src/persistence/racing/RacingPersistenceModule.test.ts:20)).
|
||||
- Ensure the Postgres integration tests are opt-in and not included in default `npm run api:test` from [`apps/api/package.json`](apps/api/package.json:10).
|
||||
|
||||
---
|
||||
|
||||
## 7) Dev bootstrap/seed strategy for Postgres (minimal, idempotent, non-test)
|
||||
|
||||
### 7.1 Current behavior
|
||||
Bootstrap currently seeds racing data only in in-memory mode via [`shouldSeedRacingData()`](apps/api/src/domain/bootstrap/BootstrapModule.ts:37), which returns `true` only for `inmemory`.
|
||||
|
||||
### 7.2 Target behavior
|
||||
In dev Postgres mode, seed minimal Racing data only when the database is empty, and never during tests.
|
||||
|
||||
Proposed logic change in [`BootstrapModule`](apps/api/src/domain/bootstrap/BootstrapModule.ts:10):
|
||||
- Seed when ALL are true:
|
||||
- `NODE_ENV !== 'production'`
|
||||
- persistence is Postgres via [`getApiPersistence()`](apps/api/src/env.ts:33)
|
||||
- database appears empty for Racing (fast check: `driverRepository.findAll().length === 0` as already used in [`SeedRacingData.execute()`](adapters/bootstrap/SeedRacingData.ts:55))
|
||||
- bootstrap is enabled (already toggled globally in app startup via [`getEnableBootstrap()`](apps/api/src/env.ts:49) and [`AppModule`](apps/api/src/app.module.ts:29))
|
||||
|
||||
Implementation detail:
|
||||
- Keep using [`SeedRacingData`](adapters/bootstrap/SeedRacingData.ts:49) because it is already idempotent-ish (it skips when drivers exist at [`SeedRacingData.execute()`](adapters/bootstrap/SeedRacingData.ts:55)).
|
||||
- Ensure Postgres-backed `driverRepository` is not required for slice 1 if we keep seed minimal; however, current seed checks drivers first, so this implies either:
|
||||
- Option A (preferred for UI usefulness): include Driver repo in Postgres slice 1b (small extension) so seeding can run fully, or
|
||||
- Option B (controlled scope): create a Postgres-only “minimal seed” class that checks `leagueRepository.findAll()` instead of drivers and seeds only leagues/seasons/races/memberships/scoring configs.
|
||||
|
||||
To keep scope controlled and aligned with “Racing only”, choose Option B for slice 1:
|
||||
- Introduce `SeedRacingDataMinimal` under `adapters/bootstrap/racing/` that seeds:
|
||||
- one league
|
||||
- one active season
|
||||
- 0..N races in the season window
|
||||
- one membership for the owner (so capacity endpoints have meaningful data)
|
||||
- one scoring config for the active season
|
||||
- Keep it idempotent:
|
||||
- skip if `leagueRepository.findAll()` returns any leagues
|
||||
- upsert behavior for scoring config by seasonId (align with [`ILeagueScoringConfigRepository.findBySeasonId()`](core/racing/domain/repositories/ILeagueScoringConfigRepository.ts:3))
|
||||
|
||||
Test contamination avoidance:
|
||||
- Tests already default to in-memory persistence and can also set `GRIDPILOT_API_BOOTSTRAP` to false if needed via [`getEnableBootstrap()`](apps/api/src/env.ts:49).
|
||||
|
||||
---
|
||||
|
||||
## 8) Dev ergonomics: docker-compose dev toggle change (exact change)
|
||||
|
||||
### 8.1 Current issue
|
||||
`docker-compose.dev.yml` forces in-memory persistence via [`GRIDPILOT_API_PERSISTENCE=inmemory`](docker-compose.dev.yml:36), which overrides the inference behavior in [`getApiPersistence()`](apps/api/src/env.ts:33).
|
||||
|
||||
### 8.2 Proposed exact change (minimal)
|
||||
Update [`docker-compose.dev.yml`](docker-compose.dev.yml:26) to stop hard-forcing in-memory:
|
||||
|
||||
- Replace the hard-coded line at [`docker-compose.dev.yml`](docker-compose.dev.yml:36) with:
|
||||
- `- GRIDPILOT_API_PERSISTENCE=${GRIDPILOT_API_PERSISTENCE:-postgres}`
|
||||
|
||||
Expected dev behavior:
|
||||
- Default dev stack uses Postgres persistence (because compose default becomes `postgres`).
|
||||
- Developers can still run in-memory explicitly by setting `GRIDPILOT_API_PERSISTENCE=inmemory` before running the compose command.
|
||||
|
||||
Alternative (if you prefer to preserve auto-detection):
|
||||
- Remove the line entirely and rely on [`getApiPersistence()`](apps/api/src/env.ts:33) + `DATABASE_URL` presence in `.env.development`.
|
||||
|
||||
This plan recommends the explicit compose default approach because it is more deterministic and avoids hidden coupling to `.env.development` contents.
|
||||
|
||||
---
|
||||
|
||||
## 9) Phased implementation plan (step-by-step)
|
||||
|
||||
### Phase 1: Prepare TypeORM adapter skeleton (no behavior change)
|
||||
1) Add the folder structure described in section 2.
|
||||
2) Add the ORM entity files for the slice 1 domain models with minimal columns and constraints (PKs, required columns, basic indices).
|
||||
3) Add mapper stubs with round-trip intent documented.
|
||||
4) Add repository class stubs that implement the Core interfaces but throw “not implemented” only for methods not used in slice 1 tests.
|
||||
|
||||
Gate:
|
||||
- No changes to runtime wiring yet; existing tests remain green.
|
||||
|
||||
### Phase 2: Add opt-in Postgres integration tests (TDD)
|
||||
1) Add TypeORM DataSource test helper.
|
||||
2) Write failing integration tests for `TypeOrmLeagueRepository` and implement it until green.
|
||||
3) Repeat for `Season`, `Race`, `Membership`, `ScoringConfig` repos.
|
||||
|
||||
Gate:
|
||||
- Integration tests pass when enabled.
|
||||
- Default `npm run api:test` remains unaffected.
|
||||
|
||||
### Phase 3: Wire Postgres Racing module to real repos (DI correctness)
|
||||
1) Update [`PostgresRacingPersistenceModule`](apps/api/src/persistence/postgres/PostgresRacingPersistenceModule.ts:51):
|
||||
- Import TypeORM feature registration (section 5.2).
|
||||
- Replace placeholder providers with real repository providers for slice 1 tokens.
|
||||
2) Update [`RacingPersistenceModule.test.ts`](apps/api/src/persistence/racing/RacingPersistenceModule.test.ts:9) to assert the Postgres providers resolve to the real TypeORM repo classes (instead of placeholders).
|
||||
|
||||
Gate:
|
||||
- Always-on module selection tests pass.
|
||||
|
||||
### Phase 4: Enable dev Postgres UX (bootstrap + compose)
|
||||
1) Update [`BootstrapModule`](apps/api/src/domain/bootstrap/BootstrapModule.ts:10) to seed minimal Racing data in dev Postgres mode only when empty.
|
||||
2) Update [`docker-compose.dev.yml`](docker-compose.dev.yml:26) per section 8.
|
||||
|
||||
Gate:
|
||||
- `docker compose` dev stack can run with Postgres persistence and UI has minimal data.
|
||||
|
||||
### Phase 5: Incrementally expand beyond slice 1 (future, explicitly not required to finish now)
|
||||
Add Driver + Result + Standings etc only when a concrete UI/endpoint requires them and after writing the next integration tests first.
|
||||
|
||||
---
|
||||
|
||||
## 10) Verification gates (exact commands and when)
|
||||
|
||||
Run these at the end of each phase that changes TS code:
|
||||
|
||||
- ESLint:
|
||||
- `npm run lint` (script defined at [`package.json`](package.json:80))
|
||||
- TypeScript:
|
||||
- `npm run typecheck:targets` (script defined at [`package.json`](package.json:120))
|
||||
- API tests:
|
||||
- `npm run api:test` (script defined at [`package.json`](package.json:64)) or `npm run test --workspace=@gridpilot/api` via [`apps/api/package.json`](apps/api/package.json:10)
|
||||
|
||||
For opt-in Postgres repository integration tests (added in Phase 2):
|
||||
- Define a dedicated command (implementation-mode decision), but the plan expects it to be an explicit command that developers run intentionally (not part of default CI).
|
||||
|
||||
---
|
||||
|
||||
## 11) Risks and mitigations
|
||||
|
||||
- Risk: ORM entities leak into Core through shared types.
|
||||
- Mitigation: enforce mappers in adapters only, keep interfaces as Core ports (example [`ILeagueRepository`](core/racing/domain/repositories/ILeagueRepository.ts:10)).
|
||||
- Risk: Seed logic contaminates tests.
|
||||
- Mitigation: preserve default in-memory persistence in tests (example env usage in [`RacingPersistenceModule.test.ts`](apps/api/src/persistence/racing/RacingPersistenceModule.test.ts:20)) and gate seeding by non-prod + emptiness checks; tests can disable bootstrap via [`getEnableBootstrap()`](apps/api/src/env.ts:49).
|
||||
- Risk: TypeORM entity registration not picked up because entities not configured.
|
||||
- Mitigation: enable `autoLoadEntities` in [`DatabaseModule`](apps/api/src/domain/database/DatabaseModule.ts:1) as part of implementation.
|
||||
|
||||
---
|
||||
|
||||
## 12) Ready-for-approval questions (for implementation mode to resolve quickly)
|
||||
|
||||
These are the only decisions that materially affect implementation detail:
|
||||
|
||||
1) Prefer Postgres integration tests using a developer-managed Postgres (via `DATABASE_URL`) or a dedicated docker-compose test database?
|
||||
2) For slice 1 seed, should we implement a minimal Racing-only seed (recommended) or extend slice 1 to include Driver repo so we can reuse [`SeedRacingData`](adapters/bootstrap/SeedRacingData.ts:49) unchanged?
|
||||
@@ -0,0 +1,300 @@
|
||||
# Racing TypeORM Adapter Clean Architecture Audit + Strict Refactor Guide
|
||||
|
||||
Scope focus: all persistence adapter code under [`adapters/racing/persistence/typeorm/`](adapters/racing/persistence/typeorm:1), especially mappers (incl. JSON mappers) and repositories, plus the ORM entities and adapter-scoped errors they depend on.
|
||||
|
||||
This guide is intentionally strict and implementation-ready.
|
||||
|
||||
---
|
||||
|
||||
## Governing constraints (authoritative)
|
||||
|
||||
- **Strict inward dependencies**: [`Only dependency-inward is allowed.`](docs/architecture/DATA_FLOW.md:24)
|
||||
- **Domain purity / no IO in domain objects**: [`Entities MUST NOT perform IO.`](docs/architecture/DOMAIN_OBJECTS.md:49)
|
||||
- **Persisted objects must rehydrate, not create**: [`Existing entities are reconstructed via rehydrate().`](docs/architecture/DOMAIN_OBJECTS.md:57)
|
||||
- **Adapters translate only (no orchestration/business logic)**: [`Adapters translate.`](docs/architecture/DATA_FLOW.md:437)
|
||||
- **Persistence entity placement**: ORM entities live in adapters, not domain: [`entities/ ORM-Entities (nicht Domain!)`](docs/architecture/FILE_STRUCTURE.md:47)
|
||||
|
||||
---
|
||||
|
||||
## Adapter surface inventory (audited)
|
||||
|
||||
### Mappers
|
||||
- [`LeagueOrmMapper`](adapters/racing/persistence/typeorm/mappers/LeagueOrmMapper.ts:30)
|
||||
- [`RaceOrmMapper`](adapters/racing/persistence/typeorm/mappers/RaceOrmMapper.ts:5)
|
||||
- [`SeasonOrmMapper`](adapters/racing/persistence/typeorm/mappers/SeasonOrmMapper.ts:4)
|
||||
- [`LeagueScoringConfigOrmMapper`](adapters/racing/persistence/typeorm/mappers/LeagueScoringConfigOrmMapper.ts:42)
|
||||
- [`ChampionshipConfigJsonMapper`](adapters/racing/persistence/typeorm/mappers/ChampionshipConfigJsonMapper.ts:17)
|
||||
- [`PointsTableJsonMapper`](adapters/racing/persistence/typeorm/mappers/PointsTableJsonMapper.ts:7)
|
||||
|
||||
### Repositories
|
||||
- [`TypeOrmLeagueRepository`](adapters/racing/persistence/typeorm/repositories/TypeOrmLeagueRepository.ts:9)
|
||||
- [`TypeOrmRaceRepository`](adapters/racing/persistence/typeorm/repositories/TypeOrmRaceRepository.ts:9)
|
||||
- [`TypeOrmSeasonRepository`](adapters/racing/persistence/typeorm/repositories/TypeOrmSeasonRepository.ts:9)
|
||||
- [`TypeOrmLeagueScoringConfigRepository`](adapters/racing/persistence/typeorm/repositories/TypeOrmLeagueScoringConfigRepository.ts:9)
|
||||
|
||||
### ORM entities
|
||||
- [`LeagueOrmEntity`](adapters/racing/persistence/typeorm/entities/LeagueOrmEntity.ts:4)
|
||||
- [`RaceOrmEntity`](adapters/racing/persistence/typeorm/entities/RaceOrmEntity.ts:4)
|
||||
- [`SeasonOrmEntity`](adapters/racing/persistence/typeorm/entities/SeasonOrmEntity.ts:4)
|
||||
- [`LeagueScoringConfigOrmEntity`](adapters/racing/persistence/typeorm/entities/LeagueScoringConfigOrmEntity.ts:6)
|
||||
|
||||
### Adapter-scoped errors
|
||||
- [`InvalidLeagueScoringConfigChampionshipsSchemaError`](adapters/racing/persistence/typeorm/errors/InvalidLeagueScoringConfigChampionshipsSchemaError.ts:1)
|
||||
|
||||
---
|
||||
|
||||
## Concrete violations (file + function, why, severity)
|
||||
|
||||
Severity rubric:
|
||||
- **Blocker**: violates non-negotiable constraints; can cause domain invariants to run on persisted state, or leaks construction/orchestration into adapters.
|
||||
- **Follow-up**: does not strictly violate constraints, but is unsafe/unclear and should be corrected to align with the canonical strict pattern defined below.
|
||||
|
||||
### 1) Rehydration violations (calling `create()` when loading from DB) — **Blocker**
|
||||
|
||||
- [`LeagueOrmMapper.toDomain()`](adapters/racing/persistence/typeorm/mappers/LeagueOrmMapper.ts:46)
|
||||
- **Why**: Uses [`League.create()`](core/racing/domain/entities/League.ts:132) for persisted reconstruction.
|
||||
- **Rule violated**: persisted objects must reconstruct via [`rehydrate()`](docs/architecture/DOMAIN_OBJECTS.md:57) semantics (new vs existing).
|
||||
- **Impact**: running creation-time defaulting + validation on persisted state can mutate meaning (e.g., defaults merged in [`League.create()`](core/racing/domain/entities/League.ts:132)) and can throw domain validation errors due to persistence schema drift.
|
||||
|
||||
- [`RaceOrmMapper.toDomain()`](adapters/racing/persistence/typeorm/mappers/RaceOrmMapper.ts:23)
|
||||
- **Why**: Uses [`Race.create()`](core/racing/domain/entities/Race.ts:81) for persisted reconstruction.
|
||||
- **Rule violated**: persisted objects must reconstruct via [`rehydrate()`](docs/architecture/DOMAIN_OBJECTS.md:57).
|
||||
- **Impact**: DB rows become subject to “new entity” validations; adapter loses ability to separate “invalid persisted schema” (adapter concern) from “invalid new command” (domain concern).
|
||||
|
||||
- [`SeasonOrmMapper.toDomain()`](adapters/racing/persistence/typeorm/mappers/SeasonOrmMapper.ts:26)
|
||||
- **Why**: Uses [`Season.create()`](core/racing/domain/entities/season/Season.ts:70) for persisted reconstruction.
|
||||
- **Rule violated**: persisted objects must reconstruct via [`rehydrate()`](docs/architecture/DOMAIN_OBJECTS.md:57).
|
||||
- **Impact**: same as above, plus schedule/scoring/drop/stewarding props are passed as `any`, making persisted-state validation unpredictable.
|
||||
|
||||
- Positive control (already compliant): [`LeagueScoringConfigOrmMapper.toDomain()`](adapters/racing/persistence/typeorm/mappers/LeagueScoringConfigOrmMapper.ts:54)
|
||||
- Uses [`LeagueScoringConfig.rehydrate()`](core/racing/domain/entities/LeagueScoringConfig.ts:63) and validates schema before converting.
|
||||
- This is the baseline pattern to replicate.
|
||||
|
||||
### 2) Adapter “translation only” violations (construction / orchestration inside repositories) — **Blocker**
|
||||
|
||||
- [`TypeOrmLeagueRepository.constructor()`](adapters/racing/persistence/typeorm/repositories/TypeOrmLeagueRepository.ts:10)
|
||||
- **Why**: Default-constructs a mapper via `new` ([`new LeagueOrmMapper()`](adapters/racing/persistence/typeorm/repositories/TypeOrmLeagueRepository.ts:12)).
|
||||
- **Rule violated**: adapters must “translate only” ([`Adapters translate.`](docs/architecture/DATA_FLOW.md:437)); object graph construction belongs in the composition root (Nest module).
|
||||
- **Impact**: makes DI inconsistent, harder to test, and encourages mapper graphs to be built ad-hoc in infrastructure code rather than composed centrally.
|
||||
|
||||
- [`TypeOrmRaceRepository.constructor()`](adapters/racing/persistence/typeorm/repositories/TypeOrmRaceRepository.ts:10)
|
||||
- **Why**: Default-constructs mapper via [`new RaceOrmMapper()`](adapters/racing/persistence/typeorm/repositories/TypeOrmRaceRepository.ts:12).
|
||||
- **Rule violated**: [`Adapters translate.`](docs/architecture/DATA_FLOW.md:437)
|
||||
- **Impact**: same.
|
||||
|
||||
- [`TypeOrmSeasonRepository.constructor()`](adapters/racing/persistence/typeorm/repositories/TypeOrmSeasonRepository.ts:10)
|
||||
- **Why**: Default-constructs mapper via [`new SeasonOrmMapper()`](adapters/racing/persistence/typeorm/repositories/TypeOrmSeasonRepository.ts:12).
|
||||
- **Rule violated**: [`Adapters translate.`](docs/architecture/DATA_FLOW.md:437)
|
||||
|
||||
- Positive control (already aligned): [`TypeOrmLeagueScoringConfigRepository.constructor()`](adapters/racing/persistence/typeorm/repositories/TypeOrmLeagueScoringConfigRepository.ts:10)
|
||||
- Requires mapper injection (no internal construction), and is enforced by [`TypeOrmLeagueScoringConfigRepository.test.ts`](adapters/racing/persistence/typeorm/repositories/TypeOrmLeagueScoringConfigRepository.test.ts:17).
|
||||
|
||||
### 3) Persistence schema typing issues that currently force `unknown`/`any` translation — **Follow-up (but required by the canonical strict pattern)**
|
||||
|
||||
These aren’t explicitly spelled out in the architecture docs, but they directly undermine “adapters translate only” by making translation ambiguous and unsafe. They also block strict `rehydrate()` mapping because you can’t validate/interpret persisted JSON precisely.
|
||||
|
||||
- [`LeagueOrmEntity.settings`](adapters/racing/persistence/typeorm/entities/LeagueOrmEntity.ts:17)
|
||||
- Current: `Record<string, unknown>`
|
||||
- Problem: forces coercion and casts in [`LeagueOrmMapper.toOrmEntity()`](adapters/racing/persistence/typeorm/mappers/LeagueOrmMapper.ts:31) and [`LeagueOrmMapper.toDomain()`](adapters/racing/persistence/typeorm/mappers/LeagueOrmMapper.ts:46).
|
||||
|
||||
- [`SeasonOrmEntity.schedule`](adapters/racing/persistence/typeorm/entities/SeasonOrmEntity.ts:32), [`SeasonOrmEntity.scoringConfig`](adapters/racing/persistence/typeorm/entities/SeasonOrmEntity.ts:38), [`SeasonOrmEntity.dropPolicy`](adapters/racing/persistence/typeorm/entities/SeasonOrmEntity.ts:41), [`SeasonOrmEntity.stewardingConfig`](adapters/racing/persistence/typeorm/entities/SeasonOrmEntity.ts:44)
|
||||
- Current: `Record<string, unknown> | null`
|
||||
- Problem: propagates into pervasive `as any` in [`SeasonOrmMapper.toDomain()`](adapters/racing/persistence/typeorm/mappers/SeasonOrmMapper.ts:26).
|
||||
|
||||
- [`RaceOrmMapper.toDomain()`](adapters/racing/persistence/typeorm/mappers/RaceOrmMapper.ts:23) and [`SeasonOrmMapper.toDomain()`](adapters/racing/persistence/typeorm/mappers/SeasonOrmMapper.ts:26)
|
||||
- Current: `as any` casts for status/sessionType/schedule/etc.
|
||||
- Problem: translation is not explicit/verified; makes persisted schema errors show up as domain behavior or runtime surprises.
|
||||
|
||||
### 4) Persistence boundary error mapping gaps — **Blocker**
|
||||
|
||||
- [`LeagueOrmMapper.toDomain()`](adapters/racing/persistence/typeorm/mappers/LeagueOrmMapper.ts:46), [`RaceOrmMapper.toDomain()`](adapters/racing/persistence/typeorm/mappers/RaceOrmMapper.ts:23), [`SeasonOrmMapper.toDomain()`](adapters/racing/persistence/typeorm/mappers/SeasonOrmMapper.ts:26)
|
||||
- **Why**: because these call `create()` and use unsafe casts, the adapter does not reliably distinguish:
|
||||
- invalid persisted schema (adapter concern) vs
|
||||
- invalid incoming command/request (domain concern).
|
||||
- **Rule violated (intent)**: separation of roles per [`Adapters translate.`](docs/architecture/DATA_FLOW.md:437) and persisted rehydration rule ([`rehydrate()`](docs/architecture/DOMAIN_OBJECTS.md:57)).
|
||||
- **Impact**: domain errors (e.g. [`RacingDomainValidationError`](core/racing/domain/entities/League.ts:148)) can leak due to persistence drift, making debugging and recovery much harder.
|
||||
|
||||
---
|
||||
|
||||
## Canonical strict pattern for this repo (target state)
|
||||
|
||||
This is the “golden path” all Racing TypeORM adapters should follow.
|
||||
|
||||
### A) ORM entity shape rules (including JSON columns)
|
||||
|
||||
- ORM entities are allowed in adapters per [`entities/ ORM-Entities (nicht Domain!)`](docs/architecture/FILE_STRUCTURE.md:47).
|
||||
- JSON columns must be **typed to a concrete serialized shape** (no `unknown`, no `Record<string, unknown>`).
|
||||
- Example target types:
|
||||
- `LeagueOrmEntity.settings: SerializedLeagueSettings`
|
||||
- `SeasonOrmEntity.schedule: SerializedSeasonSchedule | null`
|
||||
- `SeasonOrmEntity.scoringConfig: SerializedSeasonScoringConfig | null`
|
||||
- Serialized types must be:
|
||||
- JSON-safe (no `Date`, `Map`, class instances)
|
||||
- versionable (allow future `schemaVersion?: number`)
|
||||
- strict enough to validate at runtime
|
||||
- Use `null` for absent optional columns (as already done in [`RaceOrmEntity.trackId`](adapters/racing/persistence/typeorm/entities/RaceOrmEntity.ts:17)).
|
||||
|
||||
### B) Mapper rules (pure translation, no side effects)
|
||||
|
||||
- Mappers are pure, deterministic translators:
|
||||
- `domain -> orm` and `orm -> domain`
|
||||
- no IO, no logging, no Date.now(), no random ids
|
||||
- **No `as any`** in mapping. If types don’t line up, fix the schema types or add a safe interpreter.
|
||||
- **No `create()` on load**. `orm -> domain` must call `rehydrate()` semantics for entities ([`rehydrate()` rule](docs/architecture/DOMAIN_OBJECTS.md:57)).
|
||||
- On `orm -> domain`:
|
||||
- validate persisted schema and throw an **adapter-scoped persistence schema error**, not a domain validation error.
|
||||
- treat invalid persisted schema as infrastructure failure (data corruption/migration mismatch).
|
||||
- On `domain -> orm`:
|
||||
- serialize domain objects via explicit “serialize” helpers (no `as unknown as Record<...>`).
|
||||
- JSON mappers:
|
||||
- must be pure
|
||||
- must return serialized DTO-like shapes, not domain objects except as output of `fromJson`
|
||||
- should avoid calling value-object constructors directly unless the VO explicitly treats constructor as a safe “rehydrate”; otherwise introduce `fromJson()`/`rehydrate()` for VOs.
|
||||
|
||||
### C) Repository rules (IO + mapper only; composition root in Nest module)
|
||||
|
||||
- Repositories implement core ports and do:
|
||||
- DB IO (TypeORM queries)
|
||||
- mapping via injected mappers
|
||||
- Repositories must not:
|
||||
- construct mappers internally (`new` in constructor defaults)
|
||||
- embed business logic/orchestration (that belongs to application services / use cases per [`Use Cases decide... Adapters translate.`](docs/architecture/DATA_FLOW.md:437))
|
||||
- Construction belongs in the Nest composition root:
|
||||
- e.g. [`PostgresRacingPersistenceModule`](apps/api/src/persistence/postgres/PostgresRacingPersistenceModule.ts:57) should provide mapper instances and inject them into repositories.
|
||||
|
||||
### D) Error handling rules (adapter-scoped errors vs domain errors)
|
||||
|
||||
- Domain errors (e.g. [`RacingDomainValidationError`](core/racing/domain/entities/League.ts:148)) are for rejecting invalid **commands/new state transitions**.
|
||||
- Persistence adapters must throw adapter-scoped errors for:
|
||||
- invalid persisted JSON schema
|
||||
- impossible enum values/statuses stored in DB
|
||||
- missing required persisted columns
|
||||
- Pattern baseline:
|
||||
- follow [`InvalidLeagueScoringConfigChampionshipsSchemaError`](adapters/racing/persistence/typeorm/errors/InvalidLeagueScoringConfigChampionshipsSchemaError.ts:1) (adapter-owned, descriptive name, extends `Error`).
|
||||
- Repositories generally should not catch/translate DB errors unless you have a stable policy (e.g. unique violations) — keep this explicit and adapter-scoped if introduced.
|
||||
|
||||
---
|
||||
|
||||
## Controlled refactor plan (2–3 slices)
|
||||
|
||||
Each slice is designed to be reviewable and to keep the system runnable.
|
||||
|
||||
### Slice 1 — Mappers: `rehydrate()` + typed JSON schemas (DB-free unit tests)
|
||||
|
||||
**Goal**
|
||||
- Make all `orm -> domain` mapping use rehydration semantics and strict, typed persisted schemas.
|
||||
- Remove `as any` from mapper paths by fixing schema types and adding validators.
|
||||
|
||||
**Files to touch (exact)**
|
||||
- Mapper implementations:
|
||||
- [`LeagueOrmMapper`](adapters/racing/persistence/typeorm/mappers/LeagueOrmMapper.ts:30)
|
||||
- [`RaceOrmMapper`](adapters/racing/persistence/typeorm/mappers/RaceOrmMapper.ts:5)
|
||||
- [`SeasonOrmMapper`](adapters/racing/persistence/typeorm/mappers/SeasonOrmMapper.ts:4)
|
||||
- (keep as reference) [`LeagueScoringConfigOrmMapper`](adapters/racing/persistence/typeorm/mappers/LeagueScoringConfigOrmMapper.ts:42)
|
||||
- JSON mappers as needed:
|
||||
- [`PointsTableJsonMapper`](adapters/racing/persistence/typeorm/mappers/PointsTableJsonMapper.ts:7)
|
||||
- [`ChampionshipConfigJsonMapper`](adapters/racing/persistence/typeorm/mappers/ChampionshipConfigJsonMapper.ts:17)
|
||||
- ORM entities (type JSON columns to serialized types):
|
||||
- [`LeagueOrmEntity`](adapters/racing/persistence/typeorm/entities/LeagueOrmEntity.ts:4)
|
||||
- [`SeasonOrmEntity`](adapters/racing/persistence/typeorm/entities/SeasonOrmEntity.ts:4)
|
||||
- Add adapter-scoped schema error types (new files under the existing folder):
|
||||
- create new errors in [`adapters/racing/persistence/typeorm/errors/`](adapters/racing/persistence/typeorm/errors/InvalidLeagueScoringConfigChampionshipsSchemaError.ts:1)
|
||||
- Core changes required to satisfy rehydration rule (yes, this reaches into core because the adapter cannot comply otherwise):
|
||||
- add `static rehydrate(...)` to:
|
||||
- [`League`](core/racing/domain/entities/League.ts:93)
|
||||
- [`Race`](core/racing/domain/entities/Race.ts:18)
|
||||
- [`Season`](core/racing/domain/entities/season/Season.ts:14)
|
||||
|
||||
**Acceptance tests (DB-free)**
|
||||
- Add mapper unit tests in the same pattern as [`LeagueScoringConfigOrmMapper.test.ts`](adapters/racing/persistence/typeorm/mappers/LeagueScoringConfigOrmMapper.test.ts:10):
|
||||
- new: `LeagueOrmMapper.test.ts` verifies [`LeagueOrmMapper.toDomain()`](adapters/racing/persistence/typeorm/mappers/LeagueOrmMapper.ts:46) calls `rehydrate()` and does not call [`League.create()`](core/racing/domain/entities/League.ts:132).
|
||||
- new: `RaceOrmMapper.test.ts` verifies [`RaceOrmMapper.toDomain()`](adapters/racing/persistence/typeorm/mappers/RaceOrmMapper.ts:23) uses `rehydrate()` and never calls [`Race.create()`](core/racing/domain/entities/Race.ts:81).
|
||||
- new: `SeasonOrmMapper.test.ts` verifies [`SeasonOrmMapper.toDomain()`](adapters/racing/persistence/typeorm/mappers/SeasonOrmMapper.ts:26) uses `rehydrate()` and never calls [`Season.create()`](core/racing/domain/entities/season/Season.ts:70).
|
||||
- Add schema validation tests:
|
||||
- invalid JSON column shapes throw adapter error types (similar to [`InvalidLeagueScoringConfigChampionshipsSchemaError`](adapters/racing/persistence/typeorm/errors/InvalidLeagueScoringConfigChampionshipsSchemaError.ts:1)), not domain validation errors.
|
||||
|
||||
**Definition of done**
|
||||
- No `create()` calls in any `toDomain()` for persisted entities.
|
||||
- No `as any` in mapper implementations.
|
||||
- JSON columns are typed to explicit `Serialized*` types, and validated on load.
|
||||
|
||||
---
|
||||
|
||||
### Slice 2 — Repository wiring cleanup (DI, no `new` inside repos)
|
||||
|
||||
**Goal**
|
||||
- Repositories remain IO + mapper only.
|
||||
- Mapper graphs are constructed in the Nest module composition root.
|
||||
|
||||
**Files to touch (exact)**
|
||||
- Repositories:
|
||||
- [`TypeOrmLeagueRepository`](adapters/racing/persistence/typeorm/repositories/TypeOrmLeagueRepository.ts:9)
|
||||
- [`TypeOrmRaceRepository`](adapters/racing/persistence/typeorm/repositories/TypeOrmRaceRepository.ts:9)
|
||||
- [`TypeOrmSeasonRepository`](adapters/racing/persistence/typeorm/repositories/TypeOrmSeasonRepository.ts:9)
|
||||
- (already OK, keep consistent) [`TypeOrmLeagueScoringConfigRepository`](adapters/racing/persistence/typeorm/repositories/TypeOrmLeagueScoringConfigRepository.ts:9)
|
||||
- Composition root wiring:
|
||||
- [`PostgresRacingPersistenceModule`](apps/api/src/persistence/postgres/PostgresRacingPersistenceModule.ts:57)
|
||||
- Integration tests that new repos directly (update constructor signatures):
|
||||
- [`PostgresLeagueScheduleRepositorySlice.int.test.ts`](apps/api/src/persistence/postgres/typeorm/PostgresLeagueScheduleRepositorySlice.int.test.ts:55)
|
||||
- Mapper tests that assume default constructors (update as needed):
|
||||
- [`RacingOrmMappers.test.ts`](apps/api/src/persistence/postgres/typeorm/RacingOrmMappers.test.ts:20)
|
||||
|
||||
**Acceptance tests**
|
||||
- Add repository constructor tests mirroring the existing pattern in [`TypeOrmLeagueScoringConfigRepository.test.ts`](adapters/racing/persistence/typeorm/repositories/TypeOrmLeagueScoringConfigRepository.test.ts:17):
|
||||
- new: `TypeOrmLeagueRepository.test.ts` asserts no internal `new LeagueOrmMapper()`.
|
||||
- new: `TypeOrmRaceRepository.test.ts` asserts no internal `new RaceOrmMapper()`.
|
||||
- new: `TypeOrmSeasonRepository.test.ts` asserts no internal `new SeasonOrmMapper()`.
|
||||
- `tsc` should enforce injection by making mapper a required constructor param (strongest guard).
|
||||
|
||||
**Definition of done**
|
||||
- No repository has a default `new Mapper()` in constructor params.
|
||||
- Nest module provides mapper instances and injects them into repositories.
|
||||
|
||||
---
|
||||
|
||||
### Slice 3 (optional) — Postgres integration tests + minimal vertical verification
|
||||
|
||||
**Goal**
|
||||
- Verify the new strict schemas + rehydrate semantics survive real persistence roundtrips.
|
||||
|
||||
**Files to touch (exact)**
|
||||
- Existing integration test:
|
||||
- [`PostgresLeagueScheduleRepositorySlice.int.test.ts`](apps/api/src/persistence/postgres/typeorm/PostgresLeagueScheduleRepositorySlice.int.test.ts:31)
|
||||
- Potentially add one focused mapper+repo roundtrip test per aggregate if gaps remain:
|
||||
- extend [`PostgresLeagueScheduleRepositorySlice.int.test.ts`](apps/api/src/persistence/postgres/typeorm/PostgresLeagueScheduleRepositorySlice.int.test.ts:55) rather than adding many files.
|
||||
|
||||
**Acceptance tests**
|
||||
- With `DATABASE_URL` set, integration suite passes and persists/reads:
|
||||
- League with settings JSON
|
||||
- Season with nullable JSON configs
|
||||
- Race with status/sessionType mapping
|
||||
- LeagueScoringConfig with championships JSON (already covered)
|
||||
|
||||
---
|
||||
|
||||
## Notes on current wiring (for context)
|
||||
|
||||
- Nest composition root is already the correct place for construction:
|
||||
- [`PostgresRacingPersistenceModule`](apps/api/src/persistence/postgres/PostgresRacingPersistenceModule.ts:57)
|
||||
- But it currently relies on repository default constructors for some mappers:
|
||||
- e.g. provides [`TypeOrmLeagueRepository`](adapters/racing/persistence/typeorm/repositories/TypeOrmLeagueRepository.ts:9) via `new TypeOrmLeagueRepository(dataSource)` in [`PostgresRacingPersistenceModule`](apps/api/src/persistence/postgres/PostgresRacingPersistenceModule.ts:66)
|
||||
- League scoring config is already composed explicitly (good reference):
|
||||
- constructs `PointsTableJsonMapper -> ChampionshipConfigJsonMapper -> LeagueScoringConfigOrmMapper` in [`PostgresRacingPersistenceModule`](apps/api/src/persistence/postgres/PostgresRacingPersistenceModule.ts:126)
|
||||
|
||||
---
|
||||
|
||||
## Top blockers (short list)
|
||||
|
||||
- Persisted entity mapping calls `create()` instead of `rehydrate()`:
|
||||
- [`LeagueOrmMapper.toDomain()`](adapters/racing/persistence/typeorm/mappers/LeagueOrmMapper.ts:46)
|
||||
- [`RaceOrmMapper.toDomain()`](adapters/racing/persistence/typeorm/mappers/RaceOrmMapper.ts:23)
|
||||
- [`SeasonOrmMapper.toDomain()`](adapters/racing/persistence/typeorm/mappers/SeasonOrmMapper.ts:26)
|
||||
- Repositories build mappers internally (construction not confined to composition root):
|
||||
- [`TypeOrmLeagueRepository.constructor()`](adapters/racing/persistence/typeorm/repositories/TypeOrmLeagueRepository.ts:10)
|
||||
- [`TypeOrmRaceRepository.constructor()`](adapters/racing/persistence/typeorm/repositories/TypeOrmRaceRepository.ts:10)
|
||||
- [`TypeOrmSeasonRepository.constructor()`](adapters/racing/persistence/typeorm/repositories/TypeOrmSeasonRepository.ts:10)
|
||||
- Untyped JSON columns and `as any` casts prevent strict translation and reliable schema error handling:
|
||||
- [`LeagueOrmEntity.settings`](adapters/racing/persistence/typeorm/entities/LeagueOrmEntity.ts:17)
|
||||
- [`SeasonOrmEntity.schedule`](adapters/racing/persistence/typeorm/entities/SeasonOrmEntity.ts:32)
|
||||
- [`RaceOrmMapper.toDomain()`](adapters/racing/persistence/typeorm/mappers/RaceOrmMapper.ts:23)
|
||||
- [`SeasonOrmMapper.toDomain()`](adapters/racing/persistence/typeorm/mappers/SeasonOrmMapper.ts:26)
|
||||
Reference in New Issue
Block a user