db/infra: ETL de datos, backups y limpieza de despliegue (fase 5-7)

Fase 5 (migracion de datos):
- api/scripts/migrate-sqlite-to-postgres.ts: ETL unico SQLite -> Postgres.
  Pre-flight (CURP/RFC duplicados case-insensitive, FKs huerfanas,
  tenant_id invalido, fechas mal formateadas) -> carga por base/esquema
  con credenciales _owner (setval de secuencias, OVERRIDING SYSTEM VALUE,
  ON CONFLICT DO NOTHING idempotente) -> verificacion de conteos y sumas
  de dinero con tolerancia. Probado end-to-end contra un dataset SQLite
  legacy sintetico y un Postgres limpio (solo schema+catalogos, sin
  contexto dev): 100% de las filas migradas, sumas de dinero exactas,
  snapshot de uploaded_by/created_by resuelto correctamente.
- db/RUNBOOK-corte.md: checklist go/no-go para el corte por ambiente.
- Fix de bug real encontrado al probar: sin --context-filter explicito,
  Liquibase corre TAMBIEN los changesets context=dev (comportamiento por
  defecto, no "modo seguro") -- documentado en db/README.md con el
  ejemplo correcto (--context-filter='!dev' para staging/produccion).

Fase 5b: patron de compensacion para createTenant ya resuelto en el
rewrite de saas.ts (fase 3/4).

Fase 6 (backups): db/backups/ con plantillas pgBackRest por base,
politica de retencion, nota de persistencia minima de Redis (RDB, sin
retencion de negocio), checklist de simulacro de restauracion mensual,
y la validacion pendiente de si Coolify permite WAL archiving antes de
comprometerse a PITR real.

Fase 7 (limpieza y CI):
- Dockerfile.api simplificado (sin JRE/Liquibase/FFI). Dockerfile.migrate
  y Dockerfile.provision nuevos, de un solo uso, para el paso explicito
  de deploy (nunca sirven trafico).
- docker-compose.yml: postgres+redis+provision+migrate para dev local
  end-to-end; api ya no arranca hasta que migrate termina bien.
- docs/coolify.md y .env.example actualizados al modelo de 2 bases +
  Redis + Contabo.
- api/schema.sql eliminado (desincronizado, competia con Liquibase como
  fuente de verdad).
- .github/workflows/ci.yml: Postgres+Redis de servicio, aprovisiona
  roles/ACLs, dry-run de Liquibase (updateSQL) antes de aplicar,
  verify-isolation.sh, deno check + deno test, build de los dos frontends.

Co-authored-by: alberto.martinez <alberto.martinez@mrdev.mx>
This commit is contained in:
Cursor Agent 2026-09-02 21:05:07 +00:00
parent 493829d028
commit d36c287a82
No known key found for this signature in database
19 changed files with 1223 additions and 451 deletions

View file

@ -1,17 +1,16 @@
# ============================================================================= # =============================================================================
# Copiar a `.env` (gitignored) para local / Coolify. # Copiar a `.env` (gitignored) para local / Coolify.
# NUNCA subas `.env` ni `data/*.db` al repositorio. # NUNCA subas `.env` ni `data/*` al repositorio.
#
# Generar cada password/secreto con: openssl rand -hex 24
# ============================================================================= # =============================================================================
# --- OBLIGATORIAS (producción) --- # --- OBLIGATORIAS (producción) ---
# Generar ejemplos:
# SESSION_SECRET: openssl rand -hex 32
# DOCS_KEY: openssl rand -hex 32 (exactamente 64 hex)
SESSION_SECRET= SESSION_SECRET=
DOCS_KEY= DOCS_KEY=
# ^ DOCS_KEY debe ser EXACTAMENTE 64 hex (32 bytes): openssl rand -hex 32
SEED_PASSWORD= SEED_PASSWORD=
# Si la contraseña tiene `$`, en Docker Compose escríbelo como `$$` (ej. `p$$ass` → `p$ass`). # Si la contraseña tiene `$`, en Docker Compose escríbelo como `$$` (ej. `p$$ass` → `p$ass`).
# Opcional one-shot: SEED_SYNC_PASSWORD=true (actualiza password de admin al arrancar; luego quítalo)
PANEL_LOGIN_URL=https://app.tudominio/login PANEL_LOGIN_URL=https://app.tudominio/login
PORT=8000 PORT=8000
@ -25,9 +24,54 @@ CORS_ORIGINS=
API_KEY= API_KEY=
VCARD_BASE=https://vcard.arctec.com.mx?info= VCARD_BASE=https://vcard.arctec.com.mx?info=
# SMTP (opcional; también se puede configurar en SaaS → /smtp y queda en platform.db) # SMTP (opcional; también se puede configurar en SaaS → /smtp y queda en panels_platform)
SMTP_HOST= SMTP_HOST=
SMTP_PORT=587 SMTP_PORT=587
SMTP_USER= SMTP_USER=
SMTP_PASS= SMTP_PASS=
SMTP_FROM=PANELS <noreply@tudominio> SMTP_FROM=PANELS <noreply@tudominio>
# =============================================================================
# Postgres (monolito modular: panels_platform separada de panels_product)
# En Coolify, estos apuntan al recurso Postgres gestionado del ambiente
# (ver db/provision/README.md). Para `docker compose up` local, además hay
# que definir las contraseñas de cada rol (siguiente bloque) -- el compose
# arma las URLs solo.
# =============================================================================
DATABASE_URL_PLATFORM=
DATABASE_URL_IAM=
DATABASE_URL_CORE=
# _OWNER: solo para el paso de deploy (Liquibase) y scripts (bootstrap/ETL).
# Nunca usar el rol _owner para el tráfico normal de la API.
DATABASE_URL_PLATFORM_OWNER=
DATABASE_URL_IAM_OWNER=
DATABASE_URL_CORE_OWNER=
# --- Solo para `docker compose up` local (provisiona Postgres/Redis propios) ---
POSTGRES_SUPERUSER_PASSWORD=
PLATFORM_OWNER_PASSWORD=
PLATFORM_APP_PASSWORD=
IAM_OWNER_PASSWORD=
IAM_APP_PASSWORD=
CORE_OWNER_PASSWORD=
CORE_APP_PASSWORD=
# =============================================================================
# Redis (sesiones + cache, con ACL por módulo -- ver db/provision/05-redis-acl.sh)
# =============================================================================
REDIS_URL_IAM=
REDIS_URL_CORE=
# --- Solo para `docker compose up` local ---
IAM_REDIS_PASSWORD=
CORE_REDIS_PASSWORD=
# =============================================================================
# Contabo Object Storage (Fase 4c) -- expedientes/PDFs/logos cifrados.
# Sin esto, cae a disco local bajo el volumen panel-data (solo dev; en
# producción un volumen local no escala horizontalmente, ver el plan).
# =============================================================================
S3_ENDPOINT=
S3_BUCKET=
S3_REGION=
S3_ACCESS_KEY_ID=
S3_SECRET_ACCESS_KEY=

111
.github/workflows/ci.yml vendored Normal file
View file

@ -0,0 +1,111 @@
name: CI
on:
pull_request:
push:
branches: [main]
jobs:
api:
runs-on: ubuntu-latest
services:
postgres:
image: postgres:16
env:
POSTGRES_PASSWORD: postgres
ports:
- 5432:5432
options: >-
--health-cmd "pg_isready -U postgres"
--health-interval 10s
--health-timeout 5s
--health-retries 10
redis:
image: redis:7
ports:
- 6379:6379
options: >-
--health-cmd "redis-cli ping"
--health-interval 10s
--health-timeout 5s
--health-retries 10
steps:
- uses: actions/checkout@v4
- uses: denoland/setup-deno@v2
with:
deno-version: v2.x
- uses: actions/setup-java@v4
with:
distribution: temurin
java-version: "21"
- name: Aprovisionar roles/esquemas Postgres + ACLs Redis
env:
PGHOST: localhost
PGPASSWORD: postgres
PLATFORM_OWNER_PASSWORD: ci-platform-owner
PLATFORM_APP_PASSWORD: ci-platform-app
IAM_OWNER_PASSWORD: ci-iam-owner
IAM_APP_PASSWORD: ci-iam-app
CORE_OWNER_PASSWORD: ci-core-owner
CORE_APP_PASSWORD: ci-core-app
REDIS_ADMIN_URL: redis://localhost:6379
IAM_REDIS_PASSWORD: ci-iam-redis
CORE_REDIS_PASSWORD: ci-core-redis
run: |
sudo apt-get update -qq && sudo apt-get install -y -qq postgresql-client redis-tools
./db/provision/docker-provision.sh
./db/provision/05-redis-acl.sh
- name: Dry-run de Liquibase (updateSQL) -- no debe fallar antes de aplicar
env:
DATABASE_URL_PLATFORM_OWNER: postgresql://panels_platform_owner:ci-platform-owner@localhost:5432/panels_platform
DATABASE_URL_IAM_OWNER: postgresql://panels_iam_owner:ci-iam-owner@localhost:5432/panels_product
DATABASE_URL_CORE_OWNER: postgresql://panels_core_owner:ci-core-owner@localhost:5432/panels_product
run: ./db/update.sh all --context-filter='!dev' -- updateSQL
- name: Aplicar migraciones (con datos de demo, para los tests)
env:
DATABASE_URL_PLATFORM_OWNER: postgresql://panels_platform_owner:ci-platform-owner@localhost:5432/panels_platform
DATABASE_URL_IAM_OWNER: postgresql://panels_iam_owner:ci-iam-owner@localhost:5432/panels_product
DATABASE_URL_CORE_OWNER: postgresql://panels_core_owner:ci-core-owner@localhost:5432/panels_product
run: ./db/update.sh all --context-filter=dev
- name: Verificar aislamiento (roles/RLS/ACLs)
env:
DATABASE_URL_PLATFORM: postgresql://panels_platform_app:ci-platform-app@localhost:5432/panels_platform
DATABASE_URL_IAM: postgresql://panels_iam_app:ci-iam-app@localhost:5432/panels_product
DATABASE_URL_CORE: postgresql://panels_core_app:ci-core-app@localhost:5432/panels_product
REDIS_URL_IAM: redis://panels_iam_redis:ci-iam-redis@localhost:6379
REDIS_URL_CORE: redis://panels_core_redis:ci-core-redis@localhost:6379
run: ./db/provision/verify-isolation.sh
- name: deno check
working-directory: api
run: deno check main.ts
- name: deno test
working-directory: api
env:
DATABASE_URL_CORE_OWNER: postgresql://panels_core_owner:ci-core-owner@localhost:5432/panels_product
run: deno test --allow-net --allow-read --allow-write --allow-env
web:
runs-on: ubuntu-latest
strategy:
matrix:
app: [web-panel, web-saas]
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: "20"
cache: npm
cache-dependency-path: ${{ matrix.app }}/package-lock.json
- run: npm ci
working-directory: ${{ matrix.app }}
- run: npm run build
working-directory: ${{ matrix.app }}

View file

@ -1,26 +1,14 @@
# API PANELS — Deno + SQLite FFI + Liquibase/JRE (Debian slim, no Alpine) # API PANELS — Deno + Postgres (postgres.js) + Redis. Ya no necesita FFI de
# SQLite ni JRE/Liquibase en la imagen de runtime -- las migraciones corren
# como paso explícito de deploy (ver db/update.sh), no al arrancar la app.
FROM denoland/deno:2.9.5 FROM denoland/deno:2.9.5
USER root
RUN apt-get update \
&& apt-get install -y --no-install-recommends \
openjdk-21-jre-headless \
curl \
unzip \
bash \
ca-certificates \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /app WORKDIR /app
COPY db/ ./db/
COPY api/ ./api/ COPY api/ ./api/
COPY web-panel/public/brand/panel-icon-symmetric.png ./api/assets/email/logo.png COPY web-panel/public/brand/panel-icon-symmetric.png ./api/assets/email/logo.png
RUN chmod +x ./db/bootstrap-tools.sh \ RUN mkdir -p /app/data && chown -R deno:deno /app
&& ./db/bootstrap-tools.sh \
&& mkdir -p /app/data \
&& chown -R deno:deno /app
USER deno USER deno
WORKDIR /app/api WORKDIR /app/api
@ -29,7 +17,7 @@ RUN deno cache main.ts
ENV PORT=8000 ENV PORT=8000
EXPOSE 8000 EXPOSE 8000
HEALTHCHECK --interval=30s --timeout=5s --start-period=45s --retries=3 \ HEALTHCHECK --interval=30s --timeout=5s --start-period=15s --retries=3 \
CMD ["deno", "eval", "const r=await fetch('http://127.0.0.1:'+(Deno.env.get('PORT')||'8000')+'/v1/health'); if(!r.ok) Deno.exit(1)"] CMD ["deno", "eval", "const r=await fetch('http://127.0.0.1:'+(Deno.env.get('PORT')||'8000')+'/v1/health'); if(!r.ok) Deno.exit(1)"]
CMD ["deno", "run", "--allow-net", "--allow-read", "--allow-write", "--allow-env", "--allow-ffi", "--allow-run", "main.ts"] CMD ["deno", "run", "--allow-net", "--allow-read", "--allow-write", "--allow-env", "main.ts"]

17
Dockerfile.migrate Normal file
View file

@ -0,0 +1,17 @@
# PANELS — imagen mínima para el paso EXPLÍCITO de deploy que aplica
# Liquibase (Postgres). Separada de Dockerfile.api a propósito: la imagen
# que sirve tráfico real no necesita JRE/Liquibase (Fase 2/7 del plan de
# migración) -- esto solo se usa como job de un solo uso antes de levantar
# `api`, nunca como servicio de larga duración.
FROM eclipse-temurin:21-jre-jammy
RUN apt-get update \
&& apt-get install -y --no-install-recommends curl unzip bash ca-certificates \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /app
COPY db/ ./db/
RUN chmod +x ./db/bootstrap-tools.sh ./db/update.sh && ./db/bootstrap-tools.sh
ENTRYPOINT ["./db/update.sh"]
CMD ["all", "--context-filter=dev"]

11
Dockerfile.provision Normal file
View file

@ -0,0 +1,11 @@
# PANELS — imagen mínima de un solo uso para aprovisionar roles/bases/
# esquemas de Postgres y ACLs de Redis en desarrollo local (ver
# db/provision/). No se usa en producción -- ahí este paso se corre a
# mano una vez contra el Postgres/Redis reales de Coolify (ver
# db/provision/README.md).
FROM alpine:3.20
RUN apk add --no-cache postgresql16-client redis bash
WORKDIR /app
COPY db/provision/ ./db/provision/
RUN chmod +x ./db/provision/*.sh
ENTRYPOINT ["/bin/bash"]

View file

@ -1,9 +1,19 @@
{ {
"version": "5", "version": "5",
"specifiers": { "specifiers": {
"jsr:@db/sqlite@0.12": "0.12.0",
"jsr:@denosaurs/plug@1": "1.1.0",
"jsr:@hono/hono@4": "4.13.2", "jsr:@hono/hono@4": "4.13.2",
"jsr:@std/assert@0.217": "0.217.0",
"jsr:@std/assert@1": "1.0.19", "jsr:@std/assert@1": "1.0.19",
"jsr:@std/encoding@1": "1.0.11",
"jsr:@std/fmt@1": "1.0.10",
"jsr:@std/fs@1": "1.0.24",
"jsr:@std/internal@^1.0.12": "1.0.14", "jsr:@std/internal@^1.0.12": "1.0.14",
"jsr:@std/internal@^1.0.14": "1.0.14",
"jsr:@std/path@0.217": "0.217.0",
"jsr:@std/path@1": "1.1.6",
"jsr:@std/path@^1.1.5": "1.1.6",
"npm:@aws-sdk/client-s3@3": "3.1124.0", "npm:@aws-sdk/client-s3@3": "3.1124.0",
"npm:@types/node@*": "22.15.15", "npm:@types/node@*": "22.15.15",
"npm:nodemailer@6.9.16": "6.9.16", "npm:nodemailer@6.9.16": "6.9.16",
@ -14,17 +24,61 @@
"npm:xlsx@0.18.5": "0.18.5" "npm:xlsx@0.18.5": "0.18.5"
}, },
"jsr": { "jsr": {
"@db/sqlite@0.12.0": {
"integrity": "dd1ef7f621ad50fc1e073a1c3609c4470bd51edc0994139c5bf9851de7a6d85f",
"dependencies": [
"jsr:@denosaurs/plug",
"jsr:@std/path@0.217"
]
},
"@denosaurs/plug@1.1.0": {
"integrity": "eb2f0b7546c7bca2000d8b0282c54d50d91cf6d75cb26a80df25a6de8c4bc044",
"dependencies": [
"jsr:@std/encoding",
"jsr:@std/fmt",
"jsr:@std/fs",
"jsr:@std/path@1"
]
},
"@hono/hono@4.13.2": { "@hono/hono@4.13.2": {
"integrity": "715d8cc1b6b5d6b9e6a7519059778d775b2d79fffdc026ac0ccdd9971e75809c" "integrity": "715d8cc1b6b5d6b9e6a7519059778d775b2d79fffdc026ac0ccdd9971e75809c"
}, },
"@std/assert@0.217.0": {
"integrity": "c98e279362ca6982d5285c3b89517b757c1e3477ee9f14eb2fdf80a45aaa9642"
},
"@std/assert@1.0.19": { "@std/assert@1.0.19": {
"integrity": "eaada96ee120cb980bc47e040f82814d786fe8162ecc53c91d8df60b8755991e", "integrity": "eaada96ee120cb980bc47e040f82814d786fe8162ecc53c91d8df60b8755991e",
"dependencies": [ "dependencies": [
"jsr:@std/internal" "jsr:@std/internal@^1.0.12"
]
},
"@std/encoding@1.0.11": {
"integrity": "e7cef2f0b3153bccc17431e7c864a1de03a4b6d9647389c43e08aaa775d37385"
},
"@std/fmt@1.0.10": {
"integrity": "90dfba288802ac6de82fb31d0917eb9e4450b9925b954d5e51fc29ac07419db5"
},
"@std/fs@1.0.24": {
"integrity": "f3061b45b81673a2bece689da041df32d174be064c89eb6397fb5718d3fb7877",
"dependencies": [
"jsr:@std/internal@^1.0.14",
"jsr:@std/path@^1.1.5"
] ]
}, },
"@std/internal@1.0.14": { "@std/internal@1.0.14": {
"integrity": "291516b3d4c35024d6ffbc0a9df5bf4c64116e05b50012cf846710152d2ffdf7" "integrity": "291516b3d4c35024d6ffbc0a9df5bf4c64116e05b50012cf846710152d2ffdf7"
},
"@std/path@0.217.0": {
"integrity": "1217cc25534bca9a2f672d7fe7c6f356e4027df400c0e85c0ef3e4343bc67d11",
"dependencies": [
"jsr:@std/assert@0.217"
]
},
"@std/path@1.1.6": {
"integrity": "c68485c2a4dfbb5ae3cc74fae4e8c4e5d874cf8a8ed12927917235c758b46cbe",
"dependencies": [
"jsr:@std/internal@^1.0.14"
]
} }
}, },
"npm": { "npm": {

View file

@ -1,384 +0,0 @@
PRAGMA foreign_keys = ON;
PRAGMA journal_mode = WAL;
CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
username TEXT NOT NULL UNIQUE,
password_hash TEXT NOT NULL,
display_name TEXT NOT NULL,
company_id INTEGER REFERENCES companies(id),
tenant_id INTEGER,
role TEXT NOT NULL DEFAULT 'user',
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS companies (
id INTEGER PRIMARY KEY AUTOINCREMENT,
code TEXT NOT NULL UNIQUE,
name TEXT NOT NULL,
parent_id INTEGER REFERENCES companies(id),
kind TEXT NOT NULL DEFAULT 'sub' CHECK (kind IN ('principal', 'sub')),
status TEXT NOT NULL DEFAULT 'activo' CHECK (status IN ('activo', 'inactivo')),
tenant_id INTEGER,
registro_patronal TEXT NOT NULL DEFAULT '',
razon_social TEXT NOT NULL DEFAULT '',
nombre_comercial TEXT NOT NULL DEFAULT '',
rfc TEXT NOT NULL DEFAULT '',
regimen_fiscal TEXT NOT NULL DEFAULT '',
clase_riesgo TEXT NOT NULL DEFAULT '',
domicilio_fiscal TEXT NOT NULL DEFAULT '',
codigo_postal TEXT NOT NULL DEFAULT '',
ciudad TEXT NOT NULL DEFAULT '',
estado TEXT NOT NULL DEFAULT '',
telefono TEXT NOT NULL DEFAULT '',
email TEXT NOT NULL DEFAULT '',
representante_legal TEXT NOT NULL DEFAULT '',
giro TEXT NOT NULL DEFAULT '',
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS risk_levels (
code TEXT PRIMARY KEY,
label TEXT NOT NULL,
color TEXT NOT NULL,
text_color TEXT NOT NULL DEFAULT '#FFFFFF'
);
CREATE TABLE IF NOT EXISTS badge_themes (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
layout TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS projects (
id INTEGER PRIMARY KEY AUTOINCREMENT,
code TEXT NOT NULL UNIQUE,
name TEXT NOT NULL,
address TEXT NOT NULL DEFAULT '',
stage TEXT NOT NULL DEFAULT '',
status TEXT NOT NULL DEFAULT 'activo' CHECK (status IN ('activo', 'pausado', 'concluido', 'cancelado')),
theme_id TEXT NOT NULL REFERENCES badge_themes(id),
logo_left_path TEXT,
logo_right_path TEXT,
company_id INTEGER REFERENCES companies(id),
tenant_id INTEGER,
contract_amount REAL,
start_date TEXT,
end_date TEXT,
resident_name TEXT NOT NULL DEFAULT '',
siroc TEXT NOT NULL DEFAULT '',
payroll_tax_pct REAL NOT NULL DEFAULT 4,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS workers (
id INTEGER PRIMARY KEY AUTOINCREMENT,
first_name TEXT NOT NULL,
middle_name TEXT,
last_name_p TEXT NOT NULL,
last_name_m TEXT NOT NULL,
curp TEXT NOT NULL COLLATE NOCASE,
rfc TEXT NOT NULL COLLATE NOCASE,
nss TEXT NOT NULL,
phone TEXT NOT NULL,
email TEXT NOT NULL,
address TEXT NOT NULL,
blood_type TEXT,
hire_type TEXT NOT NULL,
company_id INTEGER REFERENCES companies(id),
tenant_id INTEGER,
position TEXT NOT NULL,
risk_code TEXT NOT NULL REFERENCES risk_levels(code),
work_type TEXT NOT NULL CHECK (work_type IN ('N', 'D')),
daily_wage REAL NOT NULL DEFAULT 0,
needs_badge INTEGER NOT NULL DEFAULT 1,
status TEXT NOT NULL DEFAULT 'activo' CHECK (status IN ('activo', 'baja')),
pipeline_status TEXT NOT NULL DEFAULT 'incompleto',
imss_status TEXT NOT NULL DEFAULT 'sin_alta' CHECK (imss_status IN ('sin_alta', 'alta', 'baja_imss')),
imss_company_id INTEGER REFERENCES companies(id),
imss_alta_at TEXT,
imss_baja_at TEXT,
last_rehire_at TEXT,
vcard_password_enc TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE UNIQUE INDEX IF NOT EXISTS idx_workers_curp ON workers(curp);
CREATE UNIQUE INDEX IF NOT EXISTS idx_workers_rfc ON workers(rfc);
CREATE UNIQUE INDEX IF NOT EXISTS idx_workers_nss ON workers(nss);
CREATE TABLE IF NOT EXISTS assignments (
id INTEGER PRIMARY KEY AUTOINCREMENT,
worker_id INTEGER NOT NULL REFERENCES workers(id) ON DELETE CASCADE,
project_id INTEGER NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
active INTEGER NOT NULL DEFAULT 1,
start_date TEXT NOT NULL,
end_date TEXT,
UNIQUE (worker_id, project_id)
);
CREATE TABLE IF NOT EXISTS document_types (
code TEXT PRIMARY KEY,
label TEXT NOT NULL,
required INTEGER NOT NULL DEFAULT 1,
validity_mode TEXT NOT NULL DEFAULT 'none' CHECK (validity_mode IN ('none', 'freshness', 'expiry')),
freshness_days INTEGER,
requires_issued_at INTEGER NOT NULL DEFAULT 0,
requires_expires_at INTEGER NOT NULL DEFAULT 0,
category TEXT NOT NULL DEFAULT 'identidad'
);
CREATE TABLE IF NOT EXISTS documents (
id INTEGER PRIMARY KEY AUTOINCREMENT,
worker_id INTEGER NOT NULL REFERENCES workers(id) ON DELETE CASCADE,
type_code TEXT NOT NULL REFERENCES document_types(code),
original_name TEXT NOT NULL,
mime TEXT NOT NULL,
size_bytes INTEGER NOT NULL,
sha256 TEXT NOT NULL,
iv TEXT NOT NULL,
storage_name TEXT NOT NULL,
is_current INTEGER NOT NULL DEFAULT 1,
parse_status TEXT NOT NULL DEFAULT 'manual',
issued_at TEXT,
expires_at TEXT,
imss_company_id INTEGER REFERENCES companies(id),
imss_alta_at TEXT,
uploaded_by INTEGER REFERENCES users(id),
uploaded_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS project_document_types (
code TEXT PRIMARY KEY,
label TEXT NOT NULL,
required INTEGER NOT NULL DEFAULT 1,
category TEXT NOT NULL DEFAULT 'contrato'
);
CREATE TABLE IF NOT EXISTS project_documents (
id INTEGER PRIMARY KEY AUTOINCREMENT,
project_id INTEGER NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
type_code TEXT NOT NULL REFERENCES project_document_types(code),
original_name TEXT NOT NULL,
mime TEXT NOT NULL,
size_bytes INTEGER NOT NULL,
sha256 TEXT NOT NULL,
iv TEXT NOT NULL,
storage_name TEXT NOT NULL,
is_current INTEGER NOT NULL DEFAULT 1,
parse_status TEXT NOT NULL DEFAULT 'manual',
uploaded_by INTEGER REFERENCES users(id),
uploaded_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS company_document_types (
code TEXT PRIMARY KEY,
label TEXT NOT NULL,
required INTEGER NOT NULL DEFAULT 0,
category TEXT NOT NULL DEFAULT 'otro'
);
CREATE TABLE IF NOT EXISTS company_documents (
id INTEGER PRIMARY KEY AUTOINCREMENT,
company_id INTEGER NOT NULL REFERENCES companies(id) ON DELETE CASCADE,
type_code TEXT NOT NULL REFERENCES company_document_types(code),
original_name TEXT NOT NULL,
mime TEXT NOT NULL,
size_bytes INTEGER NOT NULL,
sha256 TEXT NOT NULL,
iv TEXT NOT NULL,
storage_name TEXT NOT NULL,
is_current INTEGER NOT NULL DEFAULT 1,
parse_status TEXT NOT NULL DEFAULT 'manual',
uploaded_by INTEGER REFERENCES users(id),
uploaded_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS badge_jobs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
project_id INTEGER NOT NULL REFERENCES projects(id),
status TEXT NOT NULL DEFAULT 'done',
pdf_path TEXT,
created_by INTEGER REFERENCES users(id),
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS badge_job_people (
job_id INTEGER NOT NULL REFERENCES badge_jobs(id) ON DELETE CASCADE,
worker_id INTEGER NOT NULL REFERENCES workers(id),
delivered INTEGER NOT NULL DEFAULT 0,
delivered_at TEXT,
PRIMARY KEY (job_id, worker_id)
);
CREATE TABLE IF NOT EXISTS loans (
id INTEGER PRIMARY KEY AUTOINCREMENT,
worker_id INTEGER NOT NULL REFERENCES workers(id) ON DELETE CASCADE,
amount REAL NOT NULL,
delivered REAL NOT NULL DEFAULT 0,
balance REAL NOT NULL,
weekly_payment REAL NOT NULL,
note TEXT,
commission_pct REAL NOT NULL DEFAULT 0,
commission_amount REAL NOT NULL DEFAULT 0,
plan TEXT NOT NULL DEFAULT 'single',
installments_n INTEGER NOT NULL DEFAULT 1,
first_due TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS attendance (
id INTEGER PRIMARY KEY AUTOINCREMENT,
worker_id INTEGER NOT NULL REFERENCES workers(id) ON DELETE CASCADE,
project_id INTEGER NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
work_date TEXT NOT NULL,
present INTEGER NOT NULL DEFAULT 1,
UNIQUE (worker_id, project_id, work_date)
);
CREATE TABLE IF NOT EXISTS payroll_periods (
id INTEGER PRIMARY KEY AUTOINCREMENT,
project_id INTEGER NOT NULL REFERENCES projects(id),
week_start TEXT NOT NULL,
week_end TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'draft',
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS payroll_lines (
id INTEGER PRIMARY KEY AUTOINCREMENT,
period_id INTEGER NOT NULL REFERENCES payroll_periods(id) ON DELETE CASCADE,
worker_id INTEGER NOT NULL REFERENCES workers(id),
days REAL NOT NULL DEFAULT 0,
daily_wage REAL NOT NULL,
gross REAL NOT NULL,
discounts REAL NOT NULL DEFAULT 0,
loan_payment REAL NOT NULL DEFAULT 0,
net REAL NOT NULL
);
CREATE TABLE IF NOT EXISTS payroll_settings (
tenant_id INTEGER NOT NULL PRIMARY KEY,
loan_commission_enabled INTEGER NOT NULL DEFAULT 1,
loan_commission_pct REAL NOT NULL DEFAULT 10,
loan_small_max REAL NOT NULL DEFAULT 500
);
CREATE TABLE IF NOT EXISTS payroll_weeks (
id INTEGER PRIMARY KEY AUTOINCREMENT,
week_start TEXT NOT NULL,
week_end TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'draft' CHECK (status IN ('draft', 'assembled', 'paid')),
required_net REAL NOT NULL DEFAULT 0,
payable_net REAL NOT NULL DEFAULT 0,
assembled_at TEXT,
paid_at TEXT,
tenant_id INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS payroll_sheets (
id INTEGER PRIMARY KEY AUTOINCREMENT,
week_id INTEGER NOT NULL REFERENCES payroll_weeks(id) ON DELETE CASCADE,
kind TEXT NOT NULL CHECK (kind IN ('obra', 'destajo', 'admin')),
project_id INTEGER REFERENCES projects(id)
);
CREATE TABLE IF NOT EXISTS payroll_week_lines (
id INTEGER PRIMARY KEY AUTOINCREMENT,
sheet_id INTEGER NOT NULL REFERENCES payroll_sheets(id) ON DELETE CASCADE,
worker_id INTEGER NOT NULL REFERENCES workers(id),
destajo_cut_line_id INTEGER,
project_id INTEGER REFERENCES projects(id),
days REAL NOT NULL DEFAULT 0,
daily_wage REAL NOT NULL DEFAULT 0,
qty_planned REAL NOT NULL DEFAULT 0,
qty_actual REAL NOT NULL DEFAULT 0,
qty_extra REAL NOT NULL DEFAULT 0,
unit_price REAL NOT NULL DEFAULT 0,
unit_code TEXT,
concepto TEXT,
amount REAL NOT NULL DEFAULT 0,
gross REAL NOT NULL DEFAULT 0,
discounts REAL NOT NULL DEFAULT 0,
loan_id INTEGER REFERENCES loans(id),
loan_discount REAL NOT NULL DEFAULT 0,
loan_label TEXT,
required_net REAL NOT NULL DEFAULT 0,
payable_net REAL NOT NULL DEFAULT 0
);
CREATE TABLE IF NOT EXISTS destajo_units (
id INTEGER PRIMARY KEY AUTOINCREMENT,
tenant_id INTEGER NOT NULL DEFAULT 0,
code TEXT NOT NULL,
label TEXT NOT NULL,
UNIQUE (tenant_id, code)
);
CREATE TABLE IF NOT EXISTS destajo_periods (
id INTEGER PRIMARY KEY AUTOINCREMENT,
period_start TEXT NOT NULL,
period_end TEXT NOT NULL,
week_id INTEGER NOT NULL REFERENCES payroll_weeks(id) ON DELETE CASCADE,
tenant_id INTEGER NOT NULL DEFAULT 0,
UNIQUE (tenant_id, period_end)
);
CREATE TABLE IF NOT EXISTS destajo_jobs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
project_id INTEGER NOT NULL REFERENCES projects(id),
worker_id INTEGER NOT NULL REFERENCES workers(id),
concepto TEXT NOT NULL,
unit_code TEXT NOT NULL,
qty_total_estimated REAL NOT NULL,
unit_price REAL NOT NULL,
status TEXT NOT NULL DEFAULT 'open' CHECK (status IN ('open', 'done')),
tenant_id INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS destajo_cut_lines (
id INTEGER PRIMARY KEY AUTOINCREMENT,
period_id INTEGER NOT NULL REFERENCES destajo_periods(id) ON DELETE CASCADE,
job_id INTEGER NOT NULL REFERENCES destajo_jobs(id) ON DELETE CASCADE,
qty_planned REAL NOT NULL DEFAULT 0,
qty_actual REAL NOT NULL DEFAULT 0,
qty_extra REAL NOT NULL DEFAULT 0,
UNIQUE (period_id, job_id)
);
CREATE TABLE IF NOT EXISTS loan_payments (
id INTEGER PRIMARY KEY AUTOINCREMENT,
loan_id INTEGER NOT NULL REFERENCES loans(id) ON DELETE CASCADE,
week_id INTEGER NOT NULL REFERENCES payroll_weeks(id),
amount REAL NOT NULL,
installment_n INTEGER NOT NULL DEFAULT 1,
label TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS budget_chapters (
id INTEGER PRIMARY KEY AUTOINCREMENT,
project_id INTEGER NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
parent_id INTEGER REFERENCES budget_chapters(id) ON DELETE SET NULL,
code TEXT NOT NULL DEFAULT '',
name TEXT NOT NULL,
wbs TEXT NOT NULL DEFAULT '',
sort_order INTEGER NOT NULL DEFAULT 0
);
CREATE TABLE IF NOT EXISTS budget_items (
id INTEGER PRIMARY KEY AUTOINCREMENT,
project_id INTEGER NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
chapter_id INTEGER REFERENCES budget_chapters(id) ON DELETE SET NULL,
code TEXT NOT NULL DEFAULT '',
description TEXT NOT NULL,
unit TEXT NOT NULL DEFAULT '',
quantity REAL NOT NULL DEFAULT 0,
unit_price REAL NOT NULL DEFAULT 0,
amount REAL NOT NULL DEFAULT 0,
wbs TEXT NOT NULL DEFAULT '',
sort_order INTEGER NOT NULL DEFAULT 0
);

View file

@ -0,0 +1,447 @@
#!/usr/bin/env -S deno run --allow-ffi --allow-net --allow-read --allow-write --allow-env
/**
* PANELS · Fase 5 · ETL único SQLite -> Postgres.
*
* Migra los datos de las dos SQLite históricas (data/app.db, data/platform.db)
* a las 2 bases Postgres del monolito modular (panels_platform,
* panels_product con esquemas iam/core). Corre en 3 fases:
*
* 1. Pre-flight: detecta de antemano lo que puede reventar el load o
* corromper datos en silencio (duplicados CURP/RFC case-insensitive,
* FKs huérfanas, tenant_id sin tenant, fechas con formato inválido).
* 2. Carga: una transacción POR BASE/ESQUENA (platform, iam, core),
* preservando los ids originales (OVERRIDING SYSTEM VALUE) para no
* romper referencias cruzadas entre tablas.
* 3. Verificación: compara conteos de filas origen/destino y sumas de
* columnas de dinero con una tolerancia explícita (REAL -> NUMERIC
* puede mover centavos).
*
* Uso (correr DESDE api/, con las credenciales _owner del ambiente destino
* en el entorno -- DATABASE_URL_PLATFORM_OWNER, DATABASE_URL_IAM_OWNER,
* DATABASE_URL_CORE_OWNER; el rol _app normal no alcanza a propósito):
* cd api
* set -a && source ../.env.dev-local && set +a # o el .env real del ambiente
* deno run --allow-ffi --allow-net --allow-read --allow-write --allow-env \
* scripts/migrate-sqlite-to-postgres.ts \
* --app-db=../data/app.db --platform-db=../data/platform.db
*
* Sale con código != 0 si el pre-flight encuentra problemas o si la
* verificación posterior no cuadra -- diseñado para un pipeline de
* corte con criterios go/no-go, no para "correr y ya".
*/
import { Database as SqliteDatabase } from "jsr:@db/sqlite@0.12";
import { createPool } from "../pg.ts";
function arg(name: string, fallback: string): string {
const prefix = `--${name}=`;
const found = Deno.args.find((a) => a.startsWith(prefix));
return found ? found.slice(prefix.length) : fallback;
}
const APP_DB_PATH = arg("app-db", "../data/app.db");
const PLATFORM_DB_PATH = arg("platform-db", "../data/platform.db");
const DRY_RUN = Deno.args.includes("--dry-run");
const MONEY_TOLERANCE = 0.05; // pesos; ver nota de REAL -> NUMERIC en el plan
let exitCode = 0;
function fail(msg: string) {
console.error(`[FAIL] ${msg}`);
exitCode = 1;
}
function warn(msg: string) {
console.warn(`[WARN] ${msg}`);
}
function ok(msg: string) {
console.log(`[OK] ${msg}`);
}
// ---------------------------------------------------------------------------
// Conexiones
// ---------------------------------------------------------------------------
const appDb = new SqliteDatabase(APP_DB_PATH, { readonly: true });
const platformDb = new SqliteDatabase(PLATFORM_DB_PATH, { readonly: true });
// El ETL necesita privilegios de owner (INSERT con OVERRIDING SYSTEM VALUE +
// setval() de secuencias) -- el rol _app normal no alcanza a propósito
// (least privilege). Cada rol _owner solo manda en SU esquema
// (panels_iam_owner en iam, panels_core_owner en core, ver
// db/provision/04-product-database.sql), así que hacen falta conexiones
// separadas incluso dentro de panels_product -- no hay un "super owner"
// que pueda escribir en ambos esquemas de una vez.
function ownerUrl(name: string): string {
const url = Deno.env.get(name) || "";
if (!url) throw new Error(`Falta ${name} -- el ETL requiere credenciales _owner, no _app`);
return url;
}
const platformPg = createPool(ownerUrl("DATABASE_URL_PLATFORM_OWNER"), { max: 3 });
const iamPg = createPool(ownerUrl("DATABASE_URL_IAM_OWNER"), { max: 3 });
const corePg = createPool(ownerUrl("DATABASE_URL_CORE_OWNER"), { max: 3 });
// ---------------------------------------------------------------------------
// Fase 1: Pre-flight
// ---------------------------------------------------------------------------
async function preflight(): Promise<void> {
console.log("\n=== Pre-flight ===");
// 1. Duplicados case-insensitive de CURP/RFC (SQLite los toleraba con
// COLLATE NOCASE + índice único case-insensitive; CITEXT en Postgres
// los rechazará igual, pero mejor detectarlo ANTES de la carga).
const dupCurp = appDb.prepare(
`SELECT LOWER(curp) AS c, COUNT(*) AS n FROM workers GROUP BY LOWER(curp) HAVING COUNT(*) > 1`,
).all() as { c: string; n: number }[];
if (dupCurp.length) fail(`${dupCurp.length} CURP duplicadas (case-insensitive): ${dupCurp.map((d) => d.c).join(", ")}`);
else ok("Sin CURP duplicadas");
const dupRfc = appDb.prepare(
`SELECT LOWER(rfc) AS c, COUNT(*) AS n FROM workers GROUP BY LOWER(rfc) HAVING COUNT(*) > 1`,
).all() as { c: string; n: number }[];
if (dupRfc.length) fail(`${dupRfc.length} RFC duplicados (case-insensitive): ${dupRfc.map((d) => d.c).join(", ")}`);
else ok("Sin RFC duplicados");
// 2. FKs huérfanas (SQLite solo valida si PRAGMA foreign_keys estuvo ON
// en cada escritura histórica -- puede haber huecos).
const orphanChecks: { label: string; sql: string }[] = [
{ label: "workers.company_id sin companies", sql: `SELECT COUNT(*) AS n FROM workers WHERE company_id IS NOT NULL AND company_id NOT IN (SELECT id FROM companies)` },
{ label: "workers.risk_code sin risk_levels", sql: `SELECT COUNT(*) AS n FROM workers WHERE risk_code NOT IN (SELECT code FROM risk_levels)` },
{ label: "documents.worker_id sin workers", sql: `SELECT COUNT(*) AS n FROM documents WHERE worker_id NOT IN (SELECT id FROM workers)` },
{ label: "documents.uploaded_by sin users", sql: `SELECT COUNT(*) AS n FROM documents WHERE uploaded_by IS NOT NULL AND uploaded_by NOT IN (SELECT id FROM users)` },
{ label: "assignments.worker_id sin workers", sql: `SELECT COUNT(*) AS n FROM assignments WHERE worker_id NOT IN (SELECT id FROM workers)` },
{ label: "assignments.project_id sin projects", sql: `SELECT COUNT(*) AS n FROM assignments WHERE project_id NOT IN (SELECT id FROM projects)` },
{ label: "projects.theme_id sin badge_themes", sql: `SELECT COUNT(*) AS n FROM projects WHERE theme_id NOT IN (SELECT id FROM badge_themes)` },
{ label: "users.company_id sin companies", sql: `SELECT COUNT(*) AS n FROM users WHERE company_id IS NOT NULL AND company_id NOT IN (SELECT id FROM companies)` },
];
for (const check of orphanChecks) {
const row = appDb.prepare(check.sql).get() as { n: number };
if (row.n > 0) fail(`${row.n} filas: ${check.label}`);
else ok(check.label.replace("sin", "OK ->"));
}
// 3. tenant_id nulo o sin tenant real.
const tenantIds = new Set(
(platformDb.prepare("SELECT id FROM tenants").all() as { id: number }[]).map((t) => t.id),
);
for (const table of ["companies", "workers", "projects", "users"]) {
const rows = appDb.prepare(`SELECT id, tenant_id FROM ${table}`).all() as { id: number; tenant_id: number | null }[];
const bad = rows.filter((r) => r.tenant_id == null || !tenantIds.has(r.tenant_id));
if (bad.length) fail(`${table}: ${bad.length} filas con tenant_id nulo o inexistente (ids: ${bad.slice(0, 10).map((r) => r.id).join(",")}${bad.length > 10 ? "..." : ""})`);
else ok(`${table}.tenant_id: todas resuelven a un tenant real`);
}
// 4. Fechas con formato inválido en columnas que pasan a DATE.
const dateCols: { table: string; col: string }[] = [
{ table: "workers", col: "imss_alta_at" },
{ table: "workers", col: "imss_baja_at" },
{ table: "workers", col: "last_rehire_at" },
{ table: "projects", col: "start_date" },
{ table: "projects", col: "end_date" },
{ table: "documents", col: "issued_at" },
{ table: "documents", col: "expires_at" },
];
for (const { table, col } of dateCols) {
const rows = appDb.prepare(
`SELECT id, ${col} AS v FROM ${table} WHERE ${col} IS NOT NULL AND ${col} != ''`,
).all() as { id: number; v: string }[];
const bad = rows.filter((r) => !/^\d{4}-\d{2}-\d{2}/.test(r.v));
if (bad.length) fail(`${table}.${col}: ${bad.length} fechas con formato inválido (ej. id=${bad[0].id} -> "${bad[0].v}")`);
}
if (!dateCols.some(() => false)) ok("Formato de fechas revisado");
}
// ---------------------------------------------------------------------------
// Fase 2: Carga
// ---------------------------------------------------------------------------
type Row = Record<string, unknown>;
function toBool(v: unknown): boolean {
return v === 1 || v === true || v === "1";
}
/** Copia una tabla completa preservando ids, con transform opcional por fila.
* `identityColumn`: columna GENERATED ALWAYS AS IDENTITY a preservar via
* OVERRIDING SYSTEM VALUE (null para catálogos con PK de texto/compuesta,
* que no tienen identity y no la necesitan). `conflictColumns`: columna(s)
* de conflicto para el ON CONFLICT ... DO NOTHING (idempotencia si se
* corre el ETL dos veces). */
async function copyTable(
sqlite: SqliteDatabase,
pg: ReturnType<typeof createPool>,
schema: string,
table: string,
opts: {
sqliteTable?: string;
transform?: (row: Row) => Row | null;
columns?: string[];
identityColumn?: string | null;
conflictColumns?: string[];
} = {},
): Promise<number> {
const sqliteTable = opts.sqliteTable ?? table;
const identityColumn = opts.identityColumn === undefined ? "id" : opts.identityColumn;
const conflictColumns = opts.conflictColumns ?? (identityColumn ? [identityColumn] : []);
const rows = sqlite.prepare(`SELECT * FROM ${sqliteTable}`).all() as Row[];
let inserted = 0;
for (const raw of rows) {
const row = opts.transform ? opts.transform(raw) : raw;
if (!row) continue; // transform puede filtrar filas (ej. no migrables)
const cols = opts.columns ?? Object.keys(row);
const values = cols.map((c) => row[c]);
const placeholders = cols.map((_, i) => `$${i + 1}`).join(", ");
const overriding = identityColumn ? "OVERRIDING SYSTEM VALUE" : "";
const onConflict = conflictColumns.length
? `ON CONFLICT (${conflictColumns.join(", ")}) DO NOTHING`
: "";
const text = `INSERT INTO ${schema}.${table} (${cols.join(", ")}) ${overriding}
VALUES (${placeholders}) ${onConflict}`;
if (!DRY_RUN) await pg.unsafe(text, values as never[]);
inserted++;
}
console.log(` ${schema}.${table}: ${inserted}/${rows.length} filas`);
return inserted;
}
async function setSequence(pg: ReturnType<typeof createPool>, schema: string, table: string): Promise<void> {
if (DRY_RUN) return;
await pg.unsafe(
`SELECT setval(pg_get_serial_sequence('${schema}.${table}', 'id'), COALESCE((SELECT MAX(id) FROM ${schema}.${table}), 1), (SELECT MAX(id) IS NOT NULL FROM ${schema}.${table}))`,
);
}
async function migratePlatform(): Promise<void> {
console.log("\n=== Carga: panels_platform ===");
await platformPg.begin(async (tx) => {
await copyTable(platformDb, tx as never, "public", "tenants", {
transform: (r) => ({ ...r, status: r.status === "inactivo" ? "suspendido" : r.status }),
});
await copyTable(platformDb, tx as never, "public", "platform_users");
await copyTable(platformDb, tx as never, "public", "smtp_settings", {
identityColumn: null, // id fijo = 1 (singleton), no es GENERATED
conflictColumns: ["id"], // el baseline de Liquibase ya insertó la fila id=1 por defecto
transform: (r) => ({ ...r, enabled: toBool(r.enabled) }),
});
});
await setSequence(platformPg, "public", "tenants");
await setSequence(platformPg, "public", "platform_users");
}
async function migrateIam(): Promise<void> {
console.log("\n=== Carga: panels_product.iam ===");
await iamPg.begin(async (tx) => {
await copyTable(appDb, tx as never, "iam", "users", {
columns: [
"id", "username", "password_hash", "display_name", "company_id", "tenant_id",
"role_code", "must_change_password", "email", "created_at",
],
transform: (r) => ({
...r,
role_code: r.role === "tenant_admin" ? "tenant_admin" : "user",
must_change_password: toBool(r.must_change_password),
}),
});
});
await setSequence(iamPg, "iam", "users");
}
/** users.id -> display_name, para el snapshot de uploaded_by/created_by
* (Fase 4: documents/badge_jobs ya no tienen FK viva hacia iam). */
function userNameLookup(): Map<number, string> {
const rows = appDb.prepare("SELECT id, display_name FROM users").all() as { id: number; display_name: string }[];
return new Map(rows.map((r) => [r.id, r.display_name]));
}
async function migrateCore(): Promise<void> {
console.log("\n=== Carga: panels_product.core ===");
const names = userNameLookup();
await corePg.begin(async (tx) => {
const t = tx as never as ReturnType<typeof createPool>;
// Orden por dependencias de FK. risk_levels/badge_themes son catálogos
// con PK de texto (code/id), no tienen columna identity que preservar.
await copyTable(appDb, t, "core", "risk_levels", { identityColumn: null, conflictColumns: ["code"] });
await copyTable(appDb, t, "core", "badge_themes", { identityColumn: null, conflictColumns: ["id"] });
await copyTable(appDb, t, "core", "companies");
await copyTable(appDb, t, "core", "projects");
await copyTable(appDb, t, "core", "workers", {
transform: (r) => ({ ...r, needs_badge: toBool(r.needs_badge) }),
});
await copyTable(appDb, t, "core", "assignments", {
transform: (r) => ({ ...r, active: toBool(r.active) }),
});
await copyTable(appDb, t, "core", "document_types", {
identityColumn: null,
conflictColumns: ["code"],
transform: (r) => ({
...r,
required: toBool(r.required),
requires_issued_at: toBool(r.requires_issued_at),
requires_expires_at: toBool(r.requires_expires_at),
}),
});
await copyTable(appDb, t, "core", "documents", {
columns: [
"id", "worker_id", "type_code", "original_name", "mime", "size_bytes", "sha256", "iv",
"storage_name", "is_current", "parse_status", "issued_at", "expires_at", "imss_company_id",
"imss_alta_at", "uploaded_by_id", "uploaded_by_name", "uploaded_at",
],
transform: (r) => ({
...r,
is_current: toBool(r.is_current),
uploaded_by_id: r.uploaded_by ?? null,
uploaded_by_name: r.uploaded_by ? names.get(Number(r.uploaded_by)) ?? "" : "",
}),
});
await copyTable(appDb, t, "core", "project_document_types", {
identityColumn: null,
conflictColumns: ["code"],
transform: (r) => ({ ...r, required: toBool(r.required) }),
});
await copyTable(appDb, t, "core", "project_documents", {
columns: [
"id", "project_id", "type_code", "original_name", "mime", "size_bytes", "sha256", "iv",
"storage_name", "is_current", "parse_status", "uploaded_by_id", "uploaded_by_name", "uploaded_at",
],
transform: (r) => ({
...r,
is_current: toBool(r.is_current),
uploaded_by_id: r.uploaded_by ?? null,
uploaded_by_name: r.uploaded_by ? names.get(Number(r.uploaded_by)) ?? "" : "",
}),
});
await copyTable(appDb, t, "core", "company_document_types", {
identityColumn: null,
conflictColumns: ["code"],
transform: (r) => ({ ...r, required: toBool(r.required) }),
});
await copyTable(appDb, t, "core", "company_documents", {
columns: [
"id", "company_id", "type_code", "original_name", "mime", "size_bytes", "sha256", "iv",
"storage_name", "is_current", "parse_status", "uploaded_by_id", "uploaded_by_name", "uploaded_at",
],
transform: (r) => ({
...r,
is_current: toBool(r.is_current),
uploaded_by_id: r.uploaded_by ?? null,
uploaded_by_name: r.uploaded_by ? names.get(Number(r.uploaded_by)) ?? "" : "",
}),
});
await copyTable(appDb, t, "core", "badge_jobs", {
columns: ["id", "project_id", "status", "pdf_path", "created_by_id", "created_by_name", "created_at"],
transform: (r) => ({
...r,
created_by_id: r.created_by ?? null,
created_by_name: r.created_by ? names.get(Number(r.created_by)) ?? "" : "",
}),
});
await copyTable(appDb, t, "core", "badge_job_people", {
identityColumn: null,
conflictColumns: ["job_id", "worker_id"],
transform: (r) => ({ ...r, delivered: toBool(r.delivered) }),
});
await copyTable(appDb, t, "core", "loans");
await copyTable(appDb, t, "core", "attendance", {
transform: (r) => ({ ...r, present: toBool(r.present) }),
});
await copyTable(appDb, t, "core", "payroll_periods");
await copyTable(appDb, t, "core", "payroll_lines");
await copyTable(appDb, t, "core", "payroll_settings", {
identityColumn: null,
conflictColumns: ["tenant_id"],
transform: (r) => ({ ...r, loan_commission_enabled: toBool(r.loan_commission_enabled) }),
});
await copyTable(appDb, t, "core", "payroll_weeks");
await copyTable(appDb, t, "core", "payroll_sheets");
await copyTable(appDb, t, "core", "payroll_week_lines");
await copyTable(appDb, t, "core", "destajo_units");
await copyTable(appDb, t, "core", "destajo_periods");
await copyTable(appDb, t, "core", "destajo_jobs");
await copyTable(appDb, t, "core", "destajo_cut_lines");
await copyTable(appDb, t, "core", "loan_payments");
await copyTable(appDb, t, "core", "budget_chapters");
await copyTable(appDb, t, "core", "budget_items");
});
for (
const table of [
"companies", "projects", "workers", "assignments", "documents", "project_documents",
"company_documents", "badge_jobs", "loans", "attendance", "payroll_periods", "payroll_lines",
"payroll_weeks", "payroll_sheets", "payroll_week_lines", "destajo_units", "destajo_periods",
"destajo_jobs", "destajo_cut_lines", "loan_payments", "budget_chapters", "budget_items",
]
) {
await setSequence(corePg, "core", table);
}
}
// ---------------------------------------------------------------------------
// Fase 3: Verificación
// ---------------------------------------------------------------------------
async function verifyCounts(): Promise<void> {
console.log("\n=== Verificación: conteos de filas ===");
const checks: { sqlite: SqliteDatabase; table: string; pg: ReturnType<typeof createPool>; schema: string }[] = [
{ sqlite: platformDb, table: "tenants", pg: platformPg, schema: "public" },
{ sqlite: platformDb, table: "platform_users", pg: platformPg, schema: "public" },
{ sqlite: appDb, table: "users", pg: iamPg, schema: "iam" },
{ sqlite: appDb, table: "companies", pg: corePg, schema: "core" },
{ sqlite: appDb, table: "workers", pg: corePg, schema: "core" },
{ sqlite: appDb, table: "projects", pg: corePg, schema: "core" },
{ sqlite: appDb, table: "documents", pg: corePg, schema: "core" },
{ sqlite: appDb, table: "loans", pg: corePg, schema: "core" },
{ sqlite: appDb, table: "budget_items", pg: corePg, schema: "core" },
];
for (const c of checks) {
const src = (c.sqlite.prepare(`SELECT COUNT(*) AS n FROM ${c.table}`).get() as { n: number }).n;
const dstRows = await c.pg.unsafe(`SELECT COUNT(*)::int AS n FROM ${c.schema}.${c.table}`);
const dst = (dstRows[0] as unknown as { n: number }).n;
if (src !== dst) fail(`${c.schema}.${c.table}: origen=${src} destino=${dst}`);
else ok(`${c.schema}.${c.table}: ${dst} filas en ambos lados`);
}
}
async function verifyMoney(): Promise<void> {
console.log("\n=== Verificación: sumas de dinero (tolerancia $" + MONEY_TOLERANCE + ") ===");
const checks: { label: string; sqliteSql: string; pgSql: string }[] = [
{ label: "workers.daily_wage", sqliteSql: "SELECT COALESCE(SUM(daily_wage),0) AS n FROM workers", pgSql: "SELECT COALESCE(SUM(daily_wage),0)::float AS n FROM core.workers" },
{ label: "loans.balance", sqliteSql: "SELECT COALESCE(SUM(balance),0) AS n FROM loans", pgSql: "SELECT COALESCE(SUM(balance),0)::float AS n FROM core.loans" },
{ label: "budget_items.amount", sqliteSql: "SELECT COALESCE(SUM(amount),0) AS n FROM budget_items", pgSql: "SELECT COALESCE(SUM(amount),0)::float AS n FROM core.budget_items" },
];
for (const c of checks) {
const src = (appDb.prepare(c.sqliteSql).get() as { n: number }).n;
const dstRows = await corePg.unsafe(c.pgSql);
const dst = (dstRows[0] as unknown as { n: number }).n;
const diff = Math.abs(src - dst);
if (diff > MONEY_TOLERANCE) fail(`${c.label}: origen=${src} destino=${dst} (diff=${diff.toFixed(4)} > tolerancia)`);
else ok(`${c.label}: origen=${src} destino=${dst} (diff=${diff.toFixed(4)})`);
}
}
// ---------------------------------------------------------------------------
// main
// ---------------------------------------------------------------------------
try {
await preflight();
if (exitCode !== 0) {
console.error("\nPre-flight encontró problemas -- corrígelos antes de cargar. Abortando (no se tocó Postgres).");
Deno.exit(1);
}
if (DRY_RUN) {
console.log("\n--dry-run: se detiene aquí (pre-flight OK, no se escribió nada).");
} else {
await migratePlatform();
await migrateIam();
await migrateCore();
await verifyCounts();
await verifyMoney();
if (exitCode !== 0) {
console.error("\nLa verificación posterior a la carga NO cuadra -- revisar antes de dar por buena la migración.");
} else {
console.log("\nMigración completa y verificada.");
}
}
} finally {
appDb.close();
platformDb.close();
await platformPg.end({ timeout: 5 });
await iamPg.end({ timeout: 5 });
await corePg.end({ timeout: 5 });
}
Deno.exit(exitCode);

View file

@ -33,14 +33,20 @@ Requiere Java 17+ y las credenciales del rol `_owner` de cada módulo (nunca
# Local, después de correr db/provision/dev-local.sh: # Local, después de correr db/provision/dev-local.sh:
set -a && source .env.dev-local && set +a set -a && source .env.dev-local && set +a
./db/update.sh all --context-filter=dev # dev: incluye datos de demo ./db/update.sh all --context-filter=dev # dev: incluye datos de demo
./db/update.sh all # staging/producción: solo esquema + catálogos ./db/update.sh all --context-filter='!dev' # staging/producción: solo esquema + catálogos
# Un solo módulo # Un solo módulo
./db/update.sh core ./db/update.sh core --context-filter='!dev'
./db/update.sh iam ./db/update.sh iam --context-filter='!dev'
./db/update.sh platform ./db/update.sh platform --context-filter='!dev'
``` ```
**Importante**: Liquibase, si NO se le pasa `--context-filter`, corre TODOS
los changesets sin importar su `context` -- incluidos los de `context="dev"`.
No pasar el filtro en staging/producción NO es "modo seguro por defecto",
es lo contrario: cargaría el tenant/empresas/proyecto de demostración. Por
eso `--context-filter` es obligatorio siempre, nunca opcional.
`npm run db:migrate` corre `./db/update.sh all`. **Las migraciones ya NO `npm run db:migrate` corre `./db/update.sh all`. **Las migraciones ya NO
corren automáticamente al arrancar la API** -- son un paso explícito de corren automáticamente al arrancar la API** -- son un paso explícito de
deploy (a diferencia del `runLiquibase()` que existía con SQLite). deploy (a diferencia del `runLiquibase()` que existía con SQLite).

89
db/RUNBOOK-corte.md Normal file
View file

@ -0,0 +1,89 @@
# Runbook de corte SQLite → Postgres (Fase 5)
Checklist operativo para migrar UN ambiente (dev, luego staging, luego
producción -- nunca saltar directo a producción). Usa
[`api/scripts/migrate-sqlite-to-postgres.ts`](../api/scripts/migrate-sqlite-to-postgres.ts).
## 0. Antes de empezar
- [ ] Postgres del ambiente aprovisionado (`db/provision/`) y migrado
**sin** datos de demo: `./db/update.sh all --context-filter='!dev'`.
- [ ] Backup fresco del volumen SQLite actual (`data/app.db`, `data/platform.db`)
guardado aparte, fuera del volumen que se va a apagar.
- [ ] Credenciales `_owner` de los 3 módulos disponibles en el entorno
(`DATABASE_URL_PLATFORM_OWNER`, `DATABASE_URL_IAM_OWNER`, `DATABASE_URL_CORE_OWNER`).
## 1. Pre-flight (sin ventana de mantenimiento, se puede correr en caliente)
```bash
cd api
deno run --allow-ffi --allow-net --allow-read --allow-write --allow-env \
scripts/migrate-sqlite-to-postgres.ts --dry-run \
--app-db=../data/app.db --platform-db=../data/platform.db
```
Si el pre-flight reporta `[FAIL]`, **no continuar** -- corregir los datos
en SQLite (duplicados, huérfanas, tenant_id inválido, fechas mal
formateadas) y repetir hasta que todo salga `[OK]`.
## 2. Ventana de mantenimiento (corte real)
Hoy no existe un "modo mantenimiento" en la app. Opciones, de menor a
mayor invasividad:
- Parar el contenedor/proceso `api` (nadie puede escribir mientras está
abajo -- los fronts mostrarán error de conexión).
- Responder 503 temporal en nginx para `/v1/*` mientras se corre el ETL.
Elegir una, documentar la hora exacta de inicio.
## 3. Migración
```bash
cd api
set -a && source /ruta/al/.env.del.ambiente && set +a
deno run --allow-ffi --allow-net --allow-read --allow-write --allow-env \
scripts/migrate-sqlite-to-postgres.ts \
--app-db=/ruta/a/app.db --platform-db=/ruta/a/platform.db
```
El script hace: pre-flight → carga (una transacción por base/esquema,
todo o nada) → `setval()` de secuencias → verificación de conteos y sumas
de dinero. Si CUALQUIER paso falla, no queda un estado a medias en
Postgres (la transacción de esa base se revierte completa), pero SQLite
sigue siendo la fuente de verdad -- no se ha cortado nada todavía.
## 4. Criterios go/no-go
Antes de apuntar la app a Postgres y apagar SQLite:
- [ ] El script terminó con `Migración completa y verificada.` (exit code 0).
- [ ] Conteos de filas origen=destino en **todas** las tablas listadas
(no solo las 9 de ejemplo del script -- ampliar `verifyCounts()` si
el ambiente tiene datos en tablas no cubiertas ahí).
- [ ] Diferencia de sumas de dinero dentro de la tolerancia ($0.05) -- si
no cuadra, decidir explícitamente si se acepta el redondeo
REAL→NUMERIC o se investiga antes de continuar.
- [ ] Spot-check manual de 2-3 registros conocidos (un trabajador, un
preupuesto, un préstamo) comparando app vieja vs. Postgres.
Si algo no cuadra: **no cortar**. Volver a levantar la app contra SQLite
(no se tocó), investigar, y repetir desde el paso 1 en otro intento.
## 5. Cutover
- [ ] Actualizar `DATABASE_URL_*`/`REDIS_URL_*` del servicio `api` a los
valores del ambiente Postgres/Redis recién migrado.
- [ ] Levantar `api` -- el fail-fast de arranque (`/v1/health`) debe
responder `{"ok":true,...}` antes de reabrir tráfico.
- [ ] Reabrir tráfico (quitar el 503/levantar el contenedor).
- [ ] Login de prueba con un usuario real del ambiente.
## 6. Después del corte
- [ ] Conservar el volumen SQLite (`data/`) como respaldo frío por un
período de retención definido (ej. 30 días) antes de borrarlo.
- [ ] Correr `./db/provision/verify-isolation.sh` contra el ambiente para
confirmar que el aislamiento de roles/RLS/ACLs sigue intacto.
- [ ] Repetir todo el runbook en el siguiente ambiente (dev → staging →
producción), nunca en paralelo.

108
db/backups/README.md Normal file
View file

@ -0,0 +1,108 @@
# Backups y continuidad (Fase 6)
## 0. Validar antes de diseñar el resto
Coolify despliega Postgres como un recurso "managed" que en realidad es un
contenedor `postgres:16` estándar con volumen persistente. **Hay que
confirmar, para el ambiente real, si se puede:**
1. Montar un `postgresql.conf` custom (para `archive_mode`, `archive_command`, `wal_level`), y
2. Ejecutar un sidecar/proceso adicional (`pgbackrest`) con acceso al mismo volumen de datos y a WAL.
- **Si sí se puede**: seguir esta guía con pgBackRest (PITR real).
- **Si no se puede** (Coolify no expone esos hooks en el plan/versión
usada): el respaldo queda limitado a los `pg_dump` programados que
Coolify ya ofrece -- el RPO pasa a ser "la frecuencia del dump", no
segundos. En ese caso, para producción, evaluar auto-hospedar Postgres
(contenedor propio, fuera del recurso "managed" de Coolify) solo para
tener control de WAL -- es la única forma de bajar el RPO por debajo de
la frecuencia del dump.
Esta decisión condiciona todo lo demás; no asumir que WAL archiving va a
funcionar sin probarlo primero contra el Coolify real del ambiente.
## 1. Qué se respalda y con qué política
| Qué | Herramienta | Frecuencia | Retención | RPO objetivo |
|---|---|---|---|---|
| `panels_platform` (prod) | pgBackRest (o `pg_dump` si no hay WAL) | full diario + diferencial c/6h + WAL continuo | 30 días | segundos (con WAL) / 24h (solo dump) |
| `panels_product` (prod) | igual que arriba | igual | 30 días | igual |
| `panels_platform`/`panels_product` (staging/dev) | `pg_dump` | full diario | 7 días | 24h |
| Archivos (Contabo, Fase 4c) | versionado de objetos del bucket + backup del bucket | continuo (versionado) | igual que la BD | ver nota de coordinación abajo |
| Redis | RDB snapshot | al reiniciar/periódico | ninguna (no es fuente de verdad) | N/A -- ver `db/backups/redis-persistence.md` |
**Respaldar SIEMPRE ambas bases.** Es fácil configurar el backup de
`panels_product` (donde está "todo el negocio") y olvidar `panels_platform`
(que tiene el registro de TODOS los tenants) -- el día del incidente ahí es
cuando se descubre.
## 2. pgBackRest (si Coolify lo permite)
Ver [`pgbackrest-panels_platform.conf`](./pgbackrest-panels_platform.conf) y
[`pgbackrest-panels_product.conf`](./pgbackrest-panels_product.conf) --
plantillas, una `stanza` por base de datos (no por esquema: `panels_product`
es una sola stanza aunque tenga los esquemas iam/core adentro).
Pasos (por base):
```bash
# 1. postgresql.conf del contenedor
wal_level = replica
archive_mode = on
archive_command = 'pgbackrest --stanza=panels_platform archive-push %p'
archive_timeout = 60 # cap de RPO a 60s incluso en bases con poca escritura
# 2. Crear la stanza una vez
pgbackrest --stanza=panels_platform --log-level-console=info stanza-create
# 3. Verificar que el archiving realmente funciona ANTES de confiar en él
pgbackrest --stanza=panels_platform check
# 4. Backups programados (cron / scheduler de Coolify)
pgbackrest --stanza=panels_platform --type=full backup # 1x/semana
pgbackrest --stanza=panels_platform --type=diff backup # cada 6h
```
Repetir con `--stanza=panels_product` y su propio `archive_command`.
### Point-in-time recovery
```bash
pgbackrest --stanza=panels_platform --type=time \
--target="2026-09-01 14:00:00-06" --target-action=promote restore
```
## 3. Si NO hay WAL archiving (solo pg_dump)
```bash
pg_dump --format=custom --file=panels_platform-$(date +%Y%m%d).dump "$DATABASE_URL_PLATFORM_OWNER"
pg_dump --format=custom --file=panels_product-$(date +%Y%m%d).dump "$DATABASE_URL_CORE_OWNER"
```
`--format=custom` permite restore paralelo (`pg_restore -j4`) y restore
selectivo por tabla si algún día hace falta. Subir los `.dump` a un bucket
S3-compatible (puede ser el mismo de Contabo, con prefijo/bucket distinto
al de documentos) con retención por lifecycle policy del bucket.
## 4. Coordinación con el respaldo de archivos (Fase 4c)
Un restore de Postgres a un punto en el tiempo, sin restaurar los archivos
de Contabo al MISMO punto, deja filas (`documents.storage_name`) apuntando
a objetos que ya no existen o que cambiaron. Política:
- Activar versionado de objetos en el bucket de Contabo.
- Si se hace un PITR de Postgres a una hora `T`, documentar que los
archivos subidos/borrados después de `T` pueden quedar huérfanos o
inconsistentes -- es una ventana de inconsistencia aceptada, no un bug
a corregir en caliente durante el incidente.
## 5. Redis
Ver [`redis-persistence.md`](./redis-persistence.md). Resumen: solo RDB
para evitar deslogueo masivo, sin retención de negocio, fuera del alcance
del simulacro de restauración de abajo.
## 6. Simulacro de restauración (mensual, obligatorio)
Ver [`restore-drill-checklist.md`](./restore-drill-checklist.md). Un
backup que nunca se probó restaurar no es un backup, es una esperanza.

View file

@ -0,0 +1,24 @@
# Plantilla pgBackRest para panels_platform (control plane SaaS).
# Copiar a /etc/pgbackrest/pgbackrest.conf (o incluir via `include` si ya
# existe una stanza para panels_product en el mismo host/instancia) y
# ajustar repo1-* según el backend real (aquí: S3-compatible, ej. Contabo
# Object Storage -- puede ser el MISMO endpoint que Fase 4c con un bucket
# o prefijo distinto para no mezclar backups de BD con documentos).
[global]
repo1-type=s3
repo1-s3-endpoint=CAMBIAR-ENDPOINT-S3
repo1-s3-bucket=panels-backups
repo1-s3-key=CAMBIAR-ACCESS-KEY
repo1-s3-key-secret=CAMBIAR-SECRET-KEY
repo1-s3-region=CAMBIAR-REGION
repo1-path=/panels_platform
repo1-retention-full=4
repo1-retention-diff=14
process-max=2
compress-type=zst
log-level-console=info
[panels_platform]
pg1-path=/var/lib/postgresql/data
pg1-port=5432

View file

@ -0,0 +1,21 @@
# Plantilla pgBackRest para panels_product (iam + core). Una stanza por
# BASE DE DATOS, no por esquema -- panels_product es una sola stanza aunque
# tenga iam/core adentro (ver db/README.md sobre el modelo de 2 bases).
[global]
repo1-type=s3
repo1-s3-endpoint=CAMBIAR-ENDPOINT-S3
repo1-s3-bucket=panels-backups
repo1-s3-key=CAMBIAR-ACCESS-KEY
repo1-s3-key-secret=CAMBIAR-SECRET-KEY
repo1-s3-region=CAMBIAR-REGION
repo1-path=/panels_product
repo1-retention-full=4
repo1-retention-diff=14
process-max=2
compress-type=zst
log-level-console=info
[panels_product]
pg1-path=/var/lib/postgresql/data
pg1-port=5432

View file

@ -0,0 +1,35 @@
# Persistencia de Redis (mínima, no es backup de negocio)
Redis guarda sesiones (`iam:session:*`) y cache (`core:cache:*`, ver
Fase 4e). Ninguno de los dos es fuente de verdad:
- Sesión perdida → el usuario hace login de nuevo. Molesto, no es pérdida
de datos.
- Cache perdido → la siguiente lectura recalcula desde Postgres.
Por eso Redis **no** entra en la política de backup con retención/PITR de
las bases Postgres (Fase 6 del plan). Lo único que vale la pena es evitar
que un reinicio del contenedor Redis deslogueé a **todos** los tenants a
la vez -- eso se resuelve con RDB, no con un pipeline de backup.
## Configuración recomendada (solo producción)
```conf
# redis.conf del recurso de Coolify (o el equivalente que exponga)
save 900 1 # snapshot si hubo >=1 cambio en 15 min
save 300 10 # snapshot si hubo >=10 cambios en 5 min
save 60 10000 # snapshot si hubo >=10000 cambios en 1 min
appendonly no # AOF no hace falta -- RDB alcanza para el objetivo (evitar
# deslogueo masivo), y agrega complejidad/IO sin beneficio
# dado que nada aquí es irremplazable.
```
En dev/staging, ni siquiera hace falta esto -- perder las sesiones de
desarrollo no tiene costo real.
## Qué NO hacer
- No configurar `pgbackrest`/backups con retención larga para Redis --
sería tratar como "fuente de verdad" algo que por diseño no lo es.
- No mezclar la política de respaldo de Redis con la de Postgres en la
misma automatización -- son necesidades distintas (ver Fase 6 del plan).

View file

@ -0,0 +1,37 @@
# Simulacro de restauración (mensual, obligatorio)
Un backup que nunca se probó restaurar no es un backup. Correr esto en un
ambiente descartable (nunca contra staging/producción reales).
## Checklist
- [ ] Levantar un Postgres nuevo y vacío (contenedor descartable).
- [ ] Restaurar el backup más reciente de `panels_platform`:
- pgBackRest: `pgbackrest --stanza=panels_platform restore`
- o `pg_restore` si es dump lógico.
- [ ] Restaurar el backup más reciente de `panels_product` (mismo método).
- [ ] Correr [`db/provision/verify-isolation.sh`](../provision/verify-isolation.sh)
contra el ambiente restaurado -- confirma que roles/RLS/permisos
sobrevivieron el restore intactos, no solo los datos.
- [ ] Spot-check de datos: comparar conteos de filas y 2-3 registros
conocidos contra lo que se espera (usar el mismo enfoque que
`verifyCounts()`/`verifyMoney()` del ETL, ver
[`api/scripts/migrate-sqlite-to-postgres.ts`](../../api/scripts/migrate-sqlite-to-postgres.ts)
como referencia de qué comparar).
- [ ] Si hay WAL archiving: probar un restore a un punto en el tiempo
específico (no solo "el último backup"), confirmar que aterriza en
el estado esperado para ese instante.
- [ ] Archivos (Contabo): confirmar que al menos una descarga de
documento cifrado conocido sigue descifrando correctamente después
del restore (prueba de que la clave `DOCS_KEY` y el bucket siguen
consistentes).
- [ ] Documentar cuánto tardó el restore de punta a punta -- es el RTO
real, no el teórico.
- [ ] Destruir el ambiente descartable al terminar.
## Cuándo escalar
Si cualquier paso falla (restore no completa, RLS no aplica, datos no
cuadran, documento no descifra), **no esperar al siguiente simulacro** --
es una señal de que el backup en producción probablemente tampoco sirve.
Tratarlo como incidente, no como hallazgo de rutina.

View file

@ -0,0 +1,31 @@
#!/usr/bin/env sh
# PANELS · Fase 0 · aprovisiona roles/bases/esquemas/ACLs contra un
# Postgres/Redis alcanzables por red (uso: contenedor de docker-compose
# para desarrollo local -- ver el servicio "provision" en
# docker-compose.yml). Requiere psql y redis-cli en la imagen (usa la
# imagen postgres:16-alpine + redis-cli instalado, o dos pasos separados).
set -eu
cd "$(dirname "$0")"
: "${PGHOST:?}"; : "${PGPORT:=5432}"; : "${PGUSER:=postgres}"; : "${PGPASSWORD:?}"
export PGPASSWORD
SUPERUSER_URL="postgresql://${PGUSER}@${PGHOST}:${PGPORT}/postgres"
echo "== 1/4: roles =="
psql "$SUPERUSER_URL" -v ON_ERROR_STOP=1 \
-v platform_owner_pw="${PLATFORM_OWNER_PASSWORD}" -v platform_app_pw="${PLATFORM_APP_PASSWORD}" \
-v iam_owner_pw="${IAM_OWNER_PASSWORD}" -v iam_app_pw="${IAM_APP_PASSWORD}" \
-v core_owner_pw="${CORE_OWNER_PASSWORD}" -v core_app_pw="${CORE_APP_PASSWORD}" \
-f 01-roles.sql
echo "== 2/4: bases de datos =="
psql "$SUPERUSER_URL" -v ON_ERROR_STOP=1 -f 02-databases.sql
echo "== 3/4: privilegios panels_platform =="
psql "postgresql://${PGUSER}@${PGHOST}:${PGPORT}/panels_platform" -v ON_ERROR_STOP=1 -f 03-platform-database.sql
echo "== 4/4: esquemas + privilegios panels_product =="
psql "postgresql://${PGUSER}@${PGHOST}:${PGPORT}/panels_product" -v ON_ERROR_STOP=1 -f 04-product-database.sql
echo "Roles/bases/esquemas listos."

View file

@ -13,6 +13,7 @@
# Uso: # Uso:
# set -a && source .env.dev-local && set +a && ./db/update.sh all # set -a && source .env.dev-local && set +a && ./db/update.sh all
# ./db/update.sh core --context-filter=dev # ./db/update.sh core --context-filter=dev
# ./db/update.sh all --context-filter='!dev' -- updateSQL # dry-run (CI)
set -euo pipefail set -euo pipefail
ROOT="$(cd "$(dirname "$0")/.." && pwd)" ROOT="$(cd "$(dirname "$0")/.." && pwd)"
"$ROOT/db/bootstrap-tools.sh" "$ROOT/db/bootstrap-tools.sh"
@ -21,7 +22,19 @@ PG_JDBC="$ROOT/db/tools/postgresql-jdbc.jar"
TARGET="${1:-all}" TARGET="${1:-all}"
shift || true shift || true
EXTRA_ARGS=("$@")
LB_COMMAND="update"
EXTRA_ARGS=()
while [[ $# -gt 0 ]]; do
if [[ "$1" == "--" ]]; then
shift
LB_COMMAND="${1:-update}"
shift || true
else
EXTRA_ARGS+=("$1")
shift
fi
done
# postgresql://user:pass@host:port/db -> jdbc:postgresql://host:port/db + user/pass # postgresql://user:pass@host:port/db -> jdbc:postgresql://host:port/db + user/pass
parse_url() { parse_url() {
@ -43,7 +56,7 @@ run_one() {
exit 1 exit 1
fi fi
parse_url "$url" parse_url "$url"
echo "==> Liquibase update: $name ($PARSED_JDBC)" echo "==> Liquibase $LB_COMMAND: $name ($PARSED_JDBC)"
( (
cd "$ROOT/db/$name" cd "$ROOT/db/$name"
"$LIQUIBASE" \ "$LIQUIBASE" \
@ -52,7 +65,7 @@ run_one() {
--url="$PARSED_JDBC" \ --url="$PARSED_JDBC" \
--username="$PARSED_USER" \ --username="$PARSED_USER" \
--password="$PARSED_PASS" \ --password="$PARSED_PASS" \
update "${EXTRA_ARGS[@]}" "$LB_COMMAND" "${EXTRA_ARGS[@]}"
) )
} }

View file

@ -1,10 +1,85 @@
# Coolify / local — 3 services, SQLite en volumen panel-data # Coolify / local — Postgres + Redis (ver plan de migración: monolito
# modular panels_platform / panels_product con esquemas iam+core).
# En Coolify, postgres/redis normalmente son recursos gestionados aparte
# (ver docs/coolify.md); este compose incluye contenedores propios para
# que `docker compose up` funcione standalone en desarrollo local.
services: services:
postgres:
image: postgres:16-alpine
restart: unless-stopped
environment:
POSTGRES_PASSWORD: ${POSTGRES_SUPERUSER_PASSWORD:?define POSTGRES_SUPERUSER_PASSWORD}
volumes:
- postgres-data:/var/lib/postgresql/data
expose:
- "5432"
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres"]
interval: 10s
timeout: 5s
retries: 10
redis:
image: redis:7-alpine
restart: unless-stopped
expose:
- "6379"
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 10s
timeout: 5s
retries: 10
# Jobs de un solo uso (nunca en producción -- ver db/provision/README.md):
# 1) crea roles/bases/esquemas/ACLs, 2) aplica Liquibase. `api` espera a
# que ambos terminen bien antes de arrancar.
provision:
build:
context: .
dockerfile: Dockerfile.provision
depends_on:
postgres:
condition: service_healthy
redis:
condition: service_healthy
environment:
PGHOST: postgres
PGPASSWORD: ${POSTGRES_SUPERUSER_PASSWORD:?}
PLATFORM_OWNER_PASSWORD: ${PLATFORM_OWNER_PASSWORD:?}
PLATFORM_APP_PASSWORD: ${PLATFORM_APP_PASSWORD:?}
IAM_OWNER_PASSWORD: ${IAM_OWNER_PASSWORD:?}
IAM_APP_PASSWORD: ${IAM_APP_PASSWORD:?}
CORE_OWNER_PASSWORD: ${CORE_OWNER_PASSWORD:?}
CORE_APP_PASSWORD: ${CORE_APP_PASSWORD:?}
REDIS_ADMIN_URL: redis://redis:6379
IAM_REDIS_PASSWORD: ${IAM_REDIS_PASSWORD:?}
CORE_REDIS_PASSWORD: ${CORE_REDIS_PASSWORD:?}
command:
- -c
- "./db/provision/docker-provision.sh && ./db/provision/05-redis-acl.sh"
migrate:
build:
context: .
dockerfile: Dockerfile.migrate
depends_on:
provision:
condition: service_completed_successfully
environment:
DATABASE_URL_PLATFORM_OWNER: postgresql://panels_platform_owner:${PLATFORM_OWNER_PASSWORD:?}@postgres:5432/panels_platform
DATABASE_URL_IAM_OWNER: postgresql://panels_iam_owner:${IAM_OWNER_PASSWORD:?}@postgres:5432/panels_product
DATABASE_URL_CORE_OWNER: postgresql://panels_core_owner:${CORE_OWNER_PASSWORD:?}@postgres:5432/panels_product
api: api:
build: build:
context: . context: .
dockerfile: Dockerfile.api dockerfile: Dockerfile.api
restart: unless-stopped restart: unless-stopped
depends_on:
migrate:
condition: service_completed_successfully
redis:
condition: service_healthy
environment: environment:
PORT: ${PORT:-8000} PORT: ${PORT:-8000}
SESSION_SECRET: ${SESSION_SECRET} SESSION_SECRET: ${SESSION_SECRET}
@ -20,6 +95,23 @@ services:
SMTP_USER: ${SMTP_USER:-} SMTP_USER: ${SMTP_USER:-}
SMTP_PASS: ${SMTP_PASS:-} SMTP_PASS: ${SMTP_PASS:-}
SMTP_FROM: ${SMTP_FROM:-} SMTP_FROM: ${SMTP_FROM:-}
# Postgres: panels_platform (base separada) + panels_product (iam/core)
DATABASE_URL_PLATFORM: postgresql://panels_platform_app:${PLATFORM_APP_PASSWORD:?}@postgres:5432/panels_platform
DATABASE_URL_IAM: postgresql://panels_iam_app:${IAM_APP_PASSWORD:?}@postgres:5432/panels_product
DATABASE_URL_CORE: postgresql://panels_core_app:${CORE_APP_PASSWORD:?}@postgres:5432/panels_product
DATABASE_URL_IAM_OWNER: postgresql://panels_iam_owner:${IAM_OWNER_PASSWORD:?}@postgres:5432/panels_product
DATABASE_URL_CORE_OWNER: postgresql://panels_core_owner:${CORE_OWNER_PASSWORD:?}@postgres:5432/panels_product
# Redis: ACLs por módulo (ver db/provision/05-redis-acl.sh)
REDIS_URL_IAM: redis://panels_iam_redis:${IAM_REDIS_PASSWORD:?}@redis:6379
REDIS_URL_CORE: redis://panels_core_redis:${CORE_REDIS_PASSWORD:?}@redis:6379
# Contabo Object Storage (Fase 4c) -- opcional; sin esto cae a disco
# local bajo el volumen panel-data (no recomendado en producción,
# ver plan: un volumen local no escala horizontalmente).
S3_ENDPOINT: ${S3_ENDPOINT:-}
S3_BUCKET: ${S3_BUCKET:-}
S3_REGION: ${S3_REGION:-}
S3_ACCESS_KEY_ID: ${S3_ACCESS_KEY_ID:-}
S3_SECRET_ACCESS_KEY: ${S3_SECRET_ACCESS_KEY:-}
volumes: volumes:
- panel-data:/app/data - panel-data:/app/data
expose: expose:
@ -35,7 +127,7 @@ services:
interval: 30s interval: 30s
timeout: 5s timeout: 5s
retries: 5 retries: 5
start_period: 45s start_period: 15s
web-panel: web-panel:
build: build:
@ -64,4 +156,5 @@ services:
- "80" - "80"
volumes: volumes:
postgres-data:
panel-data: panel-data:

View file

@ -1,24 +1,29 @@
# Deploy PANELS en Coolify (Docker Compose) # Deploy PANELS en Coolify (Docker Compose + Postgres + Redis)
## Resumen ## Resumen
| Qué | Cuánto | | Qué | Cuánto |
|-----|--------| |-----|--------|
| Bases gestionadas Coolify (Postgres/MySQL) | **0** | | Postgres gestionado en Coolify | **1 instancia** por ambiente, con **2 bases**: `panels_platform`, `panels_product` (esquemas `iam`/`core`) |
| Archivos SQLite en volumen | **2** (`app.db`, `platform.db`) | | Redis gestionado en Coolify | **1 instancia** por ambiente, con **2 usuarios ACL** (`panels_iam_redis`, `panels_core_redis`) |
| Contenedores | **3** (`api`, `web-panel`, `web-saas`) | | Object storage | Contabo Object Storage (S3-compatible) para expedientes/PDFs/logos cifrados |
| Volumen persistente | **1** → `/app/data` en `api` | | Contenedores de la app | **3** (`api`, `web-panel`, `web-saas`) |
| Volumen persistente | **1** → `/app/data` en `api` (solo fallback local si no hay Contabo configurado -- no usar así en producción) |
Los fronts (Alpine + nginx) hacen proxy de `/v1` al servicio `api`, así las cookies de sesión van same-origin. Los fronts (Alpine + nginx) hacen proxy de `/v1` al servicio `api`, así las cookies de sesión van same-origin.
Ver el plan de migración para el detalle de arquitectura: por qué
`panels_platform` es una base separada, por qué `iam`/`core` son esquemas
distintos dentro de `panels_product`, y las reglas del monolito modular.
## Datos sensibles (qué NO va al git) ## Datos sensibles (qué NO va al git)
| Ítem | Estado | | Ítem | Estado |
|------|--------| |------|--------|
| `.env` | gitignored — no commitear | | `.env` | gitignored — no commitear |
| `data/` (`*.db`, expedientes, pdfs) | gitignored | | `data/` (fallback local de archivos, si no hay Contabo) | gitignored |
| SMTP password en SaaS | vive en `platform.db` (volumen) — proteger backups | | SMTP password en SaaS | vive en `panels_platform.smtp_settings` — proteger backups |
| Defaults de desarrollo en código | solo fallbacks locales; Coolify **exige** secrets | | Defaults de desarrollo en código | `SESSION_SECRET`/`DOCS_KEY` solo tienen fallback si `DENO_ENV` no es `production` -- fuera de eso, la app falla al arrancar si faltan |
## Variables de entorno (servicio `api`) ## Variables de entorno (servicio `api`)
@ -26,15 +31,23 @@ Los fronts (Alpine + nginx) hacen proxy de `/v1` al servicio `api`, así las coo
| Variable | Formato | Uso | | Variable | Formato | Uso |
|----------|---------|-----| |----------|---------|-----|
| `SESSION_SECRET` | string largo aleatorio | Firma cookie de sesión | | `SESSION_SECRET` | string largo aleatorio | Firma interna (no ya la cookie -- la sesión vive en Redis, ver Fase 4) |
| `DOCS_KEY` | **64** caracteres hex (32 bytes) | Cifrado de documentos | | `DOCS_KEY` | **64** caracteres hex (32 bytes) | Cifrado AES-GCM de documentos |
| `SEED_PASSWORD` | string | Password inicial de `admin` SaaS (solo al crear / migrar seed) | | `SEED_PASSWORD` | string | Password inicial usado por `api/scripts/bootstrap-admin.ts` |
| `PANEL_LOGIN_URL` | URL absoluta | Link en correos de acceso | | `PANEL_LOGIN_URL` | URL absoluta | Link en correos de acceso |
| `DATABASE_URL_PLATFORM` | `postgresql://panels_platform_app:...@host:5432/panels_platform` | Runtime, rol `_app` |
| `DATABASE_URL_IAM` | `postgresql://panels_iam_app:...@host:5432/panels_product` | Runtime, rol `_app`, esquema `iam` |
| `DATABASE_URL_CORE` | `postgresql://panels_core_app:...@host:5432/panels_product` | Runtime, rol `_app`, esquema `core` |
| `DATABASE_URL_IAM_OWNER` | igual, rol `_owner` | Solo para el lookup de login por username (bypassa RLS a propósito, ver `api/iam_db.ts`) |
| `DATABASE_URL_CORE_OWNER` | igual, rol `_owner` | Solo para resoluciones administrativas puntuales (ver `api/db.ts#getCoreDb`) |
| `REDIS_URL_IAM` | `redis://panels_iam_redis:...@host:6379` | Sesiones |
| `REDIS_URL_CORE` | `redis://panels_core_redis:...@host:6379` | Cache |
Generar: Generar secretos:
```bash ```bash
openssl rand -hex 32 # SESSION_SECRET o DOCS_KEY openssl rand -hex 32 # SESSION_SECRET o DOCS_KEY
openssl rand -hex 24 # passwords de roles Postgres/Redis
``` ```
### Recomendadas (HTTPS / Coolify) ### Recomendadas (HTTPS / Coolify)
@ -49,50 +62,64 @@ openssl rand -hex 32 # SESSION_SECRET o DOCS_KEY
| Variable | Uso | | Variable | Uso |
|----------|-----| |----------|-----|
| `API_KEY` | Auth alternativa por header `X-API-Key` (vacío = desactivado) | | `API_KEY` | Auth alternativa por header `X-API-Key` **+ `X-Tenant-Id` obligatorio** (ya no ve todos los tenants, ver revisión de seguridad) |
| `VCARD_BASE` | Prefijo QR/vCard gafetes | | `VCARD_BASE` | Prefijo QR/vCard gafetes |
| `SMTP_HOST` / `SMTP_PORT` / `SMTP_USER` / `SMTP_PASS` / `SMTP_FROM` | Correo por env (alternativa al panel `/smtp`) | | `SMTP_HOST` / `SMTP_PORT` / `SMTP_USER` / `SMTP_PASS` / `SMTP_FROM` | Correo por env (alternativa al panel `/smtp`) |
| `S3_ENDPOINT` / `S3_BUCKET` / `S3_REGION` / `S3_ACCESS_KEY_ID` / `S3_SECRET_ACCESS_KEY` | Contabo Object Storage (Fase 4c). Sin esto, cae a disco local -- **no recomendado en producción** |
`web-panel` y `web-saas` **no** necesitan variables de entorno en runtime (estáticos + proxy nginx). `web-panel` y `web-saas` **no** necesitan variables de entorno en runtime (estáticos + proxy nginx).
## Imágenes ## Imágenes
- **api:** Deno 2.9 + OpenJDK 21 JRE + Liquibase (Debian; no Alpine por FFI SQLite) - **api:** Deno 2.9, sin FFI ni JRE (Fase 2/7 del plan de migración).
- **web-panel / web-saas:** build Node Alpine → **nginx Alpine** - **migrate:** JRE 21 + Liquibase -- imagen aparte, de un solo uso, NO sirve tráfico (ver `Dockerfile.migrate`).
- **web-panel / web-saas:** build Node Alpine → **nginx Alpine**.
Archivos: `Dockerfile.api`, `Dockerfile.web-panel`, `Dockerfile.web-saas`, `docker-compose.yml`, `web-panel/nginx.conf`, `web-saas/nginx.conf`. Archivos: `Dockerfile.api`, `Dockerfile.migrate`, `Dockerfile.provision`, `Dockerfile.web-panel`, `Dockerfile.web-saas`, `docker-compose.yml`, `web-panel/nginx.conf`, `web-saas/nginx.conf`.
En Coolify el servicio de PANELS se llama **`web-panel`** (FQDN ej. `panels.mrdev.mx`). La carpeta `web/` queda libre para una website futura. En Coolify el servicio de PANELS se llama **`web-panel`** (FQDN ej. `panels.mrdev.mx`). La carpeta `web/` queda libre para una website futura.
## Paso a paso en Coolify ## Paso a paso en Coolify
1. **Push** este repo (sin `.env` ni `data/`). 1. **Provisionar Postgres y Redis** como recursos gestionados de Coolify para el ambiente (uno de cada, no por módulo).
2. Coolify → **New Resource → Docker Compose** → `docker-compose.yml`. 2. **Aprovisionar roles/esquemas/ACLs**: correr los scripts de [`db/provision/`](../db/provision/README.md) contra ese Postgres/Redis (una vez, desde tu máquina o un job manual -- Coolify no lo hace por ti).
3. **Persistent storage:** volumen `panel-data` → `/app/data` en `api`. 3. **Aplicar Liquibase** (paso explícito, NO ocurre al arrancar la app): `./db/update.sh all --context-filter='!dev'` con las credenciales `_owner`. En staging/producción, **nunca** olvidar el `--context-filter` -- sin él, Liquibase corre TAMBIÉN los changesets de demo (`context=dev`).
4. **Dominios:** `web-panel` → panels; `web-saas` → saas; `api` sin FQDN (proxy `/v1`). 4. **Bootstrap del primer admin**: `deno run ... api/scripts/bootstrap-admin.ts platform` y `... tenant --tenant-id=... --company-code=...` (ver `db/README.md`).
5. Cargar en Coolify las **obligatorias** + `COOKIE_SECURE=true`. 5. **Push** este repo (sin `.env` ni `data/`).
6. Deploy (Liquibase crea/migra las 2 SQLite en el volumen). 6. Coolify → **New Resource → Docker Compose** → `docker-compose.yml` (solo para `api`/`web-panel`/`web-saas` -- si Postgres/Redis ya son recursos gestionados aparte, quitar esos servicios del compose antes de desplegar, o apuntar sus variables a los recursos gestionados en vez de los contenedores locales del compose).
7. Verificar `https://app…/v1/health`, login SaaS `admin` / tu `SEED_PASSWORD`. 7. **Persistent storage:** volumen `panel-data` → `/app/data` en `api` (solo fallback de archivos si no hay Contabo).
8. SMTP en `/smtp` o por `SMTP_*`. 8. **Dominios:** `web-panel` → panels; `web-saas` → saas; `api` sin FQDN (proxy `/v1`).
9. Cargar en Coolify todas las variables **obligatorias** de la tabla de arriba + `COOKIE_SECURE=true`.
10. Deploy.
11. Verificar `https://app…/v1/health` → debe responder `{"ok":true,"core":true,"platform":true,"redis":{"iam":true,"core":true}}`. Si algo es `false`, la app ni siquiera debería haber arrancado (fail-fast, Fase 7).
12. Login SaaS `admin` / tu `SEED_PASSWORD`.
13. SMTP en `/smtp` o por `SMTP_*`.
## Local ## Local (todo en docker-compose, incluyendo Postgres/Redis propios)
```bash ```bash
cp .env.example .env # rellenar obligatorias cp .env.example .env # rellenar TODAS las variables (incluye los passwords de roles Postgres/Redis)
docker compose up --build docker compose up --build
``` ```
Sin `SESSION_SECRET` / `DOCS_KEY` / `SEED_PASSWORD` / `PANEL_LOGIN_URL`, compose **no arranca**. El compose local incluye: `provision` (roles/esquemas/ACLs, un solo uso) →
`migrate` (Liquibase con datos de demo, un solo uso) → `api`/`web-panel`/`web-saas`.
Para producción, `provision`/`migrate` NO se corren así -- ver
`db/provision/README.md` y `db/RUNBOOK-corte.md`.
Sin las variables obligatorias, compose **no arranca** (`:?` en docker-compose.yml).
## Checklist ops ## Checklist ops
- [ ] `.env` y `data/` fuera del git - [ ] `.env` y `data/` fuera del git
- [ ] Secrets distintos a los de desarrollo - [ ] Secrets distintos a los de desarrollo (incluye los 6 roles Postgres + 2 Redis)
- [ ] Backup del volumen `panel-data` (incluye DBs + SMTP guardado) - [ ] Backup de `panels_platform` Y `panels_product` (ver `db/backups/`) -- no solo una
- [ ] Backup del bucket de Contabo (documentos cifrados)
- [ ] `COOKIE_SECURE=true` en HTTPS - [ ] `COOKIE_SECURE=true` en HTTPS
- [ ] Healthcheck API OK - [ ] Healthcheck API OK (`/v1/health` con las 5 conexiones en `true`)
- [ ] `--context-filter` explícito en cada corrida de Liquibase contra staging/producción
## Qué no hace falta ## Qué no hace falta
- Postgres/MySQL/MariaDB en Coolify - Contenedor monolítico Node+Deno+Java sirviendo tráfico (Liquibase vive en su propia imagen, de un solo uso)
- Contenedor monolítico Node+Deno+Java - FFI de SQLite (ya no hay SQLite en ningún ambiente)