MarvelousDB — Reporte técnico y de proceso
Demo pequeña desplegada en Fly.io. Sustenta una publicación sobre: (1) flujo de ideas con IA con metodología, (2) habilidades de ingeniería Backend y (3) divertirse.
Producción: https://marvelousdb.fly.dev/
Repo: marvelousdb (git local)
Fecha: 07–09 de agosto de 2026
1. Resumen
MarvelousDB comenzó como un demo de 2014 (Express 3 + Orchestrate + Marvel API, ambos servicios muertos) y terminó siendo una base de datos de superhéroes 100% local y portable: 10,819 personajes (9,417 de dominio público del wiki Public Domain Super Heroes + 1,402 del seed de Marvel), 30,179 cómics, búsqueda full-text, filtros, un modo de batalla arcade y un panel de cobertura de imágenes — todo en un solo archivo SQLite de 269 MB servido por un API REST.
2. Proceso: flujo de ideas con IA (metodología)
Cada ronda siguió el mismo patrón: propuesta → pregunta de alcance → scope
down → implementación → verificación con tests → decisión persistida (en
~/nef/decisions/, 14 documentos fechados).
| # | Idea | Decisión clave | Verificación |
|---|---|---|---|
| 1 | Quitar Orchestrate (DBaaS muerto) | Reescribir como API REST separada + sitio que la consume | 12 tests |
| 2 | Búsqueda | FlexSearch en memoria → luego SQLite FTS5 | tests de búsqueda |
| 3 | Datos sin Marvel API | Importar el wiki PDSH (MediaWiki Action API): 10,076 páginas → 9,417 personajes | 15 tests |
| 4 | Diseño | Replicar el sitio de 2015 desde el Web Archive (el CDN de imágenes resultó vivo) | render verificado |
| 5 | VS Battle | Comparador estilo cameradecision + tiers de poder (el caso Cthulhu vs U.S. Agent calibra el sistema) | test de 7 rondas |
| 6 | Imágenes faltantes | Pipeline Fandom → Commons → Browser Run → SerpAPI, validado por magic bytes y poda de cachés rotos | 21 tests |
| 7 | SQLite portable | node:sqlite + FTS5 trigram, nada en memoria: arranque 0.5s, 66 MB RSS (antes 7s y 500 MB) | 21 tests |
| 8 | URLs compartibles | /vs/cthulhu-vs-u-s-agent con suerte determinística por par (mismo ganador siempre) | deepStrictEqual |
| 9 | Lore sin IA | Párrafo introductorio real de la bio, o plantilla pulp generada (estable por personaje) | 11 tests unitarios |
| 10 | Narrativa arcade | ”CTHULHU used CULT FOLLOWING! SLAM!” + quotes de las bios + onomatopeyas + WINNER/LOSER | 27 tests API |
| 11 | Cobertura | Panel /coverage + botón de reporte de imagen mala que re-encola el cacheo | 11 tests e2e |
| 12 | Deploy | Fly.io con volumen persistente (se descubrió que el volumen tapa los scripts de data/ → require perezoso) | smoke en producción |
Aprendizajes metodológicos: (a) preguntar el alcance antes de escribir
código (3 decisiones de arquitectura salieron de preguntas de 1-2 opciones);
(b) calibrar contra un caso real (Cthulhu vs U.S. Agent destapó que la bio
narrativa de Marvel generaba falsos tiers divinos → las secciones
estructuradas mandan); (c) verificar con curl con y sin Referer (el bug
del hotlinking de wikia solo aparecía en navegador); (d) cada decisión se
persiste para no regenerarla.
3. Ingeniería Backend
Arquitectura
Browser ──► web.js (Express 4 + Handlebars, :5050)
│ proxy /api/* (GET/POST/DELETE)
▼
api.js (Express 4, :3001) ──► node:sqlite (FTS5 trigram, bm25)
│
├─ data/marvelousdb.sqlite (10,819 chars + 30,179 comics)
├─ data/img-cache.json (id → imagen local, cache que manda)
├─ data/tmdb-films.json (adaptaciones cinematográficas)
└─ data/reported-images.json (reportes de imagen mala)
- Express 4 + Promesas nativas + fetch (Node ≥ 23.4, sin frameworks extra).
- SQLite embebido (
node:sqlite): esquema con columnas indexadas (universe,gender) + tablas FTS5 con tokenizer trigram (substring matching, case-insensitive) ordenadas porbm25. Nada en memoria: ~66 MB RSS. - Importadores (todos con API reales, incremental y reanudable):
data/import-pdsh.js— MediaWiki Action API (allpages + revisions + pageimages, formatversion=2), parseo de infoboxes genéricos.data/import-tmdb.js— búsqueda de películas por personaje.data/cache-images.js— pipeline de imágenes en 4 fuentes, validación por magic bytes (rechaza páginas de error guardadas como JPG), poda automática de cachés corruptos y de personajes reportados.data/build-sqlite.js— reconstruye el .db desde los JSON semilla.
- Sistema de batalla: tiers (Cosmic → Superhuman → Peak Human) detectados en secciones estructuradas + señales de identidad fuertes en la bio; poderes ponderados; suerte determinística por par de ids (strHash) para URLs compartibles; narrativa arcade generada por plantillas (sin IA).
- Seguridad/prácticas: secrets solo en env (Fly secrets), CORS acotado, queries 100% parametrizadas, respeto de rate limits a terceros (SerpAPI, Wikimedia), fallback a placeholder local en imágenes rotas.
- Tests: 49 verdes (27 API e2e + 11 unitarios de funciones puras + 11
e2e del sitio con procesos reales).
npm test.
Deployment (Fly.io)
Demo pequeña → Fly.io free tier (3 VMs shared-cpu 256 MB; la API usa ~66
MB). Config: Dockerfile (node:24-slim), fly.toml (web :5050 público, API
:3001 interna), entrypoint.sh (siembra el volumen con el .db + cache al
primer arranque; cp -n sincroniza imágenes nuevas en cada deploy),
.dockerignore (los JSON semilla NO entran a la imagen: solo el .db y el
cache).
Bug real de producción resuelto: el volumen montado en /app/data
ocultaba los scripts data/*.js de la imagen → require en top-level
fallaba con MODULE_NOT_FOUND. Solución: require perezoso de build-sqlite.js
(solo si el .db falta) + scripts de mantenimiento copiados a
/app/data-scripts/ (usables vía fly ssh console con DB_PATH apuntando
al volumen).
fly apps create marvelousdb
fly volumes create marvelousdb_data --size 3 --region ams --yes
fly secrets set SERPAPI_KEY=... TMDB_AUTH_HEADER=... CLOUDFLARE_API_TOKEN=...
fly deploy4. La parte divertida 🎮
- VS Battle (
/vs/cthulhu-vs-u-s-agent): busca por trigram, tiers de poder, barras comparativas, perdedor debilitado (gris, inclinado, como el faint de Pokémon), etiquetas WINNER (dorado neón) / LOSER. - BATTLE REPORT arcade, determinista por pareja: “Cthulhu used CULT FOLLOWING! SLAM!”, la última frase del perdedor como “last words as the dust clears”, onomatopeyas animadas (POW!/WHAM!/BOOM!).
- Lore generado: personajes sin bio reciben un párrafo pulp estable (“Some say Loki was never real…”).
- Las decisiones se guardan como “notas de campo” en
~/nef/decisions/.
5. Números finales
| Métrica | Valor |
|---|---|
| Personajes | 10,819 (9,417 dominio público) |
| Cómics | 30,179 |
| Tamaño SQLite | 269 MB (1 archivo portable) |
| Imágenes cacheadas localmente | 183 (pipeline Fandom/Commons/BrowserRun/SerpAPI) |
| Tests | 51, 0 fallos |
| API | ~0.5 s de arranque, ~66 MB RSS |
| Fuentes de datos | PDSH (MediaWiki), Marvel seed 2014, TMDB, Fandom, Commons, Browser Run, SerpAPI |
| Producción | https://marvelousdb.fly.dev/ (volumen persistente 3 GB) |
6. Pendientes
- Commitear los ~10,073 archivos nuevos (el repo sigue con el commit de 2014).
- Terminar el cacheo de imágenes (faltan ~420; los reportados se re-cachean solos).
- Subir el límite de imágenes de los personajes Marvel si se quiere 100%.
- (Actualizado: el repo ya está committeado y pusheado en https://github.com/[usuario]/marvelous — los pendientes reales son el cacheo de imágenes y el FLY_API_TOKEN para el cron de sueño/despertar.)
7. Decisiones a fondo (para revisión cuasi-forense de pares)
Cada decisión sigue el formato: Contexto → Decisión → Alternativas descartadas → Trade-offs → Evidencia en el repo. La evidencia es verificable: los 51 tests, los endpoints y los scripts citados.
7.1 Dos procesos (API + web) en vez de una sola app Express
- Contexto: el demo original era un solo proceso Express 3 que hablaba directo con Orchestrate. Al quitar Orchestrate había que decidir la forma de la capa de datos.
- Decisión:
api.js(puerto 3001, solo localhost) +web.js(puerto 5050, público) que consume la API por HTTP y la proxifica (/api/*). - Alternativas descartadas: (a) un solo proceso — se eligió dos porque la separación permite reutilizar la API fuera del sitio (curl, scripts, tests e2e contra procesos reales) y porque el usuario pidió explícitamente “API REST separada”; (b) Redis/DB en red — innecesario a esta escala (ver 7.3).
- Trade-offs: dos procesos que arrancar (resuelto con
concurrentlyen dev y unentrypoint.shen prod); el proxy añade ~1ms de latencia local. - Evidencia:
api.js,web.js,entrypoint.sh, testsweb.test.js.
7.2 node:sqlite + FTS5 trigram como motor (sin ORM, sin librería de búsqueda)
- Contexto: la primera versión cargaba 40,000 JSON en memoria e indexaba con FlexSearch (~7s de arranque, ~500 MB RSS). El usuario pidió “un solo archivo .sqlite portable”.
- Decisión: SQLite embebido vía
node:sqlite(módulo built-in de Node ≥ 23.4), FTS5 con tokenizer trigram para búsqueda substring case-insensitive, orden porbm25. Nada en memoria: 0.5s de arranque, ~66 MB RSS. Se eliminó FlexSearch (una dependencia menos). - Alternativas descartadas: (a)
better-sqlite3— excelente, pero es compilación nativa ynode:sqliteya viene en el runtime (local-first, cero deps); (b) D1 de Cloudflare — no corre fuera de Workers y habría atado el deploy a Cloudflare; (c) FlexSearch — la búsqueda en SQL elimina la carga en RAM y el índice vive en el archivo. - Trade-offs: trigram no matchea términos < 3 caracteres (fallback
LIKEen nombre/título); el índice trigram ocupa más espacio (aceptable: 269 MB totales);node:sqliteera “experimental” → se silencia el warning con--disable-warningy se fijaengines >= 23.4. - Evidencia:
api.js(búsquedas),data/build-sqlite.js(esquema FTS5),test/api.test.js.
7.3 Por qué no Redis ni un caché externo
- Decisión: los datos derivados viven en archivos JSON del lado del
volumen (
img-cache.json,tmdb-films.json,reported-images.json) + el filesystem (public/img/cache/), y la BD en SQLite. - Razón: un solo proceso local con ~66 MB de RAM no justifica un servidor de caché extra (operación, contenedor, secrets). Es la filosofía “baterías incluidas” que el propio usuario mencionó al preguntar por Redis. Si hubiera que servir a muchos usuarios, el paso natural sería el CDN de Cloudflare en el dominio de Fly, no Redis.
- Trade-offs: sin TTL ni invalidez global; el caché de imágenes manda siempre (bueno para el hotlinking de wikia, ver 7.5).
- Evidencia:
api.js(loadImgCache/loadFilms/loadReported),README.md.
7.4 El dataset de dominio público: importar el wiki PDSH con el esquema heredado
- Contexto: el seed de Marvel (2014) define el esquema (name, wiki, thumbnail, comics.items). Conservarlo permite que ambos datasets convivan en una sola BD.
- Decisión:
data/import-pdsh.jsusa la MediaWiki Action API de Fandom (allpages → revisions → pageimages,formatversion=2) y traduce los infoboxes genéricos al esquema existente (real_name,debut,publisher,created_by, bio limpia de markup wiki). - Alternativas descartadas: mcuapi (películas del MCU, con copyright) y Download-ComicBooks-API (material protegido) — evaluadas y rechazadas por licencia; Gutendex/Internet Archive/LoC no mapean imágenes por nombre de personaje.
- Trade-offs: ~10,000 requests al wiki (incremental, reanudable); la calidad del texto varía (bios con markup wiki, personajes duplicados sin desambiguar).
- Evidencia:
data/import-pdsh.js,data/pdsh/characters/*.json.
7.5 El pipeline de imágenes y el bug del hotlinking de wikia
- Contexto: el 99% de las imágenes PDSH se veían “vacías” en el navegador aunque la API las sirviera bien.
- Diagnóstico (lección metodológica): los
curlde prueba funcionaban (sinReferer), pero el navegador mandaReferer: localhost:5050ystatic.wikia.nocookie.netdevuelve 404 al hotlink. El bug solo aparecía reproduciendo el tráfico real del navegador. - Decisión: (a)
<meta name="referrer" content="no-referrer">(fix inmediato); (b) pasada--wikiaque descarga las 9,282 imágenes apublic/img/cache/y hace que el caché local mande sobre la URL remota (local-first + inmune a bloqueos futuros). - Evidencia:
data/cache-images.js(wikiaPass),api.js(charThumb),views/layouts/main.handlebars.
7.6 Fuentes de imágenes en cascada: Fandom → Commons → Browser Run → SerpAPI
- Razón: respetar costo 0 y licencias antes de gastar cuota paga:
- Fandom PDSH (gratis, sin key): la imagen del propio wiki del personaje.
- Wikimedia Commons (gratis): se valida la licencia por
extmetadata(publicdomain/cc0/pdm) — el checklist legal del usuario. - Cloudflare Browser Run (gratis, 10 min de navegador/día): scrapea
DuckDuckGo Images vía
/scrape; se descubrió que Google identifica el tráfico de Browser Run como bot y lo bloquea. - SerpAPI (key, ~250/mes): última opción, la única vía legítima a Google Images.
- Validación transversal: magic bytes (nunca guardar una página de error como JPG) y poda automática del índice (los thumbnails expirados de Google se registraban como HTML de ~400 bytes → detectados y re-encolados).
- Evidencia:
data/cache-images.js(4 pasadas + prune),README.md.
7.7 El sistema de batalla: tiers con “secciones estructuradas” que mandan
- Contexto: el usuario objetó “¿cómo U.S. Agent le gana a Cthulhu?“. El primer sistema contaba keywords de poder sobre el texto completo.
- Bug que calibra el diseño: la bio narrativa de Marvel (24 KB de prosa con Loki, Scarlet Witch, “elder god”, “dimensional rift”) daba tier divino a mortales. Se invirtió la fuente de verdad: tiers y poderes se leen de powers/abilities/categories (estructurado); la bio solo aporta señales de identidad (“is an Asgardian”, “worship”, “imprisoned for millennia”) y posesión (“possesses superhuman strength”).
- Trade-offs: un personaje oscuro con bio pobre queda “Normal Human” aunque tenga un poder menor — aceptado, la consistencia importa más que el overfitting.
- Evidencia:
api.js(TIERS, POWER_PATTERNS, LORE_POWER_PATTERNS),test/unit.test.js(detectTier), test “Cthulhu outranks U.S. Agent”.
7.8 Suerte determinística por par (URLs compartibles)
- Decisión: con
idA+idB, la suerte =strHash(idA|idB) % 3 - 1; las batallas aleatorias conservan aleatoriedad. El resultado y la narrativa son función pura del par →/vs/cthulhu-vs-u-s-agentmuestra SIEMPRE el mismo desenlace. - Razón: una URL compartida que cambia de ganador al recargarla no es
compartible; el determinismo convierte el endpoint en una función pura
cacheable y testeable (
deepStrictEqualentre llamadas). - Evidencia:
api.js(strHash, luckOverride),test/unit.test.js,test/web.test.js.
7.9 Lore y narrativa “sin IA”
- Decisión: nada de LLM (local-first, sin costos, sin latencia). El lore usa el primer párrafo real de la bio y, si no existe, plantillas pulp elegidas por hash del id (estable entre visitas). La narrativa arcade combina plantillas por fase + onomatopeyas + quotes extraídas de las bios (oraciones 25-220 chars, prefiriendo diálogo, limpias de markup wiki).
- Trade-offs: las plantillas se repiten entre personajes (aceptable); las quotes pueden salir de contexto (se eligen determinísticamente, no semánticamente).
- Evidencia:
api.js(characterLore, extractQuotes, battleNarrative),test/unit.test.js.
7.10 Tres capas de tests con procesos reales
- Decisión: (1) e2e de API contra procesos reales (child processes con
puertos efímeros); (2) unitarios de funciones puras exportadas
(
slugify,strHash,characterLore,battleNarrative,fightStats…); (3) e2e del web contra API+web reales (rutas, proxy POST/DELETE, render arcade). - Razón: los bugs más caros de la sesión (hotlinking,
.envtrackeado, mount del volumen que ocultadata/*.js) NO aparecían en tests unitarios — aparecían al reproducir el tráfico real. Los e2e con procesos reales son la red que los atrapa. - Evidencia:
test/api.test.js(27),test/unit.test.js(13),test/web.test.js(11).
7.11 Deploy en Fly.io y el bug del volumen que oculta código
- Decisión: node:24-slim + volumen persistente en
/app/data(el .db y el cache viven fuera de la imagen; el entrypoint siembra el volumen en el primer arranque concp -n). - Bug real: el mount en
/app/dataoculta los scriptsdata/*.jsde la imagen →require('./data/build-sqlite.js')en top-level fallaba con MODULE_NOT_FOUND en producción (funcionaba en local, donde no hay mount). Se resolvió con require perezoso (solo si el .db falta) + scripts de mantenimiento en/app/data-scripts/(fuera del mount). - Trade-offs: la imagen pesa 321 MB (el .db se sube como seed); los cambios al .db requieren re-deploy o rebuild en el servidor.
- Evidencia:
Dockerfile,fly.toml,entrypoint.sh,api.js(ensureDb).
7.12 Candados anti-abuso para billing 0
- Decisión: rate limit por IP (120/min, ventana deslizante en memoria,
configurable),
Cache-Control: max-age=7den production, API interna no publicada, secrets solo en Fly secrets, y cron de sueño/despertar (GitHub Actions gratuito, 23:00-08:00 CDMX) o stop manual. - Razón: en el plan free de Fly lo que cuesta es el egress; un script
externo golpeando la API o re-descargando imágenes lo quema. El rate limit
- cache headers reducen el egress a una fracción.
- Trade-offs: un rate limit en memoria no persiste entre reinicios (la ventana se reinicia) — suficiente para disuadir, no para defenderse de un DDoS real (eso sería Cloudflare delante).
- Evidencia:
web.js(createRateLimiter),.github/workflows/*.yaml,README.md.
7.13 Cosas que haría distinto (honestidad técnica)
- Los selects de 10,000 opciones del primer comparador (reemplazados por autocomplete trigram) — el rendimiento percibido importa.
- Los JSON derivados (
img-cache.json,tmdb-films.json) podrían vivir como tablas en SQLite; los dejé como archivos para sobrevivir rebuilds — hay un trade-off de consistencia que un par podría señalar con razón. - El índice FTS5 trigram no es un buscador semántico: “traje” no matchea “armadura”. Para una demo está bien; para un producto, habría que añadir sinónimos o embeddings.
- El
.envestaba trackeado desde el commit de 2014: se eliminó congit rm --cached, pero el histórico lo conserva (solo contenía un placeholder, no secretos reales). - Los tests cubren la funcionalidad, no el rendimiento: no hay benchmark de la búsqueda bajo carga concurrente.
8. Cómo auditar este repo (guía rápida para pares)
npm test→ 51 tests, 0 fallos (3 capas).node data/build-sqlite.js→ reconstruye el .db desde los JSON (11s).node data/import-pdsh.js --dry-run --max 400→ valida el importador sin escribir.- Endpoints:
GET /api/health,/api/stats,/api/battle?idA=7005&idB=1009682(determinista),/api/characters/by-slug?slug=cthulhu. fly deploy→ despliega la demo;fly machine stop -a marvelousdb→ la apaga (billing 0).- El historial de decisiones de proceso está en
~/nef/decisions/(14 notas fechadas).