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>Includellega 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ó
datalleva<operación>_<campo>:countes un número;sumymean, un número onull(sin filas);maxymin, el tipo de la columna (una fecha es texto) onull. Una expresión conaliasda<operación>_<alias>, un número onull; sin alias solo se tipa si es una columna.grouplleva cada columna degroup_bycon 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;sumymean, los números;maxymin, números, fechas y textos; una columna cifrada, hasheada o enumerada, solocount.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 uncreate, y una columna de su tabla; el valor tiene el tipo de esa columna (elemailde un autor no vale comoautor_id). Puede ir en cualquier campo, en la clave o en un filtro.tx.ref(0, 'id'), con el índice, es el$refde 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
TypeErroren 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,
TaiApiErrorconfieldoperations.<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 mismotx):api.public.transaction([publicTx.autor.create({ nombre: 'Ana' })]). - Si una tabla se llama
transaction, la función esapi.<schema>.$transaction; si se llamaref,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, ydatetimeva sin zona horaria: ver Fechas. bytesesBase64: unstringen base64, ver Bytes.bigintynumericsonnumber, y su JSDoc avisa de que pierden precisión por encima de 2^53.- Una columna
hash=Trueno aparece enRow, y una cifrada no tiene filtros. <Schema>Tableses el registro del schema (row,relationseincludede cada tabla) y<Schema>MaxDepth, sumax_depth: de ellos salen<Schema>Withy<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 elindex.tsdel cliente (from '..'): eseindex.tsimporta acustom/, y sería un ciclo. Los tipos se importan de../core/*y de../<schema>, y la instancia (createTaiClient()) se crea fuera, como siempre.generateavisa si lo encuentra.- Un nombre propio no puede pisar uno del cliente (
http,session,rbac—el/rbacgenerado— o un schema): no compila. - Con TanStack, cada dominio puede tener su
query.tscon 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—. generateregistracustom/si existe al generar: si lo creas después, vuelve a generar. SincustomResources, 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 |