Saltar a contenido

TanStack Query

El paso 7 del protocolo. Con "tanstack": true, cada schema trae <schema>/query.ts: las consultas y las mutaciones de cada tabla, sus keys, la invalidación ya hecha y lo necesario para que las consultas y mutaciones que escribís vosotros reciban la misma invalidación sin inventar keys.

Todo son objetos planos que no importan TanStack: sirven igual en useQuery, useInfiniteQuery y useMutation de @tanstack/react-query y en createQuery, createInfiniteQuery y createMutation de @tanstack/svelte-query.

Pieza Qué es
publicQueries(api) Las consultas de cada tabla: findMany, find, count, exists, sum…, agg e infinite
publicMutations(api, queryClient) Las mutaciones de cada tabla, como factorías: m.lote.create({ onSuccess })
publicMutations(...).transaction Una transacción de varias tablas como mutación, con el resultado tipado y la invalidación de todas ellas
publicKeys Las keys, las mismas que usan las consultas
publicAffects Qué tablas cambian al escribir en cada una
publicQuery({ key, tables, queryFn }) Una consulta propia, con la invalidación de las tablas que lee
publicMutation(queryClient, { tables, mutationFn }) Una mutación propia, que invalida como una generada
publicInvalidate(queryClient, tablas) Invalidar a mano lo que cambia al escribir en esas tablas
connectQueryClient(api, queryClient) Vaciar la caché al terminar la sesión

(public es el nombre del schema: con otro, ventasQueries, ventasKeys…)

Lo que hay que montar una vez

TanStack necesita un QueryClient y su QueryClientProvider envolviendo la aplicación. El cliente de tai-api no los crea: la política de caché es vuestra. Va en la raíz, junto al cliente: ver Montar la aplicación.

import { connectQueryClient } from '@/lib/api'

const queryClient = new QueryClient({
  defaultOptions: {
    queries: {
      staleTime: 0,                 // cada pantalla que se monta vuelve a pedir: dato siempre fresco
      retry: 1,
      refetchOnWindowFocus: false,
    },
  },
})

// Al terminar la sesión —logout, caducidad, otro dispositivo—, fuera los datos del usuario.
connectQueryClient(api, queryClient)

La caché

Con staleTime: 0, cada montaje de pantalla vuelve a pedir: es lo sensato con varios puestos escribiendo a la vez. Una pantalla ya abierta no se refresca sola; para eso, refetchOnWindowFocus: true o un refetchInterval en esa consulta. Lo que sí está hecho es que cada escritura invalida lo que cambia, así que tras crear un lote, la lista de lotes se vuelve a pedir.

Consultas

import { keepPreviousData, useQuery } from '@tanstack/react-query'
import { publicQueries } from '@/lib/api/public/query'
import { api } from '@/lib/tai'

const q = publicQueries(api)

const lotes = useQuery({
  ...q.lote_producto.findMany({ in_estado: ['disponible'], includes: ['producto'], order_by: ['fecha_caducidad'], limit: 20 }),
  placeholderData: keepPreviousData,
})
const lote = useQuery({ ...q.lote_producto.find(id!, { includes: ['producto', 'of_productora'] }), enabled: id !== undefined })
// lotes.data?.data, lotes.isPending, lotes.error
<script lang="ts">
  import { createQuery } from '@tanstack/svelte-query'
  import { publicQueries } from '$lib/api/public/query'
  import { api } from '$lib/tai'

  const q = publicQueries(api)

  const lotes = createQuery(q.lote_producto.findMany({ in_estado: ['disponible'], includes: ['producto'], limit: 20 }))
  const lote = createQuery({ ...q.lote_producto.find(7, { includes: ['producto'] }), enabled: true })
</script>

{#if $lotes.isPending}Cargando…{:else if $lotes.data}{#each $lotes.data.data as l (l.id)}…{/each}{/if}

Es la forma de @tanstack/svelte-query 5, un store. En la 6, para Svelte 5, se le pasa una función —createQuery(() => q.lote_producto.findMany(filtros))— y el resultado se lee sin $.

Cada consulta es { queryKey, queryFn }: el spread es la forma de añadirle lo de la pantalla —enabled, placeholderData, select, staleTime—, igual en React y en Svelte.

find, findMany e infinite se tipan con sus includes, como las llamadas (Llamadas): lote.data es LoteProductoWith<['producto', 'of_productora']>, con producto sin ? y of_productora que puede ser null. Una ruta que no existe no compila tampoco aquí.

Agregaciones

const reparto = useQuery(q.movimiento_stock.agg(
  { aggregations: { sum: ['cantidad'], count: ['id'] }, group_by: ['tipo_movimiento', 'unidad'] },
  { id_lote: idLote },
))
// reparto.data: [{ group: { tipo_movimiento: 'consumo', unidad: 'kg' }, data: { sum_cantidad: -840, count_id: 56 } }, …]
// reparto.data[0].data.sum_cantidad: number | null — tipado por la petición, como `agg` en las llamadas

Y sum, mean, max y min de un campo: q.lote_producto.sum('cantidad_disponible', filtros).

Scroll infinito

infinite da las opciones de useInfiniteQuery/createInfiniteQuery: páginas de pageSize (20 por defecto) con limit y offset, hasta la última. Sabe cuál es la última por has_next de la paginación; si la API no da el total, mientras la página llegue llena.

const libro = useInfiniteQuery({
  ...q.movimiento_stock.infinite({ id_lote: idLote, order_by: ['created_at'], order: 'DESC' }, { pageSize: 50 }),
  enabled: idLote !== null,
})
const filas = libro.data?.pages.flatMap((p) => p.data) ?? []
const total = libro.data?.pages[0]?.pagination?.total          // el contador de la cabecera
// libro.fetchNextPage(), libro.hasNextPage

Las keys

Jerárquicas y deterministas, y exportadas: publicKeys da la misma key que usa cada consulta.

publicKeys.lote_producto.all                                    // ['tai', 'public', 'lote_producto']
publicKeys.lote_producto.find(7, { includes: ['producto'] })    // la de q.lote_producto.find(7, { includes: ['producto'] })
publicKeys.lote_producto.findMany({ limit: 20 })
publicKeys.lote_producto.find(7)                                // sin el último argumento: el prefijo,
publicKeys.lote_producto.findMany()                             // todas sus variantes

Los parámetros van ordenados, y también las listas cuyo orden no cambia la respuesta —includes y los in_—: el mismo filtro escrito en otro orden no duplica la caché.

Sembrar una ficha con lo que ya trajo una página de la lista:

const INCLUDES = ['producto'] as const
const queryClient = useQueryClient()
const lote = useQuery({
  ...q.lote_producto.find(id, { includes: INCLUDES }),
  placeholderData: () => {
    for (const [, página] of queryClient.getQueriesData<Page<LoteProductoWith<typeof INCLUDES>>>({ queryKey: publicKeys.lote_producto.findMany() })) {
      const encontrado = página?.data.find((l) => l.id === id)
      if (encontrado?.producto) return encontrado     // una página pedida sin `producto` lo trae a null
    }
    return undefined
  },
})

El placeholderData tiene el tipo de la ficha —el registro con sus includes—, así que lo que se saca de la caché tiene que traerlos: un LoteProductoRead (las columnas) no compila ahí.

Mutaciones

Cada mutación es una factoría: lo que le pasa la pantalla —onSuccess, onError, onSettled, invalidate— se suma a la invalidación generada, no la pisa.

import { useMutation, useQueryClient } from '@tanstack/react-query'
import { publicMutations } from '@/lib/api/public/query'

const m = publicMutations(api, useQueryClient())

const crear = useMutation(m.equipo.create({ onSuccess: (equipo) => navigate(`/equipos/${equipo.codigo}`) }))
const editar = useMutation(m.receta.update({ includes: ['producto', 'pasos_receta.equipos'] }))
const borrar = useMutation(m.lote_producto.delete({ onError: (e) => toast(e.message) }))

editar.mutate({ key: 7, values: { nombre: 'Nueva' } })
<script lang="ts">
  import { createMutation, useQueryClient } from '@tanstack/svelte-query'
  import { publicMutations } from '$lib/api/public/query'

  const m = publicMutations(api, useQueryClient())

  const crear = createMutation(m.equipo.create({ onSuccess: (equipo) => goto(`/equipos/${equipo.codigo}`) }))
  const editar = createMutation(m.receta.update({ includes: ['producto'] }))
</script>

<button onclick={() => $editar.mutate({ key: 7, values: { nombre: 'Nueva' } })}>Guardar</button>

Cuando termina —bien o mal—, la mutación arranca la invalidación y después llama al callback de la pantalla, y no se da por terminada hasta que acaban los dos: isPending dura hasta que la pantalla tiene los datos nuevos, así que el botón no se vuelve a habilitar con la ficha vieja delante. Todo va en onSettled, así que también se invalida cuando falla: la otra mitad de una escritura compuesta puede haber entrado.

Factoría Variables Devuelve
create(opciones?) los valores el registro
createMany(opciones?) la lista de valores los registros
update({ includes?, …opciones }) { key, values } el registro con esos includes (<Tabla>With<I>), o null
updateMany(opciones?) { filter, values, confirmAll? } filas afectadas
delete(opciones?) la clave filas afectadas
deleteMany(opciones?) { filters, confirmAll? } filas afectadas
  • update relee con los includes de la factoría —de ellos sale el tipo del registro; por eso no van en las variables de mutate— en la misma petición (Prefer: return=representation) y deja el registro en publicKeys.<tabla>.find(clave, { includes }): la key de la ficha que lo pinta, que no se vuelve a pedir. Es null si el cambio lo dejó fuera de tu acceso; entonces solo se invalida.
  • delete quita de la caché el find de esa clave (con cualquier includes): volver a pedirlo sería un 404.
  • Las opciones de useMutation que no son de la factoría van con spread: useMutation({ ...m.lote_producto.create(), retry: 2 }). Un onSuccess puesto así, fuera de la factoría, se ejecuta antes de la invalidación.

Qué invalida

Una mutación invalida las consultas que leen la tabla en la que escribe o una vecina suya (publicAffects.<tabla>: ella, las que la incluyen por una relación y las vistas que declaran depender de ella). Una consulta generada lee su tabla y las de sus includes —q.producto.find(1, { includes: ['lotes_producto.movimientos_stock'] }) lee producto, lote_producto y movimiento_stock—; una propia, las tables que declara.

Lo que cambia por un trigger, en otra tabla, no lo puede saber el cliente. Se le dice con invalidate:

// Un movimiento recalcula el stock del producto (un trigger de movimiento_stock)
useMutation(m.movimiento_stock.create({ invalidate: ['producto'] }))

Y lo que cambia por fuera de las mutaciones —otro puesto, un evento—, con publicInvalidate(queryClient, ['orden_fabricacion']).

Lo propio: consultas y mutaciones que no son un endpoint

Una pantalla que lee de varias llamadas —una página y su recuento, un filtro en dos consultas, un recorrido— o una mutación de varios pasos no tiene que inventar keys: declara las tablas y le llega la misma invalidación que a las generadas.

import { publicMutation, publicQuery } from '@/lib/api/public/query'

// Una consulta propia: la key va bajo ['tai', 'public', '_q', …] y lleva sus tablas en `meta`
export const linaje = (idLote: number) => publicQuery({
  key: ['linaje', idLote],
  tables: ['lote_producto', 'consumo_subpaso', 'subpaso_ejecucion', 'paso_of', 'orden_fabricacion'],
  queryFn: ({ signal }) => fetchLinaje(idLote, signal),
})
const traza = useQuery({ ...linaje(id), enabled: pestaña === 'trazabilidad' })

// Una mutación compuesta: invalida sus tablas como una generada, y suma los callbacks igual
export const useRegistrarPesada = () => {
  const queryClient = useQueryClient()
  return useMutation(publicMutation(queryClient, {
    tables: ['movimiento_stock', 'consumo_subpaso'],
    invalidate: ['producto'],                      // lo que recalculan los triggers
    mutationFn: async (pesada: Pesada) => {
      const movimiento = await api.public.movimiento_stock.create({ … })
      return api.public.consumo_subpaso.create({ …, movimiento_stock_id: movimiento.id })
    },
  }))
}

tables también puede ser una función de las variables —(operaciones) => operaciones.map((o) => o.table)— para una mutación cuyas tablas dependen de lo que se envía.

Una transacción

m.transaction(constructor, opciones?) es una transacción (api.public.transaction) como mutación: el constructor recibe tx y las variables de mutate, y el resultado es el de cada operación, tipado. Invalida lo que cambia en todas las tablas en las que escribe —con sus vecinas, como una generada—, más las de invalidate; y suma los callbacks igual.

const m = publicMutations(api, useQueryClient())

const registrarPesada = useMutation(m.transaction((tx, pesada: Pesada) => {
  const movimiento = tx.movimiento_stock.create({ id_lote: pesada.lote, cantidad: -pesada.kg, unidad: 'kg', tipo_movimiento: 'consumo' })
  return [
    movimiento,
    tx.consumo_subpaso.create({ subpaso_id: pesada.ciclo, lote_producto_id: pesada.lote, cantidad: pesada.kg, movimiento_stock_id: tx.ref(movimiento, 'id') }),
  ]
}, {
  invalidate: ['producto', 'lote_producto'],      // lo que recalculan los triggers del movimiento
  onSuccess: ([movimiento, consumo]) => toast(`Pesada ${consumo.id}`),
}))

registrarPesada.mutate({ lote: 44, ciclo: 9, kg: 12.5 })

Su mutationKey es ['tai', 'public', '_tx']. Si falla, no queda nada escrito —es una transacción—, el onError recibe el TaiApiError con la operación en field, y también se invalida.

Una función por pantalla

Si preferís tener las consultas de cada pantalla en un fichero suyo, se quedan en una línea:

// src/pages/lotes/hooks/useLotes.ts
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { publicMutations, publicQueries } from '@/lib/api/public/query'
import type { LoteProductoFindManyParams, LoteProductoRead } from '@/lib/api/public'
import { api } from '@/lib/tai'

const q = publicQueries(api)
export const useLotes = (params: Omit<LoteProductoFindManyParams, 'includes'>) =>
  useQuery(q.lote_producto.findMany({ ...params, includes: ['producto'] }))
export const useLote = (id: number) => useQuery(q.lote_producto.find(id, { includes: ['producto'] }))

export function useCrearLote(alCrear?: (lote: LoteProductoRead) => void) {
  return useMutation(publicMutations(api, useQueryClient()).lote_producto.create({ onSuccess: alCrear }))
}
// src/lib/lotes/consultas.ts
import { createMutation, createQuery, useQueryClient } from '@tanstack/svelte-query'
import { publicMutations, publicQueries } from '$lib/api/public/query'
import type { LoteProductoFindManyParams } from '$lib/api/public'
import { api } from '$lib/tai'

const q = publicQueries(api)
export const lotes = (params: Omit<LoteProductoFindManyParams, 'includes'>) =>
  createQuery(q.lote_producto.findMany({ ...params, includes: ['producto'] }))
export const lote = (id: number) => createQuery(q.lote_producto.find(id, { includes: ['producto'] }))
export const crearLote = () => createMutation(publicMutations(api, useQueryClient()).lote_producto.create())

Se llaman al inicializar el componente, como los createQuery de dentro.

Los includes van dentro de la función: así son literales y tipan el resultado. Un parámetro LoteProductoFindManyParams con sus includes no compila al pasarlo a findMany —su includes es una lista cualquiera de rutas, que no se puede comprobar—. Si la pantalla tiene que elegirlos, la función es genérica con LoteProductoListParams:

export const useLotes = <const I extends readonly LoteProductoInclude[] = []>(params: LoteProductoListParams<I>) =>
  useQuery(q.lote_producto.findMany(params))

useLotes({ limit: 20, includes: ['producto'] }).data?.data[0]?.producto.codigo

No hay URLs, tipos copiados ni query keys inventadas.

La gestión de usuarios

rbacQueries(api, realm) y rbacMutations(api, queryClient, realm) siguen el mismo estilo, con sus keys en rbacKeys(realm) (['tai', 'rbac', realm, 'users' | 'roles' | 'rls', …]). Ver Usuarios.