51  37. El corazón: documentos versionados

Un «git interno»: snapshot + diff + puntero

NotaEn una frase

Guardar cada tecleo como un “cambio” y reconstruir sumándolos es frágil (la red cae a la mitad y el documento queda corrupto) y el undo en el navegador muere al cerrar la pestaña. Nexus guarda la historia como git por dentro: cada guardado es un snapshot completo, el documento tiene un puntero (current_revision), y undo/redo/restore son mover ese puntero — atómicamente.

51.1 El problema

El modelo “obvio”: documents.content se sobrescribe + una tabla edits con los cambios. Falla dos veces: (1) el save parcial — la red cae entre el INSERT del edit y el UPDATE del content → el documento queda a mitad, inconsistente; (2) el restore — “volver a hace 40 cambios” = re-aplicar 40 diffs esperando que ninguno esté corrupto. Necesitamos que guardar sea una operación atómica y restaurar sea una lectura, no una replay.

51.2 Cómo lo resuelve un equipo

Un equipo serio copia el modelo que ya ganó — git interno:

  • revisions(document_id, rev, snapshot, author_id, created_at) — la historia es append-only (cada guardado una fila nueva, jamás un UPDATE).
  • documents.current_revision — el puntero HEAD: qué versión es la actual.
  • Guardar = una transacción: INSERT revision(rev = current+1) + UPDATE documents SET current_revision = rev — todo o nada (cap. 13), con SELECT ... FOR UPDATE para que dos pestañas del mismo usuario no escriban el mismo rev.
  • Undo/redo/restore = UPDATE documents SET current_revision = $rev — mover el puntero, O(1), sin tocar la historia. Editar tras undo = truncar el “futuro” — exactamente git commit tras un reset.

Un commit es una foto guardada del código con un mensaje que dice por qué cambió. La historia del proyecto es una cadena de commits: puedes volver a cualquiera, ver qué cambió y quién lo hizo.

51.3 Conceptos nuevos

  • D Snapshot + diff + puntero: documents, revisions, current_revision
  • D Guardado atómico: transacciones + SELECT FOR UPDATE contra doble-submit y pestañas
  • U Undo/redo/restore como operaciones del servidor: mover el puntero, truncar el futuro
  • D JSONB para datos que evolucionan: cuándo es decisión y cuándo pereza
  • U El patrón de Google Docs / Figma / AutoCAD web — la analogía con git es exacta

51.4 La explicación visual

revisions (append-only)          documents
┌──────────────────────────┐    ┌─────────────────────────┐
│ doc 7 · rev 1 · {A}      │    │ id 7                    │
│ doc 7 · rev 2 · {A,B}    │    │ current_revision: 2 ────┼──→ rev 2
│ doc 7 · rev 3 · {A,B,C}  │    │ content: {A,B,C} (cache)│
└──────────────────────────┘    └─────────────────────────┘
   undo → puntero a 1 · editar tras undo → DELETE rev > 1 (truncate future)
   — igual que `git reset` + commit nuevo —

El snapshot es el estado completo en ese rev: restaurar es leer una fila. Los diffs existen como optimización de transporte (qué mandar por WebSocket), nunca como fuente de verdad.

WebSocket es una conexión que queda viva: en vez de preguntar- responder-cerrar como HTTP, ambos pueden hablar cuando quieran. Es como el servidor puede empujar — por eso sirve para chat y colaboración en vivo.

51.5 Implementación

El guardado atómico — el corazón de Nexus en los tres stacks:

async function saveRevision(userId: number, docPublicId: string, content: unknown) {
  return await pool.tx(async (t) => {
    const { rows } = await t.query(
      `SELECT d.id, d.current_revision FROM documents d
       JOIN projects p ON p.id = d.project_id
       JOIN team_members m ON m.team_id = p.team_id
       WHERE d.public_id = $1 AND m.user_id = $2 AND m.role IN ('admin','editor')
       FOR UPDATE OF d`,                          // lock: two tabs can't race the same rev
      [docPublicId, userId]);
    if (!rows[0]) throw new NotFoundError("document");
    const rev = rows[0].current_revision + 1;
    await t.query(
      `DELETE FROM revisions WHERE document_id = $1 AND rev > $2`,   // truncate future
      [rows[0].id, rows[0].current_revision]);
    await t.query(
      `INSERT INTO revisions (document_id, rev, snapshot, author_id) VALUES ($1,$2,$3,$4)`,
      [rows[0].id, rev, JSON.stringify(content), userId]);
    await t.query(
      `UPDATE documents SET current_revision = $1, content = $2 WHERE id = $3`,
      [rev, JSON.stringify(content), rows[0].id]);
    return { rev };                                // commit: all or nothing
  });
}
def save_revision(user_id: int, doc_public_id: str, content: dict) -> int:
    with pool.connection() as conn:
        with conn.transaction():                      # BEGIN
            row = conn.execute("""
                SELECT d.id, d.current_revision FROM documents d
                JOIN projects p ON p.id = d.project_id
                JOIN team_members m ON m.team_id = p.team_id
                WHERE d.public_id = %s AND m.user_id = %s
                  AND m.role IN ('admin','editor')
                FOR UPDATE OF d""",                    # lock against racing tabs
                (doc_public_id, user_id)).fetchone()
            if not row: raise NotFoundError("document")
            rev = row["current_revision"] + 1
            conn.execute(
                "DELETE FROM revisions WHERE document_id=%s AND rev>%s",
                (row["id"], row["current_revision"]))  # truncate future
            conn.execute(
                "INSERT INTO revisions (document_id,rev,snapshot,author_id)"
                " VALUES (%s,%s,%s,%s)",
                (row["id"], rev, Jsonb(content), user_id))
            conn.execute(
                "UPDATE documents SET current_revision=%s, content=%s WHERE id=%s",
                (rev, Jsonb(content), row["id"]))
            return rev                                 # commit on exit
func saveRevision(ctx context.Context, userID int64, docPublicID string, content []byte) (int, error) {
    tx, err := pool.Begin(ctx)
    if err != nil { return 0, err }
    defer tx.Rollback(ctx)                             // no-op after Commit

    var docID int64
    var cur int
    err = tx.QueryRow(ctx, `
        SELECT d.id, d.current_revision FROM documents d
        JOIN projects p ON p.id = d.project_id
        JOIN team_members m ON m.team_id = p.team_id
        WHERE d.public_id = $1 AND m.user_id = $2
          AND m.role IN ('admin','editor')
        FOR UPDATE OF d`, docPublicID, userID).Scan(&docID, &cur)
    if errors.Is(err, pgx.ErrNoRows) { return 0, ErrNotFound }
    if err != nil { return 0, err }

    if _, err = tx.Exec(ctx,
        "DELETE FROM revisions WHERE document_id=$1 AND rev>$2", docID, cur); err != nil {
        return 0, err                                  // truncate future
    }
    rev := cur + 1
    if _, err = tx.Exec(ctx,
        "INSERT INTO revisions (document_id,rev,snapshot,author_id) VALUES ($1,$2,$3,$4)",
        docID, rev, content, userID); err != nil { return 0, err }
    if _, err = tx.Exec(ctx,
        "UPDATE documents SET current_revision=$1, content=$2 WHERE id=$3",
        rev, content, docID); err != nil { return 0, err }
    return rev, tx.Commit(ctx)                         // all or nothing
}

Y restaurar — la operación más elegante del sistema:

-- restore: move the pointer. O(1). No replay, no corruption possible.
UPDATE documents d SET current_revision = $2
WHERE d.public_id = $1
  AND EXISTS (SELECT 1 FROM revisions r
              WHERE r.document_id = d.id AND r.rev = $2);
-- then content = SELECT snapshot FROM revisions WHERE rev = $2
Implementa el servicio de versionado de documentos de Nexus en
[Express+pg/FastAPI+psycopg/Go+pgx]. Modelo: revisions(document_id,
rev, snapshot JSONB, author_id) append-only + documents.current_revision
como puntero. Requisitos: saveRevision en una transacción con SELECT
FOR UPDATE del documento (dos pestañas no pueden escribir el mismo
rev), DELETE de revisiones futuras al editar tras undo, UPDATE del
puntero + cache de contenido en la misma tx, restore como UPDATE del
puntero (O(1), sin replay), permiso editor+ verificado EN la query, y
tests: save concurrente → revs secuenciales sin duplicados, editar tras
undo trunca el futuro, restore mueve el puntero sin tocar la historia.

51.6 ¿Por qué cada stack lo hace así?

Lo único que necesitas llevarte: la transacción hace el trabajo — lock + truncate + insert + move pointer es UN acto atómico en los tres stacks. Cambia el mecanismo (pool.tx/conn.transaction/tx), jamás el diseño.

Una transacción es un grupo de operaciones que se confirman juntas o no se confirma ninguna: transferir dinero = restar de A y sumar a B. Si falla a la mitad sin transacción, el dinero desapareció.

Un pool es el staff de conexiones a la BD: abrir una conexión por request es caro, así que el pool mantiene ~10–20 abiertas y las presta. El request la usa, la devuelve, y la siguiente la reutiliza.

Modelo Restore Espacio Corrupción posible
Solo diffs (event sourcing) Replay N eventos — O(N) y cada diff debe estar sano Mínimo Sí — un diff roto rompe todo lo posterior
Solo snapshot actual No hay historia Mínimo El undo no existe
Snapshot por rev + puntero Leer una fila — O(1) Más disco (barato) No — cada rev es autónoma

JSONB para el snapshot es decisión, no pereza: el contenido del documento tiene forma variable y evoluciona (hoy capas, mañana comentarios) — pero los metadatos del versionado son relacionales puros (rev, author, puntero). JSONB para el contenido; relacional para el control — la misma respuesta de cap-08 y Ap. K decisión 11.

51.7 Errores comunes

Error Por qué pasa Fix
Save como dos operaciones sueltas INSERT luego UPDATE fuera de tx Una transacción — la caída a mitad no existe
rev calculado en el cliente “El frontend manda rev 15” rev = current_revision + 1 calculado en el servidor bajo lock
Editar tras undo sin truncar El “futuro” viejo convive con el nuevo DELETE rev > current antes del insert — una sola línea temporal
Diffs como fuente de verdad “Para ahorrar espacio” Snapshot completo — los diffs son transporte, no historia
Save sin verificar rol en la query if user.role == 'editor' en memoria El permiso va EN el SQL — la query devuelve 0 filas → 404

51.8 Buenas prácticas

  • La historia es append-only: nunca UPDATE a una revisión — la historia que se puede editar no es historia, es rumor.
  • SELECT FOR UPDATE solo del documento (FOR UPDATE OF d) — bloquear toda la tabla es la forma lenta de hacer lo mismo.
  • El puntero y el cache viven juntos: current_revision + content en el mismo UPDATE — el doc siempre muestra el estado del rev apuntado.
  • revisions particionable: cuando crezca, se pagina por rev y se archiva — el diseño append-only ya lo permite sin cambios.

51.9 Ejercicio

  1. Dos pestañas del mismo usuario guardan a la vez: sin FOR UPDATE, ¿qué rev escriben ambas y qué queda en la tabla? ¿Por qué el lock lo evita?
  2. ¿Por qué DELETE FROM revisions WHERE rev > current y no un flag is_future? ¿Qué rompe soft-delete aquí?
  3. El cliente pide “dame el diff entre rev 12 y 14”. Tienes snapshots completos. ¿Cómo respondes sin un sistema de diffs?

51.10 Mini reto

Explicar por qué snapshot completo y no solo diffs (O(1) restore vs replay).

Ejercicio.

  1. Ambas leen current_revision = 14 y ambas escriben rev 15 — dos filas con el mismo rev (o la segunda choca con un UNIQUE y el usuario ve un error fantasma). Con FOR UPDATE: la segunda espera a que la primera haga commit, lee 15, escribe 16 — secuencia real, sin carrera.
  2. El flag deja el “futuro” en la tabla: queries que deben recordar filtrarlo, historia hinchada con ramas muertas. Git tampoco soft- deletea el futuro: lo elimina. DELETE es honesto — la línea temporal divergente deja de existir.
  3. Computas el diff al servir: jsonb permite comparar campos, o devuelves ambos snapshots al cliente para que los compare (el diff es presentación, no almacenamiento). Guardar el estado, derivar el delta — nunca al revés.

Mini reto. Restore con solo-diffs = leer rev objetivo hacia atrás aplicando inversas, o desde un checkpoint + replay — O(distancia), frágil si cualquier diff se corrompió, y más código. Restore con snapshot = SELECT snapshot WHERE rev = $1 — O(1), indestructible, una línea de SQL. El espacio extra es el precio; el disco es lo más barato del sistema.

Repaso de entrevista: las preguntas de este tema viven en el Apéndice H (bases de datos).

51.11 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.

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.

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”.

Asíncrono = empezar algo sin esperar sentado a que termine: pides la pizza (async) y sigues trabajando; cuando llega, te avisan. Lo opuesto a síncrono (esperar parado). Vital cuando la espera es larga: red, disco, bases de datos.

Un diccionario (dict en Python, map en Go, objeto en JS) guarda parejas clave→valor: {"ana": 30}. Es la estructura que más aparece en JSON — un objeto JS es un diccionario.

Concurrencia = manejar muchas cosas en progreso (atender 1000 requests intercalando). Paralelismo = ejecutar varias a la vez en varios núcleos. Un camarero con 10 mesas es concurrente; 10 camareros son paralelos.

Git es el historial de tu proyecto: cada commit es una foto del código con mensaje. Permite volver atrás, trabajar en ramas paralelas y fusionar el trabajo de varias personas sin pisarse.

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.

Un JOIN combina tablas por su relación: “cada tarea con el nombre de su proyecto”. Es la superpotencia relacional — en una query traes lo que en NoSQL serían varios viajes.

La autorización responde “¿qué puedes hacer?”: eres usuario válido (autenticado), pero ¿puedes borrar esta tarea? Se decide por rol o por ownership — y se verifica en cada request, no se recuerda.

Un caché es una copia rápida de algo costoso de obtener: el resultado de una query pesada, la sesión. Redis es el caché estándar. La regla de oro: cachear es fácil, invalidar (saber cuándo el caché ya no vale) es lo difícil.

51.12 Lo que deberías saber hacer ahora