All MicroEvals
JVG Agentic Coding — Long-Horizon Feature Integration v0.1
Create MicroEval

JVG Agentic Coding — Long-Horizon Feature Integration v0.1

Prompt

Actúa como agente de desarrollo senior implementando una feature transversal en una app real. Debes comprender el sistema completo, preservar contratos e invariantes entre cliente, API y PostgreSQL, y aplicar la solución mínima correcta. No diseñes una arquitectura genérica ni añadas infraestructura innecesaria. # Objetivo Implementar autosave robusto para el borrador de una sesión clínica. Debe ser correcto con debounce, latencia, edición durante un save, retries, respuestas perdidas, dos pestañas, entregas concurrentes, conflictos de revisión, analytics best-effort y errores reales de servidor. # Stack Next.js App Router, React, TypeScript, PostgreSQL. Sin nuevas dependencias ni state manager externo. # Código actual La página renderiza: ```tsx <SessionEditor key={session.id} initialSession={session} /> ``` `key={session.id}` es intencionado: navegar a otra sesión remonta el editor. ## components/sessions/SessionEditor.tsx ```tsx "use client"; import { useEffect, useState } from "react"; import { ApiError, apiFetch } from "@/lib/apiFetch"; type Session = { id: string; patientId: string; draft: string; revision: number; }; type SaveResponse = { session: Session }; type ConflictResponse = { error: "revision_conflict"; session: Session; }; type SaveStatus = | "saved" | "dirty" | "saving" | "error" | "conflict"; export function SessionEditor({ initialSession, }: { initialSession: Session; }) { const [draft, setDraft] = useState(initialSession.draft); const [revision, setRevision] = useState(initialSession.revision); const [status, setStatus] = useState<SaveStatus>("saved"); const [serverConflict, setServerConflict] = useState<Session | null>(null); useEffect(() => { if (draft === initialSession.draft) return; setStatus("dirty"); const timeout = window.setTimeout(async () => { setStatus("saving"); const clientMutationId = crypto.randomUUID(); try { const result = await apiFetch<SaveResponse>( `/api/sessions/${initialSession.id}`, { method: "PATCH", headers: { "content-type": "application/json" }, body: JSON.stringify({ draft, baseRevision: revision, clientMutationId, }), }, ); setRevision(result.session.revision); setStatus("saved"); } catch (error) { if ( error instanceof ApiError && error.status === 409 && error.body && typeof error.body === "object" && "error" in error.body && error.body.error === "revision_conflict" ) { const conflict = error.body as ConflictResponse; setServerConflict(conflict.session); setStatus("conflict"); return; } setStatus("error"); } }, 750); return () => window.clearTimeout(timeout); }, [draft, revision, initialSession.id, initialSession.draft]); return ( <section> <label htmlFor="session-draft">Notas de la sesión</label> <textarea id="session-draft" value={draft} onChange={(event) => setDraft(event.target.value)} /> <p aria-live="polite"> {status === "saved" && "Guardado"} {status === "dirty" && "Cambios sin guardar"} {status === "saving" && "Guardando…"} {status === "error" && "No se pudo guardar"} {status === "conflict" && "Existe una versión más reciente en el servidor"} </p> {serverConflict && ( <div role="alert"> <p>Hay cambios realizados desde otra pestaña.</p> <button type="button" onClick={() => { setDraft(serverConflict.draft); setRevision(serverConflict.revision); setServerConflict(null); setStatus("saved"); }} > Usar versión del servidor </button> </div> )} </section> ); } ``` ## lib/apiFetch.ts ```ts export class ApiError<T = unknown> extends Error { constructor( public readonly status: number, public readonly body: T, ) { super(`HTTP ${status}`); } } export async function apiFetch<T>( input: RequestInfo | URL, init?: RequestInit, ): Promise<T> { let lastError: unknown; for (let attempt = 0; attempt < 2; attempt++) { try { const response = await fetch(input, init); if (response.status >= 500 && attempt === 0) continue; const body = await response.json().catch(() => null); if (!response.ok) { throw new ApiError(response.status, body); } return body as T; } catch (error) { lastError = error; if (error instanceof ApiError) throw error; if (attempt === 1) throw error; } } throw lastError; } ``` El retry debe conservarse. Reutiliza el mismo `RequestInit`, así que un retry reenvía exactamente el mismo body. ## app/api/sessions/[sessionId]/route.ts ```ts import { NextResponse } from "next/server"; import { analytics } from "@/lib/analytics"; import { db } from "@/lib/db"; export async function PATCH( request: Request, { params }: { params: Promise<{ sessionId: string }> }, ) { const { sessionId } = await params; const body = (await request.json()) as { draft?: string; baseRevision?: number; clientMutationId?: string; }; if ( typeof body.draft !== "string" || typeof body.baseRevision !== "number" || !body.clientMutationId ) { return NextResponse.json( { error: "invalid_request" }, { status: 400 }, ); } const session = await db.session.updateUnsafe({ id: sessionId, draft: body.draft, revision: body.baseRevision + 1, }); if (!session) { return NextResponse.json( { error: "not_found" }, { status: 404 }, ); } await analytics.track("session_draft_saved", { sessionId: session.id, revision: String(session.revision), }); return NextResponse.json({ session }, { status: 200 }); } ``` # Base de datos No reescribas `lib/db.ts`. Usa solo estas interfaces: ```ts export type Session = { id: string; patientId: string; draft: string; revision: number; }; export type SessionMutationOutcome = | { kind: "applied"; session: Session } | { kind: "conflict"; session: Session } | { kind: "not_found" }; export type SessionMutation = { clientMutationId: string; sessionId: string; baseRevision: number; draft: string; outcome: SessionMutationOutcome | null; }; type TransactionClient = { session: { findById(id: string): Promise<Session | null>; updateIfRevision(input: { id: string; baseRevision: number; draft: string; }): Promise<Session | null>; }; sessionMutation: { claim(input: { clientMutationId: string; sessionId: string; baseRevision: number; draft: string; }): Promise<boolean>; findByClientMutationId( clientMutationId: string, ): Promise<SessionMutation | null>; complete( clientMutationId: string, outcome: SessionMutationOutcome, ): Promise<void>; }; }; export const db = { session: { updateUnsafe(input: { id: string; draft: string; revision: number; }): Promise<Session | null> { throw new Error("implemented"); }, }, transaction<T>( callback: (tx: TransactionClient) => Promise<T>, ): Promise<T> { throw new Error("implemented"); }, }; ``` `updateIfRevision` hace atómicamente: ```sql UPDATE sessions SET draft = $draft, revision = revision + 1 WHERE id = $id AND revision = $base_revision RETURNING *; ``` Devuelve sesión actualizada si coincide la revisión; `null` si no existe o la revisión cambió. Ya existe, sin migraciones: ```sql CREATE TABLE session_mutations ( client_mutation_id text PRIMARY KEY, session_id uuid NOT NULL, base_revision integer NOT NULL, draft text NOT NULL, outcome jsonb ); ``` Semántica de `tx.sessionMutation.claim(input)`: - `true`: esta transacción reclamó por primera vez ese `clientMutationId`; - `false`: ya existe una mutación confirmada con ese id; - claims concurrentes del mismo id se serializan; - si la primera confirma, la segunda obtiene `false`; si hace rollback, la segunda puede reclamar; - tras `claim === false`, la fila confirmada ya es visible; - una mutación completada tiene `outcome !== null`; - `claim`, `findByClientMutationId`, `updateIfRevision`, `findById` y `complete` ocurren dentro de la misma transacción. # Analytics `analytics.track(...)` puede fallar. Es best-effort, va fuera de la transacción y no debe cambiar el resultado de negocio. # Invariantes de servidor 1. Repetir exactamente (`clientMutationId`, `sessionId`, `baseRevision`, `draft`) devuelve el mismo resultado confirmado y nunca incrementa otra vez `revision`. 2. Si un `clientMutationId` existente se reutiliza con distinto `sessionId`, `baseRevision` o `draft`, responder `409`: ```json { "error": "idempotency_key_reused" } ``` sin modificar la sesión. 3. Una mutación solo aplica si `currentRevision === baseRevision`; usa `updateIfRevision`. 4. Si la revisión cambió, no sobrescribas: guarda el conflicto como outcome y responde `409` con: ```json { "error": "revision_conflict", "session": { "...": "sesión actual" } } ``` 5. Si no existe la sesión, responde `404 { "error": "not_found" }` y guarda ese outcome para que el replay sea determinista. 6. Analytics solo se intenta para una mutación nueva realmente aplicada, una vez fuera de la transacción. Nunca en replay, conflicto, not_found o key reuse. # Invariantes del editor 1. Debounce de 750 ms. 2. Máximo una petición de autosave activa por instancia. 3. Si el usuario edita durante un save: conserva el draft más reciente; deja terminar el save; adopta su nueva revisión si fue válido; si quedan cambios, guarda después el snapshot más reciente usando esa revisión. 4. Solo muestra `Guardado` si el draft visible coincide con el último draft confirmado. 5. Una respuesta de snapshot antiguo no puede marcar como guardado un draft posterior. 6. Cada save lógico crea un `crypto.randomUUID()` nuevo; los retries internos conservan ese id. 7. Tras error incluso después del retry: conserva draft, muestra `error`, no entres en loop; una edición posterior puede generar una nueva mutación. 8. Mantén `key={session.id}` y estado local. Una respuesta tardía del editor desmontado no debe afectar a la nueva sesión. # Conflictos en UI Ante `revision_conflict`: - conserva draft local y sesión del servidor; - pausa autosave; - muestra conflicto. Ofrece: **Usar versión del servidor** - adopta draft + revision remotos; - limpia conflicto; - status = saved. **Guardar mis cambios sobre la versión actual** - conserva draft local; - usa como `baseRevision` la revision del conflicto; - genera nuevo `clientMutationId`; - guarda explícitamente; - si vuelve a haber conflicto, vuelve al mismo estado. No hagas merge automático ni overwrite silencioso. # Casos de aceptación 1. Flujo normal: rev 4 → editar → save → rev 5 → Guardado. 2. `A → AB → ABC → ABCD` dentro de 750 ms → un save lógico de `"ABCD"`. 3. Save lento de `"A"` base 7; usuario cambia a `"AB"`; respuesta rev 8 → no mostrar Guardado para `"AB"`; después guardar `"AB"` con base 8. 4. `mut-X` aplica rev 10→11, respuesta se pierde y `apiFetch` reintenta → rev sigue 11, mismo resultado lógico. 5. Dos tabs base 20 con drafts distintos → una gana rev 21; otra recibe `409 revision_conflict`. 6. En conflicto, “Usar versión del servidor” adopta draft/revision remotos. 7. En conflicto, “Guardar mis cambios...” crea nueva mutación sobre revision recibida; puede aplicar o volver a conflicto. 8. `mut-X` primero con `"A"` y después con `"B"` → `409 idempotency_key_reused`, sin modificar sesión. 9. Analytics falla tras aplicar → negocio sigue siendo éxito; no provoca retry. 10. Error real de servidor → no se disfraza de conflicto/idempotencia; cliente conserva draft y acaba en `error` tras el retry interno. # Restricciones - Sin nuevas dependencias, migraciones, rutas, auth, WebSockets, polling, locks distribuidos ni state manager. - No reescribas `lib/db.ts`; no uses `updateUnsafe` en la solución final. - No elimines el retry de `apiFetch` ni añadas otro retry automático en el editor. - No resuelvas concurrencia solo con SELECT previo. - No reintentes automáticamente `revision_conflict` ni sobrescribas el servidor. - No reutilices `clientMutationId` para snapshots distintos. - No inventes funciones de `db`. - Analytics fuera de transacción y nunca requisito de éxito. - No conviertas esto en arquitectura offline/genérica. - Sin pseudocódigo; TypeScript razonablemente tipado, evitando `any`. - No afirmes haber ejecutado tests/build/app si no puedes. - Declara ambigüedades materiales y preserva comportamiento no relacionado. Puedes crear hook/helper solo si reduce materialmente la complejidad. No se premian más archivos. # Formato obligatorio Devuelve exactamente estas cinco secciones: ## 1. Diagnóstico e invariantes Máximo 12 puntos. Fallos actuales, causas, invariantes y frontera cliente/servidor. ## 2. Plan de implementación Solo archivos modificados/creados, responsabilidad y motivo. ## 3. Implementación Contenido final completo de TODOS los archivos modificados/creados. Cada archivo empieza exactamente con: ### ruta/del/archivo Sin diffs, pseudocódigo ni “resto sin cambios”. ## 4. Verificación Cubre los 10 casos. Para cada uno indica propiedad, razonamiento y test/ejecución real. Distingue explícitamente razonamiento de ejecución. ## 5. Riesgos o supuestos Solo riesgos, límites o supuestos materiales restantes. Si no hay: Ninguno material.

Drag to resize
Drag to resize
Drag to resize
Drag to resize
Drag to resize