Saltar a contenido

Llamadas y tipos

El paso 6 del protocolo. Todo lo de esta página es del núcleo del cliente y se escribe igual en React, en Svelte o en cualquier otro framework.

Una función por endpoint

api.<schema>.<tabla> tiene una función por cada endpoint que la API expone de esa tabla, y ninguna más:

Función Endpoint Devuelve
findMany(params?) GET /{tabla} { data, pagination? }, cada registro con sus includes
find(key, { includes? }) GET /{tabla}/{clave} el registro, con sus includes
count(filters?) · exists(filters?) GET /{tabla}/count · /exists number · boolean
sum · mean · max · min (field, filters?) GET /{tabla}/{campo}/… filas tipadas: data.sum_<campo>
agg(operations, filters?) POST /{tabla}/agg filas tipadas por la petición (agg)
create(values) POST /{tabla} el registro creado
createMany(values[]) POST /{tabla}/bulk los registros creados, en orden; todos o ninguno, hasta 1000
update(key, values) PATCH /{tabla}/{clave} filas afectadas
update(key, values, { returning: true, includes? }) PATCH /{tabla}/{clave} con Prefer: return=representation el registro actualizado, o null si ya no lo ves
updateMany({ filter, values }, { confirmAll? }) PATCH /{tabla} filas afectadas; values sin relaciones
delete(key) DELETE /{tabla}/{clave} filas afectadas
deleteMany(filters, { confirmAll? }) DELETE /{tabla} filas afectadas

Con returning: true, update devuelve el registro como queda, con esas relaciones, en la misma petición —la API lo relee en la misma transacción y con tu mismo acceso—. Es null solo si el cambio lo deja fuera de lo que puedes ver (una regla de filtrado por fila que ya no lo alcanza): se actualizó, pero no se puede devolver.

const receta = await api.public.receta.update(7, { nombre: 'Nueva' }, { returning: true, includes: ['producto'] })
receta?.producto.codigo

El registro lleva lo que se pidió en includes, y nada más

El tipo que devuelven find, findMany y update con returning sale de los includes: las columnas de la tabla (<Tabla>Row) y, de cada ruta pedida, la relación —con lo que cuelga de ella en la ruta—. Lo pedido no es opcional; lo que no se pidió no está en el tipo:

const receta = await api.public.receta.find(7, { includes: ['producto', 'pasos_receta.equipos.equipo'] })
receta.producto.codigo                              // ProductoRow: sin `?.` ni `?? []`
receta.pasos_receta[0]?.equipos[0]?.equipo.codigo   // una lista es una lista, también vacía
receta.documentos_receta                            // error de compilación: no se pidió

Cada relación llega como dice su clave foránea:

Relación Tipo Ejemplo en FLOR
uno a muchos una lista, [] si no hay filas orden_fabricacion.pasos
muchos a uno con la FK obligatoria el registro lote_producto.producto
muchos a uno con la FK que admite null, o uno a uno visto desde el lado sin la FK el registro o null lote_producto.of_productora

Una ruta que no existe o que pasa de max_depth (5 por defecto, el del .taiapiproject) no compila, con el mismo motivo que daría la API en modo estricto:

await api.public.consumo_subpaso.findMany({ includes: ['ciclo.paso_of.of.lotes_resultantes.producto.recetas'] })
// Type '"ciclo.paso_of.of.lotes_resultantes.producto.recetas"' is not assignable to type
// '"includes: 'ciclo.paso_of.of.lotes_resultantes.producto.recetas' tiene 6 niveles y el máximo es 5: lo que pasa de ahí llegaría vacío"'.

Las rutas tienen que ser literales: escritas en la llamada, o en una constante con as const. Un string[] no se puede comprobar ni tipar, y no compila. Con la constante se nombra además el tipo del registro, para las props de un componente:

const INCLUDES_DETALLE = ['producto.tipos_producto.tipo_producto', 'pasos_receta.productos.sustitutos.producto_sustituto'] as const

export type RecetaDetalle = RecetaWith<typeof INCLUDES_DETALLE>      // o PublicWith<'receta', typeof INCLUDES_DETALLE>
const receta: RecetaDetalle = await api.public.receta.find(7, { includes: INCLUDES_DETALLE })
  • El autocompletado de <Tabla>Include llega a dos niveles; las rutas más hondas se escriben a mano y se comprueban igual.
  • La API manda también las relaciones que no se pidieron, a null (todas las del registro, en todos los niveles). El tipo no las tiene: no se pueden leer por error como si estuvieran vacías.
  • El coste en el compilador crece con las rutas que se usan, no con el schema: la unión de todas las rutas posibles no se genera (en FLOR serían 13.848 a profundidad 5).

agg tipado por la petición

Las filas de agg se tipan con la petición escrita en la llamada (o declarada as const):

const filas = await api.public.movimiento_stock.agg({
  aggregations: { sum: ['cantidad'], count: ['id'] },
  group_by: ['tipo_movimiento', { field: 'created_at', trunc: 'month' }],
})
filas[0]!.data.sum_cantidad          // number | null
filas[0]!.data.count_id              // number
filas[0]!.group.tipo_movimiento      // TipoMovimiento
filas[0]!.group.created_at_month     // string
filas[0]!.data.mean_cantidad         // error de compilación: no se pidió
  • data lleva <operación>_<campo>: count es un número; sum y mean, un número o null (sin filas); max y min, el tipo de la columna (una fecha es texto) o null. Una expresión con alias da <operación>_<alias>, un número o null; sin alias solo se tipa si es una columna.
  • group lleva cada columna de group_by con su tipo, y una fecha truncada como <campo>_<trunc> (texto).
  • Cada operación admite las columnas que admite el DAO (<Tabla>AggColumns, de lo que tai-sql dice de cada columna): count, todas; sum y mean, los números; max y min, números, fechas y textos; una columna cifrada, hasheada o enumerada, solo count. group_by, lo que no es decimal, y una fecha con hora solo truncada. Lo demás la API lo rechaza con un 422; aquí no compila.
  • sum('cantidad'), max('fecha_caducidad')… se tipan igual: data.sum_cantidad, data.max_fecha_caducidad.

Varias tablas en una transacción

api.<schema>.transaction((tx) => [...]) → POST /<schema>/_tx: escrituras de varias tablas en una sola transacción, todas o ninguna. tx.<tabla>.<operación>(…) construye cada operación con los tipos de su tabla, y tx.ref(operación, 'campo') usa en otra una columna del registro que crea un create anterior. El resultado es el de cada operación, en orden y tipado:

const [movimiento, consumo] = await api.public.transaction((tx) => {
  const movimiento = tx.movimiento_stock.create({ id_lote: 44, cantidad: -12.5, unidad: 'kg', tipo_movimiento: 'consumo' })
  return [
    movimiento,
    tx.consumo_subpaso.create({ subpaso_id: 9, lote_producto_id: 44, cantidad: 12.5, movimiento_stock_id: tx.ref(movimiento, 'id') }),
  ]
})
movimiento.id      // MovimientoStockRead (las columnas)
consumo.cantidad   // ConsumoSubpasoRead
En tx.<tabla> Valores Resultado
create(values) <Tabla>Create <Tabla>Read
update(key, values) la clave y <Tabla>UpdateValues filas afectadas
delete(key) la clave filas afectadas
updateMany({ filter, values }, { confirmAll? }) <Tabla>Filters y <Tabla>UpdateManyValues filas afectadas
deleteMany(filters, { confirmAll? }) <Tabla>Filters filas afectadas
  • tx.ref(operación, 'campo') solo admite un create, y una columna de su tabla; el valor tiene el tipo de esa columna (el email de un autor no vale como autor_id). Puede ir en cualquier campo, en la clave o en un filtro. tx.ref(0, 'id'), con el índice, es el $ref de la API tal cual: sin comprobar.
  • La referencia apunta a la operación, no a su posición: la posición se calcula al enviar. Una referencia a una operación que no va antes en la lista es un TypeError en el cliente, sin llamar a la API.
  • Cada operación pide el permiso y aplica el RLS de su endpoint, y la API los comprueba antes de escribir nada. Si una falla, TaiApiError con field operations.<i>… (operations.1.values.titulo, operations.2).
  • Un in_ vacío en el filtro de un masivo no alcanza ninguna fila: la operación da 0.
  • La lista también se puede construir fuera, con publicTx (el mismo tx): api.public.transaction([publicTx.autor.create({ nombre: 'Ana' })]).
  • Si una tabla se llama transaction, la función es api.<schema>.$transaction; si se llama ref, tx.$ref.

Con TanStack, publicMutations(...).transaction (TanStack).

Un masivo no lleva relaciones: updateMany recibe <Tabla>UpdateManyValues, las columnas sin las escrituras anidadas —un anidado nombra los hijos de un registro, y un masivo tiene un filtro—. La API las rechaza con un 422 sobre values.

Un in_ vacío no llama a la API

Una lista vacía no viaja en una query string, y la API, sin el filtro, respondería con todas las filas: filtrar lotes por los productos de una búsqueda que no encontró ninguno devolvería todos los lotes. El cliente lo corta antes: con algún in_ a [], findMany, count, exists, las agregaciones, updateMany y deleteMany no hacen la petición y responden lo que respondería la API con un filtro que no casa con nada —{ data: [] } (con su pagination si se pidió limit u offset), 0, false, la fila de una agregación sin filas, 0—.

const codigos = productos.map((p) => p.codigo)       // puede quedar vacío
const { data } = await api.public.lote_producto.findMany({ in_codigo_producto: codigos, limit: 20 })

Una vista no tiene create, update ni delete: llamarlos no compila. Una clave compuesta es un objeto con sus columnas: find({ publicacion_id: 1, etiqueta: 'x' }). Toda función acepta al final { signal } para cancelarla.

Los tipos

De cada tabla, en import type { … } from '@/lib/api/<schema>':

Tipo Qué es
<Tabla>Row Las columnas de un registro. Lo anulable es T \| null. Es lo que devuelven create y una lectura sin includes
<Tabla>Read Alias de <Tabla>Row
<Tabla>With<I> El registro con los includes I (typeof CONSTANTE, declarada as const); igual que <Schema>With<'<tabla>', I>
<Tabla>Relations Sus relaciones: a qué tabla lleva cada una y si llega como lista, como registro o como registro o null
<Tabla>AggColumns Qué columnas admite cada agregación
<Tabla>Create Lo que se envía al crear, con sus obligatorios
<Tabla>UpdateValues Lo que se envía al actualizar: todo opcional, con las escrituras anidadas
<Tabla>UpdateManyValues Los values de updateMany: las columnas, sin relaciones
<Tabla>Filters Los filtros que acepta la API, y solo esos
<Tabla>FindManyParams Filtros + limit, offset, order_by, order, includes
<Tabla>ListParams<I> Lo que recibe findMany: lo mismo, con los includes I comprobados. Para una función propia que pasa los suyos a findMany (ejemplo): con <Tabla>FindManyParams no compila
<Tabla>Include Las rutas de includes para el autocompletado, hasta la profundidad 2 (las más hondas se comprueban igual)
<Tabla>Key La clave primaria
<Enumerado> La lista de valores y su tipo: Estado y 'borrador' \| 'publicada'
  • Los nombres de los campos son los de la base de datos, en snake_case, igual que en la API: no hay ningún mapeo en tiempo de ejecución, y el mismo nombre se busca de la tabla al componente.
  • Las fechas son texto ISO, sin convertir a Date, y datetime va sin zona horaria: ver Fechas.
  • bytes es Base64: un string en base64, ver Bytes.
  • bigint y numeric son number, y su JSDoc avisa de que pierden precisión por encima de 2^53.
  • Una columna hash=True no aparece en Row, y una cifrada no tiene filtros.
  • <Schema>Tables es el registro del schema (row, relations e include de cada tabla) y <Schema>MaxDepth, su max_depth: de ellos salen <Schema>With y <Schema>Includes (las rutas comprobadas).

Los tipos salen de las mismas reglas que los DTOs de la API, y un test de tai-api los compara con el OpenAPI de la API real: si difieren, no se publica.

Tus endpoints: custom/

Lo que expone routers/custom/ en la API no lo genera tai-api, pero se registra en el cliente igual que la API monta routers/custom/: se escribe en la carpeta custom/ del cliente, que es tuya (tai-api generate no la escribe, no la borra y no la reescribe), y su index.ts declara la lista de dominios propios con defineCustomResources. createTaiClient la registra junto a los schemas, y en la aplicación se usa como lo generado:

// src/lib/api/custom/informes/resources.ts
import type { HttpClient } from '../../core/http'

export function informesResources(http: HttpClient) {
  return {
    resumen: (body: Filtro) => http.request<Resumen>({ method: 'POST', path: '/informes/resumen', body }),
    pdf: () => http.raw({ method: 'GET', path: '/informes/resumen.pdf' }),
  }
}
// src/lib/api/custom/index.ts: la lista de lo propio, un dominio por router de routers/custom/
import { defineCustomResources } from '../core/custom'
import { informesResources } from './informes/resources'

export const customResources = defineCustomResources({
  informes: informesResources,                 // routers/custom/informes → api.informes
})

Un dominio que necesita algo más que el cliente HTTP —un realm, una URL— es una función que lo recibe y devuelve el dominio: facturas: facturasResources(REALM), con facturasResources(realm) { return (http) => ({ … }) }. La gestión de /rbac no hace falta escribirla: el cliente la genera (api.rbac(realm)), y rbac es un nombre reservado. El index.ts generado tiene una zona propia para lo propio, con estos mismos pasos, y createTaiClient solo la junta con la de los schemas.

// en cualquier pantalla, con el mismo `api`
const resumen = await api.informes.resumen({ desde: '2026-01-01' })
  • custom/ no importa el index.ts del cliente (from '..'): ese index.ts importa a custom/, y sería un ciclo. Los tipos se importan de ../core/* y de ../<schema>, y la instancia (createTaiClient()) se crea fuera, como siempre. generate avisa si lo encuentra.
  • Un nombre propio no puede pisar uno del cliente (http, session, rbac —el /rbac generado— o un schema): no compila.
  • Con TanStack, cada dominio puede tener su query.ts con la misma forma que los generados: keys bajo ['tai', '<dominio>', …] y mutaciones { mutationFn, onSuccess } que invalidan lo que cambia —lo propio no sabe qué tablas toca: lo dice cada mutación—.
  • generate registra custom/ si existe al generar: si lo creas después, vuelve a generar. Sin customResources, avisa y no lo registra. Vale también una función propia (export function customResources(http) { … }); la lista es lo recomendado.
  • Los tipos de custom/ son tuyos: el compilador no sabe si la API cambia. Los de los schemas sí salen del contrato.

Tus endpoints, y los binarios: api.http

Lo que no es una tabla —los endpoints de routers/custom/, una imagen, un PDF— se pide con api.http, con la misma sesión que el resto: el token, un solo refresco compartido si ha caducado y la expulsión si la sesión se cerró.

// Un endpoint propio que responde un APIResponse: `request` devuelve su `data`
const respuesta = await api.http.request<SendMessageResponse>({ method: 'POST', path: '/assistant/send', body: { texto } })

// Un binario: `raw` devuelve la Response sin leer
const imagen = await (await api.http.raw({ method: 'GET', path: '/assistant/imagen/42' })).blob()
img.src = URL.createObjectURL(imagen)

Un <img src="/api/…"> no manda el token, así que una imagen de un endpoint protegido se descarga con raw. Hacerlo con un fetch a mano funciona hasta que el access caduca: no refresca, no sigue la baseUrl y no se entera de una expulsión. Con raw, un error de la API llega como un TaiApiError, igual que en el resto.

Fechas

Una columna datetime es TIMESTAMP sin zona: la hora de pared, la que marca el reloj de la planta, y viaja como 2026-10-15T09:30:00. Date.toISOString() no sirve para escribirla —da UTC y desplaza la hora, y una fecha de calendario a medianoche cae en el día anterior—, ni new Date('2026-10-15') para leer una date —la toma como medianoche UTC, que al oeste de Greenwich es el día 14—. El cliente trae las conversiones, con las partes locales del Date:

import { toApiDateTime, fromApiDateTime, toApiDate, fromApiDate, startOfDay, endOfDay } from '@/lib/api'

await api.public.orden_fabricacion.update(3, { fecha_inicio: toApiDateTime(new Date()) })
const inicio: Date = fromApiDateTime(of.fecha_inicio)          // las 9:30 aquí
const caducidad = toApiDate(fechaDelCalendario)               // '2026-10-15'

// Un día entero en un rango (`min_`/`max_` incluyen los extremos)
await api.public.movimiento_stock.count({ min_created_at: startOfDay(día), max_created_at: endOfDay(día) })
// startOfDay → '2026-10-15T00:00:00', endOfDay → '2026-10-15T23:59:59.999999'

endOfDay lleva los microsegundos: con T23:59:59, lo del último segundo del día se quedaba fuera. fromApiDateTime respeta la zona si el texto la trae (una columna timestamptz).

Bytes

Una columna bytes (una imagen, un documento) viaja en base64: la API la devuelve en base64 estándar con relleno, y acepta estándar o url-safe, sin el prefijo data:…;base64, —con él es un 422—. El tipo es Base64, un alias de string:

import { fileToBytes, bytesToObjectUrl, bytesToBlob, sniffMime } from '@/lib/api'

const imagen = await fileToBytes(input.files[0])                 // sin prefijo data:
await api.public.equipo_imagen.create({ equipo_codigo: 'MEZ-01', imagen })

const url = bytesToObjectUrl(registro.imagen)                    // para <img src>; el tipo, de sus bytes
// … y URL.revokeObjectURL(url) cuando deje de verse
const pdf = bytesToBlob(documento.archivo, 'application/pdf')    // para descargarlo
sniffMime(documento.archivo)                                     // 'application/pdf', 'image/png'… o null

Un FileReader.readAsDataURL da justo el prefijo que la API rechaza; fileToBytes no lo pone. Y bytesToObjectUrl mejor que una URL data:: no mete una cadena del tamaño del fichero en el DOM.

Los errores

import { isTaiApiError, isTaiNetworkError } from '@/lib/api'

try {
  await api.public.publicacion.create(valores)
} catch (error) {
  if (isTaiApiError(error)) {
    error.status        // 409
    error.code          // 'DUPLICATE_RECORD'
    error.userMessage   // el mensaje para el usuario, si la API lo da
    error.fieldErrors   // { titulo: '…' } en un 422, para el formulario
    error.reason        // 'concurrent_login', 'refresh_reused'…
    error.constraint    // 'uq_lote_codigo': la restricción que falló en un 409
    error.requiredPermission   // 'lote_producto-update': el permiso que faltaba en un 403
  } else if (isTaiNetworkError(error)) {
    // sin red: no es un error de la API, y no cierra la sesión
  }
}

Con el código, isTaiApiError estrecha el tipo —error.code es ese literal— y un código que la API no tiene no compila:

if (isTaiApiError(error, 'DUPLICATE_RECORD') && error.constraint === 'uq_lote_codigo') {
  form.setError('codigo_lote', 'Ya existe un lote con ese código')
}

Todo lo que no es un 2xx es un TaiApiError. Un 204 es null. Ver los códigos de error.

La marca del contrato

El cliente conoce la marca del contrato de cada schema contra el que se generó, y la compara con la de la primera respuesta. Si no coinciden, avisa una vez por consola: el cliente se generó contra otra versión de la API. Se cambia con createTaiClient({ onContractMismatch: (diferencias) => … }), o se desactiva con false.

Opciones de createTaiClient

Opción Qué es Por defecto
baseUrl De dónde cuelga la API el base_path del .taiapiproject
fetch El fetch que usar: el de SvelteKit en un load, uno de prueba en un test globalThis.fetch
onContractMismatch Qué hacer si la API es otra versión un console.warn
strict El modo estricto (X-Tai-Strict: 1): un filtro, un order_by o un includes que no existen son un TaiApiError 422 en vez de ignorarse true