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 |
updaterelee con losincludesde la factoría —de ellos sale el tipo del registro; por eso no van en las variables demutate— en la misma petición (Prefer: return=representation) y deja el registro enpublicKeys.<tabla>.find(clave, { includes }): la key de la ficha que lo pinta, que no se vuelve a pedir. Esnullsi el cambio lo dejó fuera de tu acceso; entonces solo se invalida.deletequita de la caché elfindde esa clave (con cualquierincludes): volver a pedirlo sería un 404.- Las opciones de
useMutationque no son de la factoría van con spread:useMutation({ ...m.lote_producto.create(), retry: 2 }). UnonSuccesspuesto 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.