Skip to content

Disciplina de Diseño de APIs y Esquemas SQL (API_AND_SCHEMA_DISCIPLINE.md)

Estado: Reglas de diseño normativas y obligatorias para ninaku-app-api.

Este documento complementa a ARCHITECTURE.md. Su propósito es prevenir los dos fallos de diseño más comunes en el crecimiento de la plataforma: crear APIs que reflejen pasivamente tablas de la base de datos sin expresar intención de negocio, y expandir el esquema PostgreSQL con tablas especulativas que carecen de un módulo propietario o de un propósito operacional real.


1. APIs por Intención de Negocio (Prohibición del CRUD Genérico por Tabla)

Ninaku no expone endpoints HTTP por el simple hecho de que exista una tabla SQL en la base de datos. Cada endpoint publicado debe representar de forma explícita:

  • Una acción de negocio concreta.
  • Una consulta requerida por un operador, pantalla o integración autorizada.
  • Una transición explícita dentro del ciclo de vida de un agregado.
  • Un contrato público estable entre Ninaku y un consumidor externo.

La pregunta por defecto nunca es "¿qué endpoints CRUD necesita esta tabla?". Las preguntas obligatorias son:

  1. ¿Qué tarea intenta completar el usuario o sistema?
  2. ¿Qué módulo de negocio es el propietario inmutable de esa operación?
  3. ¿Qué invariantes de dominio se deben proteger?
  4. ¿Cuál es el contrato público mínimo y estable que expresa ese caso de uso?

Ejemplos Concretos

text
❌ DISEÑO RECHAZADO (CRUD orientado a tablas):
POST   /sale-lines
PATCH  /sale-lines/{id}
POST   /payment-attempts
POST   /inventory-movements
POST   /journal-entries

✅ DISEÑO OBLIGATORIO (Orientado a intención y flujos de negocio):
POST   /api/v1/sales/orders
POST   /api/v1/sales/orders/{id}/confirm
POST   /api/v1/sales/orders/{id}/cancel

POST   /api/v1/inventory/transfers
POST   /api/v1/inventory/transfers/{id}/dispatch
POST   /api/v1/inventory/transfers/{id}/receive

POST   /api/v1/payments/intents
POST   /api/v1/payments/{id}/capture
POST   /api/v1/payments/{id}/refund

El backend coordina internamente las mutaciones en las tablas y módulos correspondientes dentro de una transacción. El cliente jamás necesita conocer el diseño físico de la base de datos para ejecutar una acción comercial.


2. APIs Diseñadas Alrededor de Flujos y Pantallas (Presupuesto de Llamadas)

Las APIs operacionales deben optimizar los flujos de pantalla del usuario sin convertirse en "mega-APIs" descontroladas.

A. Presupuesto de Llamadas HTTP

Regla de Oro: Para la carga inicial normal de cualquier pantalla con sesión válida, la aplicación cliente tiene un presupuesto de como máximo dos (2) solicitudes de datos:

  1. Contexto de Sesión: GET /api/v1/session/context (identidad, selección de tenant/outlet, permisos y capacidades).
  2. Workspace de Pantalla: GET /api/v1/<modulo>/workspace (resumen, configuración visible y primera página limitada).

El contenido pesado, historiales o búsquedas avanzadas se cargan síncronamente bajo demanda.

B. Criterios de Optimización en Pantalla

No se optimiza únicamente para reducir peticiones HTTP; se deben equilibrar todos los factores del sistema:

  • Cantidad de consultas SQL: Evitar patrón N+1 mediante proyecciones de lectura unificadas o agrupamiento explícito.
  • Tamaño del payload: Enviar únicamente las propiedades requeridas para la tarea.
  • Paginación estable: Keyset por cursores opacos.
  • Uso eficiente de índices: Consultas alineadas con los patrones de acceso reales.

3. Toda Tabla Debe Justificar su Existencia (Las 7 Preguntas)

Cada tabla canónica en el esquema PostgreSQL debe responder obligatoriamente a las siguientes siete preguntas de justificación antes de ser creada o modificada:

  1. Owner (Propietario): ¿Qué módulo de negocio es el dueño exclusivo de esta tabla?
  2. Business Fact (Hecho de negocio): ¿Qué hecho inmutable o estado del ciclo de vida representa?
  3. Writer (Escritor): ¿Qué caso de uso o función específica tiene permitido insertar o mutar filas aquí?
  4. Reader (Lector): ¿Qué caso de uso, reporte, proyección o integración la consume?
  5. Invariant (Invariante): ¿Qué regla de negocio, unicidad, historial o auditoría exige que los datos residan en esta estructura?
  6. Lifecycle (Ciclo de vida): ¿Cómo se crea, cambia, expira, archiva o conserva la fila?
  7. Why Separate (Por qué separada): ¿Por qué este concepto no puede residir de forma segura dentro de un agregado o tabla existente?

Si una propuesta de tabla no puede responder a estas siete preguntas de manera contundente, debe ser rediseñada, consolidada con otra estructura o rechazada.


4. Optimización RLS en PostgreSQL: Subconsultas Escalares

Para garantizar el cumplimiento de los SLA de rendimiento bajo aislamiento multi-tenant en PostgreSQL 18, todas las políticas de Row Level Security (RLS) deben aplicar el patrón de subconsulta escalar.

A. La Regla de la Subconsulta Escalar

Dentro de cualquier cláusula CREATE POLICY (USING o WITH CHECK), toda llamada a funciones de contexto tenant de la extensión de identidad (identity.has_active_tenant_context(), identity.current_organization_id(), identity.current_identity_id()) debe escribirse obligatoriamente envuelta entre paréntesis como una subconsulta escalar (SELECT ...):

sql
-- ❌ FORMA LENTA RECHAZADA (Evaluación por fila):
CREATE POLICY organization_isolation_policy ON sales.orders
    FOR ALL TO ninaku_runtime
    USING (
        identity.has_active_tenant_context()
        AND organization_id = identity.current_organization_id()
    );

-- ✅ FORMA OPTIMIZADA OBLIGATORIA (Subconsulta escalar / InitPlan):
CREATE POLICY organization_isolation_policy ON sales.orders
    FOR ALL TO ninaku_runtime
    USING (
        (SELECT identity.has_active_tenant_context())
        AND organization_id = (SELECT identity.current_organization_id())
    );

B. Fundamento Técnico y Evidencia Medida

  1. Por qué la función desnuda es lenta: Las funciones de contexto están definidas como LANGUAGE sql STABLE SECURITY DEFINER con cláusulas SET. Debido a las cláusulas SET de seguridad, el planificador de PostgreSQL no puede inlinear la función. Si se escribe desnuda, PostgreSQL evalúa la función completa (incluyendo sus joins internos contra tablas de identidad y membresías) una vez por cada fila escaneada en la consulta.
  2. Por qué la subconsulta escalar es rápida: Al envolver la llamada entre paréntesis como (SELECT identity.current_organization_id()), el planificador de PostgreSQL la trata como un InitPlan. La función se ejecuta una sola vez por sentencia HTTP/SQL, y su resultado escalar constante se reutiliza para filtrar todas las filas.
  3. Evidencia Medida en Benchmarks (PERFORMANCE_BASELINE.md):
    • En tablas de prueba con 1,000 filas por organización:
      • Forma desnuda (S3): Latencia P95 = 18.91 ms | Buffers accedidos = 4,022.
      • Subconsulta escalar (S4): Latencia P95 = 1.29 ms | Buffers accedidos = 20.
    • Factor de Aceleración: 14.7x más rápido, reduciendo el acceso al buffer en un 99.5%.

C. Alcance Exacto de la Regla

  • Aplica ÚNICAMENTE: Dentro de las cláusulas USING y WITH CHECK de las sentencias CREATE POLICY.
  • NO Aplica: Dentro de cuerpos de funciones SQL/PLpgSQL, triggers ni restricciones CHECK de tabla. En esas estructuras, el código ya ejecuta por fila por naturaleza, y envolverlo agregaría ruido sin alterar el plan de ejecución.

D. Verificación Automatizada en CI

El script de auditoría de base de datos npm run db:sweep (que ejecuta check-policy-context-subqueries.ts) analiza estáticamente todos los archivos .sql del repositorio. Si detecta una llamada desnuda a las funciones de contexto dentro de una política RLS, el comando falla de inmediato. Imprime RLS_POLICY_CONTEXT_SUBQUERY_OK cuando el baseline está limpio.


5. Aislamiento Multi-Tenant mediante GUCs de PostgreSQL

El aislamiento de tenants no se confía al código de la aplicación NestJS ni a filtros WHERE manuales. Se exige a nivel de base de datos mediante variables de configuración de sesión de PostgreSQL (GUCs - Grand Unified Configuration) y políticas RLS.

A. Establecimiento de Contexto con set_config

La capa de infraestructura (foundation/tenancy/tenant-context-runner.service.ts) establece las variables de sesión dentro de la transacción de PostgreSQL usando la función parametrizada set_config:

ts
// Se aplica usando set_config con is_local = true (transacción local)
await client.query(`
  SELECT 
    set_config('app.actor_identity_id', $1, true),
    set_config('app.identity_id', $2, true),
    set_config('app.membership_id', $3, true),
    set_config('app.organization_id', $4, true),
    set_config('app.impersonation_session_id', $5, true);
`, [actorId, identityId, membershipId, organizationId, impersonationId]);

Se utiliza set_config($1, $2, true) en lugar de concatenar cadenas SET LOCAL para prevenir inyecciones SQL y garantizar que el contexto se limpie automáticamente al hacer COMMIT o ROLLBACK.

B. Afirmación del Contexto con identity.assert_tenant_context()

Inmediatamente después de aplicar los GUCs, la transacción ejecuta:

sql
SELECT identity.assert_tenant_context();

Si el contexto de organización no es válido, la membresía está suspendida/terminada o el tenant no existe, PostgreSQL lanza un error con SQLSTATE 42501. La aplicación captura esta excepción y la remapea a un error 403 Forbidden (tenant.context_rejected).


6. Invariantes de Esquema SQL y Prohibición de Diseño Especulativo

  1. Esquema Propietario Explícito: Todo archivo .sql debe declarar explícitamente el esquema al que pertenecen sus tablas (ej. sales.orders, inventory.stock_ledger). Prohibido crear tablas en el esquema public.
  2. Auditoría Inmutable: Las tablas de eventos, libros contables, transacciones y balances incluyen marcas de tiempo inmutables created_at TIMESTAMPTZ NOT NULL DEFAULT clock_timestamp(). Queda prohibido actualizar o eliminar registros de auditoría o historial.
  3. Claves Foráneas y Reglas de Borrado:
    • Las relaciones entre entidades principales del dominio exigen restricciones FOREIGN KEY.
    • Para datos maestros o de configuración, se utiliza ON DELETE RESTRICT. Prohibido usar ON DELETE CASCADE en tablas transaccionales de negocio para evitar eliminaciones accidentales en cascada.
  4. Prohibición Estricta de Esquema Especulativo: Prohibido agregar tablas o columnas "por si acaso las necesitamos en el futuro". Cada tabla y columna debe estar conectada a un caso de uso real en desarrollo.

7. Declaración del Contrato de Respuesta en OpenAPI

Dado que el interceptor global src/foundation/http/envelope/http-envelope.interceptor.ts envuelve automáticamente todas las respuestas JSON en la estructura { data } o { data, meta }, los controladores deben documentar sus respuestas mediante los decoradores de sobre de Foundation.

Decoradores Obligatorios (src/foundation/openapi/api-envelope-response.decorator.ts)

ts
import { ApiDataResponse, ApiCollectionResponse } from '@/foundation/openapi/api-envelope-response.decorator';

@Controller('products')
export class ProductsController {

  @Get(':id')
  @ApiDataResponse({ name: 'Product', schema: ProductSchema })
  async findOne(): Promise<ProductResponse> { ... }

  @Get()
  @ApiCollectionResponse({ name: 'Product', schema: ProductSchema })
  async list(): Promise<HttpResponseWithMeta<ProductResponse[], { pageInfo: PageInfo }>> { ... }

  @Post()
  @ApiDataResponse({ name: 'Product', schema: ProductSchema, status: HttpStatus.CREATED })
  async create(): Promise<ProductResponse> { ... }
}
  • ApiDataResponse: Declara un recurso único o resultado de comando envuelto en { data: T }.
  • ApiCollectionResponse: Declara una colección paginada envuelta en { data: T[], meta: { pageInfo: PageInfo } }.

Verificación de OpenAPI en CI/CD

El documento docs/generated/openapi.json se deriva automáticamente de los esquemas Zod a través del conversor convertZodSchemaToOpenApiSchema.

  • npm run openapi:generate: Compila el proyecto y regenera la especificación OpenAPI.
  • npm run openapi:check: Compara byte a byte la especificación regenerada contra docs/generated/openapi.json en CI. Si existe cualquier discrepancia, la build falla de inmediato.

Application Foundation in progress. Tracked in issue #13.