## 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>
447 lines
13 KiB
TypeScript
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)
|