# Payload And Serialization Refactor Plan ## Objective Reduce the risk of future Blazor Server circuit disconnects by shrinking payloads, removing unnecessary serialization hops, and making live refreshes more granular. The most expensive transport pattern is: - browser `fetch` - JSON parse in JavaScript - JS interop result marshalled over the Blazor circuit - JSON deserialization in .NET That path means every read model still competes with the SignalR hub message ceiling and pays serialization cost twice. ## Current Baseline - `GET /api/campaigns`: about `222 B` - `GET /api/campaigns/{id}`: about `1.0 KB` - `GET /api/characters/{id}/sheet`: about `11.3 KB` - `GET /api/campaigns/{id}/log`: about `13.8 KB` - Workspace refresh currently reloads roster, selected character sheet, and log together when the state SSE reports a version change. - The API client still uses JS interop for all reads and writes through `rpgRollerApi.request`. ## Target Outcomes - Keep normal interactive responses well below the default Blazor circuit limit without depending on hub-size increases. - Eliminate double JSON handling for the workspace read path. - Avoid retransmitting unchanged roster, sheet, and log data after every state change. - Establish payload and allocation guardrails so regressions are detected in tests. ## Recommended Delivery Order 1. Remove JS interop from workspace API reads. 2. Split live refresh into change-specific reloads. 3. Make campaign log loading incremental instead of retransmitting the latest 100 entries. 4. Trim DTO shape and serialization overhead. 5. Add measurement, tests, and payload budgets. ## Phase 1: Remove The JS Interop API Bottleneck ### Goal Move workspace data reads off the `fetch -> JS -> SignalR -> .NET` path. ### Recommendation Introduce a server-side workspace query facade and call it directly from Blazor components instead of routing workspace reads through `RpgRollerApiClient`. ### Why This First - Highest impact on serialization overhead. - Removes the hub-size ceiling from normal workspace query results. - Simplifies error handling and reduces duplicate parsing logic. ### Implementation Tasks - Add a scoped server-side query service for the authenticated workspace. - Resolve the session token from the current `HttpContext` or a dedicated session abstraction. - Move these read flows from `RpgRollerApiClient` to the query service: - `GetMe` - `GetCampaigns` - `GetCharacterCampaignOptions` - `GetCampaign` - `GetCharacterSheet` - `GetCampaignLog` Keep browser JS interop only for browser-only concerns: - session storage - SSE wiring - DOM scrolling helpers - Keep HTTP API endpoints for external callers and integration tests. - Leave mutation endpoints in place initially, then decide whether mutations should also move server-side or stay as HTTP calls. ### File Areas - `RpgRoller/Components/RpgRollerApiClient.cs` - `RpgRoller/Components/Pages/Workspace.razor.cs` - `RpgRoller/Api/SessionTokenHttpContextExtensions.cs` - new workspace query service under `RpgRoller/Components` or `RpgRoller/Services` ### Acceptance Criteria - Workspace reads no longer call `rpgRollerApi.request`. - Opening play and management screens does not depend on JS interop payload size. - Existing API integration tests still pass. ## Phase 2: Replace Full Scope Refreshes With Targeted Refreshes ### Goal Stop reloading roster, selected sheet, and log together for every state change. ### Recommendation Replace the single campaign version event with typed change notifications or multiple independent versions. ### Options - Preferred: emit typed SSE events such as `roster-changed`, `character-sheet-changed`, and `log-appended`. - Acceptable: keep one event stream but include separate version counters for roster, character state, and log state. ### Implementation Tasks - Extend the server-side state event model to expose change categories. - Update mutation paths in `GameService` to mark the relevant change areas. - Update `Workspace.razor.cs` so the handler refreshes only the affected slice: - roster changes reload `CampaignRoster` - skill and group changes reload `CharacterSheet` - roll events append or refresh only the log - avoid reloading the selected character sheet when another character changes - avoid reloading the log when only roster metadata changes ### File Areas - `RpgRoller/Api/StateEventEndpoints.cs` - `RpgRoller/Services/GameService.cs` - `RpgRoller/Components/Pages/Workspace.razor.cs` - `RpgRoller/wwwroot/js/rpgroller-api.js` ### Acceptance Criteria - A new roll does not trigger a roster reload. - Renaming a character does not trigger a log reload unless log labels depend on that mutation. - State refresh traffic is materially lower in browser and server traces. ## Phase 3: Make Campaign Log Loading Incremental ### Goal Stop retransmitting the same log entries after every roll. ### Recommendation Add incremental log APIs and append on the client. ### Implementation Tasks - Add query parameters such as: - `afterRollId` - `sinceTimestamp` - `limit` - retain an initial bounded load for first render - add an incremental mode for live updates - keep server ordering stable and deterministic - update the workspace to append new entries instead of replacing the whole log - trim old entries client-side to a fixed window - preserve the visibility rules for GM, owner, and observers ### Contract Changes - Introduce a dedicated log page result: - entries - cursor or last seen roll id - optional `hasMore` ### Acceptance Criteria - A new roll causes only the new log entry or entries to cross the wire. - Reconnect can rebuild log state without downloading unnecessary history. - Existing visibility behavior remains unchanged. ## Phase 4: Split Log Summary From Log Detail ### Goal Reduce the size of the hottest payload even further. ### Recommendation Do not send full dice arrays and long breakdown strings for every log row by default. ### Implementation Tasks - Introduce `CampaignLogListEntry` for the list view. - Keep only fields needed to render the collapsed row: - roll id - roller label - skill label - character label - result - visibility - timestamp - compact summary text - add `GET /api/rolls/{rollId}` or equivalent detail lookup for expanded inspection - update the log UI to lazy-load detail when a row is expanded ### Expected Benefit This should cut the log list payload materially because `Dice` and `Breakdown` are currently repeated for every row and are the least compressible fields in the list. ## Phase 5: Trim DTO Shape To Match The View ### Goal Remove repeated fields that are not needed by the consuming UI. ### Recommendations - Replace `CampaignSummary.Gm` and `CampaignRoster.Gm` full `UserSummary` usage with a slimmer campaign GM DTO if the UI only needs `Id` and `DisplayName`. - Remove parent-scope identifiers from child records where the endpoint already provides that scope. - candidate examples: - `CampaignLogEntry.CampaignId` - `SkillSummary.CharacterId` inside `CharacterSheet` - `SkillGroupSummary.CharacterId` inside `CharacterSheet` - review whether owner ids are needed in all list views or whether some can be replaced with display labels and booleans ### Guardrail Do not over-optimize DTOs until the consuming components have been made explicit. Only remove a field after all consumers are verified. ## Phase 6: Reduce Serializer CPU And Allocation Overhead ### Goal Lower per-request CPU and allocation cost after the major transport fixes are in place. ### Recommendations - Introduce source-generated `System.Text.Json` contexts for the hot contracts. - Reuse serializer options consistently rather than relying on repeated default metadata discovery. - Review whether any list contracts can be exposed as arrays end-to-end to reduce intermediate allocations. - If HTTP remains in the path for some calls, ensure response compression is enabled for normal API responses to reduce browser transfer cost. ### Note This phase is worthwhile, but it should follow the transport refactor. Serializer tuning alone will not solve circuit-size problems. ## Phase 7: Add Payload Budgets And Regression Tests ### Goal Prevent a future regression from silently reintroducing oversized read models. ### Implementation Tasks - Add integration tests that serialize representative contracts and assert upper bounds. - Add service or API tests for log pagination and incremental fetch semantics. - Add workspace tests for targeted refresh behavior. - Add a small benchmark or diagnostic test for hot payload serialization if practical. - Document soft payload budgets for any remaining JS interop responses. ### Suggested Budgets - Any remaining JS interop response: prefer under `16 KB` - initial character sheet response: target under `12 KB` - initial log list response: target under `8 KB` after summary/detail split - incremental live update response: target under `2 KB` ## Delivery Notes - Do not raise the Blazor hub message limit again as the primary fix. - Keep the existing HTTP API stable where possible so tests and external tooling do not break. - Prefer introducing new, view-specific contracts instead of reusing broad aggregate models. - Measure payload size with representative admin and non-admin datasets after each phase. ## Proposed Milestones ### Milestone A Move workspace reads off JS interop and keep behavior unchanged. ### Milestone B Introduce targeted SSE-driven refreshes without yet changing log contract shape. ### Milestone C Add incremental log loading and client append behavior. ### Milestone D Split log summary from log detail and trim DTOs. ### Milestone E Add serializer optimizations, payload budget tests, and final documentation updates. ## Definition Of Done - Workspace read models are no longer limited by Blazor JS interop payload size. - Live updates no longer reload unrelated slices. - The campaign log is loaded incrementally. - The hottest contracts are explicitly sized for their views. - Payload budgets are enforced by tests. - The default Blazor hub receive limit remains unchanged.