From 12ae6e1dad3c3a39b9b643b71d716e796565e7dd Mon Sep 17 00:00:00 2001 From: Marc Mintel Date: Mon, 29 Dec 2025 19:44:11 +0100 Subject: [PATCH] inmemory to postgres --- README.docker.md | 7 +- adapters/bootstrap/SeedRacingData.ts | 20 ++- .../bootstrap/racing/RacingDriverFactory.ts | 4 +- .../bootstrap/racing/RacingFeedFactory.ts | 14 +- .../bootstrap/racing/RacingLeagueFactory.ts | 4 +- .../racing/RacingLeagueWalletFactory.ts | 14 +- .../racing/RacingMembershipFactory.ts | 50 ++++--- .../bootstrap/racing/RacingRaceFactory.ts | 8 +- .../bootstrap/racing/RacingResultFactory.ts | 5 +- .../racing/RacingSeasonSponsorshipFactory.ts | 40 +++--- adapters/bootstrap/racing/RacingSeed.ts | 26 ++-- .../bootstrap/racing/RacingSponsorFactory.ts | 10 +- .../racing/RacingStewardingFactory.ts | 26 ++-- .../bootstrap/racing/RacingTeamFactory.ts | 16 ++- .../bootstrap/racing/SeedIdHelper.test.ts | 128 ++++++++++++++++++ adapters/bootstrap/racing/SeedIdHelper.ts | 76 +++++++++++ package.json | 7 +- 17 files changed, 361 insertions(+), 94 deletions(-) create mode 100644 adapters/bootstrap/racing/SeedIdHelper.test.ts create mode 100644 adapters/bootstrap/racing/SeedIdHelper.ts diff --git a/README.docker.md b/README.docker.md index 8ce7d7d26..e439bddb0 100644 --- a/README.docker.md +++ b/README.docker.md @@ -41,8 +41,13 @@ Access: ## Available Commands ### Development -- `npm run docker:dev` - Start dev environment +- `npm run docker:dev` - Start dev environment (alias of `docker:dev:up`) +- `npm run docker:dev:up` - Start dev environment +- `npm run docker:dev:postgres` - Start dev environment with `GRIDPILOT_API_PERSISTENCE=postgres` +- `npm run docker:dev:inmemory` - Start dev environment with `GRIDPILOT_API_PERSISTENCE=inmemory` - `npm run docker:dev:build` - Rebuild and start +- `npm run docker:dev:restart` - Restart services +- `npm run docker:dev:ps` - Show service status - `npm run docker:dev:down` - Stop services - `npm run docker:dev:logs` - View logs - `npm run docker:dev:clean` - Stop and remove volumes diff --git a/adapters/bootstrap/SeedRacingData.ts b/adapters/bootstrap/SeedRacingData.ts index 5d77d223f..21a0fe949 100644 --- a/adapters/bootstrap/SeedRacingData.ts +++ b/adapters/bootstrap/SeedRacingData.ts @@ -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); diff --git a/adapters/bootstrap/racing/RacingDriverFactory.ts b/adapters/bootstrap/racing/RacingDriverFactory.ts index db42df0f4..3c79f2e6d 100644 --- a/adapters/bootstrap/racing/RacingDriverFactory.ts +++ b/adapters/bootstrap/racing/RacingDriverFactory.ts @@ -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), diff --git a/adapters/bootstrap/racing/RacingFeedFactory.ts b/adapters/bootstrap/racing/RacingFeedFactory.ts index a7b3b0d61..1de034c5f 100644 --- a/adapters/bootstrap/racing/RacingFeedFactory.ts +++ b/adapters/bootstrap/racing/RacingFeedFactory.ts @@ -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, diff --git a/adapters/bootstrap/racing/RacingLeagueFactory.ts b/adapters/bootstrap/racing/RacingLeagueFactory.ts index b86e91216..9073a1040 100644 --- a/adapters/bootstrap/racing/RacingLeagueFactory.ts +++ b/adapters/bootstrap/racing/RacingLeagueFactory.ts @@ -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(), diff --git a/adapters/bootstrap/racing/RacingLeagueWalletFactory.ts b/adapters/bootstrap/racing/RacingLeagueWalletFactory.ts index b8f7f9c65..99187635d 100644 --- a/adapters/bootstrap/racing/RacingLeagueWalletFactory.ts +++ b/adapters/bootstrap/racing/RacingLeagueWalletFactory.ts @@ -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); diff --git a/adapters/bootstrap/racing/RacingMembershipFactory.ts b/adapters/bootstrap/racing/RacingMembershipFactory.ts index 6750fc7ed..55c38875c 100644 --- a/adapters/bootstrap/racing/RacingMembershipFactory.ts +++ b/adapters/bootstrap/racing/RacingMembershipFactory.ts @@ -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), }), ); } diff --git a/adapters/bootstrap/racing/RacingRaceFactory.ts b/adapters/bootstrap/racing/RacingRaceFactory.ts index 942d8640a..8487d661b 100644 --- a/adapters/bootstrap/racing/RacingRaceFactory.ts +++ b/adapters/bootstrap/racing/RacingRaceFactory.ts @@ -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(), diff --git a/adapters/bootstrap/racing/RacingResultFactory.ts b/adapters/bootstrap/racing/RacingResultFactory.ts index 7e7df843c..fc9a1017b 100644 --- a/adapters/bootstrap/racing/RacingResultFactory.ts +++ b/adapters/bootstrap/racing/RacingResultFactory.ts @@ -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, diff --git a/adapters/bootstrap/racing/RacingSeasonSponsorshipFactory.ts b/adapters/bootstrap/racing/RacingSeasonSponsorshipFactory.ts index cacf33191..891c9caf6 100644 --- a/adapters/bootstrap/racing/RacingSeasonSponsorshipFactory.ts +++ b/adapters/bootstrap/racing/RacingSeasonSponsorshipFactory.ts @@ -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, diff --git a/adapters/bootstrap/racing/RacingSeed.ts b/adapters/bootstrap/racing/RacingSeed.ts index 83c396089..c1d6ad5e3 100644 --- a/adapters/bootstrap/racing/RacingSeed.ts +++ b/adapters/bootstrap/racing/RacingSeed.ts @@ -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); diff --git a/adapters/bootstrap/racing/RacingSponsorFactory.ts b/adapters/bootstrap/racing/RacingSponsorFactory.ts index caea40337..4e93af81a 100644 --- a/adapters/bootstrap/racing/RacingSponsorFactory.ts +++ b/adapters/bootstrap/racing/RacingSponsorFactory.ts @@ -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 } : {}), diff --git a/adapters/bootstrap/racing/RacingStewardingFactory.ts b/adapters/bootstrap/racing/RacingStewardingFactory.ts index 5cc99b308..38e80f16d 100644 --- a/adapters/bootstrap/racing/RacingStewardingFactory.ts +++ b/adapters/bootstrap/racing/RacingStewardingFactory.ts @@ -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, diff --git a/adapters/bootstrap/racing/RacingTeamFactory.ts b/adapters/bootstrap/racing/RacingTeamFactory.ts index 01ea381f8..7cd7e1111 100644 --- a/adapters/bootstrap/racing/RacingTeamFactory.ts +++ b/adapters/bootstrap/racing/RacingTeamFactory.ts @@ -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), diff --git a/adapters/bootstrap/racing/SeedIdHelper.test.ts b/adapters/bootstrap/racing/SeedIdHelper.test.ts new file mode 100644 index 000000000..995b9cad5 --- /dev/null +++ b/adapters/bootstrap/racing/SeedIdHelper.test.ts @@ -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()); + }); + }); +}); \ No newline at end of file diff --git a/adapters/bootstrap/racing/SeedIdHelper.ts b/adapters/bootstrap/racing/SeedIdHelper.ts new file mode 100644 index 000000000..5c6b912d6 --- /dev/null +++ b/adapters/bootstrap/racing/SeedIdHelper.ts @@ -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); +} \ No newline at end of file diff --git a/package.json b/package.json index 632dd2ac3..5e52ac05e 100644 --- a/package.json +++ b/package.json @@ -79,10 +79,15 @@ "dev": "echo 'Development server placeholder - to be configured'", "lint": "npx eslint apps/api/src --ext .ts,.tsx --max-warnings 0", "docker:dev": "COMPOSE_PARALLEL_LIMIT=1 docker-compose -p gridpilot-dev -f docker-compose.dev.yml up", + "docker:dev:up": "COMPOSE_PARALLEL_LIMIT=1 docker-compose -p gridpilot-dev -f docker-compose.dev.yml up", + "docker:dev:postgres": "sh -lc \"GRIDPILOT_API_PERSISTENCE=postgres npm run docker:dev:up\"", + "docker:dev:inmemory": "sh -lc \"GRIDPILOT_API_PERSISTENCE=inmemory npm run docker:dev:up\"", "docker:dev:build": "COMPOSE_PARALLEL_LIMIT=1 docker-compose -p gridpilot-dev -f docker-compose.dev.yml up --build", - "docker:dev:clean": "docker-compose -p gridpilot-dev -f docker-compose.dev.yml down -v", + "docker:dev:restart": "docker-compose -p gridpilot-dev -f docker-compose.dev.yml restart", + "docker:dev:ps": "docker-compose -p gridpilot-dev -f docker-compose.dev.yml ps", "docker:dev:down": "docker-compose -p gridpilot-dev -f docker-compose.dev.yml down", "docker:dev:logs": "docker-compose -p gridpilot-dev -f docker-compose.dev.yml logs -f", + "docker:dev:clean": "docker-compose -p gridpilot-dev -f docker-compose.dev.yml down -v", "docker:e2e:down": "docker-compose -f docker/docker-compose.e2e.yml down", "docker:e2e:up": "docker-compose -f docker/docker-compose.e2e.yml up -d", "docker:prod": "docker-compose -p gridpilot-prod -f docker-compose.prod.yml up -d",