Rehydration functions (reconstructing typed domain objects from untyped JSON) lived in persistence adapters, duplicating domain validation. Adding a field required updating both the domain type and a separate adapter function — the adapter was missed for `level`, silently dropping it on reload. Now adding a field only requires updating the domain type and its co-located rehydration function. - Add `rehydratePlayerCharacter` and `rehydrateCombatant` to domain - Persistence adapters delegate to domain instead of reimplementing - Add `tempHp` validation (was silently dropped during rehydration) - Tighten initiative validation to integer-only - Exhaustive domain tests (53 cases); adapter tests slimmed to round-trip - Remove stale `jsinspect-plus` Knip ignoreDependencies entry Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
14 KiB
date, git_commit, branch, topic, tags, status
| date | git_commit | branch | topic | tags | status | |||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|
| 2026-03-28T01:35:07.925247+00:00 | f4fb69dbc7 |
main | Entity rehydration: current implementation and migration surface |
|
complete |
Research: Entity Rehydration — Current Implementation and Migration Surface
Research Question
Map all entity rehydration logic (reconstructing typed domain objects from untyped JSON) across the codebase. Document what validation each rehydration function performs, where it lives, how functions cross-reference each other, and what the domain layer already provides that could replace adapter-level validation. This research supports Issue #20: Move entity rehydration to domain layer.
Summary
Entity rehydration currently lives entirely in apps/web/src/persistence/. Two primary rehydration functions exist:
rehydrateCharacterinplayer-character-storage.ts— validates and reconstructsPlayerCharacterfrom unknown JSONrehydrateCombatant(private) +rehydrateEncounter(exported) inencounter-storage.ts— validates and reconstructsCombatant/Encounterfrom unknown JSON
These are consumed by three call sites: localStorage loading, undo/redo stack loading, and JSON import validation. The domain layer already contains parallel validation logic in createPlayerCharacter, addCombatant/validateInit, and createEncounter, but the rehydration functions duplicate this validation with subtly different rules (rehydration is lenient/recovering; creation is strict/rejecting).
Detailed Findings
1. PlayerCharacter Rehydration
File: apps/web/src/persistence/player-character-storage.ts:25-65
rehydrateCharacter(raw: unknown): PlayerCharacter | null performs:
| Field | Validation | Behavior on invalid |
|---|---|---|
id |
typeof string, non-empty |
Return null (reject entire PC) |
name |
typeof string, non-empty after trim |
Return null |
ac |
typeof number, integer, >= 0 |
Return null |
maxHp |
typeof number, integer, >= 1 |
Return null |
color |
Optional; if present, must be in VALID_PLAYER_COLORS |
Return null |
icon |
Optional; if present, must be in VALID_PLAYER_ICONS |
Return null |
level |
Optional; if present, must be integer 1-20 | Return null |
Constructs result via branded playerCharacterId() and type assertions for color/icon.
Helper: isValidOptionalMember(value, validSet) — shared check for optional set-membership fields (lines 18-23).
Callers:
loadPlayerCharacters()(same file, line 67) — loads from localStoragerehydrateCharacters()inexport-import.ts:21-30— filters PCs during import validation
2. Combatant Rehydration
File: apps/web/src/persistence/encounter-storage.ts:67-103
rehydrateCombatant(c: unknown) (private, no return type annotation) performs:
| Field | Validation | Behavior on invalid |
|---|---|---|
id |
Cast directly (entry.id as string) |
No validation (relies on isValidCombatantEntry pre-check) |
name |
Cast directly (entry.name as string) |
No validation (relies on pre-check) |
initiative |
typeof number or undefined |
Defaults to undefined |
ac |
Via validateAc: integer >= 0 |
Defaults to undefined |
conditions |
Via validateConditions: array, each in VALID_CONDITION_IDS |
Defaults to undefined |
isConcentrating |
Strictly === true |
Defaults to undefined |
creatureId |
Via validateCreatureId: non-empty string |
Defaults to undefined |
color |
String in VALID_PLAYER_COLORS |
Defaults to undefined |
icon |
String in VALID_PLAYER_ICONS |
Defaults to undefined |
playerCharacterId |
Non-empty string | Defaults to undefined |
maxHp / currentHp |
Via validateHp: maxHp integer >= 1, currentHp integer 0..maxHp |
Defaults to undefined; invalid currentHp falls back to maxHp |
Key difference from PC rehydration: Combatant rehydration is lenient — invalid optional fields are silently dropped rather than rejecting the entire entity. Only id and name are required (checked by isValidCombatantEntry at line 105-109 before rehydrateCombatant is called).
Helper functions (all private):
validateAc(value)— lines 24-28validateConditions(value)— lines 30-37validateCreatureId(value)— lines 39-43validateHp(rawMaxHp, rawCurrentHp)— lines 45-65
3. Encounter Rehydration
File: apps/web/src/persistence/encounter-storage.ts:111-140
rehydrateEncounter(parsed: unknown): Encounter | null validates the encounter envelope:
- Must be a non-null, non-array object
combatantsmust be an arrayactiveIndexmust be a numberroundNumbermust be a number- Empty combatant array → returns hardcoded
{ combatants: [], activeIndex: 0, roundNumber: 1 } - All entries must pass
isValidCombatantEntry(id + name check) - Maps entries through
rehydrateCombatant, then passes to domain'screateEncounterfor invariant enforcement
Callers:
loadEncounter()(same file, line 142) — localStorageloadStack()inundo-redo-storage.ts:17-36— undo/redo stacks from localStoragerehydrateStack()inexport-import.ts:10-19— import validationvalidateImportBundle()inexport-import.ts:32-65— import validation (direct call for the main encounter)
4. Import Bundle Validation
File: apps/web/src/persistence/export-import.ts:32-65
validateImportBundle(data: unknown): ExportBundle | string validates the bundle envelope:
- Version must be
1 exportedAtmust be a stringundoStackandredoStackmust be arraysplayerCharactersmust be an array- Delegates to
rehydrateEncounterfor the encounter - Delegates to
rehydrateStack(which callsrehydrateEncounter) for undo/redo - Delegates to
rehydrateCharacters(which callsrehydrateCharacter) for PCs
This function validates the envelope structure. Entity-level validation is fully delegated.
5. Domain Layer Validation (Existing)
The domain already contains validation for the same fields, but in creation context (typed inputs, DomainError returns):
createPlayerCharacter (packages/domain/src/create-player-character.ts:17-100):
- Same field rules as
rehydrateCharacter: name non-empty, ac >= 0 integer, maxHp >= 1 integer, color/icon in valid sets, level 1-20 - Returns
DomainErroron invalid input (notnull)
validateInit in addCombatant (packages/domain/src/add-combatant.ts:27-53):
- Validates maxHp (positive integer), ac (non-negative integer), initiative (integer)
- Does NOT validate conditions, color, icon, playerCharacterId, creatureId, isConcentrating
createEncounter (packages/domain/src/types.ts:50-71):
- Validates activeIndex bounds and roundNumber (positive integer)
- Already used by
rehydrateEncounteras the final step
editPlayerCharacter (packages/domain/src/edit-player-character.ts):
validateFieldsvalidates the same PC fields for edits
6. Validation Overlap and Gaps
| Field | Rehydration validates | Domain validates |
|---|---|---|
| PC.id | Non-empty string | N/A (caller provides) |
| PC.name | Non-empty string | Non-empty (trimmed) |
| PC.ac | Integer >= 0 | Integer >= 0 |
| PC.maxHp | Integer >= 1 | Integer >= 1 |
| PC.color | In VALID_PLAYER_COLORS | In VALID_PLAYER_COLORS |
| PC.icon | In VALID_PLAYER_ICONS | In VALID_PLAYER_ICONS |
| PC.level | Integer 1-20 | Integer 1-20 |
| Combatant.id | Non-empty string (via pre-check) | N/A (caller provides) |
| Combatant.name | String type (via pre-check) | Non-empty (trimmed) |
| Combatant.initiative | typeof number |
Integer |
| Combatant.ac | Integer >= 0 | Integer >= 0 |
| Combatant.maxHp | Integer >= 1 | Integer >= 1 |
| Combatant.currentHp | Integer 0..maxHp | N/A (set = maxHp on add) |
| Combatant.tempHp | Not validated | N/A |
| Combatant.conditions | Each in VALID_CONDITION_IDS | N/A (toggleCondition checks) |
| Combatant.isConcentrating | Strictly true or dropped |
N/A (toggleConcentration) |
| Combatant.creatureId | Non-empty string | N/A (passed through) |
| Combatant.color | In VALID_PLAYER_COLORS | N/A (passed through) |
| Combatant.icon | In VALID_PLAYER_ICONS | N/A (passed through) |
| Combatant.playerCharacterId | Non-empty string | N/A (passed through) |
Key observations:
- Rehydration validates
id(required for identity); domain creation functions receiveidas a typed parameter - Combatant rehydration does NOT validate
tempHpat all — it's silently passed through or ignored - Combatant rehydration checks
initiativeastypeof numberbut domain checksNumber.isInteger— slightly different strictness - Domain validation for combatant optional fields is scattered across individual mutation functions, not centralized
7. Test Coverage
Persistence tests (adapter layer):
encounter-storage.test.ts— ~27 tests covering round-trip, corrupt data, AC validation, edge casesplayer-character-storage.test.ts— ~17 tests covering round-trip, corrupt data, field validation, level
Import tests (adapter layer):
validate-import-bundle.test.ts— ~21 tests covering envelope validation, graceful recovery, PC filteringexport-import.test.ts— ~15 tests covering bundle assembly, round-trip, filename resolution
Domain tests: No rehydration tests exist in packages/domain/ — all rehydration testing is in the adapter layer.
8. Cross-Reference Map
loadPlayerCharacters() ──→ rehydrateCharacter()
↑
validateImportBundle() ──→ rehydrateCharacters() ──┘
├─→ rehydrateEncounter() ──→ isValidCombatantEntry()
│ ├─→ rehydrateCombatant() ──→ validateAc()
│ │ ├─→ validateConditions()
│ │ ├─→ validateCreatureId()
│ │ └─→ validateHp()
│ └─→ createEncounter() [domain]
└─→ rehydrateStack() ───→ rehydrateEncounter() [same as above]
loadEncounter() ───────→ rehydrateEncounter() [same as above]
loadUndoRedoStacks() ──→ loadStack() ──→ rehydrateEncounter() [same as above]
Code References
apps/web/src/persistence/player-character-storage.ts:25-65—rehydrateCharacter(PC rehydration)apps/web/src/persistence/player-character-storage.ts:18-23—isValidOptionalMemberhelperapps/web/src/persistence/encounter-storage.ts:24-28—validateAchelperapps/web/src/persistence/encounter-storage.ts:30-37—validateConditionshelperapps/web/src/persistence/encounter-storage.ts:39-43—validateCreatureIdhelperapps/web/src/persistence/encounter-storage.ts:45-65—validateHphelperapps/web/src/persistence/encounter-storage.ts:67-103—rehydrateCombatant(combatant rehydration)apps/web/src/persistence/encounter-storage.ts:105-109—isValidCombatantEntry(pre-check)apps/web/src/persistence/encounter-storage.ts:111-140—rehydrateEncounter(encounter envelope rehydration)apps/web/src/persistence/export-import.ts:10-30—rehydrateStack/rehydrateCharacters(collection wrappers)apps/web/src/persistence/export-import.ts:32-65—validateImportBundle(import envelope validation)apps/web/src/persistence/undo-redo-storage.ts:17-36—loadStack(undo/redo rehydration)packages/domain/src/create-player-character.ts:17-100— PC creation validationpackages/domain/src/add-combatant.ts:27-53—validateInit(combatant creation validation)packages/domain/src/types.ts:50-71—createEncounter(encounter invariant enforcement)packages/domain/src/types.ts:12-26—Combatanttype definitionpackages/domain/src/player-character-types.ts:70-83—PlayerCharactertype definition
Architecture Documentation
Current pattern
Rehydration is an adapter concern — persistence adapters validate raw JSON and construct typed domain objects. The domain provides creation functions that validate typed inputs for new entities, but no functions for reconstructing entities from untyped serialized data.
Rehydration vs. creation semantics
Rehydration and creation serve different purposes:
- Creation (domain): Validates business rules for new entities. Receives typed parameters. Returns
DomainErroron failure. - Rehydration (adapter): Reconstructs previously valid entities from serialized JSON. Receives
unknown. Returnsnullon failure. May be lenient (combatants drop invalid optional fields) or strict (PCs reject on any invalid field).
Delegation chain
rehydrateEncounter already delegates to createEncounter for encounter-level invariants. The entity-level rehydration functions (rehydrateCharacter, rehydrateCombatant) do NOT delegate to any domain function — they re-implement field validation inline.
tempHp gap
Combatant.tempHp is defined in the domain type but has no validation in the current rehydration code. It appears to be silently included or excluded depending on what rehydrateCombatant constructs (it's not in the explicit field list, so it would be dropped during rehydration).
Open Questions
-
Should
rehydrateCombatantremain lenient (drop invalid optional fields) or become strict likerehydrateCharacter(reject on any invalid field)? The current asymmetry is intentional: combatants can exist with minimal data (just id + name), while PCs always require ac/maxHp. -
Should
tempHpbe validated during rehydration? It's currently missing from combatant rehydration but is a valid field on the type. -
Should
rehydrateEncountermove to domain too, or only the entity-level functions? The issue acceptance criteria says "validateImportBundle and rehydrateEncounter are unchanged" — butrehydrateEncountercurrently lives alongsiderehydrateCombatantand would need to import from domain instead of calling the local function. -
Should
isValidCombatantEntry(the pre-check) be part of the domain rehydration or remain in the adapter? It's currently the gate that ensuresidandnameexist beforerehydrateCombatantis called.