17  8. Necesitamos que los datos sobrevivan al reinicio

La memoria no es un plan

Parte 2 — PostgreSQL
NotaEn una frase

El array en memoria de la Parte 1 muere cada vez que reinicias el servidor. Ahora Taskflow necesita una base de datos de verdad — y antes de escribir una línea de código, hay que diseñarla: qué tablas, qué columnas, qué tipos, cómo se relacionan. La mitad de este capítulo no es código: es pensar.

17.1 El problema

let tasks = [] funcionó perfecto… hasta que el proceso reinició y las 200 tareas del cliente desaparecieron. La memoria RAM es volátil por diseño — es el escritorio donde trabajas, no el archivero donde guardas. Necesitamos algo que sobreviva: a reinicios, a crashes, a deploys.

Una variable es una caja con nombre donde guardas un valor: let total = 42 guarda el 42 bajo el nombre total. Puedes leerla y cambiarla después (total = 50). const = caja que no se puede reemplazar.

Un proceso es un programa en ejecución: el sistema operativo le da memoria propia y un lugar en la fila del CPU. Tu API es un proceso; Postgres es otro; el navegador es varios. Que “se caiga el proceso” = el programa murió.

La memoria RAM es el escritorio de trabajo del programa: rápida, pero se borra al apagar. Los datos que deben sobrevivir van a disco o a la base de datos. Por eso let tasks = [] pierde todo al reiniciar.

¿Por qué no un archivo JSON en disco? Porque la siguiente pregunta llega inmediata: ¿cómo busco “las tareas pendientes del usuario 42” sin leer todo el archivo? ¿Qué pasa si dos requests escriben a la vez? Eso es exactamente lo que una base de datos resuelve desde 1970: guardar datos estructurados, consultarlos rápido, y sobrevivir a la concurrencia.

Una base de datos es el programa que guarda datos de forma permanente y los responde rápido. Relacional (Postgres): tablas con relaciones. No relacional: documentos, clave-valor, grafos — cada una para una forma de dato distinta.

17.2 Cómo lo resuelve un equipo

Un equipo serio diseña antes de crear. La conversación real suena así: “¿qué cosas existen en Taskflow?” → usuarios, tareas, etiquetas. “¿Cómo se relacionan?” → un usuario tiene muchas tareas; una tarea puede tener varias etiquetas. “¿Qué no puede ser nulo?” → toda tarea tiene título. Esa conversación se dibuja en un diagrama ER (entidad- relación: cajas y líneas, nada más) y después se traduce a SQL.

Elegimos PostgreSQL por razones concretas, no por moda: es relacional (los datos de Taskflow son relaciones — tareas de usuarios), open source, el estándar de facto, y sus JSONB te dan lo flexible cuando lo necesites. “MongoDB porque mi dato ya es JSON” es el atajo que revisamos en Otras cimentaciones.

17.3 Conceptos nuevos

  • D Base de datos relacional: tablas (entidades), filas (registros), columnas (atributos) — y relaciones entre ellas
  • D Tipos de columna, NOT NULL, valores por defecto — la BD como guardiana de tus invariantes
  • D Clave primaria: SERIAL vs UUID — y por qué exponer el id interno en la URL es una decisión, no un default
  • D Clave foránea + ON DELETE CASCADE vs SET NULL — qué le pasa a los hijos cuando muere el padre
  • D Diagrama ER: dibujar el modelo antes de crearlo
  • D Relacional vs las 4 familias NoSQL — y la regla “empieza relacional”
  • D Local vs producción: mismo motor, distinta operación
  • U Capítulo agnóstico: SQL es el mismo en los tres stacks

17.4 La explicación visual

El modelo de Taskflow dibujado — esto es el diagrama ER:

erDiagram
  USERS ||--o{ TASKS : "owns"
  TASKS }o--o{ TAGS : "labeled with"
  USERS {
    bigint id PK
    text email
    text password_hash
    timestamptz created_at
  }
  TASKS {
    bigint id PK
    bigint user_id FK
    text title
    int priority
    timestamptz due_date
    boolean done
    timestamptz created_at
  }
  TAGS {
    bigint id PK
    text name
  }
  TASK_TAGS {
    bigint task_id FK
    bigint tag_id FK
  }

Cómo se lee: USERS ||--o{ TASKS = un usuario tiene cero o muchas tareas. TASKS }o--o{ TAGS = muchos a muchos — y como una tabla no puede tener “listas” dentro, esa relación vive en task_tags, la tabla puente. Toda decisión del dibujo se convierte en una línea de SQL.

Un array es una colección ordenada de elementos accedidos por posición: ["a","b","c"][0] es "a" (se cuenta desde 0). Python las llama listas, Go slices — misma idea, distinto acento.

17.5 Implementación

El diagrama traducido a CREATE TABLE — SQL puro, idéntico para los tres stacks (los drivers del cap. 10 ejecutarán esto mismo):

-- 001_initial.sql — the ER diagram as DDL
CREATE TABLE users (
  id            BIGSERIAL PRIMARY KEY,          -- auto-increment internal id
  public_id     UUID NOT NULL DEFAULT gen_random_uuid() UNIQUE,
  email         TEXT NOT NULL UNIQUE,
  password_hash TEXT NOT NULL,
  created_at    TIMESTAMPTZ NOT NULL DEFAULT now()
);

CREATE TABLE tasks (
  id         BIGSERIAL PRIMARY KEY,
  public_id  UUID NOT NULL DEFAULT gen_random_uuid() UNIQUE,
  user_id    BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
  title      TEXT NOT NULL CHECK (char_length(title) BETWEEN 1 AND 200),
  priority   INT NOT NULL DEFAULT 3 CHECK (priority BETWEEN 1 AND 5),
  due_date   TIMESTAMPTZ,
  done       BOOLEAN NOT NULL DEFAULT FALSE,
  created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);

CREATE TABLE tags (
  id   BIGSERIAL PRIMARY KEY,
  name TEXT NOT NULL UNIQUE
);

-- the many-to-many bridge: no data of its own, only pairs
CREATE TABLE task_tags (
  task_id BIGINT NOT NULL REFERENCES tasks(id) ON DELETE CASCADE,
  tag_id  BIGINT NOT NULL REFERENCES tags(id)  ON DELETE CASCADE,
  PRIMARY KEY (task_id, tag_id)                -- the pair IS the key
);

Lee esto como invariantes escritos en piedra: la validación del cap. 4 protege la API; estos NOT NULL/UNIQUE/CHECK/REFERENCES protegen el dato — la última frontera, la que no puedes saltarte ni con un bug en el handler ni con un script manual.

Tres decisiones que merecen una pausa:

  1. id interno (BIGSERIAL) vs public_id (UUID). El id interno es para los JOINs (rápido, ordenado); el UUID es el que viaja en la URL /tasks/7f3a.... Exponer id=42 le dice al mundo “hay ~42 tareas” e invita a probar id=41 — el UUID no revela ni permite adivinar.
  2. ON DELETE CASCADE: borrar un usuario borra sus tareas — elegido, no default. La alternativa SET NULL dejaría tareas huérfanas.
  3. task_tags con PK compuesta: el par (task_id, tag_id) es la clave — no puede repetirse, y no necesita id propio.

Un JOIN combina tablas por su relación: “cada tarea con el nombre de su proyecto”. Es la superpotencia relacional — en una query traes lo que en NoSQL serían varios viajes.

Diseña el esquema PostgreSQL para mi app de tareas. Entidades: users,
tasks (title, priority 1-5, due_date, done), tags (many-to-many con
tasks). Requisitos: id interno BIGSERIAL + public_id UUID (el UUID es
el que se expone en la API), constraints NOT NULL/UNIQUE/CHECK donde
correspondan, FKs con ON DELETE pensado y justificado, y una tabla
puente para la relación N:M. Dame el SQL completo más un diagrama ER
en texto, y explícame cada decisión de diseño.

17.6 ¿Relacional o no relacional? — y local ≠ producción

Lo único que necesitas llevarte: existen otras familias de bases de datos — documento, clave-valor, columna ancha, grafo — y cada una existe porque alguna forma de dato le queda mejor que las tablas. Pero el 90% de los productos (Taskflow y Nexus incluidos) son relaciones disfrazadas: empieza relacional, agrega la especializada cuando una necesidad real lo pida. Y la BD de tu laptop y la de producción son el mismo motor en ligas distintas: mismo SQL, distinta operación.

Familia Ejemplo El dato se guarda como Brilla cuando Qué te cuesta
Documento MongoDB, Firestore Un JSON por registro; cada uno puede tener campos distintos Registros con forma variable de verdad: catálogos, contenido, perfiles raros Las relaciones las enforzas tú en código; no hay REFERENCES que proteja
Clave-valor Redis, DynamoDB Una llave → un valor, sin queries ricas Leer/escribir por llave a velocidad extrema: caché, sesiones, rate limits No puedes preguntar “los pendientes del usuario 42” — solo GET por llave
Columna ancha Cassandra, Bigtable Filas con columnas que pueden variar por fila Escritura masiva continua: métricas, logs, IoT, series de tiempo Diseñas por query, no por entidad — curva alta, nicho real
Grafo Neo4j Nodos + aristas: la relación es el dato “Amigos de amigos”, rutas, fraude, recomendación Si tu dato no es una red, es overkill puro

Las señales de decisión — la pregunta que responde “¿cuál uso?”:

  • ¿Los datos se relacionan y esas relaciones deben cumplirse siempre? → relacional. REFERENCES lo garantiza; en Mongo lo garantizas tú (o no, y ahí nacen los datos huérfanos).
  • ¿Cada registro puede tener campos distintos e impredecibles? → documento… o JSONB dentro de Postgres, que es la respuesta que termina ganando el 80% de las veces.
  • ¿Solo lees/escribes por una llave, a escala brutal? → clave-valor, casi siempre como complemento (Redis delante de Postgres, no en vez).
  • ¿Millones de escrituras continuas tipo log/métricas? → columna ancha.
  • ¿La pregunta del negocio es sobre la red (quién conoce a quién)? → grafo.

La trampa clásica: “MongoDB porque mi dato ya es JSON”. Eso confunde el formato (JSON es el sobre) con la estructura (tus datos son relaciones: tareas de usuarios). Taskflow responde JSON en cada endpoint y aun así su base es relacional pura.

Alternativa relacional Qué es Cuándo elegirla
PostgreSQL El estándar open source + JSONB El default para casi todo producto
SQLite Relacional en un archivo, sin servidor Apps locales, embebidas, prototipos, tests
MySQL Relacional igual de capaz Equipo que ya lo usa; hosting legacy

17.6.1 Tu laptop vs producción: mismo motor, dos ligas

El Postgres que levantas con docker compose up y el RDS de AWS (o Cloud SQL de GCP, o Azure SQL) hablan exactamente el mismo SQL — tus queries, migraciones y drivers no cambian ni una línea. Lo que cambia es

Una query es la pregunta que le haces a la base de datos en SQL: SELECT * FROM tasks WHERE done = false. La BD traduce la pregunta a un plan de búsqueda — por eso los índices importan.

Una migración es un cambio versionado del esquema: un archivo que dice “crea esta columna” (up) y “bórrala” (down). Son el historial Git de la estructura de la BD — se aplican en orden y no se editan una vez aplicadas.

todo lo que rodea al motor:

Local (Docker) Producción (administrada)
Datos Descartables — borrar y re-sembrar es lo normal Irremplazables — backups automáticos, point-in-time recovery
Si muere Reinicias el contenedor Réplica en otra zona, failover automático
Credenciales postgres:postgres en el compose Secret manager, usuarios con permisos mínimos, TLS obligatorio
Red localhost:5432, todo abierto Puerto visible solo desde tu app (VPC / security group)
Conexiones 10 bastan Límite real → pooler (PgBouncer) cuando escala
El admin Tú El proveedor parchea y hace backup; tú configuras

La regla de oro: mismo motor en ambos entornos. Usar SQLite en local “y ya Postgres en prod” es cómo se fabrican los bugs que solo existen en producción — tipos distintos, SQL distinto, NULL y constraints que se comportan distinto. Por eso el compose del cap. 31 levanta Postgres real aunque pese más: dev debe parecerse a prod en lo que importa (mismo motor, mismas migraciones); puede diferir en lo demás (sin réplicas, sin backups — esos son problemas de operación, no de desarrollo).

17.7 Errores comunes

Error Por qué pasa Fix
Diseñar “sobre la marcha” Crear tablas cuando ya hace falta el endpoint Dibujo ER primero; SQL después
Exponer id secuencial en URLs Es lo que el ORM da por defecto public_id UUID para lo externo
ON DELETE por accidente El framework eligió CASCADE sin que lo decidieras Decidirlo por relación, escribirlo explícito
Validar solo en la API “Ya lo valida el schema de Zod/Pydantic” Constraints en la BD — la API es una entrada de muchas
Tablas sin created_at “No lo necesito ahora” Barato de añadir hoy, imposible de reconstruir mañana

17.8 Buenas prácticas

  • Dibuja el ER antes del primer CREATE TABLE — cambiar una línea en el dibujo cuesta segundos; cambiar una tabla con datos cuesta una migración.
  • La BD es la última guardiana: todo lo que siempre debe ser cierto (email único, prioridad 1–5, tarea con dueño) merece un constraint, no solo validación en el handler.
  • id interno para JOINs, public_id para el mundo — separa la identidad interna de la expuesta.
  • Nombres consistentes: tablas en plural y minúscula (tasks), snake_case en columnas (due_date), PK llamada id, FK llamada tabla_id (user_id). La convención evita mil decisiones pequeñas.

Una foreign key es un puntero verificado: tasks.project_id apunta a projects.id y la BD garantiza que el proyecto existe. Es la diferencia entre “el dato dice 5” y “el dato apunta a algo real”.

EXTRA Lo que los roadmaps listan como bases que debes reconocer —

Relacionales: MySQL (la más popular históricamente), PostgreSQL, MariaDB, MS SQL Server, Oracle.

NoSQL: MongoDB, CouchDB, DynamoDB (RethinkDB quedó en el camino). Muchas startups optan por NoSQL — pero la decisión es por forma del dato, no por moda (ver la sección anterior de este capítulo).

El libro elige PostgreSQL: relacional completo, JSONB cuando el dato varía, y el mismo motor en local y producción.

17.9 Ejercicio

  1. Dibuja (en papel o texto) el ER si Taskflow agrega comentarios en las tareas: un usuario comenta en una tarea.
  2. Escribe el CREATE TABLE comments correspondiente con sus FKs y decide el ON DELETE.
  3. ¿Por qué task_tags no necesita una columna id propia?
  4. Un compañero propone: “pasemos a MongoDB, total la API ya responde JSON”. ¿Qué le respondes?
  5. Tu app corre contra Postgres en Docker en tu laptop y contra RDS en producción. Nombra dos cosas que son idénticas entre ambos y dos que cambian por completo.
  1. USERS ||--o{ COMMENTS y TASKS ||--o{ COMMENTS — un comentario pertenece a un usuario y a una tarea (dos FKs).
  2. CREATE TABLE comments (
      id         BIGSERIAL PRIMARY KEY,
      public_id  UUID NOT NULL DEFAULT gen_random_uuid() UNIQUE,
      task_id    BIGINT NOT NULL REFERENCES tasks(id) ON DELETE CASCADE,
      user_id    BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
      body       TEXT NOT NULL CHECK (char_length(body) BETWEEN 1 AND 2000),
      created_at TIMESTAMPTZ NOT NULL DEFAULT now()
    );
    CASCADE en ambos: un comentario sin tarea o sin autor no tiene sentido. (Alternativa defendible: ON DELETE SET NULL en user_id si quisieras conservar comentarios de usuarios borrados — entonces la columna sería nullable. Lo importante es que sea decisión.)
  3. Porque su PK compuesta (task_id, tag_id) ya es única y es toda la identidad que necesita: la fila es el vínculo. Un id extra sería una columna sin trabajo.
  4. “JSON es el formato de la respuesta, no la estructura de los datos. Nuestras tareas son de usuarios y con etiquetas — esas relaciones las enforza REFERENCES gratis en Postgres; en Mongo las enforzaríamos nosotros en código (o no, y ahí nacen los huérfanos). Si algún día necesitamos forma variable, JSONB ya está incluido.” — confundir formato con estructura es la trampa clásica.
  5. Idénticas: el motor (mismo SQL, mismos tipos, mismas migraciones) y tu código (driver, pool, queries — ni una línea cambia). Cambian: la operación (tú admin vs backups/réplicas/failover del proveedor) y el entorno (credenciales postgres:postgres vs secret manager + TLS + puerto cerrado salvo a tu app).

17.10 Mini reto

Decidir qué pasa con las tareas cuando se borra su usuario — y defenderlo.

Con ON DELETE CASCADE (lo elegido): el usuario se va con sus tareas — correcto para datos personales (y GDPR-friendly: “borrar mi cuenta” borra mis datos). Alternativas: SET NULL solo si las tareas tienen valor histórico sin dueño (requeriría user_id nullable), o soft-delete (deleted_at) si la regulación exige retener. Para Taskflow, CASCADE es la defensiva: dato personal huérfano es peor que dato borrado.

17.11 Vocabulario técnico del capítulo

Una clase es el molde de un objeto: define qué datos y qué métodos tiene. class Task es el molde; new Task() es una instancia concreta. Go no tiene clases — usa struct + métodos sueltos.

Un booleano es un valor de dos estados: true o false. Es el resultado de toda comparación (age > 18) y lo que los if evalúan. Nombrado por George Boole, el matemático de la lógica.

Un pool es el staff de conexiones a la BD: abrir una conexión por request es caro, así que el pool mantiene ~10–20 abiertas y las presta. El request la usa, la devuelve, y la siguiente la reutiliza.

Un ORM (Object-Relational Mapper) traduce entre objetos del código y filas de la tabla: task.save() en vez de INSERT. Cómodo para el CRUD, peligroso si no sabes qué SQL genera (el N+1 nace ahí).

La autorización responde “¿qué puedes hacer?”: eres usuario válido (autenticado), pero ¿puedes borrar esta tarea? Se decide por rol o por ownership — y se verifica en cada request, no se recuerda.

Una sesión es la conversación continuada entre tú y el servidor: HTTP no recuerda nada entre requests, así que la sesión (vía cookie o token) es el “pulso de mano” que te identifica en cada llamada.

Un entorno es una instancia completa donde corre tu app con su propia config y datos: local (tu máquina), staging (réplica de prueba), producción (la real). Cada entorno tiene sus propias llaves y su propia base de datos.

Un framework es un esqueleto de aplicación ya decidido: te da la estructura (rutas, validación, errores) y tú llenas la lógica. Diferencia con librería: la librería la llamas tú; el framework te llama a ti.

Un puerto es una puerta numerada dentro de una computadora: la IP llega al edificio, el puerto al departamento. :3000 = “la app que escucha en el departamento 3000”. Postgres suele usar 5432, HTTP el 80, HTTPS el 443.

localhost significa “esta misma máquina” — es la dirección que tu computadora usa para hablarse a sí misma. Cuando desarrollas, el “servidor” y el “cliente” viven en tu laptop: por eso todo es localhost:3000.

El esquema es el plano de la base de datos: qué tablas hay, qué columnas tiene cada una y de qué tipo. Es contrato: una fila que no cumple el esquema no entra. Se cambia con migraciones, no a mano.

17.12 Lo que deberías saber hacer ahora