← Documentación de tutor.ia

Cómo funciona el stack

Documento de orientación: qué es cada pieza, por qué está, y cómo se conectan. Para quien nunca trabajó con Cloudflare Workers, Hono o un monorepo.

Si ya sabés todo esto y sólo querés operar el entorno, andá a entorno-desarrollo.md.


El mapa, en una imagen

   navegador
       │
       ▼
   apps/web            Next.js. La cara visible. Todavía no existe.
       │
       ├──────────────┐
       ▼              ▼
   apps/core      apps/ingest        Dos servicios chicos, cada uno con su URL
       │              │
       └──────┬───────┘
              ▼
        packages/db                  Código compartido: tablas y conexión
              │
              ▼
          Postgres

Cuatro piezas de código y una base. apps/ son cosas que se despliegan y corren; packages/ es código que otros importan y no corre solo.


Primera parte: el monorepo

Qué es

Un solo repositorio con varios proyectos adentro, en vez de un repo por proyecto.

tutor_ia/
├── apps/
│   ├── core/        ← un servicio
│   └── ingest/      ← otro servicio
└── packages/
    └── db/          ← librería compartida

Es la misma idea que instalar un paquete propio desde una carpeta del repo en vez de publicarlo a un registro: compartir código entre proyectos sin pasar por internet.

Cómo se conectan entre sí

El archivo package.json de la raíz declara:

json
"workspaces": ["apps/*", "packages/*"]

Eso hace dos cosas. Primero, npm install se corre una sola vez desde la raíz e instala las dependencias de todos. Segundo, npm crea enlaces internos: cuando apps/core pide @tutoria/db, npm lo resuelve a la carpeta packages/db del propio repo.

Por eso en el código de los Workers ves:

ts
import { accounts } from "@tutoria/db";

y eso no baja nada de internet: apunta a packages/db de acá al lado.

Por qué conviene acá

El esquema de la base lo usan los dos servicios. Con repos separados habría que publicar un paquete y versionarlo cada vez que se agrega una columna. Con el monorepo, cambiás packages/db/src/schema.ts y los dos servicios ven el cambio al instante, con los tipos correctos.


Segunda parte: Docker

Si ya usaste Docker, esta parte te va a resultar conocida. Lo que cambia acá es qué metimos adentro.

Los tres conceptos, rápido

  • Imagen: un molde. Un sistema de archivos congelado con todo instalado. Se construye una vez.
  • Contenedor: una instancia viva de esa imagen, corriendo.
  • Volumen: un pedazo de disco que sobrevive cuando el contenedor muere. Sin volumen, cada reinicio empieza de cero.

docker/node/Dockerfile — la receta de la imagen

Un Dockerfile son los pasos para armar el molde. El nuestro tiene etapas (base → deps → dev → cli), que es una forma de reutilizar trabajo: deps instala las dependencias, y dev y cli parten de ahí sin repetirlo.

Dos decisiones que vale entender:

Por qué bookworm-slim y no alpine. Alpine es la imagen chica que se usa por defecto en casi todos lados. Acá no se puede: workerd, el motor de Cloudflare, es un binario compilado contra las librerías de C de Debian (glibc). Alpine usa otras (musl), y wrangler falla al arrancar con un error que no menciona la palabra “musl” en ningún lado. Es una tarde perdida si no lo sabés.

Por qué no hay etapa de producción. Cuando una app se despliega como contenedor, el Dockerfile suele tener un stage prod. Acá no: los Workers se despliegan con wrangler deploy, que sube el código a Cloudflare. El contenedor existe sólo para desarrollar.

docker/postgres/initdb/ — arrancar la base con pgvector

Cualquier .sql que dejes ahí, la imagen de Postgres lo corre la primera vez, cuando el volumen de datos está vacío. Nosotros lo usamos para habilitar pgvector, la extensión que permite guardar vectores y buscar por similitud (la vamos a necesitar para el RAG).

Detalle que confunde: pgvector se habilita en dos lugares, acá y en una migración. No es un descuido. Este script sólo corre con la base vacía, y no existe en Neon — que es donde va a correr producción. La migración cubre ese caso.

docker-compose.yml y docker-compose.override.yml

El compose es la lista de contenedores y cómo se relacionan. Están partidos en dos porque el override.yml se carga solo cuando hacés docker compose up:

  • El base define qué servicios hay, la red, los volúmenes y los healthchecks.
  • El override agrega lo que sólo tiene sentido en tu máquina: publicar puertos, montar el código para que los cambios se vean sin reconstruir.

Los cuatro servicios

Servicio Qué es
postgres La base, con pgvector
neon-proxy Un traductor. Ver abajo
workers Los dos servicios Hono corriendo
cli No queda prendido: se usa para correr comandos sueltos

Por qué existe neon-proxy

En producción la base va a estar en Neon, que es Postgres gestionado. Neon tiene un driver propio que se conecta por WebSocket en lugar del protocolo normal de Postgres, porque los Workers no pueden abrir conexiones TCP comunes.

Podríamos usar un driver normal en tu máquina y el de Neon sólo en producción. Pero entonces el código correría contra dos cosas distintas, y las diferencias del driver de Neon —que abre una conexión por operación, que maneja las transacciones distinto— aparecerían recién en Cloudflare, que es el peor lugar para descubrirlas.

El neon-proxy recibe ese WebSocket y lo traduce al Postgres local. Así usás el mismo driver en los dos lados. Pesa 10 MB y no necesita cuenta.


Tercera parte: Cloudflare Workers y wrangler

Esta es la parte más distinta de lo que conocés.

Qué es un Worker

Un Worker no es un servidor. No hay un proceso escuchando un puerto, esperando.

Cloudflare tiene servidores en muchas ciudades. Cuando alguien hace un request, tu código se ejecuta en el que esté más cerca, arranca, responde y desaparece. No hay nada corriendo entre request y request.

Comparado con lo que conocés:

Un servidor tradicional Cloudflare Worker
Servidor Hay un proceso escuchando No hay proceso
Arranque Se levanta una vez Arranca en cada request
Escalar Poner más servidores Automático, no lo administrás
Memoria La que tenga la máquina 128 MB, tope duro
Sistema de archivos Sí No
Estado en memoria Se puede No: cada request empieza limpio

Esos 128 MB son el techo en todos los planes, no se pueden subir, y explican una decisión del proyecto: el worker que va a parsear los PDF no puede ser un Worker de Cloudflare. Una bibliografía de 300 páginas con análisis de layout no entra. Va a necesitar un contenedor aparte, y eso todavía está por definir.

Qué es wrangler

Es la herramienta de línea de comandos de Cloudflare. Dos usos:

  • wrangler deploy — sube el código a Cloudflare. Eso es “desplegar” acá: no hay servidor que aprovisionar.
  • wrangler dev — corre tu Worker en tu máquina, pero con el mismo motor que usa Cloudflare (workerd). No es una imitación: es el runtime real.

Y hace algo más: emula los servicios de Cloudflare. R2 (el almacenamiento de archivos, el equivalente a S3) y Queues (las colas) funcionan localmente sin que tengas cuenta ni gastes nada. Los archivos que subís quedan en un volumen de Docker.

wrangler.jsonc — la configuración de cada Worker

Cada Worker tiene el suyo. Ahí se declaran los bindings: los recursos externos a los que puede acceder.

jsonc
{
  "name": "tutoria-ingest",
  "main": "src/index.ts",
  "r2_buckets": [{ "binding": "FILES", "bucket_name": "tutoria-files" }],
  "queues": { "producers": [{ "binding": "INGEST_QUEUE", "queue": "ingest" }] }
}

Un binding es distinto de una variable de entorno: no es una cadena de conexión que el código usa para conectarse, es el recurso mismo, ya listo. En el código escribís env.FILES.put(...) y ya estás escribiendo en R2, sin credenciales ni cliente que configurar.

La rareza del entorno: dos sesiones de wrangler en un contenedor

Esto está explicado en detalle en entorno-desarrollo.md, pero en resumen: wrangler dev sólo expone un Worker por HTTP aunque le pases varios. Para que cada uno tenga su URL — como en producción— corremos dos sesiones. Y van en el mismo contenedor porque lo que las conecta entre sí es un directorio compartido donde cada una publica su dirección local.


Cuarta parte: Hono

Hono es un framework para escribir APIs. Muy chico y muy rápido.

Si sabés Express, es prácticamente igual:

ts
import { Hono } from "hono";

const app = new Hono();
app.get("/health", (c) => c.json({ status: "ok" }));

export default app;

A diferencia de un framework completo, Hono es sólo el router. No trae ORM, ni validación, ni middleware de auth, ni inyección de dependencias. Vos armás esas capas.

Por qué Hono y no otro. El análisis técnico consideró Elysia, que rinde mejor en benchmarks. Se descartó porque Elysia está atado a Bun, y Bun no es compatible con el runtime de Cloudflare Workers. Hono corre en Workers, en Node, en Bun y en Deno sin reescribir nada — esa portabilidad fue el criterio.


Quinta parte: qué hace cada carpeta de apps/

Son dos servicios separados a propósito. La regla que usó el análisis técnico: se separan sólo cuando tienen distinto perfil de recursos, distinto eje de escalado o distinta cadencia de despliegue.

apps/core — el núcleo

La API principal: el tutor, las búsquedas sobre el material, el CRUD. Y además consume la cola: cuando ingest encola un trabajo, es core quien lo procesa en segundo plano.

apps/ingest — la ingesta

Sólo se ocupa de recibir material del docente: recibe el archivo, lo guarda en R2, encola el trabajo y contesta enseguida. No espera a que se procese.

Esa es la razón de que sea un servicio aparte. Procesar un PDF de 300 páginas puede tardar minutos; si el docente tuviera que esperar con la pantalla colgada, sería inusable. Entonces: subir es rápido, procesar ocurre después, y el docente consulta el estado cuando quiera.

El recorrido completo de una subida

Los pasos 5 y 6 son el diseño, no lo que hay hoy. Ahora mismo el núcleo baja el objeto de R2 y guarda su tamaño: eso prueba que los bytes llegaron. El parseo, la fragmentación y los embeddings llegan con el módulo de ingesta.

1. El docente sube un PDF          POST /documents  →  ingest
2. ingest lo guarda en R2                              (el archivo original, intacto)
3. ingest encola "procesá esto"                        y responde: {"status":"queued"}
4. core saca el mensaje de la cola                     ya sin nadie esperando
5. core lee el archivo de R2 y lo procesa
6. core marca "listo" en la base
7. El docente consulta             GET /documents/:id/status  →  {"status":"processed"}

Los pasos 1-3 tardan menos de un segundo. El 5 puede tardar minutos.

Si el paso 5 falla, el mensaje se reintenta tres veces y después cae en una segunda cola, ingest-dlq, que el mismo core consume para dejar el documento en failed. Sin esa cola de descarte el mensaje se perdería y el docente vería pending para siempre, sin saber que su PDF no se procesó nunca.


Sexta parte: packages/db

Es el código compartido de la base. Los dos Workers lo importan.

Archivo Qué hace
schema.ts Define las tablas en TypeScript
migrations/ El SQL que crea y modifica las tablas
client.ts Abre la conexión. El único archivo que sabe qué driver se usa
seed.ts Carga datos de prueba
migrate.ts Aplica las migraciones

Drizzle

Es el ORM. Definís las tablas en TypeScript y él genera el SQL de las migraciones y te da autocompletado con tipos:

ts
export const accounts = pgTable("accounts", {
  id: bigserial("id", { mode: "number" }).primaryKey(),
  email: text("email").notNull(),
});

A diferencia de un ORM de modelos con métodos y relaciones mágicas, Drizzle es más cercano al SQL: escribís consultas que se parecen bastante al SQL, pero con los tipos verificados. Se eligió sobre Prisma porque Prisma no corre bien en runtimes de edge.

Por qué las migraciones no corren solas

En muchos proyectos las migraciones se aplican al arrancar el contenedor. Acá es un paso explícito (./tutoria db-migrate), a propósito: si corrieran solas, un despliegue fallido dejaría la base ya migrada, y volver a la versión anterior del código podría romper igual. Primero se migra, después se despliega.


Qué pasa cuando corrés ./tutoria up-d

  1. Docker lee el compose y arranca postgres. Espera a que responda.
  2. Arranca neon-proxy, que se queda esperando conexiones WebSocket.
  3. Arranca workers, que adentro lanza dos sesiones de wrangler dev — una para core en el 8787, otra para ingest en el 8788.
  4. Cada sesión levanta workerd (el motor real de Cloudflare) y prepara R2 y las colas emuladas.
  5. Docker consulta /health cada diez segundos hasta que responden. Ahí el contenedor pasa a healthy.

Y como tu código está montado dentro del contenedor, si editás un archivo wrangler recompila solo. No hace falta reconstruir nada.


Los nombres, todos juntos

Monorepo Un repo con varios proyectos adentro
Workspace Cada uno de esos proyectos
Imagen / contenedor El molde / la instancia corriendo
Volumen Disco que sobrevive al contenedor
Worker Código que corre en Cloudflare sin servidor propio
workerd El motor que ejecuta los Workers
wrangler La herramienta de Cloudflare (correr y desplegar)
miniflare Lo que emula R2 y las colas en tu máquina
Binding Un recurso ya conectado, listo para usar desde el código
R2 El almacenamiento de archivos de Cloudflare
Queues Las colas de Cloudflare
Hono El framework de las APIs
Drizzle El ORM
pgvector La extensión de Postgres para buscar por similitud
Neon Postgres gestionado, donde va a estar producción
Clerk El servicio de login