## 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>
218 lines
6.3 KiB
TypeScript
218 lines
6.3 KiB
TypeScript
/**
|
|
* User Query Options Factory
|
|
*
|
|
* Provides type-safe, reusable query configurations for user operations
|
|
* using TanStack Query v6 patterns.
|
|
*
|
|
* @module api/queries/user
|
|
*/
|
|
|
|
import { queryOptions, infiniteQueryOptions } from '@tanstack/svelte-query'
|
|
import { userAdapter, type UserInfo, type UserProfile } from '$lib/api/adapters/user.adapter'
|
|
import type { Party } from '$lib/types/api/party'
|
|
|
|
/**
|
|
* Standard page result format for user parties infinite queries
|
|
*/
|
|
export interface UserPartiesPageResult {
|
|
results: Party[]
|
|
page: number
|
|
totalPages: number
|
|
total: number
|
|
perPage: number
|
|
}
|
|
|
|
/**
|
|
* Standard page result format for favorites infinite queries
|
|
*/
|
|
export interface FavoritesPageResult {
|
|
items: Party[]
|
|
page: number
|
|
totalPages: number
|
|
total: number
|
|
perPage: number
|
|
}
|
|
|
|
/**
|
|
* User query options factory
|
|
*
|
|
* Provides query configurations for all user-related operations.
|
|
* These can be used with `createQuery`, `createInfiniteQuery`, or for prefetching.
|
|
*
|
|
* @example
|
|
* ```typescript
|
|
* import { createQuery, createInfiniteQuery } from '@tanstack/svelte-query'
|
|
* import { userQueries } from '$lib/api/queries/user.queries'
|
|
*
|
|
* // Current user
|
|
* const currentUser = createQuery(() => userQueries.me())
|
|
*
|
|
* // User profile with parties
|
|
* const profile = createQuery(() => userQueries.profile(username))
|
|
*
|
|
* // User's parties with infinite scroll
|
|
* const parties = createInfiniteQuery(() => userQueries.parties(username))
|
|
* ```
|
|
*/
|
|
export const userQueries = {
|
|
/**
|
|
* Current user query options
|
|
*
|
|
* @returns Query options for fetching the current authenticated user
|
|
*/
|
|
me: () =>
|
|
queryOptions({
|
|
queryKey: ['user', 'me'] as const,
|
|
queryFn: () => userAdapter.getCurrentUser(),
|
|
staleTime: 1000 * 60 * 5, // 5 minutes
|
|
gcTime: 1000 * 60 * 30 // 30 minutes
|
|
}),
|
|
|
|
/**
|
|
* User info query options
|
|
*
|
|
* @param username - Username to fetch info for
|
|
* @returns Query options for fetching user info
|
|
*/
|
|
info: (username: string) =>
|
|
queryOptions({
|
|
queryKey: ['user', username, 'info'] as const,
|
|
queryFn: () => userAdapter.getInfo(username),
|
|
enabled: !!username,
|
|
staleTime: 1000 * 60 * 5, // 5 minutes
|
|
gcTime: 1000 * 60 * 30 // 30 minutes
|
|
}),
|
|
|
|
/**
|
|
* User profile query options (includes first page of parties)
|
|
*
|
|
* @param username - Username to fetch profile for
|
|
* @returns Query options for fetching user profile
|
|
*/
|
|
profile: (username: string) =>
|
|
queryOptions({
|
|
queryKey: ['user', username, 'profile'] as const,
|
|
queryFn: () => userAdapter.getProfile(username),
|
|
enabled: !!username,
|
|
staleTime: 1000 * 60 * 2, // 2 minutes - profile data changes
|
|
gcTime: 1000 * 60 * 15 // 15 minutes
|
|
}),
|
|
|
|
/**
|
|
* User parties infinite query options
|
|
*
|
|
* @param username - Username to fetch parties for
|
|
* @returns Infinite query options for fetching user's parties
|
|
*/
|
|
parties: (username: string) =>
|
|
infiniteQueryOptions({
|
|
queryKey: ['user', username, 'parties'] as const,
|
|
queryFn: async ({ pageParam }): Promise<UserPartiesPageResult> => {
|
|
const response = await userAdapter.getProfileParties(username, pageParam)
|
|
return {
|
|
results: response.results,
|
|
page: response.page,
|
|
totalPages: response.totalPages,
|
|
total: response.total,
|
|
perPage: response.perPage
|
|
}
|
|
},
|
|
initialPageParam: 1,
|
|
getNextPageParam: (lastPage) => {
|
|
if (lastPage.page < lastPage.totalPages) {
|
|
return lastPage.page + 1
|
|
}
|
|
return undefined
|
|
},
|
|
enabled: !!username,
|
|
staleTime: 1000 * 60 * 2, // 2 minutes
|
|
gcTime: 1000 * 60 * 15 // 15 minutes
|
|
}),
|
|
|
|
/**
|
|
* User favorites infinite query options
|
|
*
|
|
* @returns Infinite query options for fetching user's favorite parties
|
|
*/
|
|
favorites: () =>
|
|
infiniteQueryOptions({
|
|
queryKey: ['user', 'favorites'] as const,
|
|
queryFn: async ({ pageParam }): Promise<FavoritesPageResult> => {
|
|
const response = await userAdapter.getFavorites({ page: pageParam })
|
|
return {
|
|
items: response.items,
|
|
page: response.page,
|
|
totalPages: response.totalPages,
|
|
total: response.total,
|
|
perPage: response.perPage
|
|
}
|
|
},
|
|
initialPageParam: 1,
|
|
getNextPageParam: (lastPage) => {
|
|
if (lastPage.page < lastPage.totalPages) {
|
|
return lastPage.page + 1
|
|
}
|
|
return undefined
|
|
},
|
|
staleTime: 1000 * 60 * 2, // 2 minutes
|
|
gcTime: 1000 * 60 * 15 // 15 minutes
|
|
}),
|
|
|
|
/**
|
|
* Username availability check query options
|
|
*
|
|
* @param username - Username to check availability for
|
|
* @returns Query options for checking username availability
|
|
*/
|
|
checkUsername: (username: string) =>
|
|
queryOptions({
|
|
queryKey: ['user', 'check', 'username', username] as const,
|
|
queryFn: () => userAdapter.checkUsernameAvailability(username),
|
|
enabled: !!username && username.length >= 3,
|
|
staleTime: 1000 * 30, // 30 seconds - availability can change
|
|
gcTime: 1000 * 60 * 5 // 5 minutes
|
|
}),
|
|
|
|
/**
|
|
* Email availability check query options
|
|
*
|
|
* @param email - Email to check availability for
|
|
* @returns Query options for checking email availability
|
|
*/
|
|
checkEmail: (email: string) =>
|
|
queryOptions({
|
|
queryKey: ['user', 'check', 'email', email] as const,
|
|
queryFn: () => userAdapter.checkEmailAvailability(email),
|
|
enabled: !!email && email.includes('@'),
|
|
staleTime: 1000 * 30, // 30 seconds - availability can change
|
|
gcTime: 1000 * 60 * 5 // 5 minutes
|
|
})
|
|
}
|
|
|
|
/**
|
|
* Query key helpers for cache invalidation
|
|
*
|
|
* @example
|
|
* ```typescript
|
|
* import { useQueryClient } from '@tanstack/svelte-query'
|
|
* import { userKeys } from '$lib/api/queries/user.queries'
|
|
*
|
|
* const queryClient = useQueryClient()
|
|
*
|
|
* // Invalidate current user
|
|
* queryClient.invalidateQueries({ queryKey: userKeys.me() })
|
|
*
|
|
* // Invalidate a user's profile
|
|
* queryClient.invalidateQueries({ queryKey: userKeys.profile('username') })
|
|
* ```
|
|
*/
|
|
export const userKeys = {
|
|
all: ['user'] as const,
|
|
me: () => [...userKeys.all, 'me'] as const,
|
|
info: (username: string) => [...userKeys.all, username, 'info'] as const,
|
|
profile: (username: string) => [...userKeys.all, username, 'profile'] as const,
|
|
parties: (username: string) => [...userKeys.all, username, 'parties'] as const,
|
|
favorites: () => [...userKeys.all, 'favorites'] as const,
|
|
checkUsername: (username: string) => [...userKeys.all, 'check', 'username', username] as const,
|
|
checkEmail: (email: string) => [...userKeys.all, 'check', 'email', email] as const
|
|
}
|