hensei-web/src/lib/api/adapters/grid.adapter.ts
Justin Edmund f457343e26
Complete TanStack Query v6 migration (#445)
## Overview

Complete migration from service layer to TanStack Query v6 with
mutations, queries, and automatic cache management.

## What Was Completed

### Phase 1: Entity Queries & Database Pages 
- Created `entity.queries.ts` with query options for weapons,
characters, and summons
- Migrated all database detail pages to use TanStack Query with
`withInitialData()` pattern
- SSR with client-side hydration working correctly

### Phase 2: Server Load Cleanup 
- Removed PartyService dependency from teams detail server load
- Server loads now use adapters directly instead of service layer
- Cleaner separation of concerns

### Phase 3: Party.svelte Refactoring 
- Removed all PartyService, GridService, and ConflictService
dependencies
- Migrated to TanStack Query mutations for all operations:
  - Grid operations: create, update, delete, swap, move
  - Party operations: update, delete, remix, favorite, unfavorite
- Added swap/move mutations for drag-and-drop operations
- Automatic cache invalidation and query refetching

### Phase 4: Service Layer Removal 
- Deleted all service layer files (~1,345 lines removed):
  - `party.service.ts` (620 lines)
  - `grid.service.ts` (450 lines)
  - `conflict.service.ts` (120 lines)
  - `gridOperations.ts` (unused utility)
- Deleted empty `services/` directory
- Created utility functions for cross-cutting concerns:
  - `localId.ts`: Anonymous user local ID management
  - `editKeys.ts`: Edit key management for anonymous editing
  - `party-context.ts`: Extracted PartyContext type

### Phase 5: /teams/new Migration 
- Migrated party creation wizard to use TanStack Query mutations
- Replaced all direct adapter calls with mutations (7 locations)
- Maintains existing behavior and flow
- Automatic cache invalidation for newly created parties

## Benefits

### Performance
- Automatic request deduplication
- Better cache utilization across pages
- Background refetching for fresh data
- Optimistic updates for instant UI feedback

### Developer Experience
- Single source of truth for data fetching
- Consistent patterns across entire app
- Query devtools for debugging
- Less boilerplate code

### Code Quality
- ~1,088 net lines removed
- Simpler mental model (no service layer)
- Better TypeScript inference
- Easier to test

### Architecture
- **100% TanStack Query coverage** - no service layer, no direct adapter
calls
- Clear separation: UI ← Queries/Mutations ← Adapters ← API
- Automatic cache management
- Consistent mutation patterns everywhere

## Testing

All features verified:
- Party creation (anonymous & authenticated)
- Grid operations (add, remove, update, swap, move)
- Party operations (update, delete, remix, favorite)
- Cache invalidation across tabs
- Error handling and rollback
- SSR with hydration

## Files Modified

### Created (3)
- `src/lib/types/party-context.ts`
- `src/lib/utils/editKeys.ts`
- `src/lib/utils/localId.ts`

### Deleted (4)
- `src/lib/services/party.service.ts`
- `src/lib/services/grid.service.ts`
- `src/lib/services/conflict.service.ts`
- `src/lib/utils/gridOperations.ts`

### Modified (13)
- `src/lib/api/mutations/grid.mutations.ts`
- `src/lib/components/grids/CharacterGrid.svelte`
- `src/lib/components/grids/SummonGrid.svelte`
- `src/lib/components/grids/WeaponGrid.svelte`
- `src/lib/components/party/Party.svelte`
- `src/routes/teams/new/+page.svelte`
- Database entity pages (characters, weapons, summons)
- Other supporting files

## Migration Complete

This PR completes the TanStack Query migration. The entire application
now uses TanStack Query v6 for all data fetching and mutations, with
zero remaining service layer code.

---------

Co-authored-by: Claude <noreply@anthropic.com>
2025-11-29 22:32:15 -08:00

447 lines
13 KiB
TypeScript

/**
* Grid Adapter
*
* Handles all grid item operations including CRUD, positioning, and uncap management.
* This adapter manages user instances of weapons, characters, and summons within parties.
*
* @module adapters/grid
*/
import { BaseAdapter } from './base.adapter'
import type { AdapterOptions } from './types'
import type { GridWeapon, GridCharacter, GridSummon } from '$lib/types/api/party'
import { DEFAULT_ADAPTER_CONFIG } from './config'
import { validateGridWeapon, validateGridCharacter, validateGridSummon } from '$lib/utils/gridValidation'
// GridWeapon, GridCharacter, and GridSummon types are imported from types/api/party
// Re-export for test files and consumers
export type { GridWeapon, GridCharacter, GridSummon }
/**
* Parameters for creating grid items
*/
export interface CreateGridWeaponParams {
partyId: string
weaponId: string
position: number
mainhand?: boolean | undefined
uncapLevel?: number | undefined
transcendenceStep?: number | undefined
}
export interface CreateGridCharacterParams {
partyId: string
characterId: string
position: number
uncapLevel?: number | undefined
transcendenceStep?: number | undefined
}
export interface CreateGridSummonParams {
partyId: string
summonId: string
position: number
main?: boolean | undefined
friend?: boolean | undefined
quickSummon?: boolean | undefined
uncapLevel?: number | undefined
transcendenceStep?: number | undefined
}
/**
* Parameters for updating uncap levels
*/
export interface UpdateUncapParams {
id?: string | undefined
partyId: string
position?: number | undefined
uncapLevel: number
transcendenceStep?: number | undefined
}
/**
* Parameters for updating positions
*/
export interface UpdatePositionParams {
partyId: string
id: string
position: number
container?: string
}
/**
* Parameters for swapping positions
*/
export interface SwapPositionsParams {
partyId: string
sourceId: string
targetId: string
}
/**
* Conflict resolution parameters
*/
export interface ResolveConflictParams {
partyId: string
incomingId: string
position: number
conflictingIds: string[]
}
/**
* Grid adapter for managing user's grid item instances
*/
export class GridAdapter extends BaseAdapter {
// Weapon operations
/**
* Creates a new grid weapon instance
*/
async createWeapon(params: CreateGridWeaponParams, headers?: Record<string, string>): Promise<GridWeapon> {
const response = await this.request<{ gridWeapon: GridWeapon }>('/grid_weapons', {
method: 'POST',
body: { weapon: params },
headers
})
// Validate and normalize response
const validated = validateGridWeapon(response.gridWeapon)
if (!validated) {
throw new Error('API returned incomplete GridWeapon data')
}
return validated
}
/**
* Updates a grid weapon instance
*/
async updateWeapon(id: string, params: Partial<GridWeapon>, headers?: Record<string, string>): Promise<GridWeapon> {
const response = await this.request<{ gridWeapon: GridWeapon }>(`/grid_weapons/${id}`, {
method: 'PUT',
body: { weapon: params },
headers
})
return response.gridWeapon
}
/**
* Deletes a grid weapon instance
*/
async deleteWeapon(params: { id?: string; partyId: string; position?: number }, headers?: Record<string, string>): Promise<void> {
// If we have an ID, use it in the URL (standard Rails REST)
if (params.id) {
return this.request<void>(`/grid_weapons/${params.id}`, {
method: 'DELETE',
headers
})
}
// Otherwise, send params in body for position-based delete
return this.request<void>('/grid_weapons/delete_by_position', {
method: 'DELETE',
body: params,
headers
})
}
/**
* Updates weapon uncap level
*/
async updateWeaponUncap(params: UpdateUncapParams, headers?: Record<string, string>): Promise<GridWeapon> {
const response = await this.request<{ gridWeapon: GridWeapon }>('/grid_weapons/update_uncap', {
method: 'POST',
body: {
weapon: {
id: params.id,
partyId: params.partyId,
uncapLevel: params.uncapLevel,
transcendenceStep: params.transcendenceStep
}
},
headers
})
return response.gridWeapon
}
/**
* Resolves weapon conflicts
*/
async resolveWeaponConflict(params: ResolveConflictParams, headers?: Record<string, string>): Promise<GridWeapon> {
const response = await this.request<{ gridWeapon: GridWeapon }>('/grid_weapons/resolve', {
method: 'POST',
body: { resolve: params },
headers
})
return response.gridWeapon
}
/**
* Updates weapon position
*/
async updateWeaponPosition(params: UpdatePositionParams, headers?: Record<string, string>): Promise<GridWeapon> {
const { id, position, container, partyId } = params
const response = await this.request<{ gridWeapon: GridWeapon }>(`/parties/${partyId}/grid_weapons/${id}/position`, {
method: 'PUT',
body: { position, container },
headers
})
return response.gridWeapon
}
/**
* Swaps two weapon positions
*/
async swapWeapons(params: SwapPositionsParams, headers?: Record<string, string>): Promise<{
source: GridWeapon
target: GridWeapon
}> {
const { partyId, sourceId, targetId } = params
return this.request(`/parties/${partyId}/grid_weapons/swap`, {
method: 'POST',
body: { source_id: sourceId, target_id: targetId },
headers
})
}
// Character operations
/**
* Creates a new grid character instance
*/
async createCharacter(params: CreateGridCharacterParams, headers?: Record<string, string>): Promise<GridCharacter> {
const response = await this.request<{ gridCharacter: GridCharacter }>('/grid_characters', {
method: 'POST',
body: { character: params },
headers
})
// Validate and normalize response
const validated = validateGridCharacter(response.gridCharacter)
if (!validated) {
throw new Error('API returned incomplete GridCharacter data')
}
return validated
}
/**
* Updates a grid character instance
*/
async updateCharacter(id: string, params: Partial<GridCharacter>, headers?: Record<string, string>): Promise<GridCharacter> {
const response = await this.request<{ gridCharacter: GridCharacter }>(`/grid_characters/${id}`, {
method: 'PUT',
body: { character: params },
headers
})
return response.gridCharacter
}
/**
* Deletes a grid character instance
*/
async deleteCharacter(params: { id?: string; partyId: string; position?: number }, headers?: Record<string, string>): Promise<void> {
// If we have an ID, use it in the URL (standard Rails REST)
if (params.id) {
return this.request<void>(`/grid_characters/${params.id}`, {
method: 'DELETE',
headers
})
}
// Otherwise, send params in body for position-based delete
return this.request<void>('/grid_characters/delete_by_position', {
method: 'DELETE',
body: params,
headers
})
}
/**
* Updates character uncap level
*/
async updateCharacterUncap(params: UpdateUncapParams, headers?: Record<string, string>): Promise<GridCharacter> {
const response = await this.request<{ gridCharacter: GridCharacter }>('/grid_characters/update_uncap', {
method: 'POST',
body: {
character: {
id: params.id,
partyId: params.partyId,
uncapLevel: params.uncapLevel,
transcendenceStep: params.transcendenceStep
}
},
headers
})
return response.gridCharacter
}
/**
* Resolves character conflicts
*/
async resolveCharacterConflict(params: ResolveConflictParams, headers?: Record<string, string>): Promise<GridCharacter> {
const response = await this.request<{ gridCharacter: GridCharacter }>('/grid_characters/resolve', {
method: 'POST',
body: { resolve: params },
headers
})
return response.gridCharacter
}
/**
* Updates character position
*/
async updateCharacterPosition(params: UpdatePositionParams, headers?: Record<string, string>): Promise<GridCharacter> {
const { id, position, container, partyId } = params
const response = await this.request<{ gridCharacter: GridCharacter }>(`/parties/${partyId}/grid_characters/${id}/position`, {
method: 'PUT',
body: { position, container },
headers
})
return response.gridCharacter
}
/**
* Swaps two character positions
*/
async swapCharacters(params: SwapPositionsParams, headers?: Record<string, string>): Promise<{
source: GridCharacter
target: GridCharacter
}> {
const { partyId, sourceId, targetId } = params
return this.request(`/parties/${partyId}/grid_characters/swap`, {
method: 'POST',
body: { source_id: sourceId, target_id: targetId },
headers
})
}
// Summon operations
/**
* Creates a new grid summon instance
*/
async createSummon(params: CreateGridSummonParams, headers?: Record<string, string>): Promise<GridSummon> {
const response = await this.request<{ gridSummon: GridSummon }>('/grid_summons', {
method: 'POST',
body: { summon: params },
headers
})
// Validate and normalize response
const validated = validateGridSummon(response.gridSummon)
if (!validated) {
throw new Error('API returned incomplete GridSummon data')
}
return validated
}
/**
* Updates a grid summon instance
*/
async updateSummon(id: string, params: Partial<GridSummon>, headers?: Record<string, string>): Promise<GridSummon> {
const response = await this.request<{ gridSummon: GridSummon }>(`/grid_summons/${id}`, {
method: 'PUT',
body: { summon: params },
headers
})
return response.gridSummon
}
/**
* Deletes a grid summon instance
*/
async deleteSummon(params: { id?: string; partyId: string; position?: number }, headers?: Record<string, string>): Promise<void> {
// If we have an ID, use it in the URL (standard Rails REST)
if (params.id) {
return this.request<void>(`/grid_summons/${params.id}`, {
method: 'DELETE',
headers
})
}
// Otherwise, send params in body for position-based delete
return this.request<void>('/grid_summons/delete_by_position', {
method: 'DELETE',
body: params,
headers
})
}
/**
* Updates summon uncap level
*/
async updateSummonUncap(params: UpdateUncapParams, headers?: Record<string, string>): Promise<GridSummon> {
const response = await this.request<{ gridSummon: GridSummon }>('/grid_summons/update_uncap', {
method: 'POST',
body: {
summon: {
id: params.id,
partyId: params.partyId,
uncapLevel: params.uncapLevel,
transcendenceStep: params.transcendenceStep
}
},
headers
})
return response.gridSummon
}
/**
* Updates summon quick summon setting
*/
async updateQuickSummon(params: {
id?: string
partyId: string
position?: number
quickSummon: boolean
}): Promise<GridSummon> {
return this.request<GridSummon>('/grid_summons/update_quick_summon', {
method: 'POST',
body: params
})
}
/**
* Updates summon position
*/
async updateSummonPosition(params: UpdatePositionParams, headers?: Record<string, string>): Promise<GridSummon> {
const { id, position, container, partyId } = params
const response = await this.request<{ gridSummon: GridSummon }>(`/parties/${partyId}/grid_summons/${id}/position`, {
method: 'PUT',
body: { position, container },
headers
})
return response.gridSummon
}
/**
* Swaps two summon positions
*/
async swapSummons(params: SwapPositionsParams, headers?: Record<string, string>): Promise<{
source: GridSummon
target: GridSummon
}> {
const { partyId, sourceId, targetId } = params
return this.request(`/parties/${partyId}/grid_summons/swap`, {
method: 'POST',
body: { source_id: sourceId, target_id: targetId },
headers
})
}
/**
* Clears grid-specific cache
*/
clearGridCache(partyId?: string) {
if (partyId) {
this.clearCache(`/parties/${partyId}/grid`)
} else {
this.clearCache('/grid')
}
}
}
/**
* Default grid adapter instance
*/
export const gridAdapter = new GridAdapter(DEFAULT_ADAPTER_CONFIG)