diff --git a/src/lib/api/adapters/resources/party.resource.svelte.ts b/src/lib/api/adapters/resources/party.resource.svelte.ts index f1141f4e..cf4af750 100644 --- a/src/lib/api/adapters/resources/party.resource.svelte.ts +++ b/src/lib/api/adapters/resources/party.resource.svelte.ts @@ -4,6 +4,27 @@ * Provides reactive state management for party operations with * automatic loading states, error handling, and optimistic updates. * + * @deprecated This resource class is deprecated in favor of TanStack Query. + * Use `partyQueries` from `$lib/api/queries/party.queries` and + * mutation hooks from `$lib/api/mutations/party.mutations` instead. + * + * Migration example: + * ```typescript + * // Before (PartyResource) + * const party = createPartyResource() + * party.load('ABC123') + * party.update({ shortcode: 'ABC123', name: 'New Name' }) + * + * // After (TanStack Query) + * import { createQuery } from '@tanstack/svelte-query' + * import { partyQueries } from '$lib/api/queries/party.queries' + * import { useUpdateParty } from '$lib/api/mutations/party.mutations' + * + * const party = createQuery(() => partyQueries.byShortcode('ABC123')) + * const updateParty = useUpdateParty() + * updateParty.mutate({ shortcode: 'ABC123', name: 'New Name' }) + * ``` + * * @module adapters/resources/party */ diff --git a/src/lib/api/adapters/resources/search.resource.svelte.ts b/src/lib/api/adapters/resources/search.resource.svelte.ts index 68c8d1a1..180d31e4 100644 --- a/src/lib/api/adapters/resources/search.resource.svelte.ts +++ b/src/lib/api/adapters/resources/search.resource.svelte.ts @@ -4,6 +4,23 @@ * Provides reactive state management for search operations with * automatic loading states, error handling, and debouncing. * + * @deprecated This resource class is deprecated in favor of TanStack Query. + * Use `searchQueries` from `$lib/api/queries/search.queries` with `createInfiniteQuery` instead. + * + * Migration example: + * ```typescript + * // Before (SearchResource) + * const search = createSearchResource({ debounceMs: 300 }) + * search.searchWeapons({ query }) + * + * // After (TanStack Query) + * import { createInfiniteQuery } from '@tanstack/svelte-query' + * import { searchQueries } from '$lib/api/queries/search.queries' + * + * let debouncedQuery = $state('') + * const weapons = createInfiniteQuery(() => searchQueries.weapons(debouncedQuery)) + * ``` + * * @module adapters/resources/search */ @@ -227,4 +244,4 @@ export class SearchResource { */ export function createSearchResource(options?: SearchResourceOptions): SearchResource { return new SearchResource(options) -} \ No newline at end of file +} diff --git a/src/lib/api/mutations/grid.mutations.ts b/src/lib/api/mutations/grid.mutations.ts new file mode 100644 index 00000000..edb38f78 --- /dev/null +++ b/src/lib/api/mutations/grid.mutations.ts @@ -0,0 +1,568 @@ +/** + * Grid Mutation Configurations + * + * Provides mutation configurations for grid item operations (weapons, characters, summons) + * with cache invalidation and optimistic updates using TanStack Query v6. + * + * @module api/mutations/grid + */ + +import { useQueryClient, createMutation } from '@tanstack/svelte-query' +import { + gridAdapter, + type CreateGridWeaponParams, + type CreateGridCharacterParams, + type CreateGridSummonParams, + type UpdateUncapParams, + type ResolveConflictParams +} from '$lib/api/adapters/grid.adapter' +import { partyKeys } from '$lib/api/queries/party.queries' +import type { Party, GridWeapon, GridCharacter, GridSummon } from '$lib/types/api/party' + +// ============================================================================ +// Weapon Mutations +// ============================================================================ + +/** + * Create grid weapon mutation + * + * Adds a weapon to a party's grid. + * + * @example + * ```svelte + * + * ``` + */ +export function useCreateGridWeapon() { + const queryClient = useQueryClient() + + return createMutation(() => ({ + mutationFn: (params: CreateGridWeaponParams) => gridAdapter.createWeapon(params), + onSuccess: (_data, params) => { + // Invalidate the party to refetch with new weapon + queryClient.invalidateQueries({ queryKey: partyKeys.detail(params.partyId) }) + } + })) +} + +/** + * Update grid weapon mutation + * + * Updates a weapon in a party's grid with optimistic updates. + * + * @example + * ```svelte + * + * ``` + */ +export function useUpdateGridWeapon() { + const queryClient = useQueryClient() + + return createMutation(() => ({ + mutationFn: ({ id, updates }: { id: string; partyShortcode: string; updates: Partial }) => + gridAdapter.updateWeapon(id, updates), + onMutate: async ({ id, partyShortcode, updates }) => { + await queryClient.cancelQueries({ queryKey: partyKeys.detail(partyShortcode) }) + + const previousParty = queryClient.getQueryData(partyKeys.detail(partyShortcode)) + + if (previousParty?.weapons) { + const updatedWeapons = previousParty.weapons.map((w) => + w.id === id ? { ...w, ...updates } : w + ) + queryClient.setQueryData(partyKeys.detail(partyShortcode), { + ...previousParty, + weapons: updatedWeapons + }) + } + + return { previousParty } + }, + onError: (_err, { partyShortcode }, context) => { + if (context?.previousParty) { + queryClient.setQueryData(partyKeys.detail(partyShortcode), context.previousParty) + } + }, + onSettled: (_data, _err, { partyShortcode }) => { + queryClient.invalidateQueries({ queryKey: partyKeys.detail(partyShortcode) }) + } + })) +} + +/** + * Delete grid weapon mutation + * + * Removes a weapon from a party's grid. + */ +export function useDeleteGridWeapon() { + const queryClient = useQueryClient() + + return createMutation(() => ({ + mutationFn: (params: { id?: string; partyId: string; partyShortcode: string; position?: number }) => + gridAdapter.deleteWeapon({ id: params.id, partyId: params.partyId, position: params.position }), + onMutate: async ({ partyShortcode, id, position }) => { + await queryClient.cancelQueries({ queryKey: partyKeys.detail(partyShortcode) }) + + const previousParty = queryClient.getQueryData(partyKeys.detail(partyShortcode)) + + if (previousParty?.weapons) { + const updatedWeapons = previousParty.weapons.filter((w) => + id ? w.id !== id : w.position !== position + ) + queryClient.setQueryData(partyKeys.detail(partyShortcode), { + ...previousParty, + weapons: updatedWeapons + }) + } + + return { previousParty } + }, + onError: (_err, { partyShortcode }, context) => { + if (context?.previousParty) { + queryClient.setQueryData(partyKeys.detail(partyShortcode), context.previousParty) + } + }, + onSettled: (_data, _err, { partyShortcode }) => { + queryClient.invalidateQueries({ queryKey: partyKeys.detail(partyShortcode) }) + } + })) +} + +/** + * Update weapon uncap mutation + * + * Updates a weapon's uncap level with optimistic updates. + */ +export function useUpdateWeaponUncap() { + const queryClient = useQueryClient() + + return createMutation(() => ({ + mutationFn: (params: UpdateUncapParams & { partyShortcode: string }) => + gridAdapter.updateWeaponUncap(params), + onMutate: async ({ partyShortcode, id, uncapLevel, transcendenceStep }) => { + await queryClient.cancelQueries({ queryKey: partyKeys.detail(partyShortcode) }) + + const previousParty = queryClient.getQueryData(partyKeys.detail(partyShortcode)) + + if (previousParty?.weapons) { + const updatedWeapons = previousParty.weapons.map((w) => + w.id === id + ? { + ...w, + uncapLevel, + ...(transcendenceStep !== undefined && { transcendenceStep }) + } + : w + ) + queryClient.setQueryData(partyKeys.detail(partyShortcode), { + ...previousParty, + weapons: updatedWeapons + }) + } + + return { previousParty } + }, + onError: (_err, { partyShortcode }, context) => { + if (context?.previousParty) { + queryClient.setQueryData(partyKeys.detail(partyShortcode), context.previousParty) + } + }, + onSettled: (_data, _err, { partyShortcode }) => { + queryClient.invalidateQueries({ queryKey: partyKeys.detail(partyShortcode) }) + } + })) +} + +/** + * Resolve weapon conflict mutation + * + * Resolves conflicts when adding a weapon that conflicts with existing weapons. + */ +export function useResolveWeaponConflict() { + const queryClient = useQueryClient() + + return createMutation(() => ({ + mutationFn: (params: ResolveConflictParams & { partyShortcode: string }) => + gridAdapter.resolveWeaponConflict(params), + onSuccess: (_data, { partyShortcode }) => { + queryClient.invalidateQueries({ queryKey: partyKeys.detail(partyShortcode) }) + } + })) +} + +// ============================================================================ +// Character Mutations +// ============================================================================ + +/** + * Create grid character mutation + * + * Adds a character to a party's grid. + */ +export function useCreateGridCharacter() { + const queryClient = useQueryClient() + + return createMutation(() => ({ + mutationFn: (params: CreateGridCharacterParams) => gridAdapter.createCharacter(params), + onSuccess: (_data, params) => { + queryClient.invalidateQueries({ queryKey: partyKeys.detail(params.partyId) }) + } + })) +} + +/** + * Update grid character mutation + * + * Updates a character in a party's grid with optimistic updates. + */ +export function useUpdateGridCharacter() { + const queryClient = useQueryClient() + + return createMutation(() => ({ + mutationFn: ({ id, updates }: { id: string; partyShortcode: string; updates: Partial }) => + gridAdapter.updateCharacter(id, updates), + onMutate: async ({ id, partyShortcode, updates }) => { + await queryClient.cancelQueries({ queryKey: partyKeys.detail(partyShortcode) }) + + const previousParty = queryClient.getQueryData(partyKeys.detail(partyShortcode)) + + if (previousParty?.characters) { + const updatedCharacters = previousParty.characters.map((c) => + c.id === id ? { ...c, ...updates } : c + ) + queryClient.setQueryData(partyKeys.detail(partyShortcode), { + ...previousParty, + characters: updatedCharacters + }) + } + + return { previousParty } + }, + onError: (_err, { partyShortcode }, context) => { + if (context?.previousParty) { + queryClient.setQueryData(partyKeys.detail(partyShortcode), context.previousParty) + } + }, + onSettled: (_data, _err, { partyShortcode }) => { + queryClient.invalidateQueries({ queryKey: partyKeys.detail(partyShortcode) }) + } + })) +} + +/** + * Delete grid character mutation + * + * Removes a character from a party's grid. + */ +export function useDeleteGridCharacter() { + const queryClient = useQueryClient() + + return createMutation(() => ({ + mutationFn: (params: { id?: string; partyId: string; partyShortcode: string; position?: number }) => + gridAdapter.deleteCharacter({ id: params.id, partyId: params.partyId, position: params.position }), + onMutate: async ({ partyShortcode, id, position }) => { + await queryClient.cancelQueries({ queryKey: partyKeys.detail(partyShortcode) }) + + const previousParty = queryClient.getQueryData(partyKeys.detail(partyShortcode)) + + if (previousParty?.characters) { + const updatedCharacters = previousParty.characters.filter((c) => + id ? c.id !== id : c.position !== position + ) + queryClient.setQueryData(partyKeys.detail(partyShortcode), { + ...previousParty, + characters: updatedCharacters + }) + } + + return { previousParty } + }, + onError: (_err, { partyShortcode }, context) => { + if (context?.previousParty) { + queryClient.setQueryData(partyKeys.detail(partyShortcode), context.previousParty) + } + }, + onSettled: (_data, _err, { partyShortcode }) => { + queryClient.invalidateQueries({ queryKey: partyKeys.detail(partyShortcode) }) + } + })) +} + +/** + * Update character uncap mutation + * + * Updates a character's uncap level with optimistic updates. + */ +export function useUpdateCharacterUncap() { + const queryClient = useQueryClient() + + return createMutation(() => ({ + mutationFn: (params: UpdateUncapParams & { partyShortcode: string }) => + gridAdapter.updateCharacterUncap(params), + onMutate: async ({ partyShortcode, id, uncapLevel, transcendenceStep }) => { + await queryClient.cancelQueries({ queryKey: partyKeys.detail(partyShortcode) }) + + const previousParty = queryClient.getQueryData(partyKeys.detail(partyShortcode)) + + if (previousParty?.characters) { + const updatedCharacters = previousParty.characters.map((c) => + c.id === id + ? { + ...c, + uncapLevel, + ...(transcendenceStep !== undefined && { transcendenceStep }) + } + : c + ) + queryClient.setQueryData(partyKeys.detail(partyShortcode), { + ...previousParty, + characters: updatedCharacters + }) + } + + return { previousParty } + }, + onError: (_err, { partyShortcode }, context) => { + if (context?.previousParty) { + queryClient.setQueryData(partyKeys.detail(partyShortcode), context.previousParty) + } + }, + onSettled: (_data, _err, { partyShortcode }) => { + queryClient.invalidateQueries({ queryKey: partyKeys.detail(partyShortcode) }) + } + })) +} + +/** + * Resolve character conflict mutation + * + * Resolves conflicts when adding a character that conflicts with existing characters. + */ +export function useResolveCharacterConflict() { + const queryClient = useQueryClient() + + return createMutation(() => ({ + mutationFn: (params: ResolveConflictParams & { partyShortcode: string }) => + gridAdapter.resolveCharacterConflict(params), + onSuccess: (_data, { partyShortcode }) => { + queryClient.invalidateQueries({ queryKey: partyKeys.detail(partyShortcode) }) + } + })) +} + +// ============================================================================ +// Summon Mutations +// ============================================================================ + +/** + * Create grid summon mutation + * + * Adds a summon to a party's grid. + */ +export function useCreateGridSummon() { + const queryClient = useQueryClient() + + return createMutation(() => ({ + mutationFn: (params: CreateGridSummonParams) => gridAdapter.createSummon(params), + onSuccess: (_data, params) => { + queryClient.invalidateQueries({ queryKey: partyKeys.detail(params.partyId) }) + } + })) +} + +/** + * Update grid summon mutation + * + * Updates a summon in a party's grid with optimistic updates. + */ +export function useUpdateGridSummon() { + const queryClient = useQueryClient() + + return createMutation(() => ({ + mutationFn: ({ id, updates }: { id: string; partyShortcode: string; updates: Partial }) => + gridAdapter.updateSummon(id, updates), + onMutate: async ({ id, partyShortcode, updates }) => { + await queryClient.cancelQueries({ queryKey: partyKeys.detail(partyShortcode) }) + + const previousParty = queryClient.getQueryData(partyKeys.detail(partyShortcode)) + + if (previousParty?.summons) { + const updatedSummons = previousParty.summons.map((s) => + s.id === id ? { ...s, ...updates } : s + ) + queryClient.setQueryData(partyKeys.detail(partyShortcode), { + ...previousParty, + summons: updatedSummons + }) + } + + return { previousParty } + }, + onError: (_err, { partyShortcode }, context) => { + if (context?.previousParty) { + queryClient.setQueryData(partyKeys.detail(partyShortcode), context.previousParty) + } + }, + onSettled: (_data, _err, { partyShortcode }) => { + queryClient.invalidateQueries({ queryKey: partyKeys.detail(partyShortcode) }) + } + })) +} + +/** + * Delete grid summon mutation + * + * Removes a summon from a party's grid. + */ +export function useDeleteGridSummon() { + const queryClient = useQueryClient() + + return createMutation(() => ({ + mutationFn: (params: { id?: string; partyId: string; partyShortcode: string; position?: number }) => + gridAdapter.deleteSummon({ id: params.id, partyId: params.partyId, position: params.position }), + onMutate: async ({ partyShortcode, id, position }) => { + await queryClient.cancelQueries({ queryKey: partyKeys.detail(partyShortcode) }) + + const previousParty = queryClient.getQueryData(partyKeys.detail(partyShortcode)) + + if (previousParty?.summons) { + const updatedSummons = previousParty.summons.filter((s) => + id ? s.id !== id : s.position !== position + ) + queryClient.setQueryData(partyKeys.detail(partyShortcode), { + ...previousParty, + summons: updatedSummons + }) + } + + return { previousParty } + }, + onError: (_err, { partyShortcode }, context) => { + if (context?.previousParty) { + queryClient.setQueryData(partyKeys.detail(partyShortcode), context.previousParty) + } + }, + onSettled: (_data, _err, { partyShortcode }) => { + queryClient.invalidateQueries({ queryKey: partyKeys.detail(partyShortcode) }) + } + })) +} + +/** + * Update summon uncap mutation + * + * Updates a summon's uncap level with optimistic updates. + */ +export function useUpdateSummonUncap() { + const queryClient = useQueryClient() + + return createMutation(() => ({ + mutationFn: (params: UpdateUncapParams & { partyShortcode: string }) => + gridAdapter.updateSummonUncap(params), + onMutate: async ({ partyShortcode, id, uncapLevel, transcendenceStep }) => { + await queryClient.cancelQueries({ queryKey: partyKeys.detail(partyShortcode) }) + + const previousParty = queryClient.getQueryData(partyKeys.detail(partyShortcode)) + + if (previousParty?.summons) { + const updatedSummons = previousParty.summons.map((s) => + s.id === id + ? { + ...s, + uncapLevel, + ...(transcendenceStep !== undefined && { transcendenceStep }) + } + : s + ) + queryClient.setQueryData(partyKeys.detail(partyShortcode), { + ...previousParty, + summons: updatedSummons + }) + } + + return { previousParty } + }, + onError: (_err, { partyShortcode }, context) => { + if (context?.previousParty) { + queryClient.setQueryData(partyKeys.detail(partyShortcode), context.previousParty) + } + }, + onSettled: (_data, _err, { partyShortcode }) => { + queryClient.invalidateQueries({ queryKey: partyKeys.detail(partyShortcode) }) + } + })) +} + +/** + * Update quick summon mutation + * + * Updates a summon's quick summon setting with optimistic updates. + */ +export function useUpdateQuickSummon() { + const queryClient = useQueryClient() + + return createMutation(() => ({ + mutationFn: (params: { + id?: string + partyId: string + partyShortcode: string + position?: number + quickSummon: boolean + }) => + gridAdapter.updateQuickSummon({ + id: params.id, + partyId: params.partyId, + position: params.position, + quickSummon: params.quickSummon + }), + onMutate: async ({ partyShortcode, id, quickSummon }) => { + await queryClient.cancelQueries({ queryKey: partyKeys.detail(partyShortcode) }) + + const previousParty = queryClient.getQueryData(partyKeys.detail(partyShortcode)) + + if (previousParty?.summons) { + const updatedSummons = previousParty.summons.map((s) => + s.id === id ? { ...s, quickSummon } : s + ) + queryClient.setQueryData(partyKeys.detail(partyShortcode), { + ...previousParty, + summons: updatedSummons + }) + } + + return { previousParty } + }, + onError: (_err, { partyShortcode }, context) => { + if (context?.previousParty) { + queryClient.setQueryData(partyKeys.detail(partyShortcode), context.previousParty) + } + }, + onSettled: (_data, _err, { partyShortcode }) => { + queryClient.invalidateQueries({ queryKey: partyKeys.detail(partyShortcode) }) + } + })) +} diff --git a/src/lib/api/mutations/job.mutations.ts b/src/lib/api/mutations/job.mutations.ts new file mode 100644 index 00000000..8784da54 --- /dev/null +++ b/src/lib/api/mutations/job.mutations.ts @@ -0,0 +1,184 @@ +/** + * 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 + * + * ``` + */ +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(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 + * + * ``` + */ +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 + * + * ``` + */ +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(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 + * + * ``` + */ +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) }) + } + })) +} diff --git a/src/lib/api/mutations/party.mutations.ts b/src/lib/api/mutations/party.mutations.ts new file mode 100644 index 00000000..0a035273 --- /dev/null +++ b/src/lib/api/mutations/party.mutations.ts @@ -0,0 +1,313 @@ +/** + * Party Mutation Configurations + * + * Provides mutation configurations for party CRUD operations + * with cache invalidation and optimistic updates using TanStack Query v6. + * + * @module api/mutations/party + */ + +import { useQueryClient, createMutation } from '@tanstack/svelte-query' +import { + partyAdapter, + type CreatePartyParams, + type UpdatePartyParams +} from '$lib/api/adapters/party.adapter' +import { partyKeys } from '$lib/api/queries/party.queries' +import { userKeys } from '$lib/api/queries/user.queries' +import type { Party } from '$lib/types/api/party' + +/** + * Create party mutation + * + * Creates a new party and invalidates relevant caches. + * + * @example + * ```svelte + * + * ``` + */ +export function useCreateParty() { + const queryClient = useQueryClient() + + return createMutation(() => ({ + mutationFn: (params: CreatePartyParams) => partyAdapter.create(params), + onSuccess: (party) => { + // Set the new party in cache + queryClient.setQueryData(partyKeys.detail(party.shortcode), party) + // Invalidate party lists to include the new party + queryClient.invalidateQueries({ queryKey: partyKeys.lists() }) + // Invalidate user's party lists + queryClient.invalidateQueries({ queryKey: userKeys.all }) + } + })) +} + +/** + * Update party mutation + * + * Updates an existing party with optimistic updates. + * + * @example + * ```svelte + * + * ``` + */ +export function useUpdateParty() { + const queryClient = useQueryClient() + + return createMutation(() => ({ + mutationFn: (params: UpdatePartyParams) => partyAdapter.update(params), + onMutate: async (params) => { + // Cancel any outgoing refetches + await queryClient.cancelQueries({ queryKey: partyKeys.detail(params.shortcode) }) + + // Snapshot the previous value + const previousParty = queryClient.getQueryData(partyKeys.detail(params.shortcode)) + + // Optimistically update the cache + if (previousParty) { + queryClient.setQueryData(partyKeys.detail(params.shortcode), { + ...previousParty, + ...params + }) + } + + return { previousParty } + }, + onError: (_err, params, context) => { + // Rollback on error + if (context?.previousParty) { + queryClient.setQueryData(partyKeys.detail(params.shortcode), context.previousParty) + } + }, + onSettled: (_data, _err, params) => { + // Always refetch after error or success + queryClient.invalidateQueries({ queryKey: partyKeys.detail(params.shortcode) }) + } + })) +} + +/** + * Delete party mutation + * + * Deletes a party and removes it from all caches. + * + * @example + * ```svelte + * + * ``` + */ +export function useDeleteParty() { + const queryClient = useQueryClient() + + return createMutation(() => ({ + mutationFn: (shortcode: string) => partyAdapter.delete(shortcode), + onSuccess: (_data, shortcode) => { + // Remove the party from cache + queryClient.removeQueries({ queryKey: partyKeys.detail(shortcode) }) + // Invalidate party lists + queryClient.invalidateQueries({ queryKey: partyKeys.lists() }) + // Invalidate user's party lists + queryClient.invalidateQueries({ queryKey: userKeys.all }) + } + })) +} + +/** + * Remix party mutation + * + * Creates a copy of an existing party. + * + * @example + * ```svelte + * + * ``` + */ +export function useRemixParty() { + const queryClient = useQueryClient() + + return createMutation(() => ({ + mutationFn: (shortcode: string) => partyAdapter.remix(shortcode), + onSuccess: (newParty) => { + // Set the new party in cache + queryClient.setQueryData(partyKeys.detail(newParty.shortcode), newParty) + // Invalidate party lists to include the new party + queryClient.invalidateQueries({ queryKey: partyKeys.lists() }) + // Invalidate user's party lists + queryClient.invalidateQueries({ queryKey: userKeys.all }) + } + })) +} + +/** + * Favorite party mutation + * + * Adds a party to the user's favorites. + * + * @example + * ```svelte + * + * ``` + */ +export function useFavoriteParty() { + const queryClient = useQueryClient() + + return createMutation(() => ({ + mutationFn: (shortcode: string) => partyAdapter.favorite(shortcode), + onMutate: async (shortcode) => { + // Cancel any outgoing refetches + await queryClient.cancelQueries({ queryKey: partyKeys.detail(shortcode) }) + + // Snapshot the previous value + const previousParty = queryClient.getQueryData(partyKeys.detail(shortcode)) + + // Optimistically update the cache + if (previousParty) { + queryClient.setQueryData(partyKeys.detail(shortcode), { + ...previousParty, + favorited: true + }) + } + + return { previousParty } + }, + onError: (_err, shortcode, context) => { + // Rollback on error + if (context?.previousParty) { + queryClient.setQueryData(partyKeys.detail(shortcode), context.previousParty) + } + }, + onSettled: (_data, _err, shortcode) => { + // Invalidate favorites list + queryClient.invalidateQueries({ queryKey: userKeys.favorites() }) + // Refetch the party to get accurate state + queryClient.invalidateQueries({ queryKey: partyKeys.detail(shortcode) }) + } + })) +} + +/** + * Unfavorite party mutation + * + * Removes a party from the user's favorites. + * + * @example + * ```svelte + * + * ``` + */ +export function useUnfavoriteParty() { + const queryClient = useQueryClient() + + return createMutation(() => ({ + mutationFn: (shortcode: string) => partyAdapter.unfavorite(shortcode), + onMutate: async (shortcode) => { + // Cancel any outgoing refetches + await queryClient.cancelQueries({ queryKey: partyKeys.detail(shortcode) }) + + // Snapshot the previous value + const previousParty = queryClient.getQueryData(partyKeys.detail(shortcode)) + + // Optimistically update the cache + if (previousParty) { + queryClient.setQueryData(partyKeys.detail(shortcode), { + ...previousParty, + favorited: false + }) + } + + return { previousParty } + }, + onError: (_err, shortcode, context) => { + // Rollback on error + if (context?.previousParty) { + queryClient.setQueryData(partyKeys.detail(shortcode), context.previousParty) + } + }, + onSettled: (_data, _err, shortcode) => { + // Invalidate favorites list + queryClient.invalidateQueries({ queryKey: userKeys.favorites() }) + // Refetch the party to get accurate state + queryClient.invalidateQueries({ queryKey: partyKeys.detail(shortcode) }) + } + })) +} + +/** + * Regenerate preview mutation + * + * Triggers regeneration of a party's preview image. + * + * @example + * ```svelte + * + * ``` + */ +export function useRegeneratePreview() { + const queryClient = useQueryClient() + + return createMutation(() => ({ + mutationFn: (shortcode: string) => partyAdapter.regeneratePreview(shortcode), + onSuccess: (_data, shortcode) => { + // Invalidate preview status to trigger refetch + queryClient.invalidateQueries({ queryKey: partyKeys.preview(shortcode) }) + } + })) +} diff --git a/src/lib/api/queries/job.queries.ts b/src/lib/api/queries/job.queries.ts new file mode 100644 index 00000000..fb438513 --- /dev/null +++ b/src/lib/api/queries/job.queries.ts @@ -0,0 +1,182 @@ +/** + * Job Query Options Factory + * + * Provides type-safe, reusable query configurations for job operations + * using TanStack Query v6 patterns. + * + * @module api/queries/job + */ + +import { queryOptions, infiniteQueryOptions } from '@tanstack/svelte-query' +import { + jobAdapter, + type SearchJobSkillsParams +} from '$lib/api/adapters/job.adapter' +import type { Job, JobSkill, JobAccessory } from '$lib/types/api/entities' + +/** + * Standard page result format for job skill infinite queries + */ +export interface JobSkillPageResult { + results: JobSkill[] + page: number + totalPages: number + total: number +} + +/** + * Job query options factory + * + * Provides query configurations for all job-related operations. + * These can be used with `createQuery`, `createInfiniteQuery`, or for prefetching. + * + * @example + * ```typescript + * import { createQuery, createInfiniteQuery } from '@tanstack/svelte-query' + * import { jobQueries } from '$lib/api/queries/job.queries' + * + * // All jobs + * const jobs = createQuery(() => jobQueries.list()) + * + * // Skills for a specific job with infinite scroll + * const skills = createInfiniteQuery(() => jobQueries.skills(jobId, { query: searchTerm })) + * ``` + */ +export const jobQueries = { + /** + * All jobs list query options + * + * @returns Query options for fetching all jobs + */ + list: () => + queryOptions({ + queryKey: ['jobs'] as const, + queryFn: () => jobAdapter.getAll(), + staleTime: 1000 * 60 * 30, // 30 minutes - jobs rarely change + gcTime: 1000 * 60 * 60 // 1 hour + }), + + /** + * Single job query options + * + * @param id - Job ID + * @returns Query options for fetching a single job + */ + byId: (id: string) => + queryOptions({ + queryKey: ['jobs', id] as const, + queryFn: () => jobAdapter.getById(id), + enabled: !!id, + staleTime: 1000 * 60 * 30, // 30 minutes + gcTime: 1000 * 60 * 60 // 1 hour + }), + + /** + * Job skills query options (non-paginated) + * + * @param jobId - Job ID to fetch skills for + * @returns Query options for fetching all skills for a job + */ + skillsByJob: (jobId: string) => + queryOptions({ + queryKey: ['jobs', jobId, 'skills'] as const, + queryFn: () => jobAdapter.getSkills(jobId), + enabled: !!jobId, + staleTime: 1000 * 60 * 30, // 30 minutes + gcTime: 1000 * 60 * 60 // 1 hour + }), + + /** + * Job skills search infinite query options + * + * @param jobId - Job ID to search skills for + * @param params - Optional search parameters (query, filters, locale) + * @returns Infinite query options for searching job skills + */ + skills: ( + jobId: string, + params?: Omit + ) => + infiniteQueryOptions({ + queryKey: ['jobs', jobId, 'skills', 'search', params] as const, + queryFn: async ({ pageParam }): Promise => { + const response = await jobAdapter.searchSkills({ + jobId, + ...params, + page: pageParam + }) + return { + results: response.results, + page: response.page, + totalPages: response.totalPages, + total: response.total + } + }, + initialPageParam: 1, + getNextPageParam: (lastPage) => { + if (lastPage.page < lastPage.totalPages) { + return lastPage.page + 1 + } + return undefined + }, + enabled: !!jobId, + staleTime: 1000 * 60 * 5, // 5 minutes - search results can change + gcTime: 1000 * 60 * 15 // 15 minutes + }), + + /** + * Job accessories query options + * + * @param jobId - Job ID to fetch accessories for + * @returns Query options for fetching job accessories + */ + accessories: (jobId: string) => + queryOptions({ + queryKey: ['jobs', jobId, 'accessories'] as const, + queryFn: () => jobAdapter.getAccessories(jobId), + enabled: !!jobId, + staleTime: 1000 * 60 * 30, // 30 minutes + gcTime: 1000 * 60 * 60 // 1 hour + }), + + /** + * All job skills query options (not filtered by job) + * + * @returns Query options for fetching all job skills + */ + allSkills: () => + queryOptions({ + queryKey: ['jobs', 'skills', 'all'] as const, + queryFn: () => jobAdapter.getAllSkills(), + staleTime: 1000 * 60 * 30, // 30 minutes + gcTime: 1000 * 60 * 60 // 1 hour + }) +} + +/** + * Query key helpers for cache invalidation + * + * @example + * ```typescript + * import { useQueryClient } from '@tanstack/svelte-query' + * import { jobKeys } from '$lib/api/queries/job.queries' + * + * const queryClient = useQueryClient() + * + * // Invalidate all jobs + * queryClient.invalidateQueries({ queryKey: jobKeys.all }) + * + * // Invalidate skills for a specific job + * queryClient.invalidateQueries({ queryKey: jobKeys.skills('job-id') }) + * ``` + */ +export const jobKeys = { + all: ['jobs'] as const, + lists: () => [...jobKeys.all] as const, + detail: (id: string) => [...jobKeys.all, id] as const, + skills: (jobId: string) => [...jobKeys.all, jobId, 'skills'] as const, + skillsSearch: (jobId: string, params?: Omit) => + [...jobKeys.skills(jobId), 'search', params] as const, + accessories: (jobId: string) => [...jobKeys.all, jobId, 'accessories'] as const, + allSkills: () => [...jobKeys.all, 'skills', 'all'] as const +} diff --git a/src/lib/api/queries/party.queries.ts b/src/lib/api/queries/party.queries.ts new file mode 100644 index 00000000..d1460ae1 --- /dev/null +++ b/src/lib/api/queries/party.queries.ts @@ -0,0 +1,185 @@ +/** + * Party Query Options Factory + * + * Provides type-safe, reusable query configurations for party operations + * using TanStack Query v6 patterns. + * + * @module api/queries/party + */ + +import { queryOptions, infiniteQueryOptions } from '@tanstack/svelte-query' +import { + partyAdapter, + type ListUserPartiesParams +} from '$lib/api/adapters/party.adapter' +import type { Party } from '$lib/types/api/party' + +/** + * Standard page result format for infinite queries + */ +export interface PartyPageResult { + results: Party[] + page: number + totalPages: number + total?: number + perPage?: number +} + +/** + * Parameters for listing parties + */ +export interface ListPartiesParams { + page?: number + per?: number +} + +/** + * Party query options factory + * + * Provides query configurations for all party-related operations. + * These can be used with `createQuery`, `createInfiniteQuery`, or for prefetching. + * + * @example + * ```typescript + * import { createQuery, createInfiniteQuery } from '@tanstack/svelte-query' + * import { partyQueries } from '$lib/api/queries/party.queries' + * + * // Single party by shortcode + * const party = createQuery(() => partyQueries.byShortcode(shortcode)) + * + * // Infinite list of parties + * const parties = createInfiniteQuery(() => partyQueries.list()) + * ``` + */ +export const partyQueries = { + /** + * Single party query options + * + * @param shortcode - Party shortcode identifier + * @returns Query options for fetching a single party + */ + byShortcode: (shortcode: string) => + queryOptions({ + queryKey: ['party', shortcode] as const, + queryFn: () => partyAdapter.getByShortcode(shortcode), + enabled: !!shortcode, + staleTime: 1000 * 60 * 5, // 5 minutes + gcTime: 1000 * 60 * 30 // 30 minutes + }), + + /** + * Public parties list (explore page) infinite query options + * + * @param params - Optional pagination parameters + * @returns Infinite query options for listing public parties + */ + list: (params?: Omit) => + infiniteQueryOptions({ + queryKey: ['parties', 'list', params] as const, + queryFn: async ({ pageParam }): Promise => { + const response = await partyAdapter.list({ + ...params, + page: 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 + }, + staleTime: 1000 * 60 * 2, // 2 minutes - parties change more frequently + gcTime: 1000 * 60 * 15 // 15 minutes + }), + + /** + * User parties list infinite query options + * + * @param username - Username to fetch parties for + * @param params - Optional filter parameters + * @returns Infinite query options for listing user's parties + */ + userParties: ( + username: string, + params?: Omit + ) => + infiniteQueryOptions({ + queryKey: ['parties', 'user', username, params] as const, + queryFn: async ({ pageParam }): Promise => { + const response = await partyAdapter.listUserParties({ + username, + ...params, + page: 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 + }), + + /** + * Party preview status query options + * + * @param shortcode - Party shortcode identifier + * @returns Query options for fetching party preview status + */ + previewStatus: (shortcode: string) => + queryOptions({ + queryKey: ['party', shortcode, 'preview'] as const, + queryFn: () => partyAdapter.getPreviewStatus(shortcode), + enabled: !!shortcode, + staleTime: 1000 * 30, // 30 seconds - preview status changes + gcTime: 1000 * 60 * 5 // 5 minutes + }) +} + +/** + * Query key helpers for cache invalidation + * + * @example + * ```typescript + * import { useQueryClient } from '@tanstack/svelte-query' + * import { partyKeys } from '$lib/api/queries/party.queries' + * + * const queryClient = useQueryClient() + * + * // Invalidate a specific party + * queryClient.invalidateQueries({ queryKey: partyKeys.detail('abc123') }) + * + * // Invalidate all party lists + * queryClient.invalidateQueries({ queryKey: partyKeys.lists() }) + * ``` + */ +export const partyKeys = { + all: ['parties'] as const, + lists: () => [...partyKeys.all, 'list'] as const, + list: (params?: ListPartiesParams) => [...partyKeys.lists(), params] as const, + userLists: () => [...partyKeys.all, 'user'] as const, + userList: (username: string, params?: Omit) => + [...partyKeys.userLists(), username, params] as const, + details: () => ['party'] as const, + detail: (shortcode: string) => [...partyKeys.details(), shortcode] as const, + preview: (shortcode: string) => [...partyKeys.detail(shortcode), 'preview'] as const +} diff --git a/src/lib/api/queries/user.queries.ts b/src/lib/api/queries/user.queries.ts new file mode 100644 index 00000000..3130ec8d --- /dev/null +++ b/src/lib/api/queries/user.queries.ts @@ -0,0 +1,218 @@ +/** + * 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 => { + 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 => { + 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 +} diff --git a/src/lib/components/sidebar/JobSelectionSidebar.svelte b/src/lib/components/sidebar/JobSelectionSidebar.svelte index 5c814020..08b42159 100644 --- a/src/lib/components/sidebar/JobSelectionSidebar.svelte +++ b/src/lib/components/sidebar/JobSelectionSidebar.svelte @@ -1,7 +1,7 @@ + +{#if $party.data} +

{$party.data.name}

+{/if} +``` + +### Pattern 2: Prefetching in Universal Load Functions + +For pages that can use `+page.ts` (not server-only): + +```typescript +// +page.ts +import type { PageLoad } from './$types' +import { prefetchQuery } from '$lib/query/ssr' +import { partyQueries } from '$lib/api/queries/party.queries' + +export const load: PageLoad = async ({ parent, params }) => { + const { queryClient } = await parent() + + // Prefetch party data into the cache + await prefetchQuery(queryClient, partyQueries.byShortcode(params.id)) + + // No need to return data - it's already in the QueryClient cache + return { shortcode: params.id } +} +``` + +```svelte + + + +{#if $party.data} +

{$party.data.name}

+{/if} +``` + +### Pattern 3: Infinite Queries with Prefetching + +For paginated data: + +```typescript +// +page.ts +import type { PageLoad } from './$types' +import { prefetchInfiniteQuery } from '$lib/query/ssr' +import { partyQueries } from '$lib/api/queries/party.queries' + +export const load: PageLoad = async ({ parent }) => { + const { queryClient } = await parent() + + // Prefetch first page of parties + await prefetchInfiniteQuery(queryClient, partyQueries.list()) +} +``` + +## Migration Guide + +### From Server-Only to TanStack Query + +1. **Keep existing +page.server.ts** - No changes needed to server load functions +2. **Add TanStack Query to component** - Use `createQuery` with `withInitialData` +3. **Benefit from caching** - Subsequent navigations use cached data + +### From Custom Resources to TanStack Query + +1. **Replace resource imports** with query/mutation imports +2. **Use createQuery** instead of resource state +3. **Use mutations** for CRUD operations with automatic cache invalidation + +## Files + +- `queryClient.ts` - QueryClient factory (legacy, kept for reference) +- `ssr.ts` - SSR utilities (withInitialData, prefetchQuery, etc.) diff --git a/src/lib/query/ssr.ts b/src/lib/query/ssr.ts new file mode 100644 index 00000000..008177cd --- /dev/null +++ b/src/lib/query/ssr.ts @@ -0,0 +1,190 @@ +/** + * SSR Utilities for TanStack Query + * + * Provides utilities for integrating server-side data fetching with TanStack Query. + * These utilities support the initialData pattern for pages that use +page.server.ts + * load functions. + * + * @module query/ssr + */ + +import type { QueryClient } from '@tanstack/svelte-query' + +/** + * Options for creating a query with initial data from SSR + */ +export interface InitialDataOptions { + /** + * The data fetched on the server to use as initial data. + * TanStack Query accepts TData | undefined but NOT null. + */ + initialData?: TData + + /** + * Optional timestamp when the data was fetched on the server. + * If not provided, defaults to 0 (will be considered stale immediately). + * Use Date.now() on the server to get accurate timestamps. + */ + initialDataUpdatedAt?: number +} + +/** + * Creates query options with initial data from server-side rendering. + * + * Use this helper when you have data fetched in a +page.server.ts load function + * and want to use it as initial data for a TanStack Query. + * + * Note: This helper strips `null` from the input since TanStack Query's + * initialData only accepts `TData | undefined`, not `null`. + * + * @example + * ```svelte + * + * ``` + * + * @param initialData - The data fetched on the server (null is converted to undefined) + * @param updatedAt - Optional timestamp when data was fetched (defaults to 0) + * @returns Query options object with initialData and initialDataUpdatedAt + */ +export function withInitialData( + initialData: TData | undefined | null, + updatedAt?: number +): InitialDataOptions> { + return { + initialData: (initialData ?? undefined) as NonNullable | undefined, + initialDataUpdatedAt: updatedAt ?? 0 + } +} + +/** + * Prefetches a query on the server and returns the data. + * + * Use this in +page.ts load functions when you want to prefetch data + * into the QueryClient cache. This is the recommended approach for + * pages that don't use +page.server.ts. + * + * Note: This will NOT work with +page.server.ts load functions. + * Use withInitialData() instead for server-only load functions. + * + * @example + * ```typescript + * // +page.ts + * import type { PageLoad } from './$types' + * import { prefetchQuery } from '$lib/query/ssr' + * import { partyQueries } from '$lib/api/queries/party.queries' + * + * export const load: PageLoad = async ({ parent, params }) => { + * const { queryClient } = await parent() + * + * await prefetchQuery(queryClient, partyQueries.byShortcode(params.id)) + * + * // No need to return data - it's in the cache + * } + * ``` + * + * @param queryClient - The QueryClient instance from parent layout + * @param options - Query options from a query factory + */ +export async function prefetchQuery( + queryClient: QueryClient, + options: { + queryKey: readonly unknown[] + queryFn: () => Promise + staleTime?: number + gcTime?: number + } +): Promise { + await queryClient.prefetchQuery({ + queryKey: options.queryKey, + queryFn: options.queryFn, + staleTime: options.staleTime, + gcTime: options.gcTime + }) +} + +/** + * Prefetches an infinite query on the server. + * + * Use this in +page.ts load functions when you want to prefetch + * paginated data into the QueryClient cache. + * + * @example + * ```typescript + * // +page.ts + * import type { PageLoad } from './$types' + * import { prefetchInfiniteQuery } from '$lib/query/ssr' + * import { partyQueries } from '$lib/api/queries/party.queries' + * + * export const load: PageLoad = async ({ parent }) => { + * const { queryClient } = await parent() + * + * await prefetchInfiniteQuery(queryClient, partyQueries.list()) + * } + * ``` + * + * @param queryClient - The QueryClient instance from parent layout + * @param options - Infinite query options from a query factory + */ +export async function prefetchInfiniteQuery( + queryClient: QueryClient, + options: { + queryKey: readonly unknown[] + queryFn: (context: { pageParam: number }) => Promise + initialPageParam: number + staleTime?: number + gcTime?: number + } +): Promise { + await queryClient.prefetchInfiniteQuery({ + queryKey: options.queryKey, + queryFn: options.queryFn, + initialPageParam: options.initialPageParam, + staleTime: options.staleTime, + gcTime: options.gcTime + }) +} + +/** + * Sets query data directly in the cache. + * + * Use this when you have data from a server load function and want + * to populate the QueryClient cache directly. This is useful when + * migrating from server-only load functions to TanStack Query. + * + * @example + * ```typescript + * // In a component or effect + * import { useQueryClient } from '@tanstack/svelte-query' + * import { setQueryData } from '$lib/query/ssr' + * import { partyKeys } from '$lib/api/queries/party.queries' + * + * const queryClient = useQueryClient() + * + * // Populate cache with server data + * setQueryData(queryClient, partyKeys.detail(shortcode), serverParty) + * ``` + * + * @param queryClient - The QueryClient instance + * @param queryKey - The query key to set data for + * @param data - The data to set in the cache + */ +export function setQueryData( + queryClient: QueryClient, + queryKey: readonly unknown[], + data: TData +): void { + queryClient.setQueryData(queryKey, data) +} diff --git a/src/routes/+layout.svelte b/src/routes/+layout.svelte index 7b066550..b09c94fc 100644 --- a/src/routes/+layout.svelte +++ b/src/routes/+layout.svelte @@ -11,14 +11,12 @@ import { authStore } from '$lib/stores/auth.store' import { browser } from '$app/environment' import { QueryClientProvider } from '@tanstack/svelte-query' - import { createQueryClient } from '$lib/query/queryClient' - - const queryClient = createQueryClient() + import type { LayoutData } from './$types' // Get `data` and `children` from the router via $props() - // Use a more flexible type that allows additional properties from child pages + // QueryClient is now created in +layout.ts for SSR support const { data, children } = $props<{ - data: any // Allow any data to pass through from child pages + data: LayoutData & { [key: string]: any } // Allow any data to pass through from child pages children: () => any }>() @@ -110,7 +108,7 @@ - +
diff --git a/src/routes/+layout.ts b/src/routes/+layout.ts new file mode 100644 index 00000000..52cf5021 --- /dev/null +++ b/src/routes/+layout.ts @@ -0,0 +1,34 @@ +/** + * Root Layout Load Function + * + * Creates a QueryClient instance for SSR support with TanStack Query v6. + * The QueryClient is created here so it can be used for prefetching in + * child page load functions. + * + * @module routes/+layout + */ + +import type { LayoutLoad } from './$types' +import { browser } from '$app/environment' +import { QueryClient } from '@tanstack/svelte-query' + +export const load: LayoutLoad = async () => { + const queryClient = new QueryClient({ + defaultOptions: { + queries: { + // Disable queries on server - they will be prefetched explicitly + enabled: browser, + // Cache data for 5 minutes before considering it stale + staleTime: 1000 * 60 * 5, + // Keep unused data in cache for 30 minutes + gcTime: 1000 * 60 * 30, + // Retry failed requests twice + retry: 2, + // Don't refetch on window focus by default + refetchOnWindowFocus: false + } + } + }) + + return { queryClient } +} diff --git a/src/routes/teams/[id]/+page.svelte b/src/routes/teams/[id]/+page.svelte index c5db8ee6..a953737d 100644 --- a/src/routes/teams/[id]/+page.svelte +++ b/src/routes/teams/[id]/+page.svelte @@ -1,12 +1,39 @@ -{#if data?.party} - +{#if party} + {:else}

Party not found