Appearance
Ninaku Core: módulos, API y operaciones confiables
Documento migrado desde
franmc01/ninaku-core-api. Las decisiones de negocio, dominio y base de datos siguen vigentes; las referencias a ASP.NET Core / .NET son históricas: el backend actual es NestJS y su arquitectura se define en ARCHITECTURE.md y NESTJS_STACK_GUIDE.md.
Estado: contrato de diseño migrado desde el backend ASP.NET Core 10, hoy histórico; el backend actual es NestJS (ver ARCHITECTURE.md), acordado a partir de las preferencias del usuario. No representa endpoints ni middleware ya implementados. Los SQL actuales se revisan contra este contrato; no se reemplazan por tablas nuevas sin comprobar qué mecanismos existen.
Un monolito modular con Clean Architecture por módulo
Una aplicación desplegable, módulos con propietarios explícitos y contratos públicos. Cada módulo contiene Domain, Application, Infrastructure y Presentation. No se requiere un servicio HTTP, contenedor o base independiente para cada módulo. Las dependencias entre módulos se verificarán contra la estructura real de src/ cuando los módulos de negocio existan.
Las últimas flechas representan dependencias de código: los adaptadores implementan puertos definidos hacia dentro. Domain no referencia NestJS, el ORM ni infraestructura. Presentation traduce HTTP; Application coordina el caso de uso; Domain expresa reglas; Infrastructure adapta persistencia e integraciones. El host compone dependencias. Los DTO públicos no exponen entidades de persistencia.
Un módulo escribe sus datos. La colaboración interna usa contratos en proceso; no llamadas HTTP entre módulos del mismo host. Los contratos públicos no exportan DbContext, repositorios internos o IQueryable. Se permiten referencias SQL justificadas: compartir PostgreSQL y tener FK no demuestra ni impide por sí solo la independencia de aplicación.
Una operación que necesita atomicidad entre venta, reserva de stock y outbox comparte conexión, transacción y contexto tenant mediante contratos explícitos; cada participante conserva sus escrituras. No habrá un commit oculto por repositorio. Una integración externa usa outbox y un proceso recuperable, porque el commit PostgreSQL no confirma una operación en un proveedor externo.
Mejoras a la propuesta y su razón
| Ajuste | Razón |
|---|---|
| Independencia de módulo sin despliegue separado obligatorio | Evita costo de red y operación distribuida mientras se conservan límites de código |
| Dos solicitudes iniciales con DTO limitado y paginación | Una respuesta enorme también perjudica móvil y offline |
| Un compositor de lectura por necesidad, acciones por caso de uso | La interfaz recibe datos listos sin convertir el backend en un controlador gigante |
| Envoltorio de éxito sencillo y Problem Details para errores | Mantiene consistencia sin ocultar códigos HTTP ni inventar otro protocolo de errores |
| Un mismo comando para online y offline | Evita divergencias de reglas y permite deduplicar la misma intención |
| Concurrencia elegida por regla de negocio | La edición de un borrador y consumir la última unidad necesitan controles diferentes |
APIs por tarea y pantalla
Presupuesto: como máximo dos solicitudes de datos para la carga inicial normal de cada pantalla. Los endpoints siguientes son ejemplos propuestos. El presupuesto se mide en una prueba de navegación con sesión válida y caché fría; autenticación, archivos estáticos y reconexión se reportan por separado, sin ocultar llamadas de negocio adicionales.
| Pantalla | Carga inicial | Bajo demanda |
|---|---|---|
| Punto de venta | GET /api/v1/session/context y GET /api/v1/pos/workspace?outletId=... | Buscar catálogo paginado, abrir cliente, consultar historial |
| Inventario | Contexto reutilizable y GET /api/v1/inventory/workspace?locationId=... | Movimientos paginados, lotes de un item, detalle de transferencia |
| Orden de venta | Contexto reutilizable y GET /api/v1/sales/orders/{id} | Auditoría y documentos pesados |
El contexto incluye solo identidad visible, selección de organización/outlet, permisos efectivos y capacidades necesarias. Se reutiliza durante navegación con invalidación al cambiar contexto o permisos; el servidor vuelve a autorizar cada operación. El workspace devuelve lo necesario para empezar: resumen, configuración visible y primera página limitada. No descarga todo el inventario ni todo el catálogo para ahorrar una llamada.
Un compositor de consultas consume contratos de lectura de los módulos y devuelve un DTO de pantalla. Puede usar una proyección de lectura con propietario y política de actualización claros; no accede arbitrariamente a todas las tablas. Las acciones siguen siendo casos de uso concretos: POST /sales/orders, POST /inventory/transfers/{id}/dispatch, POST /inventory/counts/{id}/post. No se crea un CRUD público por tabla ni un endpoint universal de acciones.
Reducir solicitudes del navegador no basta: registrar latencia, tamaño de respuesta, consultas SQL y ausencia de N+1. Las colecciones siempre tienen un límite y cursor/paginación. Definir orden estable y evitar consultas paralelas ilimitadas. Una pantalla operacional no autoriza una salida usando un saldo de lectura atrasado: la escritura valida disponibilidad dentro de su transacción.
Si un bloque es obligatorio, su fallo impide mostrar la pantalla como lista para operar. Los bloques opcionales pueden indicar unavailable y una acción de recarga explícita; no se transforman silenciosamente en cero o listas vacías. Los reportes pueden declarar asOf y consistencia eventual. Un resumen que requiera coherencia transaccional debe leerse desde un snapshot común.
JSON, wrappers y errores
Éxito con cuerpo JSON: un único envoltorio data y meta opcional. meta contiene solo información transversal útil, por ejemplo cursor y trazabilidad. No anidar Result<Result<T>>, no incluir success: true redundante ni enviar entidades del ORM. Las respuestas 204/304 y las descargas conservan sus contratos HTTP.
json
{
"data": {
"orderId": "84e7252c-c8bc-4cda-8e77-945818caa260",
"status": "confirmed",
"version": 3,
"total": { "amount": "24.50", "currency": "USD" }
},
"meta": { "traceId": "f794ebdb186044bda9e338d8e1386cc7" }
}Contrato Ninaku propuesto: camelCase; UUID como string; importes y cantidades decimales exactas como strings documentados en OpenAPI; moneda y unidad explícitas. Instantes en UTC ISO 8601, fechas de negocio como fecha sin conversión de zona y zona horaria del outlet explícita. El frontend usa aritmética decimal para valores de negocio.
Para errores, usar application/problem+json con RFC 9457; el error no se envuelve dentro de data. code es estable y sirve para lógica/traducción del frontend; detail ayuda al usuario y no se analiza como código. Ejemplo de contrato, con URI relativa de tipo:
json
{
"type": "/problems/inventory/insufficient-stock",
"title": "Stock insuficiente",
"status": 409,
"detail": "No hay cantidad disponible para completar la salida.",
"code": "inventory.insufficient_stock",
"traceId": "f794ebdb186044bda9e338d8e1386cc7",
"retryable": false
}El campo type identifica una clase de problema documentada; code, traceId, retryable y errors de validación son extensiones de Ninaku. retryable no basta para reintentar una escritura: también debe ser idempotente y cumplir la política del comando. El traceId identifica el intento HTTP actual, no sustituye al commandId estable de negocio.
| HTTP | Uso propuesto |
|---|---|
| 400 | JSON inválido o validación del contrato de entrada; errors por campo |
| 401 / 403 | Autenticación requerida / operación no autorizada |
| 404 | Recurso no disponible en el contexto autorizado; no filtrar existencia de otro tenant |
| 409 | Conflicto de negocio, clave idempotente con otro contenido u operación aún en proceso |
| 412 / 428 | If-Match obsoleto / precondición requerida ausente |
| 429 / 503 | Límite temporal / indisponibilidad transitoria; Retry-After cuando se conoce |
| 500 | Error inesperado; mensaje seguro y traceId para diagnóstico |
Un filtro RFC 9457 propio del proyecto, junto con su registro de códigos en src/foundation/errors, centraliza la traducción de fallos y el manejo de excepciones. La autenticación, autorización, validación y rutas inexistentes también deben respetar el contrato. Los módulos devuelven errores tipados de aplicación; no construyen respuestas HTTP en Domain. No exponer SQL, nombres de constraints, stack traces, secretos ni datos personales. Traducir por códigos/constraints conocidos, nunca por buscar texto en mensajes de PostgreSQL. Un constraint desconocido no se convierte automáticamente en un 409 de negocio.
Base estándar: RFC 9457. Las decisiones sobre wrappers, códigos y presupuestos de llamadas son contratos de Ninaku, no requisitos de esa fuente; el catálogo implementado está documentado en FOUNDATION_HTTP_AND_OBSERVABILITY.md.
Idempotencia: repetir sin duplicar efectos
Aplicarla a comandos que crean efectos: confirmar venta, cobrar, recibir mercancía, despachar, contabilizar y aplicar comandos offline. Un GET no necesita reservar una clave idempotente. El frontend genera una clave por intención y la conserva ante timeout, reinicio o reconexión.
- Autenticar y autorizar antes de ejecutar o devolver un resultado previo.
- Resolver un commandId estable y un alcance explícito: organización + tipo de comando + clave. Para comandos de dispositivo, documentar el mapeo al alcance actual de
sync(dispositivo + clave); no cambiar esas claves UNIQUE sin analizar compatibilidad. - Calcular una huella de la entrada normalizada, versión del contrato, destino y contexto relevante. Excluir datos volátiles como traceId. Definir normalización, no depender del orden textual de propiedades JSON.
- Arbitrar duplicados con una restricción UNIQUE persistente. La misma clave/contenido devuelve el resultado de negocio registrado; distinto contenido devuelve
409 idempotency.key_reused. - Confirmar efectos de negocio, resultado idempotente y outbox en una transacción para comandos síncronos. El registro puede estar en curso dentro de esa transacción; otra solicitud espera de forma acotada o recibe conflicto, nunca ejecuta de nuevo por un simple timeout.
- Los trabajos asíncronos pueden confirmar aceptación y responder 202 con recurso de operación. Su ejecución tiene claim, lease, token de exclusión de trabajadores antiguos y recuperación; no se confunde aceptar con completar.
Tras rollback no hay efecto confirmado. Tras commit con respuesta perdida, el retry recupera el mismo resultado. Guardar estado HTTP y resultado de negocio reproducible; generar la trazabilidad del intento actual al responder. Revalidar acceso al consultar un resultado evita filtrar datos si cambian permisos.
La retención de deduplicación debe cubrir desconexiones admitidas y reintentos tardíos. Cuando caduque una clave, conservar una identidad de operación o evidencia suficiente para que un cliente antiguo no cree otra venta. No purgar por TTL sin un contrato de expiración/rebootstrap. No registrar tarjetas ni tokens de pago dentro del resultado persistido.
En pagos externos, propagar una clave estable al proveedor cuando lo soporte y conciliar resultados inciertos. Un timeout no prueba que no se cobró. Los webhooks usan una inbox deduplicada; la transacción local no promete exactamente una ejecución de la red.
Concurrencia: proteger cada regla
| Operación | Control propuesto |
|---|---|
| Editar borrador/configuración | Versión del agregado, ETag e If-Match; actualización condicional |
| Reservar/despachar stock | Validación y actualización atómica o bloqueo de los saldos pertinentes; orden de locks estable |
| Confirmar documento | Transición de estado condicionada y unicidad del efecto |
| Procesar cola | Claim con lease/token; un trabajador vencido no puede confirmar el trabajo reclamado por otro |
| Evitar referencias entre tenants | Contexto autorizado, claves/FK con organización donde corresponda y RLS probada con rol runtime |
No usar un mutex en memoria como garantía entre instancias. No aplicar SERIALIZABLE a todo sin necesidad. Si se usa aislamiento que puede abortar o se produce deadlock, reintentar la transacción completa de manera acotada, con la misma intención idempotente y sin repetir efectos externos. No reintentar errores de permisos, entrada inválida o reglas de negocio. La cancelación HTTP no demuestra rollback si el commit pudo completarse.
El nivel de aislamiento y los bloqueos se eligen por invariante y se prueban con conexiones simultáneas. PostgreSQL: aislamiento transaccional describe las anomalías y los reintentos requeridos por sus niveles; la política por operación es una decisión de Ninaku.
Offline: el mismo comando, con transporte diferido
La BD local guarda cambios y cola de salida atómicamente. Los estados visibles distinguen pendiente de sincronizar, confirmado, rechazado y requiere conciliación. El servidor recibe comandos versionados, no un volcado de tablas ni SQL del dispositivo. Online y offline invocan el mismo caso de uso; Sync no reimplementa reglas de Ventas/Inventario.
Un lote de sincronización devuelve resultado por commandId y respeta dependencias declaradas. Cada comando tiene su transacción salvo que el contrato declare explícitamente una operación compuesta atómica. El transporte puede entregar varias veces; consumidores e inbox deduplican. No prometer entrega exactamente una vez.
Un cliente desconectado no puede garantizar stock global estricto sin capacidad previamente asignada. Definir por operación: cupo exclusivo, aceptación con conciliación o requisito online. Dinero, cantidades, permisos y numeración no se resuelven con «gana el último timestamp». Una venta física en efectivo ya realizada requiere conservar evidencia y conciliar, aunque no pueda confirmarse automáticamente en el servidor.
La descarga usa snapshot consistente y cursor seguro de publicación, con ack después de persistir localmente. No usar MAX(UUID), fecha del dispositivo ni un contador asignado antes de commit como garantía de orden confirmado. La expiración de cursor obliga a rebootstrap preservando comandos locales pendientes. Las capacidades offline son limitadas y tienen vigencia; una revocación no llega instantáneamente a un equipo aislado.
La impresión tiene identidad de trabajo propia. Si el papel salió y se perdió el ack, el estado es incierto; repetir sincronización no debe imprimir automáticamente otra vez. Estas políticas se desarrollan en BUSINESS_DESIGN.md.
Qué existe en los SQL y qué falta demostrar
| Evidencia actual | Alcance observado | Trabajo pendiente |
|---|---|---|
sync.commands, sync.ensure_command en 0370 | UNIQUE por dispositivo/clave y rechazo de replay con semántica distinta | Probar duplicados simultáneos, permisos, recuperación y contrato común online/offline |
sync.command_processing, intentos y resultados | Estructura para ejecución y seguimiento | Verificar atomicidad del resultado con los efectos de cada dominio |
events.domain_events y events.outbox_records en 0360 | Claves idempotentes, publicación y leases | Pruebas de commit/respuesta perdida, exclusión de trabajadores vencidos y consumidor duplicado |
Suscripciones, checkpoints y bootstrap de sync | Estructura de protocolo y cursores | Pruebas de snapshot concurrente, huecos de publicación y rebootstrap |
| Restricciones y triggers en dominios | Reglas persistentes existentes | Contrato de error tipado, concurrencia y RLS con rol runtime |
No crear una segunda tabla de idempotencia ni un segundo outbox solo porque un patrón C# lo sugiera. Primero decidir si los mecanismos existentes cubren comandos sin dispositivo, dónde vive cada responsabilidad y qué cambios son necesarios. La prueba de instalación limpia solo valida que la estructura se instala; no valida estas garantías.
Criterios de salida antes de llamar a esto implementado
- Prueba HTTP de una pantalla real: máximo dos solicitudes iniciales, respuesta acotada, permisos y contrato JSON/OpenAPI comprobados; SQL sin N+1.
- Dos peticiones simultáneas con la misma clave: un efecto; clave repetida con otra entrada: 409; respuesta perdida después de commit: mismo resultado.
- Dos salidas compiten por la última unidad: bajo política estricta una sola gana; sin saldos inconsistentes ni efectos parciales.
- Caída entre escritura de negocio y outbox: ambos se confirman o ambos revierten.
- Trabajador con lease vencido: no puede confirmar ni sobrescribir al nuevo trabajador.
- Cliente offline reiniciado y replay duplicado: una operación confirmada; conflicto visible y sin perder evidencia local.
- Respuestas de validación, autorización, conflictos y fallo inesperado: status correcto, código estable, sin información interna.
Estas pruebas complementan VALIDATION.md. Se implementarán por un primer flujo completo de negocio; la presente entrega establece el contrato sin crear un backend ficticio.