inmemory to postgres

This commit is contained in:
2025-12-29 19:44:11 +01:00
parent f5639a367f
commit 12ae6e1dad
17 changed files with 361 additions and 94 deletions

View File

@@ -22,6 +22,8 @@ import type { IPenaltyRepository } from '@core/racing/domain/repositories/IPenal
import type { IFeedRepository } from '@core/social/domain/repositories/IFeedRepository';
import type { ISocialGraphRepository } from '@core/social/domain/repositories/ISocialGraphRepository';
import { createRacingSeed } from './racing/RacingSeed';
import { getApiPersistence } from '../../apps/api/src/env';
import { seedId } from './racing/SeedIdHelper';
export type RacingSeedDependencies = {
driverRepository: IDriverRepository;
@@ -60,7 +62,8 @@ export class SeedRacingData {
return;
}
const seed = createRacingSeed();
const persistence = getApiPersistence();
const seed = createRacingSeed({ persistence });
let sponsorshipRequestsSeededViaRepo = false;
const seedableSponsorshipRequests = this.seedDeps
@@ -97,7 +100,7 @@ export class SeedRacingData {
const activeSeasons = seed.seasons.filter((season) => season.status.isActive());
for (const season of activeSeasons) {
const presetId = this.selectScoringPresetIdForSeason(season);
const presetId = this.selectScoringPresetIdForSeason(season, persistence);
const preset = getLeagueScoringPresetById(presetId);
if (!preset) {
@@ -276,7 +279,7 @@ export class SeedRacingData {
const existing = await this.seedDeps.leagueScoringConfigRepository.findBySeasonId(season.id);
if (existing) continue;
const presetId = this.selectScoringPresetIdForSeason(season);
const presetId = this.selectScoringPresetIdForSeason(season, 'postgres');
const preset = getLeagueScoringPresetById(presetId);
if (!preset) {
@@ -297,13 +300,16 @@ export class SeedRacingData {
}
}
private selectScoringPresetIdForSeason(season: Season): string {
if (season.leagueId === 'league-5' && season.status.isActive()) {
private selectScoringPresetIdForSeason(season: Season, persistence: 'postgres' | 'inmemory'): string {
const expectedLeagueId = seedId('league-5', persistence);
const expectedSeasonId = seedId('season-1-b', persistence);
if (season.leagueId === expectedLeagueId && season.status.isActive()) {
return 'sprint-main-driver';
}
if (season.leagueId === 'league-3') {
return season.id.endsWith('-b') ? 'sprint-main-team' : 'club-default-nations';
if (season.leagueId === seedId('league-3', persistence)) {
return season.id === expectedSeasonId ? 'sprint-main-team' : 'club-default-nations';
}
const match = /^league-(\d+)$/.exec(season.leagueId);

View File

@@ -1,10 +1,12 @@
import { Driver } from '@core/racing/domain/entities/Driver';
import { faker } from '@faker-js/faker';
import { seedId } from './SeedIdHelper';
export class RacingDriverFactory {
constructor(
private readonly driverCount: number,
private readonly baseDate: Date,
private readonly persistence: 'postgres' | 'inmemory' = 'inmemory',
) {}
create(): Driver[] {
@@ -14,7 +16,7 @@ export class RacingDriverFactory {
const i = idx + 1;
return Driver.create({
id: `driver-${i}`,
id: seedId(`driver-${i}`, this.persistence),
iracingId: String(100000 + i),
name: faker.person.fullName(),
country: faker.helpers.arrayElement(countries),

View File

@@ -3,9 +3,13 @@ import { League } from '@core/racing/domain/entities/League';
import { Race } from '@core/racing/domain/entities/Race';
import type { FeedItem } from '@core/social/domain/types/FeedItem';
import type { Friendship } from './RacingSeed';
import { seedId } from './SeedIdHelper';
export class RacingFeedFactory {
constructor(private readonly baseDate: Date) {}
constructor(
private readonly baseDate: Date,
private readonly persistence: 'postgres' | 'inmemory' = 'inmemory',
) {}
create(drivers: Driver[], friendships: Friendship[], races: Race[], leagues: League[]): FeedItem[] {
const items: FeedItem[] = [];
@@ -18,13 +22,13 @@ export class RacingFeedFactory {
const now = this.addMinutes(this.baseDate, 10);
for (let i = 2; i <= 10; i++) {
const actor = drivers.find((d) => d.id === `driver-${i}`);
const actor = drivers.find((d) => d.id === seedId(`driver-${i}`, this.persistence));
if (!actor) continue;
if (!friendMap.has(`driver-1:${actor.id}`)) continue;
items.push({
id: `feed:${actor.id}:joined:${i}`,
id: seedId(`feed:${actor.id}:joined:${i}`, this.persistence),
type: 'friend-joined-league',
timestamp: this.addMinutes(now, -(i * 7)),
actorDriverId: actor.id,
@@ -38,7 +42,7 @@ export class RacingFeedFactory {
if (completedRace) {
items.push({
id: `feed:${actor.id}:result:${i}`,
id: seedId(`feed:${actor.id}:result:${i}`, this.persistence),
type: 'friend-finished-race',
timestamp: this.addMinutes(now, -(i * 7 + 3)),
actorDriverId: actor.id,
@@ -56,7 +60,7 @@ export class RacingFeedFactory {
if (upcomingRace) {
items.push({
id: `feed:system:scheduled:${upcomingRace.id}`,
id: seedId(`feed:system:scheduled:${upcomingRace.id}`, this.persistence),
type: 'new-race-scheduled',
timestamp: this.addMinutes(now, -3),
leagueId: upcomingRace.leagueId,

View File

@@ -1,11 +1,13 @@
import { League } from '@core/racing/domain/entities/League';
import { Driver } from '@core/racing/domain/entities/Driver';
import { faker } from '@faker-js/faker';
import { seedId } from './SeedIdHelper';
export class RacingLeagueFactory {
constructor(
private readonly baseDate: Date,
private readonly drivers: Driver[],
private readonly persistence: 'postgres' | 'inmemory' = 'inmemory',
) {}
create(): League[] {
@@ -54,7 +56,7 @@ export class RacingLeagueFactory {
socialLinks?: { discordUrl?: string; youtubeUrl?: string; websiteUrl?: string };
participantCount?: number;
} = {
id: `league-${i}`,
id: seedId(`league-${i}`, this.persistence),
name: faker.company.name() + ' Racing League',
description: faker.lorem.sentences(2),
ownerId: owner.id.toString(),

View File

@@ -5,6 +5,7 @@ import { LeagueWalletId } from '@core/racing/domain/entities/league-wallet/Leagu
import { Transaction } from '@core/racing/domain/entities/league-wallet/Transaction';
import { TransactionId } from '@core/racing/domain/entities/league-wallet/TransactionId';
import { Money } from '@core/racing/domain/value-objects/Money';
import { seedId } from './SeedIdHelper';
type LeagueWalletSeed = {
wallets: LeagueWallet[];
@@ -12,7 +13,10 @@ type LeagueWalletSeed = {
};
export class RacingLeagueWalletFactory {
constructor(private readonly baseDate: Date) {}
constructor(
private readonly baseDate: Date,
private readonly persistence: 'postgres' | 'inmemory' = 'inmemory',
) {}
create(leagues: League[]): LeagueWalletSeed {
const wallets: LeagueWallet[] = [];
@@ -20,7 +24,7 @@ export class RacingLeagueWalletFactory {
for (const league of leagues) {
const leagueId = league.id.toString();
const walletId = `wallet-${leagueId}`;
const walletId = seedId(`wallet-${leagueId}`, this.persistence);
const createdAt = faker.date.past({ years: 2, refDate: this.baseDate });
// Ensure coverage:
@@ -55,7 +59,7 @@ export class RacingLeagueWalletFactory {
: faker.number.int({ min: 2, max: 18 });
for (let i = 0; i < transactionCount; i++) {
const id = TransactionId.create(`tx-${leagueId}-${i + 1}`);
const id = TransactionId.create(seedId(`tx-${leagueId}-${i + 1}`, this.persistence));
const type = this.pickTransactionType(i, leagueId);
const amount = this.pickAmount(type, currency, leagueId);
@@ -100,7 +104,7 @@ export class RacingLeagueWalletFactory {
// Explicit edge-case: pending prize payout shows up in "pending payouts"
if (leagueId === 'league-5') {
const pendingPrize = Transaction.create({
id: TransactionId.create(`tx-${leagueId}-pending-prize`),
id: TransactionId.create(seedId(`tx-${leagueId}-pending-prize`, this.persistence)),
walletId: LeagueWalletId.create(walletId),
type: 'prize_payout',
amount: Money.create(600, currency),
@@ -108,7 +112,7 @@ export class RacingLeagueWalletFactory {
createdAt: faker.date.recent({ days: 15, refDate: this.baseDate }),
completedAt: undefined,
description: 'Season prize pool payout (pending)',
metadata: { seasonId: 'season-2', placement: 'P1-P3' },
metadata: { seasonId: seedId('season-2', this.persistence), placement: 'P1-P3' },
});
transactions.push(pendingPrize);

View File

@@ -4,9 +4,13 @@ import { JoinRequest } from '@core/racing/domain/entities/JoinRequest';
import { LeagueMembership } from '@core/racing/domain/entities/LeagueMembership';
import { Race } from '@core/racing/domain/entities/Race';
import { RaceRegistration } from '@core/racing/domain/entities/RaceRegistration';
import { seedId } from './SeedIdHelper';
export class RacingMembershipFactory {
constructor(private readonly baseDate: Date) {}
constructor(
private readonly baseDate: Date,
private readonly persistence: 'postgres' | 'inmemory' = 'inmemory',
) {}
createLeagueMemberships(drivers: Driver[], leagues: League[]): LeagueMembership[] {
const memberships: LeagueMembership[] = [];
@@ -34,19 +38,20 @@ export class RacingMembershipFactory {
// Empty league: intentionally no memberships.
// (Keep `league-2` empty if it exists.)
// Widen the type to avoid TS2367 no overlap comparisons in some build modes.
const emptyLeagueId: string | undefined = leagueById.has('league-2') ? ('league-2' as string) : undefined;
// Widen the type to avoid TS2367 "no overlap" comparisons in some build modes.
const emptyLeagueId: string | undefined = leagueById.has(seedId('league-2', this.persistence)) ? (seedId('league-2', this.persistence) as string) : undefined;
// Demo league: "full" + overbooked with pending/inactive members.
const demoLeague = leagueById.get('league-5');
const demoLeague = leagueById.get(seedId('league-5', this.persistence));
if (demoLeague) {
const maxDrivers = demoLeague.settings.maxDrivers ?? 32;
const activeDrivers = drivers.slice(0, Math.min(maxDrivers, drivers.length));
activeDrivers.forEach((driver, idx) => {
const driverId = driver.id.toString();
const expectedDriverId = seedId('driver-1', this.persistence);
const role =
driverId === 'driver-1'
driverId === expectedDriverId
? 'owner'
: idx === 1 || idx === 2
? 'admin'
@@ -71,7 +76,7 @@ export class RacingMembershipFactory {
}
// League with mixed statuses and roles (but not full).
const league1 = leagueById.get('league-1');
const league1 = leagueById.get(seedId('league-1', this.persistence));
if (league1) {
const pick = drivers.slice(15, 25);
pick.forEach((driver, idx) => {
@@ -86,7 +91,7 @@ export class RacingMembershipFactory {
}
// League with only pending memberships (tests "pending list" UX).
const league4 = leagueById.get('league-4');
const league4 = leagueById.get(seedId('league-4', this.persistence));
if (league4) {
drivers.slice(40, 48).forEach((driver, idx) => {
add({
@@ -106,10 +111,10 @@ export class RacingMembershipFactory {
for (const league of leagues) {
const leagueId = league.id.toString();
if (leagueId === 'league-5') continue;
if (leagueId === seedId('league-5', this.persistence)) continue;
if (emptyLeagueId && leagueId === emptyLeagueId) continue;
if (driverNumber % 11 === 0 && leagueId === 'league-3') {
if (driverNumber % 11 === 0 && leagueId === seedId('league-3', this.persistence)) {
add({
leagueId,
driverId,
@@ -157,7 +162,7 @@ export class RacingMembershipFactory {
};
// League with lots of requests + membership/request conflicts (everyone is a member of league-5 already).
const demoLeagueId = 'league-5';
const demoLeagueId = seedId('league-5', this.persistence);
const demoDrivers = drivers.slice(10, 35);
demoDrivers.forEach((driver, idx) => {
const message =
@@ -178,7 +183,7 @@ export class RacingMembershipFactory {
});
// League with a few "normal" requests (only drivers who are NOT members already).
const targetLeagueId = 'league-1';
const targetLeagueId = seedId('league-1', this.persistence);
const nonMembers = drivers
.filter(driver => !membershipIds.has(`${targetLeagueId}:${driver.id.toString()}`))
.slice(0, 6);
@@ -193,11 +198,11 @@ export class RacingMembershipFactory {
});
// Single request with no message (explicit id).
const league3Exists = leagues.some(l => l.id.toString() === 'league-3');
const league3Exists = leagues.some(l => l.id.toString() === seedId('league-3', this.persistence));
if (league3Exists && drivers[0]) {
addRequest({
id: 'league-3-join-req-1',
leagueId: 'league-3',
id: seedId('league-3-join-req-1', this.persistence),
leagueId: seedId('league-3', this.persistence),
driverId: drivers[0].id.toString(),
requestedAt: this.addDays(this.baseDate, -9),
});
@@ -206,16 +211,16 @@ export class RacingMembershipFactory {
// Duplicate id edge case (last write wins in in-memory repo).
if (drivers[1]) {
addRequest({
id: 'dup-league-join-req-1',
leagueId: 'league-7',
id: seedId('dup-league-join-req-1', this.persistence),
leagueId: seedId('league-7', this.persistence),
driverId: drivers[1].id.toString(),
requestedAt: this.addDays(this.baseDate, -2),
message: 'First request message (will be overwritten).',
});
addRequest({
id: 'dup-league-join-req-1',
leagueId: 'league-7',
id: seedId('dup-league-join-req-1', this.persistence),
leagueId: seedId('league-7', this.persistence),
driverId: drivers[1].id.toString(),
requestedAt: this.addDays(this.baseDate, -1),
message: 'Updated request message (duplicate id).',
@@ -223,10 +228,11 @@ export class RacingMembershipFactory {
}
// Explicit conflict: join request exists even though membership exists.
const driver1 = drivers.find(d => d.id.toString() === 'driver-1');
const expectedDriverId = seedId('driver-1', this.persistence);
const driver1 = drivers.find(d => d.id.toString() === expectedDriverId);
if (driver1) {
addRequest({
id: 'conflict-req-league-5-driver-1',
id: seedId('conflict-req-league-5-driver-1', this.persistence),
leagueId: demoLeagueId,
driverId: driver1.id.toString(),
requestedAt: this.addDays(this.baseDate, -15),
@@ -311,12 +317,12 @@ export class RacingMembershipFactory {
}
// Keep a tiny curated "happy path" for the demo league as well
const upcomingDemoLeague = races.filter((r) => r.status.toString() === 'scheduled' && r.leagueId === 'league-5').slice(0, 3);
const upcomingDemoLeague = races.filter((r) => r.status.toString() === 'scheduled' && r.leagueId === seedId('league-5', this.persistence)).slice(0, 3);
for (const race of upcomingDemoLeague) {
registrations.push(
RaceRegistration.create({
raceId: race.id,
driverId: 'driver-1',
driverId: seedId('driver-1', this.persistence),
}),
);
}

View File

@@ -1,9 +1,13 @@
import { League } from '@core/racing/domain/entities/League';
import { Race } from '@core/racing/domain/entities/Race';
import { Track } from '@core/racing/domain/entities/Track';
import { seedId } from './SeedIdHelper';
export class RacingRaceFactory {
constructor(private readonly baseDate: Date) {}
constructor(
private readonly baseDate: Date,
private readonly persistence: 'postgres' | 'inmemory' = 'inmemory',
) {}
create(leagues: League[], tracks: Track[]): Race[] {
const cars = ['GT3 Porsche 911', 'GT3 BMW M4', 'LMP3 Prototype', 'GT4 Alpine', 'Touring Civic'];
@@ -51,7 +55,7 @@ export class RacingRaceFactory {
}
const base = {
id: `race-${i}`,
id: seedId(`race-${i}`, this.persistence),
leagueId,
scheduledAt,
track: track.name.toString(),

View File

@@ -1,8 +1,11 @@
import { Driver } from '@core/racing/domain/entities/Driver';
import { Race } from '@core/racing/domain/entities/Race';
import { Result as RaceResult } from '@core/racing/domain/entities/result/Result';
import { seedId } from './SeedIdHelper';
export class RacingResultFactory {
constructor(private readonly persistence: 'postgres' | 'inmemory' = 'inmemory') {}
create(drivers: Driver[], races: Race[]): RaceResult[] {
const results: RaceResult[] = [];
const completed = races.filter((r) => r.status.toString() === 'completed');
@@ -50,7 +53,7 @@ export class RacingResultFactory {
results.push(
RaceResult.create({
id: `${race.id}:${driver.id}`,
id: seedId(`${race.id}:${driver.id}`, this.persistence),
raceId: race.id,
driverId: driver.id,
position,

View File

@@ -6,9 +6,13 @@ import { SeasonSponsorship } from '@core/racing/domain/entities/season/SeasonSpo
import type { Sponsor } from '@core/racing/domain/entities/sponsor/Sponsor';
import type { SeasonStatusValue } from '@core/racing/domain/value-objects/SeasonStatus';
import { Money } from '@core/racing/domain/value-objects/Money';
import { seedId } from './SeedIdHelper';
export class RacingSeasonSponsorshipFactory {
constructor(private readonly baseDate: Date) {}
constructor(
private readonly baseDate: Date,
private readonly persistence: 'postgres' | 'inmemory' = 'inmemory',
) {}
createSeasons(leagues: League[]): Season[] {
const seasons: Season[] = [];
@@ -16,10 +20,10 @@ export class RacingSeasonSponsorshipFactory {
for (const league of leagues) {
const leagueId = league.id.toString();
if (leagueId === 'league-5') {
if (leagueId === seedId('league-5', this.persistence)) {
seasons.push(
Season.create({
id: 'season-1',
id: seedId('season-1', this.persistence),
leagueId,
gameId: 'iracing',
name: 'Season 1 (GT Sprint)',
@@ -29,7 +33,7 @@ export class RacingSeasonSponsorshipFactory {
startDate: this.daysFromBase(-30),
}),
Season.create({
id: 'season-2',
id: seedId('season-2', this.persistence),
leagueId,
gameId: 'iracing',
name: 'Season 2 (Endurance Cup)',
@@ -40,7 +44,7 @@ export class RacingSeasonSponsorshipFactory {
endDate: this.daysFromBase(-60),
}),
Season.create({
id: 'season-3',
id: seedId('season-3', this.persistence),
leagueId,
gameId: 'iracing',
name: 'Season 3 (Planned)',
@@ -53,10 +57,10 @@ export class RacingSeasonSponsorshipFactory {
continue;
}
if (leagueId === 'league-3') {
if (leagueId === seedId('league-3', this.persistence)) {
seasons.push(
Season.create({
id: 'league-3-season-a',
id: seedId('league-3-season-a', this.persistence),
leagueId,
gameId: 'iracing',
name: 'Split Season A',
@@ -66,7 +70,7 @@ export class RacingSeasonSponsorshipFactory {
startDate: this.daysFromBase(-10),
}),
Season.create({
id: 'league-3-season-b',
id: seedId('league-3-season-b', this.persistence),
leagueId,
gameId: 'iracing',
name: 'Split Season B',
@@ -80,16 +84,16 @@ export class RacingSeasonSponsorshipFactory {
}
const baseYear = this.baseDate.getUTCFullYear();
const seasonCount = leagueId === 'league-2' ? 1 : faker.number.int({ min: 1, max: 3 });
const seasonCount = leagueId === seedId('league-2', this.persistence) ? 1 : faker.number.int({ min: 1, max: 3 });
for (let i = 0; i < seasonCount; i++) {
const id = `${leagueId}-season-${i + 1}`;
const id = seedId(`${leagueId}-season-${i + 1}`, this.persistence);
const isFirst = i === 0;
const status: SeasonStatusValue =
leagueId === 'league-1' && isFirst
leagueId === seedId('league-1', this.persistence) && isFirst
? 'active'
: leagueId === 'league-2'
: leagueId === seedId('league-2', this.persistence)
? 'planned'
: isFirst
? faker.helpers.arrayElement(['active', 'planned'] as const)
@@ -131,8 +135,9 @@ export class RacingSeasonSponsorshipFactory {
const sponsorIds = sponsors.map((s) => s.id.toString());
for (const season of seasons) {
const expectedSeasonId = seedId('season-1', this.persistence);
const sponsorshipCount =
season.id === 'season-1'
season.id === expectedSeasonId
? 2
: season.status.isActive()
? faker.number.int({ min: 0, max: 2 })
@@ -152,7 +157,7 @@ export class RacingSeasonSponsorshipFactory {
usedSponsorIds.add(sponsorId);
const base = SeasonSponsorship.create({
id: `season-sponsorship-${season.id}-${i + 1}`,
id: seedId(`season-sponsorship-${season.id}-${i + 1}`, this.persistence),
seasonId: season.id,
leagueId: season.leagueId,
sponsorId,
@@ -190,7 +195,8 @@ export class RacingSeasonSponsorshipFactory {
const sponsorIds = sponsors.map((s) => s.id.toString());
for (const season of seasons) {
const isHighTrafficDemo = season.id === 'season-1';
const expectedSeasonId = seedId('season-1', this.persistence);
const isHighTrafficDemo = season.id === expectedSeasonId;
const maxRequests =
isHighTrafficDemo
? 8
@@ -204,7 +210,7 @@ export class RacingSeasonSponsorshipFactory {
const sponsorId =
isHighTrafficDemo && i === 0
? 'demo-sponsor-1'
? seedId('demo-sponsor-1', this.persistence)
: faker.helpers.arrayElement(sponsorIds);
const offeredAmount = Money.create(
@@ -230,7 +236,7 @@ export class RacingSeasonSponsorshipFactory {
requests.push(
SponsorshipRequest.create({
id: `sponsorship-request-${season.id}-${i + 1}`,
id: seedId(`sponsorship-request-${season.id}-${i + 1}`, this.persistence),
sponsorId,
entityType: 'season',
entityId: season.id,

View File

@@ -62,6 +62,7 @@ export type RacingSeed = {
export type RacingSeedOptions = {
driverCount?: number;
baseDate?: Date;
persistence?: 'postgres' | 'inmemory';
};
export const racingSeedDefaults: Readonly<
@@ -69,33 +70,36 @@ export const racingSeedDefaults: Readonly<
> = {
driverCount: 100,
baseDate: new Date(),
persistence: 'inmemory',
};
class RacingSeedFactory {
private readonly driverCount: number;
private readonly baseDate: Date;
private readonly persistence: 'postgres' | 'inmemory';
constructor(options: RacingSeedOptions) {
this.driverCount = options.driverCount ?? racingSeedDefaults.driverCount;
this.baseDate = options.baseDate ?? racingSeedDefaults.baseDate;
this.persistence = options.persistence ?? racingSeedDefaults.persistence;
}
create(): RacingSeed {
const driverFactory = new RacingDriverFactory(this.driverCount, this.baseDate);
const driverFactory = new RacingDriverFactory(this.driverCount, this.baseDate, this.persistence);
const trackFactory = new RacingTrackFactory();
const raceFactory = new RacingRaceFactory(this.baseDate);
const resultFactory = new RacingResultFactory();
const raceFactory = new RacingRaceFactory(this.baseDate, this.persistence);
const resultFactory = new RacingResultFactory(this.persistence);
const standingFactory = new RacingStandingFactory();
const membershipFactory = new RacingMembershipFactory(this.baseDate);
const sponsorFactory = new RacingSponsorFactory(this.baseDate);
const seasonSponsorshipFactory = new RacingSeasonSponsorshipFactory(this.baseDate);
const leagueWalletFactory = new RacingLeagueWalletFactory(this.baseDate);
const membershipFactory = new RacingMembershipFactory(this.baseDate, this.persistence);
const sponsorFactory = new RacingSponsorFactory(this.baseDate, this.persistence);
const seasonSponsorshipFactory = new RacingSeasonSponsorshipFactory(this.baseDate, this.persistence);
const leagueWalletFactory = new RacingLeagueWalletFactory(this.baseDate, this.persistence);
const friendshipFactory = new RacingFriendshipFactory();
const feedFactory = new RacingFeedFactory(this.baseDate);
const feedFactory = new RacingFeedFactory(this.baseDate, this.persistence);
const drivers = driverFactory.create();
const tracks = trackFactory.create();
const leagueFactory = new RacingLeagueFactory(this.baseDate, drivers);
const leagueFactory = new RacingLeagueFactory(this.baseDate, drivers, this.persistence);
const leagues = leagueFactory.create();
const sponsors = sponsorFactory.create();
const seasons = seasonSponsorshipFactory.createSeasons(leagues);
@@ -107,7 +111,7 @@ class RacingSeedFactory {
const { wallets: leagueWallets, transactions: leagueWalletTransactions } = leagueWalletFactory.create(leagues);
const teamFactory = new RacingTeamFactory(this.baseDate);
const teamFactory = new RacingTeamFactory(this.baseDate, this.persistence);
const teams = teamFactory.createTeams(drivers, leagues);
const races = raceFactory.create(leagues, tracks);
const results = resultFactory.create(drivers, races);
@@ -116,7 +120,7 @@ class RacingSeedFactory {
const teamMemberships = teamFactory.createTeamMemberships(drivers, teams);
const teamJoinRequests = teamFactory.createTeamJoinRequests(drivers, teams, teamMemberships);
const stewardingFactory = new RacingStewardingFactory(this.baseDate);
const stewardingFactory = new RacingStewardingFactory(this.baseDate, this.persistence);
const { protests, penalties } = stewardingFactory.create(races, drivers, leagueMemberships);
const friendships = friendshipFactory.create(drivers);

View File

@@ -1,12 +1,16 @@
import { Sponsor } from '@core/racing/domain/entities/sponsor/Sponsor';
import { faker } from '@faker-js/faker';
import { seedId } from './SeedIdHelper';
export class RacingSponsorFactory {
constructor(private readonly baseDate: Date) {}
constructor(
private readonly baseDate: Date,
private readonly persistence: 'postgres' | 'inmemory' = 'inmemory',
) {}
create(): Sponsor[] {
const demoSponsor = Sponsor.create({
id: 'demo-sponsor-1',
id: seedId('demo-sponsor-1', this.persistence),
name: 'GridPilot Sim Racing Supply',
contactEmail: 'partnerships@gridpilot.example',
logoUrl: 'http://localhost:3000/images/header.jpeg',
@@ -111,7 +115,7 @@ export class RacingSponsorFactory {
const websiteUrl = websiteUrls[idx % websiteUrls.length]!;
return Sponsor.create({
id: `sponsor-${i}`,
id: seedId(`sponsor-${i}`, this.persistence),
name,
contactEmail: `partnerships+${safeName}@example.com`,
...(logoUrl ? { logoUrl } : {}),

View File

@@ -4,6 +4,7 @@ import type { LeagueMembership } from '@core/racing/domain/entities/LeagueMember
import { Protest } from '@core/racing/domain/entities/Protest';
import type { Race } from '@core/racing/domain/entities/Race';
import { Penalty } from '@core/racing/domain/entities/penalty/Penalty';
import { seedId } from './SeedIdHelper';
type StewardingSeed = {
protests: Protest[];
@@ -11,7 +12,10 @@ type StewardingSeed = {
};
export class RacingStewardingFactory {
constructor(private readonly baseDate: Date) {}
constructor(
private readonly baseDate: Date,
private readonly persistence: 'postgres' | 'inmemory' = 'inmemory',
) {}
create(races: Race[], drivers: Driver[], leagueMemberships: LeagueMembership[]): StewardingSeed {
const protests: Protest[] = [];
@@ -57,7 +61,7 @@ export class RacingStewardingFactory {
if (firstRace) {
protests.push(
Protest.create({
id: 'protest-1',
id: seedId('protest-1', this.persistence),
raceId: firstRace.id.toString(),
protestingDriverId: protester,
accusedDriverId: accused,
@@ -76,7 +80,7 @@ export class RacingStewardingFactory {
// No penalty yet (pending), but seed a direct steward warning on same race.
penalties.push(
Penalty.create({
id: 'penalty-1',
id: seedId('penalty-1', this.persistence),
leagueId: 'league-5',
raceId: firstRace.id.toString(),
driverId: accused,
@@ -94,7 +98,7 @@ export class RacingStewardingFactory {
if (secondRace) {
protests.push(
Protest.create({
id: 'protest-2',
id: seedId('protest-2', this.persistence),
raceId: secondRace.id.toString(),
protestingDriverId: spare,
accusedDriverId: accused,
@@ -113,14 +117,14 @@ export class RacingStewardingFactory {
// Under review penalty still pending (linked to protest)
penalties.push(
Penalty.create({
id: 'penalty-2',
id: seedId('penalty-2', this.persistence),
leagueId: 'league-5',
raceId: secondRace.id.toString(),
driverId: accused,
type: 'time_penalty',
value: 10,
reason: 'Unsafe rejoin (protest pending review)',
protestId: 'protest-2',
protestId: seedId('protest-2', this.persistence),
issuedBy: steward,
status: 'pending',
issuedAt: faker.date.recent({ days: 10, refDate: this.baseDate }),
@@ -131,7 +135,7 @@ export class RacingStewardingFactory {
if (thirdRace) {
const upheld = Protest.create({
id: 'protest-3',
id: seedId('protest-3', this.persistence),
raceId: thirdRace.id.toString(),
protestingDriverId: protester,
accusedDriverId: spare,
@@ -151,7 +155,7 @@ export class RacingStewardingFactory {
penalties.push(
Penalty.create({
id: 'penalty-3',
id: seedId('penalty-3', this.persistence),
leagueId: 'league-5',
raceId: thirdRace.id.toString(),
driverId: spare,
@@ -168,7 +172,7 @@ export class RacingStewardingFactory {
);
const dismissed = Protest.create({
id: 'protest-4',
id: seedId('protest-4', this.persistence),
raceId: thirdRace.id.toString(),
protestingDriverId: accused,
accusedDriverId: protester,
@@ -210,7 +214,7 @@ export class RacingStewardingFactory {
const status = faker.helpers.arrayElement(['awaiting_defense', 'withdrawn'] as const);
const protest = Protest.create({
id: `protest-${leagueId}-${race.id.toString()}`,
id: seedId(`protest-${leagueId}-${race.id.toString()}`, this.persistence),
raceId: race.id.toString(),
protestingDriverId: a,
accusedDriverId: b,
@@ -239,7 +243,7 @@ export class RacingStewardingFactory {
// A non-protest-linked penalty can still exist for the same race.
penalties.push(
Penalty.create({
id: `penalty-${leagueId}-${race.id.toString()}`,
id: seedId(`penalty-${leagueId}-${race.id.toString()}`, this.persistence),
leagueId,
raceId: race.id.toString(),
driverId: b,

View File

@@ -3,9 +3,13 @@ import { League } from '@core/racing/domain/entities/League';
import { Team } from '@core/racing/domain/entities/Team';
import type { TeamJoinRequest, TeamMembership } from '@core/racing/domain/types/TeamMembership';
import { faker } from '@faker-js/faker';
import { seedId } from './SeedIdHelper';
export class RacingTeamFactory {
constructor(private readonly baseDate: Date) {}
constructor(
private readonly baseDate: Date,
private readonly persistence: 'postgres' | 'inmemory' = 'inmemory',
) {}
createTeams(drivers: Driver[], leagues: League[]): Team[] {
const teamCount = 15;
@@ -19,7 +23,7 @@ export class RacingTeamFactory {
);
return Team.create({
id: `team-${i}`,
id: seedId(`team-${i}`, this.persistence),
name: faker.company.name() + ' Racing',
tag: faker.string.alpha({ length: 4, casing: 'upper' }),
description: faker.lorem.sentences(2),
@@ -128,7 +132,7 @@ export class RacingTeamFactory {
candidateDriverIds.forEach((driverId, idx) => {
addRequest({
id: `team-join-${team1.id.toString()}-${driverId}`,
id: seedId(`team-join-${team1.id.toString()}-${driverId}`, this.persistence),
teamId: team1.id.toString(),
driverId,
requestedAt: this.addDays(this.baseDate, -(5 + idx)),
@@ -142,7 +146,7 @@ export class RacingTeamFactory {
// Conflict edge case: owner submits a join request to own team.
addRequest({
id: `team-join-${team1.id.toString()}-${team1.ownerId.toString()}-conflict`,
id: seedId(`team-join-${team1.id.toString()}-${team1.ownerId.toString()}-conflict`, this.persistence),
teamId: team1.id.toString(),
driverId: team1.ownerId.toString(),
requestedAt: this.addDays(this.baseDate, -1),
@@ -154,7 +158,7 @@ export class RacingTeamFactory {
if (team3 && drivers[0]) {
const driverId = drivers[0].id.toString();
addRequest({
id: 'dup-team-join-req-1',
id: seedId('dup-team-join-req-1', this.persistence),
teamId: team3.id.toString(),
driverId,
requestedAt: this.addDays(this.baseDate, -10),
@@ -162,7 +166,7 @@ export class RacingTeamFactory {
});
addRequest({
id: 'dup-team-join-req-1',
id: seedId('dup-team-join-req-1', this.persistence),
teamId: team3.id.toString(),
driverId,
requestedAt: this.addDays(this.baseDate, -9),

View File

@@ -0,0 +1,128 @@
import { describe, expect, it } from 'vitest';
import { stableUuidFromSeedKey, seedId, seedUuid } from './SeedIdHelper';
describe('SeedIdHelper', () => {
describe('stableUuidFromSeedKey', () => {
it('should return a valid UUID v4 format', () => {
const uuid = stableUuidFromSeedKey('team-3');
// UUID v4 format: xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx
// where y is one of [8, 9, a, b]
const uuidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
expect(uuid).toMatch(uuidRegex);
});
it('should be deterministic - same input produces same output', () => {
const input = 'driver-1';
const uuid1 = stableUuidFromSeedKey(input);
const uuid2 = stableUuidFromSeedKey(input);
expect(uuid1).toBe(uuid2);
});
it('should produce different UUIDs for different inputs', () => {
const uuid1 = stableUuidFromSeedKey('team-1');
const uuid2 = stableUuidFromSeedKey('team-2');
expect(uuid1).not.toBe(uuid2);
});
it('should handle various seed patterns', () => {
const testCases = [
'team-3',
'driver-1',
'league-5',
'race-10',
'sponsor-1',
'season-1',
'demo-sponsor-1',
'league-3-season-a',
'team-join-team-1-driver-5',
'dup-team-join-req-1',
];
testCases.forEach((seed) => {
const uuid = stableUuidFromSeedKey(seed);
const uuidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
expect(uuid).toMatch(uuidRegex);
expect(uuid.length).toBe(36);
});
});
it('should handle empty string', () => {
const uuid = stableUuidFromSeedKey('');
const uuidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
expect(uuid).toMatch(uuidRegex);
});
it('should handle long seed strings', () => {
const longSeed = 'a'.repeat(1000);
const uuid = stableUuidFromSeedKey(longSeed);
const uuidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
expect(uuid).toMatch(uuidRegex);
});
});
describe('seedId', () => {
it('should return UUID for postgres mode', () => {
const result = seedId('team-3', 'postgres');
const uuidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
expect(result).toMatch(uuidRegex);
});
it('should return original string for inmemory mode', () => {
const result = seedId('team-3', 'inmemory');
expect(result).toBe('team-3');
});
it('should be deterministic for postgres mode', () => {
const result1 = seedId('driver-5', 'postgres');
const result2 = seedId('driver-5', 'postgres');
expect(result1).toBe(result2);
});
it('should preserve original string for inmemory mode', () => {
const testCases = ['team-3', 'driver-1', 'league-5', 'race-10'];
testCases.forEach((seed) => {
const result = seedId(seed, 'inmemory');
expect(result).toBe(seed);
});
});
});
describe('seedUuid', () => {
it('should return a valid UUID', () => {
const uuid = seedUuid('team-3');
const uuidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
expect(uuid).toMatch(uuidRegex);
});
it('should be deterministic', () => {
const uuid1 = seedUuid('driver-1');
const uuid2 = seedUuid('driver-1');
expect(uuid1).toBe(uuid2);
});
it('should produce different UUIDs for different inputs', () => {
const uuid1 = seedUuid('team-1');
const uuid2 = seedUuid('team-2');
expect(uuid1).not.toBe(uuid2);
});
});
describe('UUID v4 compliance', () => {
it('should have version 4 bits set correctly', () => {
const uuid = stableUuidFromSeedKey('test');
// Version is in the 13th character (0-indexed), should be '4'
expect(uuid.charAt(14)).toBe('4');
});
it('should have variant bits set correctly', () => {
const uuid = stableUuidFromSeedKey('test');
// Variant is in the 19th character (0-indexed), should be 8, 9, a, or b
const variantChar = uuid.charAt(19);
expect(['8', '9', 'a', 'b']).toContain(variantChar.toLowerCase());
});
});
});

View File

@@ -0,0 +1,76 @@
import { createHash } from 'crypto';
/**
* Generates a deterministic UUID v4 from a seed string.
*
* This is used for postgres seeding to ensure all IDs are valid UUIDs
* while maintaining determinism across runs (important for tests and reproducible seeds).
*
* The function uses SHA-256 hash of the seed, then formats it as a UUID v4
* with proper version (0100) and variant (10xx) bits.
*
* @param seedKey - The deterministic seed string (e.g., "team-3", "driver-1")
* @returns A valid UUID v4 string
*
* @example
* stableUuidFromSeedKey("team-3")
* // Returns: "a1b2c3d4-e5f6-4a7b-8c9d-0e1f2a3b4c5d" (deterministic)
*/
export function stableUuidFromSeedKey(seedKey: string): string {
// Create a deterministic hash from the seed
const hash = createHash('sha256').update(seedKey).digest('hex');
// Take first 32 characters for UUID
const uuidHex = hash.substring(0, 32);
// Format as UUID v4 with proper version and variant bits
// UUID v4 format: xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx
// where x is any hex digit, y is one of [8, 9, a, b]
// Build the UUID step by step
const part1 = uuidHex.substring(0, 8);
const part2 = uuidHex.substring(8, 12);
const part3 = '4' + uuidHex.substring(13, 16); // Version 4
const variantChar = ['8', '9', 'a', 'b'][parseInt(uuidHex.charAt(16), 16) % 4];
const part4 = variantChar + uuidHex.substring(17, 20);
const part5 = uuidHex.substring(20, 32);
return `${part1}-${part2}-${part3}-${part4}-${part5}`;
}
/**
* Returns an ID appropriate for the current persistence mode.
*
* For postgres mode: returns a deterministic UUID
* For inmemory mode: returns the original deterministic string ID
*
* @param seedKey - The deterministic seed string (e.g., "team-3")
* @param mode - The persistence mode
* @returns Either a UUID (postgres) or the original string (inmemory)
*
* @example
* seedId("team-3", "postgres") // Returns UUID
* seedId("team-3", "inmemory") // Returns "team-3"
*/
export function seedId(seedKey: string, mode: 'postgres' | 'inmemory'): string {
if (mode === 'postgres') {
return stableUuidFromSeedKey(seedKey);
}
return seedKey;
}
/**
* Returns a UUID for postgres seeding.
*
* Convenience wrapper around seedId with mode already set to 'postgres'.
* Use this when you know you're in postgres mode.
*
* @param seedKey - The deterministic seed string
* @returns A valid UUID v4 string
*
* @example
* seedUuid("driver-1") // Returns UUID
*/
export function seedUuid(seedKey: string): string {
return stableUuidFromSeedKey(seedKey);
}