Apéndice G — G. Un día de trabajo: 50 tickets
Del «conecta a esta API» al «escribe el test primero»
50 tareas como las daría un lead en tu primer trabajo — de “haz un fetch a esta API” hasta “aquí está el test, escribe la implementación”. Cada ticket tiene su solución colapsada: intenta resolverlo primero, luego compara. Si uno te cuesta, la referencia → cap. X te dice qué releer.
Cómo usarlo: un nivel por sesión, en orden — están graduados como una semana real de onboarding. Tu AI CLI es el lead: si una solución no te queda clara, pégale el ticket y pídele que te lo explique distinto.
Un CLI (Command Line Interface) es un programa que se usa escribiendo comandos en la terminal: git, npm, docker son CLIs. Lo contrario es una GUI (interfaz gráfica con botones).
G.1 Nivel 1 · Primeros tickets
Los que te asignan la primera semana: leer, conectar, no romper.
Te llega: “Necesito saber si esta API pública responde. Trae la lista de usuarios de https://jsonplaceholder.typicode.com/users e imprime sus nombres. Cualquier stack.”
Solución.
const res = await fetch("https://jsonplaceholder.typicode.com/users");
const users = await res.json();
users.forEach(u => console.log(u.name));fetch devuelve una Promise del response; .json() devuelve otra Promise del body parseado — por eso dos await. → fe-05, cap-00
Te llega: “El ticket 1 explota si la API responde 500 — agrega manejo de error real: status no-ok y JSON malformado.”
Solución.
const res = await fetch(url);
if (!res.ok) throw new Error(`HTTP ${res.status}`); // 404/500 are NOT exceptions
const users = await res.json(); // throws if body isn't JSONfetch solo rechaza por falla de red — un 500 es un response “exitoso” para fetch. res.ok es tu frontera. → fe-05, cap-06
Te llega: “Antes de escribir nada, mira qué devuelve GET /tasks/7 del staging. Quiero status, headers y body.”
Solución.
curl -i https://staging.taskflow.dev/tasks/7
# -i includes response headers — status line + headers + body-i muestra el intercambio completo: status code, content-type, el JSON. Inspeccionar antes de codificar es el hábito — el endpoint te dice su contrato. → cap-00
Te llega: TypeError: Cannot read properties of undefined (reading 'map') at listTasks (src/services/tasks.ts:14) at handler (src/routes/tasks.ts:8). ¿Qué pasó y dónde miras primero?
Solución. Se lee de arriba abajo: qué (undefined.map — algo que creíste array vino vacío), dónde (tasks.ts:14 — la primera línea tuya en el stack), desde quién (el handler de routes/tasks.ts:8). La BD devolvió undefined donde esperabas rows[] — típico cuando la query falla silenciosamente o se desestructura mal. → cap-06
Te llega: “Hay https://api.stripe.com quemada en el código. En staging apunta a otro host. Corrígelo.”
Solución.
API_URL = os.environ["API_URL"] # fails fast if missing — better at boot
# requests.get(f"{API_URL}/charges")Config por entorno, no por código: el mismo artefacto corre en dev, staging y prod. El [] que lanza error si falta es a propósito — mejor explotar al arrancar que a las 3am. → cap-02, cap-24
Te llega: “Del array de tareas, dame las pendientes ordenadas por prioridad (1 primero), solo id y título.”
Solución.
const open = tasks
.filter(t => !t.done)
.sort((a, b) => a.priority - b.priority)
.map(t => ({ id: t.public_id, title: t.title }));filter elige filas, sort ordena, map proyecta columnas — es WHERE/ORDER BY/SELECT en memoria. El pipeline de colecciones ES SQL mental. → cap-00, cap-09
Te llega: código legacy con fetch(url).then(r => r.json()).then(data => ...). Reescríbelo legible.
Solución.
const res = await fetch(url);
const data = await res.json();
// same Promise chain — await is syntax for .then(), not new machinery.then() y await son la misma asincronía con distinta sintaxis. await se lee como pasos; .then() se encadena. Saber ambas = leer código viejo y escribir nuevo. → fe-05
Te llega: “En la pantalla de detalle, si el usuario sale antes de que responda la API, el setState sobre un componente muerto ensucia la consola.”
Solución.
const ctrl = new AbortController();
fetch(url, { signal: ctrl.signal }).then(/* ... */);
return () => ctrl.abort(); // cleanup del useEffectAbortController es el “cancelar” de fetch — en React va en el cleanup del useEffect. → fe-05
Te llega: “Crea la tarea llamando POST /tasks con {title: '...'} — el servidor espera JSON.”
Solución.
const res = await fetch("/tasks", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ title }), // serialize — don't interpolate
});Tres piezas obligatorias: method, el header que declara el formato, y el body serializado (string, no objeto). → fe-05, cap-03
Te llega: el endpoint devuelve a veces 400, 401, 403, 404, 422, 500. Para cada uno: ¿quién tiene la culpa y qué revisas?
Solución. 400/422: el request está mal (tu body/schema) → revisa lo que envías. 401: no autenticado → el token. 403: autenticado sin permiso → roles. 404: no existe (o no debes saber que existe). 500: ellos — revisa logs del servidor, no tu cliente. La familia 4xx es “arregla tu pedido”; 5xx es “avisar al backend”. → cap-00, cap-06
G.2 Nivel 2 · El día a día: endpoints
Tu primera semana construyendo: un CRUD que no avergüence.
Te llega: “POST /tasks debe crear la tarea, devolver 201 con el objeto creado, y el public_id (no el id interno).”
Solución.
@app.post("/tasks", status_code=201)
def create_task(body: TaskIn, user=Depends(get_current_user)):
row = create_task_service(user.id, body.title)
return TaskPublic(public_id=row["public_id"], title=row["title"],
done=row["done"], created_at=row["created_at"])201 + el recurso creado con su forma pública — el id interno no sale. → cap-03, cap-05
Te llega: “Están entrando tareas sin título y con prioridad 99. Rechaza en la entrada con errores por campo.”
Solución.
const TaskIn = z.object({
title: z.string().min(1).max(200),
priority: z.number().int().min(1).max(5).default(3),
});
const parsed = TaskIn.safeParse(req.body);
if (!parsed.success) throw new ValidationError(parsed.error.issues);
// → 422 {"error":{"code":"VALIDATION","fields":[{"field":"priority",...}]}}Schema declarativo una vez, validación antes del handler — el body malo nunca toca la BD. → cap-04
Te llega: “GET /tasks/{id} devuelve el objeto aunque sea de otro usuario. Y cuando no existe, devuelve un stack trace.”
Solución.
task, err := getTask(ctx, user.ID, publicID) // WHERE public_id AND user_id
if errors.Is(err, pgx.ErrNoRows) {
writeError(w, 404, `{"error":{"code":"TASK_NOT_FOUND"}}`)
return
}Dos fixes en uno: la query filtra por dueño (cap. 17) y el error pasa por el mapper — el cliente ve el formato {error:{code}}, el stack queda en logs. → cap-05, cap-06, cap-17
Te llega: “DELETE responde 200 con la tarea borrada. Y si no existe, 200 igual con null. Estándar, porfa.”
Solución.
DELETE FROM tasks WHERE public_id = $1 AND user_id = $2;
-- rows affected: 1 → 204 No Content · 0 → 404204 sin body = “borrado, no hay nada que devolver”. 0 filas = 404 (no existe o no es tuya — indistinguibles a propósito). → cap-03, cap-17
Te llega: “La lista de tareas ya son 40.000. Pagina — y no con ?page= que se degrada.”
Solución.
SELECT public_id, title, created_at FROM tasks
WHERE user_id = $1 AND (created_at, id) < ($2, $3)
ORDER BY created_at DESC, id DESC LIMIT 21; -- 21: one extra to know if there's a next pageRespuesta: {items, next_cursor} — el cursor es created_at,id de la última fila. El índice salta directo; OFFSET cuenta y descarta. → cap-12
Te llega: “GET /me está devolviendo password_hash en el JSON. Literalmente.”
Solución.
class UserPublic(BaseModel): # the output contract
public_id: str
email: str
created_at: datetime
# password_hash doesn't exist here — can't leakWhitelist, no blacklist: defines lo que sí sale. SELECT * + devolver la fila es cómo se filtran secretos. → cap-05, cap-14
Te llega: “El toggle de ‘hecha’ manda la tarea entera. Quiero PATCH /tasks/{id} que solo acepte {done}.”
Solución.
const PatchTask = z.object({ done: z.boolean() });
// PATCH = partial update: only declared fields, only changed onesPATCH declara solo los campos mutables — title no viaja si no cambia. Más seguro que PUT-por-costumbre: menos superficie para pisar datos. → cap-03, cap-04
Te llega: “Unos endpoints devuelven {message}, otros {errors}, otros un string. Un solo formato, todos.”
Solución.
{"error": {"code": "TASK_NOT_FOUND", "message": "task not found"}}Un mapper central (cap. 6): toda excepción de dominio se traduce a ese formato — code estable para el cliente programático, message para humanos. El frontend escribe UN parser de errores. → cap-06
Te llega: “GET /tasks?done=false&priority=1 — dos filtros combinables.”
Solución.
SELECT ... FROM tasks
WHERE user_id = $1
AND ($2::boolean IS NULL OR done = $2)
AND ($3::int IS NULL OR priority = $3)
ORDER BY created_at DESC;Parámetros opcionales: si no vienen, el filtro se desactiva (IS NULL OR). La validación del query param también es schema — done=banana → 422. → cap-03, cap-09
Te llega: “El orquestador necesita saber si estamos vivos, y yo necesito saber si la BD responde — sin que uno dispare alarmas del otro.”
Solución.
@app.get("/health") # liveness: process up — cheap, for the orchestrator
def health(): return {"status": "ok"}
@app.get("/health/deep") # readiness: dependencies reachable — for you
def health_deep():
conn.execute("SELECT 1")
return {"status": "ok", "db": "up"}/health barato para el load balancer; /health/deep toca la BD para diagnóstico real. Separarlos evita que un flap de BD tumbe instancias sanas. → cap-33
G.3 Nivel 3 · Base de datos
Te dejan tocar la BD. Cada ticket es una query o una migración.
Te llega: “Necesito: tareas abiertas de cada usuario, cuántas son — usuarios con cero también cuentan.”
Solución.
SELECT u.email, COUNT(t.id) AS open_tasks
FROM users u
LEFT JOIN tasks t ON t.user_id = u.id AND t.done = FALSE
GROUP BY u.id, u.email
ORDER BY open_tasks DESC;LEFT JOIN porque “con cero cuentan” — INNER los borraría. El COUNT(t.id) cuenta filas emparejadas: NULL (sin tareas) cuenta 0. → cap-09
Te llega: “El endpoint de detalle hace 1 query por tarea más 1 por sus tags. Una sola query.”
Solución.
SELECT t.public_id, t.title, tg.name AS tag
FROM tasks t
LEFT JOIN task_tags tt ON tt.task_id = t.id
LEFT JOIN tags tg ON tg.id = tt.tag_id
WHERE t.public_id = $1;
-- 3 rows if 3 tags — group in code: one task, tag listUna tarea con 3 tags devuelve 3 filas — agrupas en código. Una ida a la BD, cero N+1. → cap-09, cap-12
Te llega: “WHERE user_id=? AND done=? ORDER BY created_at DESC va a 400ms con 2M de filas. El EXPLAIN dice Seq Scan.”
Solución.
CREATE INDEX CONCURRENTLY idx_tasks_user_done_created
ON tasks (user_id, done, created_at DESC);
-- re-EXPLAIN: Index Scan, ~0.5msIgualdad primero (user_id, done), orden al final (created_at). CONCURRENTLY en prod: no bloquea escrituras mientras se construye. → cap-12
Te llega: “Hay que agregar tasks.completed_at TIMESTAMPTZ. Prod tiene 500k filas. Sin downtime.”
Solución.
-- 000003_add_completed_at.up.sql
ALTER TABLE tasks ADD COLUMN completed_at TIMESTAMPTZ;
-- down: ALTER TABLE tasks DROP COLUMN completed_at;Aditivo puro: filas viejas quedan NULL, nada se reescribe, la migración viaja con el código que la usa en el mismo PR. → cap-11
Te llega: “Al completar una tarea hay que: marcarla, registrar el evento y dar el punto. Si el paso 2 falla, hoy queda a medias.”
Solución.
with pool.connection() as conn:
with conn.transaction(): # BEGIN
conn.execute("UPDATE tasks SET done=TRUE WHERE public_id=%s", (tid,))
conn.execute("INSERT INTO events (type, ref) VALUES ('task.done', %s)", (tid,))
conn.execute("UPDATE users SET points=points+1 WHERE id=%s", (uid,))
# exit → COMMIT; exception → ROLLBACK, all or nothingUna conexión, un BEGIN, los tres writes juntos. El with garantiza el ROLLBACK si algo lanza. → cap-13
Te llega: “En logs veo que GET /projects ejecuta 61 queries. El código tiene un loop con tres await dentro.”
Solución. Tres N+1 anidados: 1 + 20×3 = 61. La cura — una query con agregación:
SELECT p.id, u.email AS owner, COUNT(DISTINCT t.id) AS task_count,
array_agg(DISTINCT tg.name) AS tags
FROM projects p
JOIN users u ON u.id = p.owner_id
LEFT JOIN tasks t ON t.project_id = p.id
LEFT JOIN project_tags pt ON pt.project_id = p.id
LEFT JOIN tags tg ON tg.id = pt.tag_id
GROUP BY p.id, u.email;→ cap-12
Te llega: (code review) cursor.execute("SELECT * FROM users WHERE email = '" + email + "'") — el junior dice “funciona”.
Solución.
cursor.execute("SELECT * FROM users WHERE email = %s", (email,))El dato viaja por canal separado — ' OR '1'='1 se trata como texto literal, no como SQL. En code review: “funciona” no es el criterio; “es segura” sí. → cap-09
Te llega: “comments cuelga de tasks y de users. ¿Qué ON DELETE y por qué?”
Solución.
task_id BIGINT NOT NULL REFERENCES tasks(id) ON DELETE CASCADE,
user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE,CASCADE en ambos: comentario sin tarea ni autor no tiene sentido. Alternativa defendible: SET NULL en user_id si los comentarios deben sobrevivir al usuario borrado (columna nullable entonces). Lo que no se vale: que el framework lo elija sin que tú lo decidieras. → cap-08
Te llega: “Dos usuarios completan la misma tarea a la vez y ambos ganan el punto. Solo el primero debe ganar.”
Solución.
UPDATE tasks SET done = TRUE
WHERE public_id = $1 AND done = FALSE
RETURNING id;
-- first tx: 1 row → award point · second: 0 rows → "already completed"Compare-and-set: el WHERE convierte check-then-act (con carrera) en un acto atómico. No necesitas FOR UPDATE cuando el UPDATE es la condición. → cap-13
Te llega: “Esta query anda en psql — pásala al servicio con el driver, parametrizada, mapeada al struct.”
Solución (Go).
rows, err := pool.Query(ctx,
`SELECT public_id, title, done FROM tasks
WHERE user_id = $1 AND done = FALSE
ORDER BY priority`, userID)
if err != nil { return nil, err }
defer rows.Close()
return pgx.CollectRows(rows, pgx.RowToStructByName[Task])Pool prestado, $1 parametrizado, defer rows.Close() devuelve la conexión, CollectRows mapea a structs. → cap-10
G.4 Nivel 4 · Auth y seguridad
Te confían la puerta. Cada error aquí es un incidente.
Te llega: “POST /auth/register — y obvio, la contraseña no puede quedar legible.”
Solución.
const passwordHash = await bcrypt.hash(password, 10);
await pool.query(
"INSERT INTO users (email, password_hash) VALUES ($1, $2)",
[email, passwordHash],
);bcrypt con salt por usuario y costo 10. La clave en claro vive solo durante el request — nunca en la tabla, nunca en logs. → cap-14
Te llega: “Login que devuelva el JWT — en cookie, no en el body. Y el mismo error si falla email o password.”
Solución.
token = jwt.encode({"sub": str(user["id"]), "exp": now()+timedelta(minutes=15)},
SECRET, algorithm="HS256")
response.set_cookie("token", token, httponly=True, secure=True,
samesite="lax", max_age=900)
# wrong email OR wrong password → same 401 "invalid credentials"httpOnly contra XSS, Secure para HTTPS, SameSite contra CSRF, exp corto. Error genérico = no enumerar usuarios. → cap-15
Te llega: “Las 8 rutas de /tasks y /projects necesitan auth. No copies la verificación en cada una.”
Solución.
app.use("/tasks", auth);
app.use("/projects", auth);
// or in FastAPI: user = Depends(get_current_user) per route
// or in Go: mux.HandleFunc("GET /tasks", auth(listTasks))El middleware verifica una vez, propaga el usuario, corta con 401. Proteger = una palabra, no un copipega. → cap-16
Te llega: “GET /tasks/42 devuelve 403 si es de otro. Se puede enumerar qué tareas existen probando ids.”
Solución.
SELECT ... WHERE public_id = $1 AND user_id = $2
-- 0 rows → 404, identical to "doesn't exist"404 para lo ajeno: “no existe” y “no es tuya” producen respuestas byte a byte iguales — no hay nada que enumerar. El 403 se reserva para miembros con rol insuficiente (cap. 17). → cap-17
Te llega: “Los proyectos se comparten: viewer solo lee, editor edita, admin todo. El rol es de ese proyecto, no global.”
Solución.
CREATE TABLE project_members (
project_id BIGINT REFERENCES projects(id) ON DELETE CASCADE,
user_id BIGINT REFERENCES users(id) ON DELETE CASCADE,
role TEXT NOT NULL CHECK (role IN ('viewer','editor','admin')),
PRIMARY KEY (project_id, user_id)
);- un factory
requireRole("editor")que consulta la membresía: no-miembro → 404, rol insuficiente → 403. → cap-17
Te llega: “Están probando contraseñas contra /auth/login a máquina.”
Solución.
POST /auth/login → rate_limit(5 req/min per IP AND per email)
attempt #6 → 429 {"error":{"code":"RATE_LIMITED"}} + Retry-After: 42
Por IP y por cuenta (el botnet tiene IPs, no tu cuenta). Antes de bcrypt — no gastes el hash caro en intentos que rechazarás. → cap-18
Te llega: “El frontend en :5173 no puede llamar la API en :8000 — ‘blocked by CORS policy’ y con cookies encima.”
Solución.
Access-Control-Allow-Origin: http://localhost:5173 ← exact, never * with credentials
Access-Control-Allow-Credentials: true
Access-Control-Allow-Headers: Content-Type
Con cookies, * está prohibido por el estándar — se nombra el origen exacto. Y recuerda: CORS lo enforcea el navegador; curl no pide permiso. → cap-18
Te llega: “Los refresh tokens viven 30 días y se reusan — si uno se filtra, es un mes de sesión regalada.”
Solución.
if session.used_at or session.revoked_at:
revoke_family(session.user_id) # used token came back = theft
raise UnauthorizedError("session reuse detected")
mark_used(session.id)
return issue_access(), issue_refresh() # new pair every timeCada canje mata el viejo y emite uno nuevo. El viejo reapareciendo = robo detectado → sesión entera revocada. → cap-18
Te llega: “La tabla sessions guarda el refresh token en texto plano.”
Solución.
refresh_hash TEXT NOT NULL UNIQUE, -- store the hash, send the tokenMisma lección que contraseñas: si sessions se filtra, tokens en claro = sesiones regaladas. Se guarda sha256(token); el opaco viaja al cliente. → cap-14, cap-18
Te llega: “El usuario perdió su laptop. Quiere matar todas sus sesiones ya.”
Solución.
UPDATE sessions SET revoked_at = now()
WHERE user_id = $1 AND revoked_at IS NULL;Por eso el refresh es persistido: revocar es un UPDATE. Los access viven hasta su exp (~15 min — el precio conocido del híbrido). Re-pedir contraseña antes de ejecutar esto: es operación sensible. → cap-18
G.5 Nivel 5 · Senior
Donde el ticket es un problema, no una instrucción. Aquí se gana el título.
Te llega: “Aquí está el test. Hazlo pasar con lo mínimo — no escribas una línea que el test no pida.”
def test_create_task_rejects_empty_title():
with pytest.raises(ValidationError):
create_task(user_id=1, title="")Solución.
def create_task(user_id: int, title: str) -> Task:
if not title.strip():
raise ValidationError("title is required")
return insert_task(user_id, title.strip())Esto es TDD: el test es la especificación. Red (falla) → Green (lo mínimo que pasa) → Refactor (mejora sin romper). Programar “al revés” — primero qué debe ser verdad, luego el código. → cap-27
Te llega: “El test dice: GET /tasks devuelve {items: [...], next_cursor: str|null} y con ?cursor= continúa la lista. Hazlo pasar.”
Solución.
def list_tasks(user_id: int, cursor: str | None):
where = "AND (created_at, id) < (%s, %s)" if cursor else ""
params = (user_id, *decode(cursor)) if cursor else (user_id,)
rows = fetch(f"SELECT ... WHERE user_id=%s {where} LIMIT 21", params)
next_cursor = encode(rows[20]) if len(rows) == 21 else None
return {"items": rows[:20], "next_cursor": next_cursor}El test definió el contrato antes que el código — por eso TDD produce APIs pensadas desde el consumidor. → cap-12, cap-28
Te llega: “Este handler valida, consulta la BD, decide reglas y serializa — 80 líneas. Sácale capas.”
Solución.
handler: parse → call service → status code (5 líneas)
service: business rules, domain errors (decisiones)
storage: SQL + row mapping (persistencia)
schemas: TaskIn / TaskPublic (contratos)
El handler queda tonto: traduce HTTP y delega. La regla de negocio se puede testear sin HTTP ni request falso — es la paga real del refactor. → cap-07
Te llega: “El cliente reintenta POST /payments tras timeout y cobra dos veces.”
Solución.
key = request.headers["Idempotency-Key"]
if existing := find_by_idempotency_key(key):
return existing, 200 # same request → same answer
result = process_payment(...)
store_idempotency(key, result)
return result, 201El cliente manda una llave única por operación (no por intento); el servidor recuerda el resultado y lo repite en reintentos en vez de re-ejecutar. Así funcionan Stripe y toda API de pagos seria. → cap-21
Te llega: “El listado pasó de 200ms a 2s después del release de ayer. Sin más info.”
Solución. El método, en orden: (1) ¿qué cambió? — el diff del release. (2) Medir: logs/APM → qué endpoint; EXPLAIN ANALYZE → qué hace la query. (3) Sospechosos usuales: Seq Scan nuevo (¿índice perdido?), N+1 (¿un loop nuevo?), lock contention (¿una transacción larga?). (4) Fix + misma medición para confirmar. Lo que nunca: más máquina a un problema no medido. → cap-12, cap-23
Te llega: “Un webhook de facturación llega ~50 veces al día con picos. ¿VPS, PaaS o Lambda? Defiende tu respuesta.”
Solución. Lambda/FaaS: eventos irregulares, pagas por ejecución — un proceso 24/7 por 50 eventos/día es un taxi corriendo en la puerta. Si los picos fueran sostenidos y altos → PaaS/contenedor. La defensa no es la elección, es el criterio: forma del tráfico + estado + operación disponible. → cap-01, nu-05
Te llega: “En 6 meses nadie va a saber por qué elegiste Postgres con JSONB para los documentos. Documéntalo.”
Solución.
# ADR-03: Postgres + JSONB para documentos
## Contexto: contenido variable + miembros/permisos relacionales
## Decisión: JSONB en documents.content, relacional para el resto
## Alternativas: MongoDB (pierde FKs), columnas fijas (rígido)
## Consecuencias: +flexibilidad con índices GIN; -validación en appMedia página: contexto, decisión, alternativas descartadas, precio aceptado. El código dice qué; el ADR dice por qué. → cap-36
Te llega: “Vamos a recibir webhooks de un proveedor. Que nadie pueda falsificarlos y que si fallamos reintente bien.”
Solución.
signature = request.headers["X-Signature"]
expected = hmac_sha256(WEBHOOK_SECRET, request.body)
if not hmac.compare_digest(signature, expected): # verify BEFORE parsing
return 401
# respond 200 fast → process async (job queue): provider retries on failureFirma HMAC con secreto compartido — verificas antes de procesar. Y respondes rápido: el procesamiento va a una cola — si tardas, el proveedor reintenta y duplicas trabajo. → cap-22
Te llega: “El registro tarda 3s porque el email de bienvenida se envía dentro del request.”
Solución.
# in the register service — respond first, work after
insert_user(...)
enqueue("send_welcome_email", {"user_id": user.id}) # job queue
return user # 201 in ~50msEl response no espera al email: enqueue lo deja para un worker (BullMQ / Celery / asynq). “Responder ya, terminar después” — el patrón de toda tarea que excede la paciencia HTTP. → cap-21
Te llega: este endpoint en review. Lista qué cambiarías antes de aprobar:
@app.get("/users/{id}")
def get_user(id: str):
row = conn.execute(f"SELECT * FROM users WHERE id = {id}").fetchone()
return rowSolución. Cinco hallazgos: (1) f-string = inyección SQL → %s parametrizado. (2) SELECT * + devolver la fila → password_hash se filtra: contrato UserPublic. (3) Sin autenticación ni ownership → WHERE id = %s AND id = %s con el usuario del token, 404 a lo ajeno. (4) Sin manejo de None → 404 estructurado, no 500. (5) id como str → tipo en la firma para validación. Un endpoint de 3 líneas enseña cinco capítulos. → caps. 5, 9, 16, 17
G.6 Cierre del apéndice
50 tickets después, el patrón es visible: el trabajo real no es “saber sintaxis” — es reconocer qué tipo de problema es cada ticket y saber qué capítulo lo resuelve. Para seguir: convierte cualquiera de estos en un prompt para tu AI (el formato de los “Prompt listo” de cada capítulo) y compara su respuesta con la solución de aquí. Donde difieran, aprendes dos veces.
G.7 Vocabulario técnico del capítulo
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.
Una función es una receta reutilizable: recibe ingredientes (parámetros), hace pasos y devuelve un plato (return). La escribes una vez y la llamas mil veces: add(2, 3) → 5.
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.
Un objeto es una colección de datos con nombre: {name: "Ana", age: 30} — cada dato es una propiedad (clave → valor). Python los llama dict, Go los arma con struct, TS con objetos/interface.
Un bucle repite una acción por cada elemento o hasta cumplir una condición: for task in tasks hace algo con cada tarea. map y filter son bucles disfrazados de funciones — transforman/filtran colecciones sin for explícito.
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 string es texto entre comillas: "hola". El nombre viene de “cadena de caracteres” — una secuencia de letras. Todo lo que llega de un formulario o una URL llega como string, aunque parezca número.
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.
null (TS), None (Py), nil (Go) = “aquí no hay valor”. Es la respuesta a “¿qué devuelvo cuando no hay nada?” — y la fuente del bug más famoso de la historia (su inventor lo llamó “el error del billón de dólares”). Por eso el código revisa if x is not None.
return hace dos cosas a la vez: devuelve el resultado Y termina la función — lo que esté debajo nunca corre. return task = “aquí está el plato, salgo de la cocina”.
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.
La terminal (consola, línea de comandos) es la interfaz de texto con el sistema operativo: escribes comandos, lees resultados. Es como hablarle a la computadora por cartas en vez de señalar con el mouse.
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.
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.
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.
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.
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.