Client Operations

packages/client-operations/README.md

@repo/client-operations

Utilidades opt-in para estandarizar queries, mutations y errores de operaciones iniciadas desde una app cliente. Reexporta AppError desde @repo/app-errors, pero no implementa servicios ni presenta UI.

Responsabilidades

  • AppError reexportado de @repo/app-errors ofrece un contrato runtime neutral para fallos de validación, negocio, permisos, autenticación, red y resultados no encontrados.
  • toAppError normaliza errores desconocidos y objetos con forma Firebase sin depender del SDK.
  • unwrapOperationResult convierte respuestas callable discriminadas en éxito o AppError.
  • useAppQuery y useAppMutation conectan definiciones de operación con TanStack Query y normalizan excepciones.
  • ClientOperationsProvider y createQueryClient proporcionan configuración opcional de queries y mutations.
  • createQueryKeyFactory mantiene keys con namespace y soporte de invalidación por raíz.

Este paquete no importa @repo/ui, Firebase, Next.js ni código de apps/. Los servicios viven en @repo/app-services; los textos visibles, toasts, errores de formulario, navegación y efectos posteriores al éxito pertenecen al consumidor.

Instalación por app

El package declara React y TanStack Query como peers. Una app que lo adopta debe instalarlos y montar el provider deliberadamente. apps/system ya lo monta desde app/root-layout-client.tsx.

tsx
'use client';

import { ClientOperationsProvider } from '@repo/client-operations';

export function Providers({ children }: { children: React.ReactNode }) {
  return <ClientOperationsProvider>{children}</ClientOperationsProvider>;
}

Errores de servicio

Los servicios importan AppError desde @repo/app-errors, usan códigos estables y preservan el error original en cause. message es un mensaje técnico seguro, no copy de producto. La UI traduce code/kind a copy local; nunca presenta directamente message ni texto crudo del SDK.

ts
import { AppError } from '@repo/app-errors';

const adapterError = new Error('adapter failure');

throw new AppError({
  code: 'ESTATE_PERMISSION_DENIED',
  kind: 'permission',
  message: 'Estate update rejected',
  cause: adapterError,
});

En límites que aceptan errores desconocidos, toAppError(error, { mapError }) admite un mapper del dominio. Úsalo como normalización de borde, no para inferir por texto errores de dominio que el servicio puede tipar directamente. La normalización estructural reconoce códigos comunes como permission-denied, unauthenticated, not-found, invalid-argument y unavailable.

Composición usada por Information

apps/system/modules/information/hooks/use-information-page-data.ts combina las primitivas del package con una suscripción de larga duración:

  1. useAppQuery carga la lectura inicial con una key namespaced por comunidad y rol.
  2. subscribeToInfoChanges mantiene la caché sincronizada con Firestore mediante queryClient.setQueryData; el listener no se modela como una query de una sola ejecución.
  3. Las escrituras usan useAppMutation. Al asentarse, invalidan la query para reconciliar la caché, incluso cuando la mutation falla.
  4. Si el listener termina con error, el hook conserva el AppError; retry refetchea y crea de nuevo la suscripción.
  5. La UI traduce code/kind a copy local y mantiene efectos de formulario/toast fuera del package.

El provider se monta una vez en el layout de la app. Comunidad, rol y uid son contexto del consumidor, no responsabilidades de @repo/client-operations.

Callable responses

Un servicio puede adoptar un resultado discriminado para no devolver mensajes de error como strings:

ts
import { unwrapOperationResult } from '@repo/client-operations';

const payload = unwrapOperationResult(await callableService(input));

El resultado debe tener forma { success: true, data } | { success: false, code?, message?, fieldErrors? }. Un fallo se convierte en AppError y sigue el mismo camino que una excepción.

Definir y ejecutar operaciones

tsx
import { createQueryKeyFactory, useAppMutation, useAppQuery } from '@repo/client-operations';
import { useQueryClient } from '@tanstack/react-query';

const estateKeys = createQueryKeyFactory<[string]>('estates');

export function useEstate(estateId: string) {
  return useAppQuery({
    queryKey: estateKeys(estateId),
    queryFn: () => estateService.getById(estateId),
  });
}

export function useUpdateEstate() {
  const queryClient = useQueryClient();

  return useAppMutation(
    {
      mutationKey: ['estates', 'update'],
      mutationFn: estateService.update,
    },
    {
      onSuccess: (_estate, variables) =>
        queryClient.invalidateQueries({ queryKey: estateKeys(variables.id) }),
    },
  );
}

El hook de módulo/app llama mutateAsync, presenta un único toast según resultado y conserva los efectos locales, como mapear fieldErrors al formulario o cerrar un diálogo. La definición de la operación mantiene cerca del dominio sus keys, servicio, mapper de copy e invalidaciones.

Defaults de caché y reintentos

  • Queries: staleTime de 30 segundos y gcTime de 5 minutos; ambos configurables al crear el cliente.
  • Queries: hasta dos reintentos únicamente para errores normalizados como network.
  • Mutations: sin reintentos automáticos, para evitar repetir escrituras no idempotentes.
  • CreateQueryClientOptions.retry permite ajustar el criterio al producto.

Migración futura

  1. Instalar el package y sus peers en una app y montar ClientOperationsProvider una sola vez.
  2. Añadir en esa app un adaptador que conecte AppError con @repo/ui y los mensajes del producto.
  3. Migrar una mutation de estates y verificar invalidación, estados y feedback.
  4. Migrar auth conservando errores de campo, navegación y flujos especiales.
  5. Continuar módulo por módulo, sin convertir las suscripciones Firestore en queries de una sola ejecución.

Validación

bash
pnpm --filter @repo/client-operations test:run
pnpm --filter @repo/client-operations check-types
pnpm --filter @repo/client-operations lint
pnpm --filter @repo/client-operations build