Accueil / Articles / Traiter le contenu comme du code : un pipeline de semis Git vers Postgres

Traiter le contenu comme du code : un pipeline de semis Git vers Postgres

Montre comment remplacer un CMS par du JSON suivi par Git, une validation Zod et des opérations upsert Prisma afin d’insérer en toute sécurité du contenu structuré dans Postgres.

2258 mots

Imaginez créer une application de quiz avec des questions à choix multiples, des extraits de code intégrés, des explications et des niveaux de difficulté. Ce type de contenu évolue assez fréquemment, mais pas au point d’exiger une modification en temps réel à des heures inhabituelles.

Le choix évident pourrait être un CMS comme Sanity ou Strapi. Mais avant d’en choisir un, il est utile de définir précisément les besoins :

  • Un historique complet de toutes les modifications apportées au contenu
  • La possibilité d’examiner les changements avant qu’ils ne soient mis en production
  • Une validation qui bloque la compilation plutôt que la production
  • Aucune infrastructure supplémentaire nécessaire pour un MVP
  • Un flux de travail similaire à celui utilisé actuellement pour déployer du code

Au vu de ces exigences, stocker le contenu directement dans Git semble plus logique que d’ajouter un CMS.

Cette approche utilise des fichiers JSON, des schémas Zod, un script de semis Prisma et PostgreSQL pour le stockage en temps de exécution. Le pipeline est le suivant :

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

Il est délibérément peu intéressant. Un pipeline ennuyeux est un pipeline sur lequel on peut compter.

Pourquoi pas « simplement utiliser un CMS » ?

Les plateformes CMS se révèlent utiles lorsque des personnes non techniques publient du contenu quotidiennement, lorsqu’on a besoin d’états de brouillon et de rôles d’autorisation, ou lorsque la structure des données évolue de manière imprévisible.

Mais pour du contenu structuré rédigé par des ingénieurs eux-mêmes — banques de questions, données de démarrage, flux d’onboarding, niveaux de tarification — l’intégration d’un CMS ajoute généralement :

  • Un autre service à héberger et à sécuriser
  • Un autre schéma à maintenir en cohérence avec l’application
  • Une autre faille par laquelle des données invalides peuvent s’infiltrer
  • Un autre changement de contexte loin de votre éditeur
  • Ce dont on avait vraiment besoin, ce n’était pas une plateforme de publication — c’était un pipeline de contenu :

    auteur → validation → révision → déploiement → initialisation → diffusion

    Git gère déjà les quatre premières étapes. Le seul élément manquant était un moyen fiable d’importer le contenu dans la base de données.

    L’architecture

    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 règle absolue ici : votre application ne doit jamais lire de fichiers JSON en temps de exécution en production. Le JSON n’existe que comme entrée au moment du déploiement. Postgres reste la couche qui traite réellement les requêtes.

    Cela vous permet de bénéficier des avantages du flux de travail de Git sans transformer votre base de données en un simple proxy de fichiers.

    Étape 1 : Commencer avec Zod, pas JSON

    Au préalable de rédiger tout contenu, définissez le contrat qu’il doit respecter.

    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>;
    

    Quelques choix de conception délibérés se distinguent :

    • Le champ id se trouve directement dans le fichier de contenu lui-même — c’est ce qui rend les redeploiements sûrs. Les clés primaires générées par la base de données ne sont que des détails d’implémentation ; c’est un identifiant stable comme js-closures-loop-001 qui est réellement utilisé pour enregistrer les progrès de l’utilisateur.
    • Des enums sont utilisés à la place de chaînes brutes, ce qui empêche des majuscules/minuscules incohérentes comme js, JS ou javascript d’apparaître dans différents fichiers.
    • .refine() gère les règles de validation qui concernent plusieurs champs — des choses que une simple contrainte min() ne peut pas exprimer, comme garantir que l’index d’une réponse reste dans les limites autorisées.
    • Chaque schéma valide tout le fichier JSON en tant qu’unique tableau, et non en vérifiant chaque enregistrement séparément.

    Le résultat est que votre contenu dispose d’un contrat contraignant, et non simplement d’une convention documentée quelque part que personne ne lit.

    Étape 2 : Créer du JSON ennuyeux

    [
      {
        "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."
      }
    ]
    

    Le format est délibérément simple : types univoques, différences claires, et pas de débats sur les cas limites de parsing. Si les auteurs de contenu souhaitent un jour écrire en Markdown ou YAML, vous pouvez générer du JSON à partir de ces formats lors d’une étape préalable de construction — le script de base doit rester simple et prévisible.

    Pour le texte enrichi en particulier, stockez la chaîne source brute dans la base de données — que ce soit en Markdown, en texte brut ou dans n’importe quel format avec lequel les auteurs se sentent à l’aise — et affichez-la là où l’application la présente. L’affichage en HTML au moment de la création vous lie à une bibliothèque d’affichage spécifique et provoque des problèmes de migration si vous changez un jour. Gardez la source telle quelle et n’affichez-la que là où elle est réellement nécessaire.

    Étape 3 : Initialiser avec des upserts, pas des suppressions massives

    Puisque vous n’avez pas encore de vrais utilisateurs, il est acceptable de vider la table avec deleteMany puis de la remplir à nouveau via createMany. Dès que les enregistrements d’utilisateurs commencent à faire référence aux lignes de contenu, passez à des upserts basés sur un identifiant stable.

    Un modèle Prisma simplifié :

    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
    }
    

    Le script d’initialisation :

    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());
    

    Trois détails sont importants ici :

    1. questionsFileSchema.parse(...) empêche complètement l’entrée de JSON mal formaté dans la base de données.
    2. findUnique({ where: { externalId: q.id } }) effectue la recherche sur l’ID propre du contenu, et non sur la clé primaire interne de la base de données.
    3. La mise à jour plutôt que la suppression permet de conserver toutes les réponses ou progrès déjà associés à cet enregistrement par l’utilisateur.

    Vous vous demandez peut-être pourquoi ne pas simplement utiliser createMany({ skipDuplicates: true }). Cette option empêche les lignes dupliquées, mais elle laisse le texte obsolète inchangé lorsque le contenu est modifié. Pour synchroniser le contenu au moment du déploiement, écrire des opérations d’insertion/mise à jour explicites est plus transparent et fonctionne avec tous les moteurs de base de données. N’optez pour une instruction INSERT ... ON CONFLICT DO UPDATE brute que si un analyse montre réellement le besoin de vitesse.

    Étape 4 : Échouer rapidement en CI

    Vous n’avez pas besoin d’une base de données pour valider le contenu. Exécutez ceci pour chaque demande de fusion qui touche 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
    

    Un contenu défectueux fait échouer la demande de fusion — il n’est jamais livré en silence.

    Le déploiement se déroule comme suit :

    npx prisma migrate deploy
    npx prisma db seed
    

    Les mises à jour de contenu deviennent une simple publication : fusion, migration, initialisation.

    Étape 5 : Lorsque votre schéma change

    Tôt ou tard, votre schéma Zod évoluera. Peut-être ajouterez-vous un champ hint. Peut-être renommerez-vous codeSnippet en code. Peut-être que options passera d’un tableau de chaînes de caractères à un tableau d’objets.

    Quelle que soit la modification, vos fichiers JSON existants ne correspondront plus au schéma. Le script de démarrage échouera. Ne paniquez pas : traitez les changements de schéma pour le contenu exactement comme des migrations de base de données.

    Commencez par versionner le schéma lui-même :

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

    Puis exécutez un codemod unique sur les fichiers présents sur le disque :

    // 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));
    }
    

    Exécutez le codemod, committez les fichiers JSON mis à jour, pointez le script de démarrage vers le schéma v2, et déploiez. Voilà tout le processus : codemod, commit, mise à jour, déploiement.

    C’est la même discipline que vous appliquez déjà avec prisma migrate, et les résultats sont identiques.

    Quels avantages ?

    La révision du contenu devient une revue de code. La question « Cette explication est-elle vraiment correcte ? » se transforme en demande de fusion avec un diff visible, au lieu d’un message Slack.

    Les environnements restent reproductibles. Clonez le dépôt, exécutez les migrations, lancez la génération de données initiales, et vous obtiendrez à chaque fois un ensemble de questions identique, sur n’importe quel ordinateur.

    Les modifications en masse se transforment en scripts au lieu d’actions manuelles. Réattribuer quarante questions de MEDIUM à HARD se fait grâce à une commande sed en une seule ligne ou à un petit script, plutôt que par quarante modifications individuelles dans l’interface d’administration.

    Les progrès des utilisateurs survivent aux déploiements. Comme vous utilisez des identifiants stables associés à des opérations d’insertion ou de mise à jour, vous pouvez corriger une faute dans une question sans rompre le lien avec les réponses déjà soumises par les utilisateurs.

    Ce que vous sacrifiez

    Il est utile d’être clair sur les compromis à accepter :

    • Les personnes qui ne sont pas ingénieurs n’apprécieront généralement pas travailler avec Git. Si des éditeurs non techniques doivent contribuer, vous aurez besoin d’un moyen d’importation via CSV, d’une outil interne ou d’un CMS sans interface qui exporte en JSON.
    • Il n’existe pas de flux de travail intégré entre les versions brouillon et publiée. Ce qui se trouve sur main est ce qui est utilisé par défaut. Si vous avez besoin de versions brouillon, vous devrez les gérer à l’aide de branches.
  • Les ressources multimédias ne doivent pas se trouver à l’intérieur des fichiers JSON. Les images et les vidéos doivent être stockées dans un système de stockage d’objets, référencées par URL.
  • Les conflits de fusion représentent un risque réel lorsque plusieurs personnes modifient le contenu. Atténuez ce problème en divisant le contenu en petits fichiers — organisés par thème ou niveau de difficulté — plutôt qu’en ayant un seul énorme fichier questions.json.
  • Toute modification du contenu en production nécessite un déploiement. Si votre cas d’usage exige des mises à jour de contenu sans redéploiement, cette approche n’est pas adaptée.
  • Quand l’utiliser

    Cet schéma convient bien lorsque votre contenu est structuré et répétitif, que votre équipe d’ingénierie est petite, que vous êtes entre le stade MVP et la production initiale, que les modifications du contenu doivent être traçables, et que vous utilisez déjà Prisma.

    Cela ne fonctionne pas bien lorsque des éditeurs non techniques publient quotidiennement, que les chaînes d’approbation sont complexes, qu’il y a une forte dépendance aux médias, ou lorsqu’il est nécessaire d’éditer du contenu en direct dans l’environnement de production.

    Le point clé

    Ce dont on avait vraiment besoin, ce n’était pas un CMS — c’était du contenu soumis aux mêmes normes que le code : versionné, validé, examiné et déployé via une infrastructure déjà fiable.

    JSON dans Git constitue la source de vérité. Zod en est le gardien. L’étape seed de Prisma sert à charger les données. Les identifiants stables permettent de conserver l’intégrité des données utilisateur même lorsque le contenu change en dessous.

    Si aucun utilisateur n’existe encore, commencez simplement avec une méthode de génération de données en effaçant puis en rechargement. Passez aux opérations upsert dès que les données de progression deviennent importantes. Ajoutez une validation CI avant qu’un mauvais typo dans un enum n’atteigne l’environnement de pré-production. Versionnez votre schéma de contenu avant d’introduire votre deuxième modification critique.

    Toute cette configuration est délibérément peu attrayante — et c’est justement le but. Réservez l’excitation au produit lui-même, et non au système chargé de stocker vos questions.

    Lectures complémentaires

  • Remplacer any de TypeScript : Six patterns sécurisés pour les cas courants — Découvrez des alternatives pratiques et sécurisées à any de TypeScript — y compris les types inconnus, les génériques, les unions discriminées et les vérifications exhaustives — pour gérer des données imprévisibles.
  • Migrer de Prisma à Drizzle : Un retour sur six mois — Un développeur partage des benchmarks et des compromis tirés de la transition d’un stack TypeScript PostgreSQL de Prisma vers Drizzle ORM.
  • Déployer NestJS sur Bun et Prisma 7 vers Cloud Run sans les erreurs de construction — Un pipeline GitHub Actions fonctionnel pour déployer une application NestJS sur Bun avec Prisma 7 et Neon vers Cloud Run, ainsi que les corrections relatives à Docker et aux connexions qui posent problème aux équipes.