Saltar a contenido

Migrar al cliente

Dos puntos de partida: un frontend con la autenticación y las llamadas escritas a mano, o uno que ya usa el cliente generado por tai-api 0.6 (más abajo).

Desde una autenticación escrita a mano

Es lo habitual en un frontend anterior al cliente: un servicio que guarda los tokens en localStorage y envuelve fetch, un proveedor de sesión con su hook o su store, un componente que protege las rutas, unas constantes PERMISSIONS y una declaración de rutas que lo junta todo. El cliente hace lo mismo, generado desde la API. Esta página dice qué se borra, qué se cambia y qué se queda, siguiendo el protocolo.

Se puede migrar pantalla a pantalla: el cliente y vuestro servicio de autenticación conviven mientras dure, porque los dos hablan con la misma API. Lo único que no puede convivir es la sesión: o la lleva uno o la lleva el otro, así que los pasos 1 a 4 van juntos.

0. Antes de empezar

En la API, la sesión con cookie y el cliente, en el .taiapiproject (con "react": true o "svelte": true, según el frontend):

{
  "auth": { "type": "database", "config": { … }, "session": { "transport": "cookie" } },
  "frontend": { "output_dir": "../app/frontend/src/lib/api", "react": true }
}

Los permisos que tenéis en vuestras constantes, declarados en la API, en rbac/main.py:

app_permission("gestion_usuarios-access")
app_permission("gestion_usuarios-create")
app_permission("gestion_usuarios-update")
app_permission("gestion_usuarios-delete")

Después, tai-api generate y tai-api rbac push. En el frontend, el proxy /api (ver Despliegue): sustituye a la variable con la URL de la API (VITE_API_URL…).

1. Los permisos

Vuestras constantes PERMISSIONS se borran. Las generadas tienen la misma forma —PERMISSIONS.<RECURSO>.<ACCIÓN>—, así que basta con cambiar el import:

- import { PERMISSIONS } from '@/constants/permissions'
+ import { PERMISSIONS } from '@/lib/api'

PERMISSIONS.GESTION_USUARIOS.ACCESS sigue siendo 'gestion_usuarios-access'. La diferencia es que ahora sale de la API: si alguien renombra o borra un permiso del RBAC, el frontend deja de compilar en vez de dejar una pantalla inaccesible.

2. El proveedor de la sesión

El vuestro se sustituye por el del adaptador, que además recupera la sesión al cargar:

- <AuthProvider>
+ <TaiProvider client={api}>
- authStore.init()
+ setTaiClient(api)      // en el +layout.svelte raíz

Ver Montar la aplicación.

3. El hook o el store de la sesión

- const { user, loading, isAuthenticated, login, logout, hasRequiredPermission } = useAuth()
+ const { user, loading, isAuthenticated, login, logout, hasPermission } = useSession()
- import { auth } from '$lib/stores/auth'
+ const session = getSession()
- {#if $auth.user && hasRequiredPermission($auth.user, p)}
+ {#if $session.hasPermission(p)}
Lo habitual a mano En la sesión del cliente
user.username user.preferred_username
user.full_name user.name
user.permissions user.permissions
login(usuario, contraseña) login('main', usuario, contraseña): el realm va explícito
hasRequiredPermission(p) hasPermission(p)
hasRequiredRole(r) No hay: decidid por permisos, que son lo que se declara en el RBAC

Si tenéis muchos componentes con el hook o el store antiguo, podéis dejar uno vuestro que llame al del cliente y devuelva los nombres de antes, e ir cambiándolos con calma.

4. Las rutas y su protección

El componente que protegía las rutas se borra, y la declaración de rutas se queda, con pocos cambios:

- import { ProtectedRoute } from '@/auth/components/protected_route'
+ import { defineRoutes } from '@/lib/api/react'

- interface RouteConfig { … }
- export const routes: RouteConfig[] = [
+ export const routes = defineRoutes([
    { path: '/login', element: <LoginPage />, protected: false, enabled: true },
    { path: '/lotes', element: <LotesPage />, icon: Boxes, label: 'Lotes', protected: true, enabled: true },
    …
- ]
+ ])

- export function renderRoutes() { … }

Y donde se pintaban:

- <Routes>{renderRoutes()}</Routes>
+ <Routes>{renderRoutes(routes, { layout: <AppLayout />, notFound: <Page404 />, underConstruction: <UnderConstruction /> })}</Routes>

Lo que envolvía a las pantallas protegidas —el sidebar, la cabecera— pasa a un AppLayout con un <Outlet />, y ese es el layout.

Las comprobaciones que había en cada +page.svelte o en un +layout.svelte —«si no hay usuario, al login»— se sustituyen por la guarda del layout raíz, accessStore(routes, page), y la declaración pasa a src/lib/routes.ts con defineRoutes. Ver Proteger las pantallas.

protected: true y enabled: true se pueden quitar: son lo que vale por defecto.

En un árbol con children, el permiso de cada pantalla se comprueba, también el de los hijos: ver En árbol: con children.

5. El sidebar y la cabecera

Si el sidebar recorría las rutas y pintaba todas las que tuvieran label e icon, también enseñaba las que el usuario no podía abrir. Y si había una lista de secciones aparte, que repetía la ruta, el título y el icono de cada pantalla, se borra: el label y el icon van en la ruta, y el menú sale de la declaración.

- {routes.map(({ label, icon: Icon, path }) => { if (!Icon || !label) return null; … })}
+ {useNavigation(routes).map(({ label, icon: Icon, path, enabled }) => …)}
- {#each secciones as s}…{/each}
+ {#each $menú as item (item.id)}…{/each}      <!-- const menú = navigationStore(routes) -->

El título y las migas: ver El menú y las migas.

6. Las llamadas

El fetch envuelto y los servicios que solo construían URLs y copiaban tipos se van sustituyendo pantalla a pantalla:

- const BASE_URL = `${import.meta.env.VITE_API_URL_BASE}/public/producto`
- const r = await AuthService.authorizedFetchConMeta<ProductoAPI[]>(`${BASE_URL}?limit=20&includes=tipos_producto.tipo_producto`, 'GET')
+ const r = await api.public.producto.findMany({ limit: 20, includes: ['tipos_producto.tipo_producto'] })
+ // r.data[0].tipos_producto[0].tipo_producto.tipo   r.pagination?.total

Los tipos escritos a mano ya no se escriben: son ProductoRow, ProductoCreate…, importados de @/lib/api/public, y el de un registro con relaciones sale de sus includes. Las constantes de includes pasan de texto de URL a una lista as const, y de ella sale el tipo que reciben los componentes:

- const INCLUDES_DETALLE = ['includes=producto.tipos_producto.tipo_producto', 'includes=documentos_receta'].join('&')
- export interface RecetaDetalle extends RecetaAPI { producto?: ProductoAPI; documentos_receta?: DocumentoAPI[] }
+ const INCLUDES_DETALLE = ['producto.tipos_producto.tipo_producto', 'documentos_receta'] as const
+ export type RecetaDetalle = RecetaWith<typeof INCLUDES_DETALLE>
+ const receta = await api.public.receta.find(id, { includes: INCLUDES_DETALLE })   // RecetaDetalle

Con eso se van los ?. y los ?? [] sobre las relaciones pedidas, y un include que falta o que pasa del sexto nivel deja de compilar (Llamadas). Las agregaciones dejan de tiparse a mano («contrato de /agg, tipado a mano»): agg devuelve data.sum_cantidad y group.tipo_movimiento con su tipo.

Lo que se queda es vuestra lógica: las funciones que transforman para la pantalla, los cálculos, las validaciones de formulario.

Y las consultas de TanStack se quedan en una línea: ver Una función por pantalla. Lo que se va con ellas:

  • Las claves propias (['lotes', 'list'], MOVIMIENTOS_KEY) y las funciones que las invalidan a mano (invalidarStock): las keys son las de publicKeys, y una mutación invalida por tablas. Lo que un trigger cambia en otra tabla se dice con invalidate: ['producto'].
  • Las consultas de varias llamadas se quedan, pero como publicQuery({ key, tables, queryFn }): declaran las tablas que leen y les llega la invalidación. Las mutaciones compuestas, como publicMutation(queryClient, { tables, mutationFn }).
  • El onSuccess que pisaba la invalidación (useMutation({ ...m.x.create, onSuccess })): el callback va dentro de la factoría, m.x.create({ onSuccess }), y se suma.
  • PATCH + GET con includes en cada ficha: m.x.update({ includes }) lo hace en una petición y deja el registro en la key de la ficha.
  • El atajo del in_ vacío («si no hay códigos, devuelve { items: [] } sin llamar»): lo hace el cliente.
  • Las fechas y los bytes a mano (toDateInput, aFechaHoraApi, normalizarBytes): son toApiDate/toApiDateTime/fromApiDateTime/endOfDay y fileToBytes/bytesToObjectUrl.

7. Lo que se borra al terminar

  • El servicio de autenticación y el proveedor de sesión hechos a mano, con sus interfaces y sus datos de prueba.
  • Las constantes PERMISSIONS.
  • El componente que protegía las rutas y su función para pintarlas.
  • La lista de secciones del menú, si la había.
  • Los servicios que solo hacían fetch.
  • Los tokens en localStorage: el cliente no guarda ninguno.

Los mocks

Para trabajar sin la API, el sitio es el fetch del cliente: createTaiClient({ fetch: miFetchFalso }) recibe cada petición con su ruta y su método, y puede responder lo que quiera con el formato de la API ({ status: 'success', data: … }).

Desde el cliente de tai-api 0.6

El cliente de la 0.7 rompe compatibilidad (la API, solo en lo que cuenta Novedades): al regenerar, tsc señala cada sitio que cambia. Lo que hay que hacer, en el orden en que suele salir.

0. Regenerar

tai-api generate

Si falla con A012, el bloque frontend activa algo que el package.json no tiene —lo habitual, "svelte": true en un proyecto de React—: se quita del bloque o se instala. Después, npm run build.

1. Las mutaciones son factorías

- const crear = useMutation(m.lote_producto.create)
+ const crear = useMutation(m.lote_producto.create())

- const crear = useMutation({ ...m.lote_producto.create, onSuccess: cerrarModal })   // pisaba la invalidación
+ const crear = useMutation(m.lote_producto.create({ onSuccess: cerrarModal }))       // se suma a ella

- const editar = useMutation(m.receta.update)                 // releía sin includes: la ficha, sin tocar
+ const editar = useMutation(m.receta.update({ includes: INCLUDES_DETALLE }))   // deja la ficha al día

update lleva los includes en la factoría, no en mutate, y su resultado es el registro con ellos o null (ver TanStack).

2. Las keys

publicKeys.<tabla> era el prefijo; ahora es un objeto con la key de cada consulta:

- queryClient.invalidateQueries({ queryKey: publicKeys.lote_producto })
+ publicInvalidate(queryClient, ['lote_producto'])      // la tabla y lo que la lee por una relación
+ // o el prefijo a mano: publicKeys.lote_producto.all

Las keys propias (['lotes', 'list']) y las invalidaciones a mano (invalidarStock) pueden irse: una consulta de varias llamadas es un publicQuery({ key, tables, queryFn }) y le llega la invalidación de sus tablas (Lo propio).

3. El registro trae lo que se pidió

<Tabla>Read ya no tiene relaciones opcionales: son las columnas (<Tabla>Row). El tipo con relaciones lo da find/findMany a partir de los includes:

- const receta: RecetaRead = await api.public.receta.find(7, { includes: ['producto'] })
- receta.producto?.codigo ?? '—'
+ const receta = await api.public.receta.find(7, { includes: ['producto'] })
+ receta.producto.codigo
  • Una constante de includes necesita as const, y de ella sale el tipo de las props: type RecetaDetalle = RecetaWith<typeof INCLUDES_DETALLE>. Una string[] no compila.
  • Una función que recibía <Tabla>FindManyParams y se los pasaba a findMany no compila si lleva includes: ver Una función por pantalla.
  • Un placeholderData o un setQueryData a mano tienen que tener el tipo con los includes.
  • Las interfaces escritas a mano con relaciones opcionales se borran.

4. La sesión y el adaptador

- api.session.onSessionEnd(() => queryClient.clear())
+ connectQueryClient(api, queryClient)
  • Del adaptador de React se importa de @/lib/api/react. Sus ficheros se partieron en componentes y hooks (para Fast Refresh): @/lib/api/react/session y @/lib/api/react/routes ya no tienen los hooks.
  • El realm es de tipo Realm, la unión de los del proyecto: login(realm, …) con un string suelto no compila; login('main', …) sí.

5. El modo estricto

El cliente manda siempre X-Tai-Strict: 1: un filtro, un order_by o un includes que la API ignoraba —una errata como in_idd, una relación renombrada— es ahora un TaiApiError 422 con su field. Se arregla el nombre. Mientras tanto, createTaiClient({ strict: false }) lo apaga.

6. Lo que se puede borrar

  • Los atajos del in_ vacío («si no hay códigos, devuelve vacío sin llamar»): lo hace el cliente.
  • Las compensaciones (crear, y si lo siguiente falla, borrar): api.public.transaction, o m.transaction con TanStack (Varias tablas en una transacción).
  • Las conversiones de fechas y bytes: toApiDateTime, fromApiDateTime, toApiDate, endOfDay, fileToBytes, bytesToObjectUrl.
  • El servicio de /rbac escrito a mano: api.rbac(realm) (Usuarios).
  • El PATCH seguido de un GET en cada ficha: update(…, { returning: true, includes }).

7. Y en la API

Al regenerar, las escrituras anidadas piden el permiso de las tablas hijas —una pantalla que guarda hijos desde el formulario del padre con un rol que solo tiene el permiso del padre recibe 403—, el PATCH masivo no admite relaciones y un in_ vacío en un filtro de cuerpo no toca ninguna fila. Qué hacer con cada cosa, en Novedades.

Un doble de QueryClient en los tests (un QueryClientLike propio) necesita ahora invalidateQueries({ predicate }), removeQueries, setQueryData y clear.