racing typeorm

This commit is contained in:
2025-12-29 00:24:56 +01:00
parent 2f6657f56d
commit 9e17d0752a
55 changed files with 3528 additions and 22 deletions

View File

@@ -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 (whats 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 modules 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 doesnt run in production (align with `NODE_ENV` usage in [`DatabaseModule`](apps/api/src/domain/database/DatabaseModule.ts:18)).

View File

@@ -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 repos 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 Leagues 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?

View File

@@ -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 arent 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 cant 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 dont 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 (23 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)