hensei-web/src/lib/api/mutations/job.mutations.ts
devin-ai-integration[bot] 5764161803
feat: add TanStack Query v6 integration with SSR support (#441)
## Summary

This PR establishes the foundation for migrating from custom Svelte 5
resource classes to TanStack Query v6 for server state management. It
adds:

**Query Options Factories** (in `src/lib/api/queries/`):
- `party.queries.ts` - Party fetching with infinite scroll support
- `job.queries.ts` - Job and skill queries with pagination
- `user.queries.ts` - User profile, parties, and favorites queries

**Mutation Configurations** (in `src/lib/api/mutations/`):
- `party.mutations.ts` - Party CRUD with cache invalidation
- `grid.mutations.ts` - Weapon/character/summon mutations with
optimistic updates
- `job.mutations.ts` - Job and skill update mutations

**Deprecation Notices**:
- Added `@deprecated` JSDoc to `search.resource.svelte.ts` and
`party.resource.svelte.ts` with migration examples

**SSR Integration** (Phase 4):
- Created `+layout.ts` to initialize QueryClient for SSR support
- Updated `+layout.svelte` to receive QueryClient from load function
- Added SSR utilities in `src/lib/query/ssr.ts`:
  - `withInitialData()` - for pages using +page.server.ts
- `prefetchQuery()` / `prefetchInfiniteQuery()` - for pages using
+page.ts
  - `setQueryData()` - for direct cache population
- Added documentation in `src/lib/query/README.md`

**Component Wiring Examples** (Phase 5):
- `JobSelectionSidebar.svelte` - Migrated from `createJobResource()` to
`createQuery(() => jobQueries.list())`. Demonstrates client-side query
pattern with automatic loading/error states.
- `teams/[id]/+page.svelte` - Added `withInitialData()` pattern for SSR
integration. Server-fetched party data is used as initial cache value
with background refetching support.

**Migration Guide**:
- Added `src/lib/query/MIGRATION.md` with follow-up prompts for
remaining component migrations (JobSkillSelectionSidebar, search modal,
user profile, teams explore, Party mutations, resource class removal)

## Updates Since Last Revision

Fixed TypeScript type errors in the TanStack Query integration:
- `party.queries.ts`: Made `total` and `perPage` optional in
`PartyPageResult` interface to match adapter return type
- `ssr.ts`: Fixed `withInitialData` to properly handle null values using
`NonNullable<TData>` return type
- `job.mutations.ts`: Fixed slot indexing by casting through `unknown`
to `keyof typeof updatedSkills`

Type checks now pass for all files modified in this PR (16 remaining
errors are pre-existing project issues unrelated to this PR - paraglide
modules not generated, hooks.ts implicit anys).

## Review & Testing Checklist for Human

- [ ] **Verify app loads correctly**: The `+layout.ts` and
`+layout.svelte` changes are critical path - confirm the app still
renders
- [ ] **Test JobSelectionSidebar**: Open job selection sidebar and
verify jobs load correctly, search/filter works, and retry button works
on error
- [ ] **Test teams/[id] page**: Navigate to a party detail page and
verify it renders without loading flash (SSR data should be immediate)
- [ ] **Review type casts**: Check `job.mutations.ts:135` - the `as
unknown as keyof typeof` cast for slot indexing is a workaround for
jobSkills having string literal keys ('0', '1', '2', '3') while slot is
a number
- [ ] **Verify withInitialData behavior**: The `NonNullable<TData>`
return type change in `ssr.ts` should work correctly with `data.party`
which can be `Party | null`

**Recommended test plan**: 
1. Run `pnpm install` to ensure dependencies are up to date
2. Start dev server and verify the app loads without errors
3. Navigate to a party detail page (`/teams/[shortcode]`) - should
render immediately without loading state
4. Open job selection sidebar (click job icon on a party you can edit) -
verify jobs load and filtering works
5. Test error handling by temporarily breaking network - verify retry
button appears

### Notes


- Pre-existing project issues remain (paraglide modules not generated,
hooks.ts implicit anys) - these are unrelated to this PR
- Local testing could not run due to missing node_modules (vite not
found) - project setup issue
- TanStack Query devtools installation was skipped due to Storybook
version conflicts
- The existing `search.queries.ts` file was used as the pattern
reference for new query factories
- SSR approach uses hybrid pattern: existing `+page.server.ts` files
work with `withInitialData()`, while new pages can use `prefetchQuery()`
in `+page.ts`
- Migration guide includes 6 follow-up prompts for completing the
remaining component migrations

**Link to Devin run**:
https://app.devin.ai/sessions/33e97a98ae3e415aa4dc35378cad3a2b
**Requested by**: Justin Edmund (justin@jedmund.com) / @jedmund

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Co-authored-by: Justin Edmund <justin@jedmund.com>
2025-11-29 00:36:59 -08:00

184 lines
5.2 KiB
TypeScript

/**
* Job Mutation Configurations
*
* Provides mutation configurations for job-related operations
* with cache invalidation using TanStack Query v6.
*
* @module api/mutations/job
*/
import { useQueryClient, createMutation } from '@tanstack/svelte-query'
import { partyAdapter } from '$lib/api/adapters/party.adapter'
import { partyKeys } from '$lib/api/queries/party.queries'
import type { Party } from '$lib/types/api/party'
/**
* Update party job mutation
*
* Updates the job for a party with optimistic updates.
*
* @example
* ```svelte
* <script lang="ts">
* import { useUpdatePartyJob } from '$lib/api/mutations/job.mutations'
*
* const updateJob = useUpdatePartyJob()
*
* function handleJobSelect(jobId: string) {
* updateJob.mutate({ shortcode: 'abc123', jobId })
* }
* </script>
* ```
*/
export function useUpdatePartyJob() {
const queryClient = useQueryClient()
return createMutation(() => ({
mutationFn: ({ shortcode, jobId }: { shortcode: string; jobId: string }) =>
partyAdapter.updateJob(shortcode, jobId),
onMutate: async ({ shortcode, jobId }) => {
await queryClient.cancelQueries({ queryKey: partyKeys.detail(shortcode) })
const previousParty = queryClient.getQueryData<Party>(partyKeys.detail(shortcode))
// Optimistically update the job ID
// Note: We don't have the full job object here, so we just update the ID
// The full job will be fetched when the query is invalidated
if (previousParty) {
queryClient.setQueryData(partyKeys.detail(shortcode), {
...previousParty,
job: previousParty.job ? { ...previousParty.job, id: jobId } : undefined
})
}
return { previousParty }
},
onError: (_err, { shortcode }, context) => {
if (context?.previousParty) {
queryClient.setQueryData(partyKeys.detail(shortcode), context.previousParty)
}
},
onSettled: (_data, _err, { shortcode }) => {
queryClient.invalidateQueries({ queryKey: partyKeys.detail(shortcode) })
}
}))
}
/**
* Update party job skills mutation
*
* Updates the job skills for a party.
*
* @example
* ```svelte
* <script lang="ts">
* import { useUpdatePartyJobSkills } from '$lib/api/mutations/job.mutations'
*
* const updateSkills = useUpdatePartyJobSkills()
*
* function handleSkillsUpdate(skills: Array<{ id: string; slot: number }>) {
* updateSkills.mutate({ shortcode: 'abc123', skills })
* }
* </script>
* ```
*/
export function useUpdatePartyJobSkills() {
const queryClient = useQueryClient()
return createMutation(() => ({
mutationFn: ({
shortcode,
skills
}: {
shortcode: string
skills: Array<{ id: string; slot: number }>
}) => partyAdapter.updateJobSkills(shortcode, skills),
onSuccess: (_data, { shortcode }) => {
queryClient.invalidateQueries({ queryKey: partyKeys.detail(shortcode) })
}
}))
}
/**
* Remove party job skill mutation
*
* Removes a job skill from a party.
*
* @example
* ```svelte
* <script lang="ts">
* import { useRemovePartyJobSkill } from '$lib/api/mutations/job.mutations'
*
* const removeSkill = useRemovePartyJobSkill()
*
* function handleRemoveSkill(slot: number) {
* removeSkill.mutate({ shortcode: 'abc123', slot })
* }
* </script>
* ```
*/
export function useRemovePartyJobSkill() {
const queryClient = useQueryClient()
return createMutation(() => ({
mutationFn: ({ shortcode, slot }: { shortcode: string; slot: number }) =>
partyAdapter.removeJobSkill(shortcode, slot),
onMutate: async ({ shortcode, slot }) => {
await queryClient.cancelQueries({ queryKey: partyKeys.detail(shortcode) })
const previousParty = queryClient.getQueryData<Party>(partyKeys.detail(shortcode))
// Optimistically remove the skill from the slot
// Convert slot number to string key to match jobSkills type (0-3)
if (previousParty?.jobSkills) {
const updatedSkills = { ...previousParty.jobSkills }
const key = String(slot) as unknown as keyof typeof updatedSkills
delete updatedSkills[key]
queryClient.setQueryData(partyKeys.detail(shortcode), {
...previousParty,
jobSkills: updatedSkills
})
}
return { previousParty }
},
onError: (_err, { shortcode }, context) => {
if (context?.previousParty) {
queryClient.setQueryData(partyKeys.detail(shortcode), context.previousParty)
}
},
onSettled: (_data, _err, { shortcode }) => {
queryClient.invalidateQueries({ queryKey: partyKeys.detail(shortcode) })
}
}))
}
/**
* Update party accessory mutation
*
* Updates the accessory for a party.
*
* @example
* ```svelte
* <script lang="ts">
* import { useUpdatePartyAccessory } from '$lib/api/mutations/job.mutations'
*
* const updateAccessory = useUpdatePartyAccessory()
*
* function handleAccessorySelect(accessoryId: string) {
* updateAccessory.mutate({ shortcode: 'abc123', accessoryId })
* }
* </script>
* ```
*/
export function useUpdatePartyAccessory() {
const queryClient = useQueryClient()
return createMutation(() => ({
mutationFn: ({ shortcode, accessoryId }: { shortcode: string; accessoryId: string }) =>
partyAdapter.updateAccessory(shortcode, accessoryId),
onSuccess: (_data, { shortcode }) => {
queryClient.invalidateQueries({ queryKey: partyKeys.detail(shortcode) })
}
}))
}