Inicio / Artículos / Trata el contenido como código: un pipeline de siembra de Git a Postgres

Trata el contenido como código: un pipeline de siembra de Git a Postgres

Muestra cómo reemplazar un CMS por JSON controlado con Git, validación con Zod y operaciones upsert de Prisma para introducir de forma segura contenido estructurado en Postgres.

2258 palabras

Imagínese crear una aplicación de cuestionarios con preguntas de opción múltiple, fragmentos de código incrustados, explicaciones y calificaciones de dificultad. Este tipo de contenido cambia con bastante frecuencia, pero no de una manera que exija ediciones en tiempo real a horas inusuales.

La opción obvia inicial podría ser un CMS como Sanity o Strapi. Pero antes de optar por uno, es útil detallar los requisitos reales:

  • Un historial completo de todos los cambios realizados en el contenido
  • La posibilidad de revisar los cambios antes de que lleguen a producción
  • Una validación que interrumpa el proceso de compilación en lugar de afectar la producción
  • No se necesita infraestructura adicional para un MVP
  • Un flujo de trabajo que refleje la forma en que ya envía código

Dadas esas necesidades, almacenar el contenido directamente en Git tiene más sentido que añadir un CMS.

El enfoque utiliza archivos JSON, esquemas Zod, un script de semilla Prisma y PostgreSQL para el almacenamiento en tiempo de ejecución. El flujo es el siguiente:

JSON → Zod → Semilla (upsert) → Postgres → API

Se hace intencionadamente poco interesante. Un flujo aburrido es uno en el que se puede confiar.

¿Por qué no “simplemente usar un CMS”?

Las plataformas CMS demuestran su utilidad cuando personas no técnicas publican contenido a diario, cuando se necesitan estados de borrador y roles de permisos, o cuando la estructura de los datos cambia de forma impredecible.

Pero para contenido estructurado que redactan los propios ingenieros — bancos de preguntas, datos de semilla, flujos de onboarding, niveles de precios — incorporar un CMS suele añadir:

  • Otro servicio que hay que alojar y proteger
  • Otro esquema con el que se debe mantener la coherencia en la aplicación
  • Otra brecha por la que pueden colarse datos inválidos
  • Otro cambio de contexto lejos de su editor
  • Lo que realmente se necesitaba no era una plataforma de publicación, sino un pipeline de contenido:

    autor → validar → revisar → desplegar → sembrar → servir

    Git ya gestiona los primeros cuatro pasos. La única pieza que faltaba era una forma fiable de importar contenido a la base de datos.

    La arquitectura

    content/
      questions/
        javascript/
          easy.json
          medium.json
          hard.json
        html/
          easy.json
    packages/db/
      prisma/schema.prisma
      src/seed.ts              ← read, validate, upsert
    packages/shared/
      schemas/question.ts      ← Zod contract
    scripts/
      validate-content.ts      ← CI, no DB required
    
    | Layer        | Responsibility                      |
    |--------------|-------------------------------------|
    | JSON         | Human-editable source of truth      |
    | Zod          | Runtime validation + inferred types |
    | Prisma seed  | Idempotent import into the database |
    

    La regla innegociable aquí: su aplicación nunca debe leer archivos JSON en tiempo de ejecución en producción. JSON solo existe como entrada en el momento del despliegue. Postgres sigue siendo la capa que realmente procesa las consultas.

    Esto le brinda los beneficios del flujo de trabajo de Git sin convertir su base de datos en algo que solo proxye archivos.

    Paso 1: Comience con Zod, no con JSON

    Antes de escribir cualquier contenido, defina el contrato que debe cumplir.

    import { z } from 'zod';
    
    export enum Topic {
      JavaScript = 'JAVASCRIPT',
      HTML = 'HTML',
      TypeScript = 'TYPESCRIPT',
    }
    
    export enum Difficulty {
      Easy = 'EASY',
      Medium = 'MEDIUM',
      Hard = 'HARD',
    }
    
    export const questionSchema = z
      .object({
        id: z.string().min(1), // stable slug: js-closures-loop-001
        topic: z.nativeEnum(Topic),
        subtopic: z.string().min(1),
        difficulty: z.nativeEnum(Difficulty),
        text: z.string().min(1),
        codeSnippet: z.string().nullable().optional(),
        options: z.array(z.string().min(1)).min(2),
        correctOptionIndex: z.number().int().min(0),
        explanation: z.string().min(1),
      })
      .refine((q) => q.correctOptionIndex < q.options.length, {
        message: 'correctOptionIndex must point to a valid option',
      });
    
    export const questionsFileSchema = z.array(questionSchema);
    export type QuestionContent = z.infer<typeof questionSchema>;
    

    Algunas decisiones de diseño intencionales destacan:

    • El campo id se encuentra dentro del propio archivo de contenido; esto es lo que hace seguras las nuevas implementaciones. Las claves primarias generadas por la base de datos son solo detalles de implementación; un identificador estable como js-closures-loop-001 es lo que realmente determina el progreso guardado por el usuario.
    • Se utilizan enums en lugar de cadenas simples, lo que evita que existan diferencias de mayúsculas y minúsculas como js, JS o javascript en diferentes archivos.
    • .refine() gestiona las reglas de validación que abarcan varios campos; algo que una simple restricción min() no puede expresar, como mantener un índice de respuesta dentro de los límites permitidos.
    • Cada esquema valida todo un archivo JSON como un único array, y no registro por registro.

    El resultado es que su contenido cuenta con un contrato vinculante, y no solo con una convención documentada en algún lugar que nadie lee.

    Paso 2: Crear JSON aburrido

    [
      {
        "id": "js-closures-loop-001",
        "topic": "JAVASCRIPT",
        "subtopic": "closures",
        "difficulty": "MEDIUM",
        "text": "What will this code log?",
        "codeSnippet": "for (var i = 0; i < 3; i++) {\n  setTimeout(() => console.log(i), 0);\n}",
        "options": ["0 1 2", "3 3 3", "undefined undefined undefined", "0 0 0"],
        "correctOptionIndex": 1,
        "explanation": "`var` is function-scoped, so by the time the timeouts run, `i` is 3."
      }
    ]
    

    El formato es deliberadamente sencillo: tipos inequívocos, diferencias claras y sin debates sobre casos límite de análisis. Si los autores de contenido eventualmente quieren escribir en Markdown o YAML, puede generar JSON a partir de esos formatos en un paso previo a la compilación; el propio script inicial debe mantenerse simple y predecible.

    En cuanto al texto enriquecido, guarde la cadena de origen sin procesar en la base de datos — ya sea en Markdown, texto plano o cualquier formato con el que se sientan cómodos los autores — y réstelo donde la aplicación lo muestre. Renderizarlo como HTML en el momento inicial lo ata a una biblioteca de renderizado específica y genera problemas de migración si decide cambiarla. Mantenga el contenido almacenado tal como está, y réstelo solo donde sea realmente necesario.

    Paso 3: Sembrar con upserts, no con borrar todo

    Mientras aún no tengas usuarios reales, está bien borrar la tabla con deleteMany y volver a llenarla mediante createMany. Una vez que los registros de usuarios comiencen a hacer referencia a filas de contenido, cambia a upserts basados en un identificador estable.

    Un modelo Prisma simplificado:

    model Question {
      id                 String   @id @default(cuid())
      externalId         String   @unique
      topic              Topic
      subtopic           String
      difficulty         Difficulty
      text               String
      codeSnippet        String?
      options            String[]
      correctOptionIndex Int
      explanation        String
    }
    

    El script de sembrado:

    import fs from 'node:fs/promises';
    import path from 'node:path';
    import { PrismaClient } from '@prisma/client';
    import { questionsFileSchema, type QuestionContent } from '@myapp/shared';
    
    const prisma = new PrismaClient();
    const CONTENT_DIR = path.resolve(__dirname, '../../../content/questions');
    
    async function loadQuestionsFromDisk(): Promise<QuestionContent[]> {
      const rows: QuestionContent[] = [];
      const topicDirs = await fs.readdir(CONTENT_DIR, { withFileTypes: true });
    
      for (const topicDir of topicDirs) {
        if (!topicDir.isDirectory()) continue;
    
        const dirPath = path.join(CONTENT_DIR, topicDir.name);
        const files = (await fs.readdir(dirPath)).filter((f) => f.endsWith('.json'));
    
        for (const file of files) {
          const raw = await fs.readFile(path.join(dirPath, file), 'utf8');
          const questions = questionsFileSchema.parse(JSON.parse(raw));
          rows.push(...questions);
        }
      }
    
      return rows;
    }
    
    async function main() {
      const questions = await loadQuestionsFromDisk();
      let created = 0;
      let updated = 0;
    
      for (const q of questions) {
        const data = {
          topic: q.topic,
          subtopic: q.subtopic,
          difficulty: q.difficulty,
          text: q.text,
          codeSnippet: q.codeSnippet ?? null,
          options: q.options,
          correctOptionIndex: q.correctOptionIndex,
          explanation: q.explanation,
        };
    
        const existing = await prisma.question.findUnique({
          where: { externalId: q.id },
        });
    
        if (existing) {
          await prisma.question.update({ where: { externalId: q.id }, data });
          updated++;
        } else {
          await prisma.question.create({ data: { externalId: q.id, ...data } });
          created++;
        }
      }
    
      console.log(`Seeded: ${created} created, ${updated} updated`);
    }
    
    main()
      .catch((err) => {
        console.error(err);
        process.exit(1);
      })
      .finally(() => prisma.$disconnect());
    

    Tres detalles son importantes aquí:

    1. questionsFileSchema.parse(...) impide que el JSON mal formado llegue a la base de datos.
    2. findUnique({ where: { externalId: q.id } }) realiza la búsqueda con el ID propio del contenido, no con la clave primaria interna de la base de datos.
    3. Al actualizar en lugar de borrar, se conservan las respuestas o el progreso que el usuario ya tenga asociados a ese registro.

    Puede preguntarse por qué no simplemente llamar a createMany({ skipDuplicates: true }). Esa opción evita filas duplicadas, pero deja el texto obsoleto sin modificar cuando cambia el contenido. Para sincronizar el contenido en el momento del despliegue, escribir inserciones explícitas con actualizaciones es más transparente y funciona en todos los motores de base de datos. Solo recurra a una instrucción INSERT ... ON CONFLICT DO UPDATE sin procesamiento adicional después de que un análisis demuestre que realmente necesita esa velocidad.

    Paso 4: Fallar rápidamente en CI

    No necesita una base de datos para validar el contenido. Ejecute esto en cada solicitud de integración que modifique la carpeta content/:

    // scripts/validate-content.ts
    import fs from 'node:fs/promises';
    import { glob } from 'glob';
    import { questionsFileSchema } from '@myapp/shared';
    
    const files = await glob('content/**/*.json');
    let failed = 0;
    
    for (const file of files) {
      try {
        const raw = await fs.readFile(file, 'utf8');
        questionsFileSchema.parse(JSON.parse(raw));
        console.log(`✓ ${file}`);
      } catch (err) {
        console.error(`✗ ${file}`, err);
        failed++;
      }
    }
    
    process.exit(failed > 0 ? 1 : 0);
    
    # .github/workflows/validate-content.yml
    name: Validate content
    on:
      pull_request:
        paths: ['content/**']
    
    jobs:
      validate:
        runs-on: ubuntu-latest
        steps:
          - uses: actions/checkout@v4
          - uses: pnpm/action-setup@v2
          - run: pnpm install
          - run: pnpm tsx scripts/validate-content.ts
    

    El contenido defectuoso hace que la solicitud de integración falle; nunca se envía en silencio.

    El despliegue se realiza de la siguiente manera:

    npx prisma migrate deploy
    npx prisma db seed
    

    Las actualizaciones de contenido se convierten en un lanzamiento normal: fusión, migración e inicialización.

    Paso 5: Cuando cambie su esquema

    Tarde o temprano, tu esquema Zod evolucionará. Quizás agregues un campo hint. Quizás cambies el nombre de codeSnippet a code. Quizás options pase de ser un array de cadenas a un array de objetos.

    Cualquiera que sea el cambio, tus archivos JSON existentes dejarán de coincidir con el esquema. La script inicial fallará. No entres en pánico: trata los cambios en el esquema del contenido exactamente como lo harías con las migraciones de base de datos.

    Comienza versionando el esquema en sí:

    // schemas/question.v1.ts — old shape
    // schemas/question.v2.ts — new shape
    // schemas/question.ts   — export latest as `questionSchema`
    

    Luego ejecuta un codemod único sobre los archivos del disco:

    // scripts/codemod-questions-v2.ts
    import fs from 'node:fs/promises';
    import { glob } from 'glob';
    import { z } from 'zod';
    import { questionSchemaV1 } from '@myapp/shared/schemas/question.v1';
    
    const v1File = z.array(questionSchemaV1);
    
    for (const file of await glob('content/**/*.json')) {
      const old = v1File.parse(JSON.parse(await fs.readFile(file, 'utf8')));
    
      const next = old.map((q) => ({
        ...q,
        hint: null,
        code: q.codeSnippet,
        codeSnippet: undefined,
      }));
    
      await fs.writeFile(file, JSON.stringify(next, null, 2));
    }
    

    Ejecuta el codemod, guarda los JSON actualizados, dirige la script inicial hacia el esquema v2 y despliega. Ese es todo el proceso: codemod, guardar cambios, actualizar y publicar.

    Es la misma disciplina que ya aplicas con prisma migrate, y da los mismos resultados.

    Qué ganas

    La revisión de contenido se convierte en una revisión de código. “¿Es esta explicación realmente correcta?” pasa a ser una solicitud de integración con una diferencia visible en lugar de un mensaje en Slack.

    Obtienes validación de forma gratuita, sin necesidad de crear un analizador personalizado. Zod detecta errores tipográficos en los enumerados, campos obligatorios faltantes e índices fuera del rango; el propio esquema funciona como analizador.

    Saltas por completo la complejidad de un CMS. No hay panel de administración que construir, ni autenticación separada para editores, ni destino de despliegue adicional que mantener, especialmente cuando las personas que escriben el contenido son los mismos ingenieros que envían el código.

    Los entornos siguen siendo reproducibles. Clona el repositorio, ejecuta las migraciones y la inicialización, y obtendrás siempre un banco de preguntas idéntico en cada máquina.

    Los cambios en masa se convierten en scripts en lugar de clics manuales. Cambiar la etiqueta de cuarenta preguntas de MEDIUM a HARD se hace con una orden sed de una sola línea o un script breve, en lugar de realizar cuarenta ediciones individuales a través de la interfaz de administración.

    El progreso del usuario se mantiene tras las actualizaciones. Dado que utiliza identificadores estables junto con operaciones de inserción o actualización, puede corregir un error tipográfico en una pregunta sin interrumpir el enlace con las respuestas que los usuarios ya enviaron.

    Qué se sacrifica

    Vale la pena ser transparente respecto a los compromisos:

    • Las personas que no son ingenieros generalmente no disfrutarán trabajando con Git. Si los editores no técnicos necesitan contribuir, será necesario contar con una ruta de importación en CSV, una herramienta interna o un CMS sin interfaz gráfica que exporte a JSON.
    • No existe un flujo de trabajo integrado entre borradores y versiones publicadas. Lo que se encuentra en main es lo que se utiliza como base. Si necesita borradores, tendrá que gestionarlos mediante ramas.
  • Los recursos multimedia no deben encontrarse dentro de los archivos JSON. Las imágenes y los videos deben almacenarse en sistemas de almacenamiento de objetos, a los que se hace referencia mediante URL.
  • Los conflictos al fusionar contenido son un riesgo real cuando varias personas editan el mismo material. Para mitigarlo, divida el contenido en archivos pequeños —organizados por tema o nivel de dificultad— en lugar de tener un único archivo enorme llamado questions.json.
  • Cualquier cambio en el contenido en producción requiere una implementación. Si su caso de uso exige actualizaciones de contenido sin necesidad de volver a implementar, este enfoque no es la opción adecuada.
  • Cuándo usarlo

    Este patrón es adecuado cuando su contenido está estructurado y repetitivo, su equipo de ingeniería es pequeño, se encuentra entre la fase MVP y la producción inicial, los cambios en el contenido deben ser rastreables, y ya está utilizando Prisma.

    No funciona bien cuando hay editores no técnicos que publican a diario, cadenas de aprobación complejas, una gran dependencia de los medios o la necesidad de editar contenido en vivo directamente en producción.

    Conclusión

    Lo que realmente se necesitaba no era un CMS, sino contenido sometido al mismo estándar que el código: versionado, validado, revisado e implementado a través de una infraestructura ya confiable.

    JSON en Git es la fuente de verdad. Zod es el controlador de acceso. El paso seed de Prisma es el cargador. Los IDs estables son lo que mantiene intactos los datos de los usuarios a medida que el contenido cambia debajo de ellos.

    Si aún no hay usuarios, comience con un método sencillo de inicialización mediante borrado y recarga. Pásese a las operaciones upsert en cuanto los datos de progreso comiencen a ser importantes. Añada validación CI antes de que un error tipográfico en un enum llegue a la fase de pruebas. Versione su esquema de contenido antes de introducir su segunda modificación que cause problemas.

    Toda la configuración está diseñada intencionadamente para no ser llamativa, y ese es precisamente el objetivo. Reserve la emoción para el producto en sí, no para el sistema encargado de almacenar sus preguntas.

    Leer más

  • Sustituyendo any de TypeScript: Seis patrones seguros de tipos para casos comunes — Aprenda alternativas prácticas y seguras de tipo a any de TypeScript, incluyendo valores desconocidos, genericos, uniones discriminadas y verificaciones exhaustivas, para manejar datos impredecibles.
  • Migrando de Prisma a Drizzle: Una retrospectiva de seis meses — Un desarrollador comparte resultados reales y consideraciones al cambiar una pila de TypeScript con PostgreSQL de Prisma a Drizzle ORM.
  • Desplegando NestJS en Bun y Prisma 7 en Cloud Run sin los errores de construccion — Un pipeline funcional de GitHub Actions para enviar una aplicación NestJS en Bun con Prisma 7 y Neon a Cloud Run, además de las soluciones para Docker y las conexiones que causan problemas a los equipos.