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
│
▼
PostgresCuatro 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 compartidaEs 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:
"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:
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.
{
"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:
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:
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
- Docker lee el compose y arranca postgres. Espera a que responda.
- Arranca neon-proxy, que se queda esperando conexiones WebSocket.
- Arranca workers, que adentro lanza dos sesiones de
wrangler dev— una paracoreen el 8787, otra paraingesten el 8788. - Cada sesión levanta
workerd(el motor real de Cloudflare) y prepara R2 y las colas emuladas. - Docker consulta
/healthcada diez segundos hasta que responden. Ahí el contenedor pasa ahealthy.
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 |