Skip to content

Multi-Tenant: Schema-Based Tenancy

QUÉ es Multi-Tenant Schema-Based

Definición: Arquitectura donde cada "tenant" (sucursal o caja) tiene su propio schema PostgreSQL dentro de la misma base de datos física. Un schema es un namespace que contiene tablas, vistas, funciones, etc.

Principio fundamental: PostgreSQL busca objetos (tablas) en el schema definido por search_path. Cambiando el search_path, aislamos completamente los datos sin modificar las queries.

Analogía: Imagina una empresa con 10 sucursales. En lugar de tener 10 bases de datos separadas, tenemos 1 base de datos con 10 "carpetas" (schemas). Cada sucursal solo ve su carpeta.

bautista (database)
├── suc0001 (schema) ← Sucursal Casa Central
│   ├── facturas
│   ├── clientes
│   └── productos
├── suc0002 (schema) ← Sucursal Filial Norte
│   ├── facturas
│   ├── clientes
│   └── productos
└── suc0001caja001 (schema) ← Caja 001 de Casa Central
    ├── recibos
    ├── movimientos_caja
    └── cierre_caja

CUÁNDO usar: SIEMPRE. Multi-tenant es la arquitectura base de Sistema Bautista. Cada request HTTP trabaja en un schema específico determinado por X-Schema header o JWT payload.

Niveles de Schema (Jerarquía)

Sistema Bautista implementa 3 niveles de schemas, organizados jerárquicamente:

NivelConstanteSchemaAlcanceEjemplo
EmpresaLEVEL_EMPRESA = 1publicDatos maestros compartidosplan_cuentas, conceptos_retencion
SucursalLEVEL_SUCURSAL = 2suc0001, suc0002, etc.Datos de sucursalfacturas, clientes, productos
CajaLEVEL_CAJA = 3suc0001caja001, etc.Datos de caja individualrecibos, movimientos_caja

Regla clave: Datos de nivel superior (empresa) son compartidos por niveles inferiores (sucursal/caja). Datos de nivel inferior NO son visibles para otros tenants del mismo nivel.

Diagrama de Jerarquía de Schemas

mermaid
graph TB
    E[public - EMPRESA<br/>Level 1]

    S1[suc0001 - SUCURSAL<br/>Level 2]
    S2[suc0002 - SUCURSAL<br/>Level 2]

    C1[suc0001caja001 - CAJA<br/>Level 3]
    C2[suc0001caja002 - CAJA<br/>Level 3]
    C3[suc0002caja001 - CAJA<br/>Level 3]

    E -.->|Comparte maestros| S1
    E -.->|Comparte maestros| S2

    S1 -.->|Comparte datos sucursal| C1
    S1 -.->|Comparte datos sucursal| C2
    S2 -.->|Comparte datos sucursal| C3

    E -->|Contiene| DM[Datos Maestros:<br/>plan_cuentas<br/>conceptos_retencion<br/>configuracion_global]

    S1 -->|Contiene| DS1[Datos Sucursal:<br/>facturas<br/>clientes<br/>productos<br/>stock]

    C1 -->|Contiene| DC1[Datos Caja:<br/>recibos<br/>movimientos_caja<br/>cierre_caja]

    style E fill:#e1f5ff
    style S1 fill:#fff4e1
    style S2 fill:#fff4e1
    style C1 fill:#f0ffe1
    style C2 fill:#f0ffe1
    style C3 fill:#f0ffe1

Qué datos viven en cada nivel:

  • LEVEL_EMPRESA (public): Configuración global, plan de cuentas, conceptos fiscales, usuarios
  • LEVEL_SUCURSAL (sucXXXX): Facturas, clientes, proveedores, productos, stock
  • LEVEL_CAJA (sucXXXXcajaXXX): Recibos, movimientos de caja, cierres de caja

ConnectionManager y Conexiones Nombradas

ConnectionManager es el componente central que gestiona conexiones a PostgreSQL. En lugar de trabajar con conexiones anónimas, usamos nombres lógicos (aliases):

ConexiónPropósitoDatabaseSchemaConfigurado por
oficialDatos de producciónbautistaSegún X-Schema/JWTSiempre apunta a DB oficial
principal (alias)Conexión de trabajo por requestDinámica (oficial o prueba)Según X-Schema/JWTResuelto según prueba parameter
pruebaDatos de testingbautista_p (con sufijo _p)Según X-Schema/JWTSiempre apunta a DB prueba
iniConexión inicial sin schemabautistapublicUsado en migrations, setup

Pattern de uso:

php
// En servicios: SIEMPRE usar 'principal' para transacciones
$conn = ConnectionManager::getConnection('principal');

// Para maestros: SIEMPRE usar 'oficial' (incluso en modo prueba)
$connOficial = ConnectionManager::getConnection('oficial');
$planCuentas = $connOficial->fetchAllAssociative("SELECT * FROM plan_cuentas");

// Para testing directo: usar 'prueba' explícitamente
$connPrueba = ConnectionManager::getConnection('prueba');

Beneficios:

  • ✅ Transparencia: Servicios no saben si están en modo oficial o prueba
  • ✅ Maestros consistentes: Siempre de 'oficial', sin duplicación
  • ✅ Testing seguro: 'prueba' nunca afecta producción
  • ✅ Transacciones simples: 1 conexión = 1 transacción atómica

X-Schema Header y JWT Payload

El schema se determina en orden de precedencia:

  1. X-Schema HTTP Header (máxima prioridad)
  2. JWT Payload (schema field)
  3. GLOBALS (fallback en scripts legacy)

Precedencia y Casos de Uso

mermaid
graph TD
    REQ[HTTP Request]

    REQ --> CHECK1{¿Tiene X-Schema<br/>header?}
    CHECK1 -->|Sí| USE_HEADER[Usar X-Schema]
    CHECK1 -->|No| CHECK2{¿Tiene JWT con<br/>schema field?}

    CHECK2 -->|Sí| USE_JWT[Usar JWT schema]
    CHECK2 -->|No| CHECK3{¿GLOBALS tiene<br/>schema?}

    CHECK3 -->|Sí| USE_GLOBALS[Usar GLOBALS<br/>Legacy fallback]
    CHECK3 -->|No| ERROR[Error: Schema no determinado]

    USE_HEADER --> CM[ConnectionManager]
    USE_JWT --> CM
    USE_GLOBALS --> CM

    CM --> SET_PATH[SET search_path]

    style USE_HEADER fill:#90EE90
    style USE_JWT fill:#FFD700
    style USE_GLOBALS fill:#FFA500
    style ERROR fill:#FF6B6B

Flujo de Autenticación y Schema

mermaid
sequenceDiagram
    participant F as Frontend (Schema Selector)
    participant AM as AuthMiddleware
    participant CM as ConnectionMiddleware
    participant ConnMgr as ConnectionManager
    participant PG as PostgreSQL

    F->>AM: POST /api/ventas/facturas<br/>Authorization: Bearer <JWT><br/>X-Schema: suc0001caja001

    AM->>AM: Validar JWT (RSA signature)
    AM->>AM: Extraer payload: {schema: "suc0001", ...}

    AM->>CM: Request con JWT validado

    CM->>CM: Determinar schema:<br/>1. X-Schema header → suc0001caja001<br/>2. JWT schema → suc0001<br/>Precedencia: X-Schema gana

    CM->>ConnMgr: setupConnection('principal', schema='suc0001caja001')

    ConnMgr->>ConnMgr: Construir search_path jerárquico:<br/>suc0001caja001, suc0001, public

    ConnMgr->>PG: SET search_path = suc0001caja001, suc0001, public

    PG-->>ConnMgr: Search path configurado
    ConnMgr-->>CM: Connection ready
    CM-->>F: Request procesado en schema correcto

    Note over F,PG: Multi-Tenant activo:<br/>Schema = suc0001caja001<br/>Search path jerárquico establecido

Casos de Uso por Precedencia

EscenarioX-SchemaJWTGLOBALSSchema FinalUso
Schema selector en frontendsuc0001caja001suc0001-suc0001caja001Usuario elige caja manualmente
Login normal-suc0001-suc0001Schema del usuario logueado
Admin viendo reportessuc0002suc0001-suc0002Admin consulta otra sucursal
Testing automatizado--suc0001suc0001Scripts de testing legacy
Error: sin schema---❌ ErrorRequest rechazado

Search_path Jerárquico

Concepto: PostgreSQL permite definir múltiples schemas en search_path. Busca objetos (tablas) de izquierda a derecha hasta encontrar uno que coincida.

Construcción en Sistema Bautista:

search_path = caja, sucursal, empresa (public)

Ejemplo concreto:

sql
-- Schema: suc0001caja001
SET search_path = suc0001caja001, suc0001, public;

-- Query: SELECT * FROM recibos;
-- PostgreSQL busca en este orden:
-- 1. suc0001caja001.recibos ✅ ENCONTRADO → usa esta tabla
-- 2. suc0001.recibos (no se busca, ya encontrado)
-- 3. public.recibos (no se busca, ya encontrado)

-- Query: SELECT * FROM facturas;
-- PostgreSQL busca en este orden:
-- 1. suc0001caja001.facturas ❌ NO EXISTE
-- 2. suc0001.facturas ✅ ENCONTRADO → usa esta tabla
-- 3. public.facturas (no se busca, ya encontrado)

-- Query: SELECT * FROM plan_cuentas;
-- PostgreSQL busca en este orden:
-- 1. suc0001caja001.plan_cuentas ❌ NO EXISTE
-- 2. suc0001.plan_cuentas ❌ NO EXISTE
-- 3. public.plan_cuentas ✅ ENCONTRADO → usa esta tabla

Beneficios:

  • ✅ Queries agnósticas: SELECT * FROM facturas funciona sin especificar schema
  • ✅ Herencia jerárquica: Caja hereda datos de sucursal, sucursal hereda maestros de empresa
  • ✅ Aislamiento automático: Caja 001 nunca ve datos de caja 002

Construcción del search_path:

php
// ConnectionUtils::buildSearchPath()
public static function buildSearchPath(string $schema): string
{
    $paths = [$schema]; // 1. Schema específico (ej: suc0001caja001)

    // 2. Extraer schema de sucursal si es caja (ej: suc0001caja001 → suc0001)
    if (preg_match('/^(suc\d+)caja\d+$/', $schema, $matches)) {
        $paths[] = $matches[1]; // Agregar sucursal
    }

    $paths[] = 'public'; // 3. Siempre incluir public (empresa)

    return implode(', ', $paths);
}

// Resultado para suc0001caja001: "suc0001caja001, suc0001, public"
// Resultado para suc0001: "suc0001, public"
// Resultado para public: "public"

Configuración de Niveles de Tabla

Cada tabla tiene un nivel lógico configurado en configuracion_niveles_tablas (JSON en public.configuracion):

json
{
  "facturas": 2,           // LEVEL_SUCURSAL
  "clientes": 2,           // LEVEL_SUCURSAL
  "recibos": 3,            // LEVEL_CAJA
  "plan_cuentas": 1,       // LEVEL_EMPRESA
  "movimientos_caja": 3    // LEVEL_CAJA
}

Regla de uso:

  1. Si tabla NO está en configuración: Usar defaults hardcoded en código
  2. Si tabla SÍ está en configuración: Usar el nivel configurado (permite personalizar)

Ejemplo de lógica:

php
// En Service
private const DEFAULT_LEVELS = [
    'facturas' => SchemaConstants::LEVEL_SUCURSAL,
    'clientes' => SchemaConstants::LEVEL_SUCURSAL,
    'recibos' => SchemaConstants::LEVEL_CAJA,
];

public function getTableLevel(string $table): int
{
    // 1. Intentar obtener de configuración JSON
    $config = $this->configService->get('configuracion_niveles_tablas');
    if (isset($config[$table])) {
        return (int) $config[$table];
    }

    // 2. Fallback a defaults
    return self::DEFAULT_LEVELS[$table] ?? SchemaConstants::LEVEL_SUCURSAL;
}

Beneficios:

  • ✅ Flexibilidad: Cambiar nivel de tabla sin modificar código
  • ✅ Defaults sensatos: Código funciona sin configuración explícita
  • ✅ Migraciones simples: Mover tabla de nivel es cambio de configuración

Reglas Arquitecturales

RA-MT-001: Aislamiento por Schema

Descripción: Cada tenant (sucursal/caja) DEBE tener su propio schema PostgreSQL. Los datos transaccionales de un tenant NO DEBEN ser accesibles desde otro tenant sin multi-schema querying explícito.

Implicación:

  • ✅ Factura de suc0001 NO visible desde suc0002
  • ✅ Recibo de caja001 NO visible desde caja002
  • ❌ NO usar WHERE conditions para filtrar por tenant (ej: WHERE sucursal_id = 1)

RA-MT-002: Validación de Schema

Descripción: El schema DEBE ser validado y existir en information_schema.schemata antes de establecer search_path.

Implicación:

  • ✅ ConnectionMiddleware valida schema antes de setup
  • ✅ SchemaService::schemaExists() consulta PostgreSQL
  • ❌ NO permitir schemas inventados o inyección SQL

Código de validación:

php
// SchemaService::schemaExists()
public function schemaExists(string $schemaName): bool
{
    $sql = "SELECT schema_name
            FROM information_schema.schemata
            WHERE schema_name = :schema";

    $result = $this->connection->fetchOne($sql, ['schema' => $schemaName]);
    return $result !== false;
}

RA-MT-003: Datos Maestros Compartidos

Descripción: Los datos maestros (LEVEL_EMPRESA) DEBEN residir en public schema y ser compartidos entre todos los tenants. NO duplicar maestros por schema.

Implicación:

  • plan_cuentas en public, accesible desde todas las sucursales
  • conceptos_retencion en public, compartido
  • ❌ NO crear suc0001.plan_cuentas (duplicación innecesaria)

Tablas maestras típicas:

  • plan_cuentas
  • conceptos_retencion
  • tipos_comprobante
  • monedas
  • condiciones_iva

RA-MT-004: Cambio de Schema Durante Request

Descripción: NO se permite cambiar de schema durante el procesamiento de un request HTTP. El schema se establece en ConnectionMiddleware y permanece fijo hasta finalizar el request.

Implicación:

  • ✅ 1 request = 1 schema (aislamiento transaccional)
  • ❌ NO llamar SET search_path manualmente en servicios
  • ⚠️ Excepción: Multi-schema querying usa múltiples conexiones (1 por schema)

Referencias a Código Clave

Componentes Principales

  • server/connection/ConnectionManager.php - Gestión de conexiones nombradas, aliases, transacciones
  • server/connection/Database.php - Wrapper Doctrine DBAL, ejecución de SET search_path
  • server/connection/ConnectionUtils.php - buildSearchPath(), setSchemaContext()
  • server/Middleware/AuthMiddleware.php - Validación JWT, extracción de schema field
  • server/Middleware/ConnectionMiddleware.php - Setup de conexión por request, precedencia X-Schema
  • server/service/Config/SchemaService.php - Consulta information_schema, validación de schemas

Migraciones y Configuración

  • server/migrations/CLAUDE.md - Sistema de migraciones multi-tenant con Phinx
  • docs/backend/configuration-system.md - Sistema de configuración con niveles de tabla

Ejemplos de Uso

Ejemplo 1: Servicio con Multi-Tenant Básico

php
namespace App\service\Ventas;

use App\connection\ConnectionManager;

class FacturaService
{
    public function createFactura(array $data): array
    {
        // ConnectionMiddleware ya configuró el schema según X-Schema/JWT
        // NO necesitamos hacer nada, simplemente usamos 'principal'

        $conn = ConnectionManager::getConnection('principal');

        // Esta query se ejecuta en el schema correcto automáticamente
        // Ej: Si X-Schema = suc0001, se inserta en suc0001.facturas
        $sql = "INSERT INTO facturas (fecha, cliente_id, total)
                VALUES (:fecha, :cliente_id, :total)";

        $conn->executeStatement($sql, [
            'fecha' => $data['fecha'],
            'cliente_id' => $data['cliente_id'],
            'total' => $data['total'],
        ]);

        return ['id' => $conn->lastInsertId()];
    }

    public function getPlanCuentas(): array
    {
        // Maestros SIEMPRE de oficial (incluso en modo prueba)
        $connOficial = ConnectionManager::getConnection('oficial');

        // Esta query busca en public.plan_cuentas
        return $connOficial->fetchAllAssociative("SELECT * FROM plan_cuentas");
    }
}

Ejemplo 2: Frontend con Schema Selector

typescript
// ts/config/SucursalSelector.tsx
import { useState } from 'react';
import { api } from '../api/api';

export function SucursalSelector() {
  const [schema, setSchema] = useState('suc0001');

  const handleSchemaChange = (newSchema: string) => {
    setSchema(newSchema);

    // Axios interceptor inyectará automáticamente X-Schema header
    // Ver ts/api/api.ts
  };

  const handleCreateFactura = async (data: FacturaDTO) => {
    // Esta llamada irá con X-Schema: suc0001 (o el schema seleccionado)
    const response = await api.post('/ventas/facturas', data);

    // Backend procesará en el schema correcto automáticamente
    return response.data;
  };

  return (
    <select value={schema} onChange={e => handleSchemaChange(e.target.value)}>
      <option value="suc0001">Casa Central</option>
      <option value="suc0002">Filial Norte</option>
      <option value="suc0001caja001">Caja 001 - Casa Central</option>
    </select>
  );
}
typescript
// ts/api/api.ts - Axios interceptor
import axios from 'axios';
import { getSelectedSchema } from '../config/schemaManager';

export const api = axios.create({
  baseURL: '/api',
});

// Interceptor que inyecta X-Schema en TODOS los requests
api.interceptors.request.use(config => {
  const schema = getSelectedSchema(); // Obtener del state global

  if (schema) {
    config.headers['X-Schema'] = schema;
  }

  return config;
});

Relación con Multi-Schema y Multi-Modo

Multi-Tenant vs Multi-Schema

AspectoMulti-TenantMulti-Schema
PropósitoAislamiento de datosBúsqueda cross-schema
Schemas por operación1 schemaN schemas simultáneos
CuándoSIEMPRE (base)Solo cuando necesitamos buscar en múltiples schemas
RelaciónEstablece el schema inicialExpande búsqueda a otros schemas

Combinación: Multi-tenant establece el schema base (suc0001caja001). Si activamos multi-schema, buscamos en ese schema + otros schemas de la misma sucursal.

Multi-Tenant vs Multi-Modo

AspectoMulti-TenantMulti-Modo
PropósitoAislamiento por tenantSeparación oficial/prueba
Database1 database (bautista)2 databases (bautista + bautista_p)
CuándoSIEMPRESolo en modo prueba o consolidación
RelaciónTrabaja en nivel de schemaTrabaja en nivel de database

Combinación: Multi-tenant determina el schema. Multi-modo determina la database (bautista o bautista_p). Ambos son ortogonales.

Ejemplo: Request con prueba=true y X-Schema=suc0001caja001:

  • Multi-modo → Database: bautista_p
  • Multi-tenant → Schema: suc0001caja001
  • Resultado: bautista_p.suc0001caja001.recibos

Diagrama Completo de Multi-Tenant

mermaid
graph TB
    subgraph "Frontend"
        SS[Schema Selector<br/>suc0001caja001]
        JWT[JWT Token<br/>schema: suc0001]
    end

    subgraph "Middleware"
        AM[AuthMiddleware<br/>Valida JWT]
        CM[ConnectionMiddleware<br/>Setup conexión]
    end

    subgraph "ConnectionManager"
        ALIAS[Resolver Alias<br/>principal → oficial o prueba]
        SETUP[Setup Connection<br/>Determinar schema<br/>X-Schema > JWT > GLOBALS]
    end

    subgraph "PostgreSQL"
        PG_OFICIAL[bautista<br/>oficial]
        PG_PRUEBA[bautista_p<br/>prueba]

        subgraph "Schemas (oficial)"
            S1[suc0001<br/>facturas, clientes]
            C1[suc0001caja001<br/>recibos, movimientos]
            PUB[public<br/>plan_cuentas, maestros]
        end
    end

    SS -->|X-Schema: suc0001caja001| AM
    JWT -->|schema: suc0001| AM

    AM -->|Request validado| CM

    CM -->|getConnection('principal')| ALIAS
    CM -->|Determinar schema| SETUP

    ALIAS -->|prueba=false| PG_OFICIAL
    ALIAS -->|prueba=true| PG_PRUEBA

    SETUP -->|SET search_path| S_PATH[suc0001caja001, suc0001, public]

    S_PATH -.->|Busca en orden| C1
    S_PATH -.->|Si no encuentra| S1
    S_PATH -.->|Si no encuentra| PUB

    style SS fill:#90EE90
    style JWT fill:#FFD700
    style AM fill:#87CEEB
    style CM fill:#87CEEB
    style S_PATH fill:#FF69B4

Siguiente paso: Leer Multi-Schema (Cross-Schema Querying) para entender cómo buscar en múltiples schemas simultáneamente.

Referencias: