Ir al contenido

Base de datos

  • Modelo de datos — diagrama entidad-relación y qué representa cada tabla en términos del dominio.
  • Migraciones — cómo se crean, cómo se aplican, cómo se revierte.
  • Índices y consultas críticas — las consultas que tienen que ser rápidas sí o sí.
  • Datos semilla — qué necesita una instalación limpia para arrancar.

El modelo tiene que distinguir producto de envase retornable. Una paca sale y no vuelve; un botellón sale, se espera que vuelva, y a veces no vuelve. Son dos ciclos de vida distintos y un solo campo stock no los representa.

Esa decisión, cuando se tome, va documentada como ADR — es de las caras de revertir.

M0 — Auth + RBAC: ✅ (20-ago-2026). M1 — Productos: ✅ (22-ago-2026). M2 — Stock: ✅ (22-ago-2026). M3 — Insumos, M4 — Producción, M5 — Clientes, M6 — Ventas, M7 — Retornables: ✅. M9 — Proveedores, M11 — Contador, M12 — Alertas: ✅. M13 — Auditoría: ✅ (transversal desde M0). M14 — Clientes ubicables: ✅ (10-sep-2026). M15 — Seguimientos: ✅ (21-sep-2026). M10 — Precios: ✅ absorbido por M1 y M6. M8 — Rutas y seller mobile: ⏸ POST-MVP (diferido).

30 tablas y 22 migraciones aplicadas, sobre PostgreSQL 17.

Tabla Para qué
users Identidad. email es citext: el login es case-insensitive en la base, sin depender de que cada query recuerde un LOWER()
accounts Acá vive el hash de la contraseña, no en users. Better-Auth separa identidad de credencial para poder sumar OAuth sin tocar usuarios
sessions Con roles[] congelados al iniciar sesión (RN-ACC-01)
verifications Tokens de un solo uso para recuperar la contraseña
roles Catálogo cerrado de los cuatro
user_roles N:M, con quién otorgó cada rol
audit_log Bitácora append-only
productos Catálogo. Precios con piso garantizado por CHECK y litros como columna generada
lotes Producto empacado un día, con su saldo encima. FIFO por vencimiento
movimientos_stock El libro que explica el saldo. Append-only, igual que audit_log

La tabla del catálogo (M1) apoya tres reglas del dominio en Postgres y no en la capa de servicio.

El criterio general está en ADR-0006: la base impide el dato malo aunque un endpoint se olvide, y el servicio existe para que el error sea legible. No son dos validaciones de lo mismo — son dos responsabilidades.

Mecanismo Qué garantiza
CHECK productos_precio_minimo_es_piso RN-CAT-04 — el piso nunca supera un precio de lista
GENERATED ALWAYS AS en litros RN-PRD-01 — el derivado no puede desincronizarse de contenido_ml × unidades
REVOKE DELETE a aquazaku_app RN-CAT-02 — un producto se desactiva, no se borra

El servicio igual valida el piso, pero para otra cosa: para que el error diga qué corregir en vez de escupir un mensaje de Postgres. La base es la que impide el dato malo aunque un endpoint se olvide.

lotes y movimientos_stock: el saldo y el libro que lo explica

Sección titulada «lotes y movimientos_stock: el saldo y el libro que lo explica»

El saldo vive en el lote, no en una tabla aparte: un lote ya es “producto + fecha”, y una tabla de saldos solo repetiría esa clave para agregar un número.

Los dos se escriben en la misma transacción. Si el saldo baja y el movimiento no queda, el libro deja de explicar el saldo — es la primera forma de descuadre, y la más difícil de rastrear meses después.

Mecanismo Qué garantiza
CHECK cantidad_disponible >= 0 RN-STK-03 — no hay stock negativo
REVOKE UPDATE, DELETE en movimientos_stock RN-STK-02 — un libro editable no es un libro
REVOKE DELETE en lotes Un lote no se borra, se queda en cero. Borrarlo dejaría movimientos huérfanos y ventas sin trazabilidad
CHECK condicionales de motivo y causa RN-STK-02 y RN-STK-06 — exigen el dato solo en ajuste y descarte

Dos mecanismos independientes, porque ninguno alcanza solo — ADR-0004:

  1. Triggers que rechazan UPDATE, DELETE y TRUNCATE. Son FOR EACH STATEMENT: un DELETE que no matchea ninguna fila nunca dispararía uno por fila y pasaría en silencio.
  2. El rol de la aplicación (aquazaku_app) no es dueño de las tablas y sobre audit_log solo tiene SELECT e INSERT.

Al dueño lo frena el trigger; al trigger lo podría desactivar el dueño. Juntas, adulterar la bitácora exige credenciales del rol dueño y un ALTER TABLE explícito.

audit_log no tiene foreign key a users, a propósito: si un usuario se borra, su rastro tiene que sobrevivir. Un log que se borra en cascada no es un log.

Rol Lo usa Puede
aquazaku drizzle-kit y el runner de migraciones Dueño de las tablas
aquazaku_app el servidor en runtime CRUD sobre datos; sobre audit_log, solo leer e insertar

Se traduce en dos variables: DATABASE_URL y DATABASE_MIGRATION_URL. El servidor nunca abre una conexión con la segunda. Provisionamiento en Entorno local.

Ventana de terminal
pnpm db:migrate # base de desarrollo
pnpm db:migrate:test # base de tests
pnpm db:seed # roles, primer admin y catálogo de productos
Migración Qué trae
0000_m0_initial Las siete tablas de M0
0001_audit_append_only Triggers de audit_log + permisos de aquazaku_app
0002_productos Catálogo de productos, sus CHECK y el REVOKE DELETE
0003_stock Lotes con saldo, libro de movimientos append-only y sus REVOKE

Las migraciones son SQL explícito de Drizzle Kit, nunca auto-generadas contra producción. Verificado: corren sobre una base virgen sin pasos manuales y son idempotentes.

El seed también es idempotente: si ya hay un administrador activo no hace nada y termina con éxito, así puede vivir en el pipeline de deploy. En producción exige SEED_CONFIRM=yes.

Las reglas de negocio viven en /dominio/ y no se duplican acá.