Startseite / Artikel / Inhalt als Code behandeln: Ein Pipelinesystem für das Einspielen von Daten von Git in Postgres

Inhalt als Code behandeln: Ein Pipelinesystem für das Einspielen von Daten von Git in Postgres

Zeigt, wie man ein CMS durch JSON ersetzen kann, das mit Git verfolgt wird, sowie durch Zod-Validierung und Prisma-Upserts, um strukturierte Inhalte sicher in Postgres einzufügen.

2258 Wörter

Stellen Sie sich die Erstellung einer Quiz-App vor, die Multiple-Choice-Fragen, eingebettete Codeausschnitte, Erklärungen sowie Schwierigkeitsgrade enthält. Diese Art von Inhalt ändert sich recht häufig, aber nicht in einem Maße, das eine Echtzeit-Änderung zu ungewöhnlichen Zeiten erfordert.

Die offensichtliche erste Wahl könnte ein CMS wie Sanity oder Strapi sein. Doch bevor man sich dafür entscheidet, ist es hilfreich, die tatsächlichen Anforderungen zu klären:

  • Eine vollständige Änderungshistorie für jede am Inhalt vorgenommene Änderung
  • Die Möglichkeit, Änderungen vor ihrem Einsatz in der Produktion zu überprüfen
  • Eine Validierung, die den Build stört anstatt die Produktion
  • Keine zusätzliche Infrastruktur für ein MVP erforderlich
  • Ein Workflow, der dem entspricht, wie Sie bereits Code veröffentlichen

Angesichts dieser Anforderungen macht es mehr Sinn, den Inhalt direkt in Git zu speichern, anstatt ein CMS hinzuzufügen.

Der Ansatz verwendet JSON-Dateien, Zod-Schemata, ein Prisma-Seed-Skript sowie PostgreSQL für die Laufzeitspeicherung. Der Pipeline-Ablauf sieht wie folgt aus:

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

Er ist absichtlich unspektakulär. Eine langweilige Pipeline ist eine, der man vertrauen kann.

Warum nicht einfach ein CMS verwenden?

CMS-Plattformen erweisen sich als nützlich, wenn nicht-technische Nutzer täglich Inhalte veröffentlichen, wenn man Entwurfszustände und Berechtigungsrollen benötigt oder wenn die Struktur der Daten unvorhersehbar ändert.

Aber für strukturierte Inhalte, die von Ingenieuren selbst erstellt werden – Quizbanken, Seed-Daten, Onboarding-Flüsse, Preisstufen – führt der Einsatz eines CMS in der Regel zu folgenden Problemen:

  • Noch ein Service, den man hosten und sichern muss
  • Noch ein Schema, das mit der Anwendung abgestimmt werden muss
  • Noch eine Stelle, an der ungültige Daten eindringen können
  • Noch ein Kontextwechsel weg vom Editor
  • Was tatsächlich benötigt wurde, war keine Veröffentlichungsplattform – es handelte sich um einen Inhaltspipeline:

    autor → validieren → überprüfen → bereitstellen → initialisieren → liefern

    Git kümmert sich bereits um die ersten vier Schritte. Das einzige Fehlende war eine zuverlässige Methode, um Inhalte in die Datenbank einzubringen.

    Die Architektur

    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 |
    

    Die unverhandelbare Regel hier: Ihre Anwendung darf unter keinen Umständen laufend JSON-Dateien in der Produktion lesen. JSON existiert nur als Eingabe zum Zeitpunkt der Bereitstellung. Postgres bleibt die Schicht, die tatsächlich Abfragen ausführt.

    Dadurch erhalten Sie die Vorteile des Git-Workflows, ohne Ihre Datenbank in etwas zu verwandeln, das lediglich Dateien weiterleitet.

    Schritt 1: Beginnen Sie mit Zod, nicht mit JSON

    Bevor Sie Inhalte schreiben, definieren Sie den Vertrag, den sie erfüllen müssen.

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

    Mehrere bewusste Gestaltungsentscheidungen fallen auf:

    • Das Feld id befindet sich direkt im Inhalt der Datei – dadurch sind Neuveröffentlichungen sicher. Von der Datenbank generierte Primärschlüssel sind lediglich Implementierungsdetails; ein stabiler Identifikator wie js-closures-loop-001 ist es, von dem der gespeicherte Fortschritt eines Benutzers tatsächlich abhängt.
    • Anstelle von rohen Zeichenketten werden Enums verwendet, wodurch inkonsistente Großschreibweisen wie js, JS oder javascript nicht in verschiedene Dateien eindringen können.
    • .refine() kümmert sich um Validierungsregeln, die mehrere Felder betreffen – etwas, was eine einfache min()-Beschränkung nicht ausdrücken kann, wie beispielsweise die Einhaltung der Grenzen für einen Antwortindex.
    • Jedes Schema validiert eine ganze JSON-Datei als einzigen Array, und nicht Record für Record.

    Das Ergebnis ist, dass Ihr Inhalt einen durchsetzbaren Vertrag hat und nicht nur eine irgendwo dokumentierte Konvention, die niemand liest.

    Schritt 2: Langweiliges JSON erstellen

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

    Das Format ist absichtlich schlicht: eindeutige Typen, klare Unterschiede sowie keine Debatten über Randfälle bei der Parsing. Falls Inhaltsersteller später in Markdown oder YAML schreiben möchten, können Sie aus diesen Formaten in einem Vorbereitungs-Schritt JSON erzeugen – das Seed-Skript selbst sollte einfach und vorhersehbar bleiben.

    Für reichen Text sollten Sie den rohen Quelltext in der Datenbank speichern – egal ob als Markdown, reiner Text oder in jedem anderen Format, mit dem sich die Ersteller wohlfühlen – und ihn dort rendern, wo die App ihn anzeigt. Die Renderung zu HTML zum Zeitpunkt der Erstellung bindet Sie an eine bestimmte Rendering-Bibliothek und verursacht Probleme bei einem späteren Wechsel. Behalten Sie den Quelltext in seinem ursprünglichen Zustand und rendern Sie ihn nur dort, wo er tatsächlich benötigt wird.

    Schritt 3: Initialisierung mit Upserts, nicht mit Löschungen

    Da es noch keine echten Benutzer gibt, ist es in Ordnung, die Tabelle mit deleteMany zu leeren und sie anschließend über createMany wieder zu füllen. Sobald Benutzerdaten auf Inhaltsspalten verweisen, wechseln Sie zu Upserts, die anhand eines stabilen Identifikators erfolgen.

    Ein vereinfachtes Prisma-Modell:

    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
    }
    

    Das Initialisierungs-Skript:

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

    Drei Aspekte sind hier wichtig:

    1. questionsFileSchema.parse(...) verhindert, dass fehlerhaftes JSON überhaupt in die Datenbank gelangt.
    2. findUnique({ where: { externalId: q.id } }) vergleicht anhand der eigenen ID des Inhalts, nicht anhand der internen Primärschlüssel der Datenbank.
    3. Durch Aktualisieren anstelle von Löschung bleiben alle Antworten oder Fortschritte, die ein Benutzer bereits mit dieser Datenspur verknüpft hat, erhalten.

    Man könnte sich fragen, warum man nicht einfach createMany({ skipDuplicates: true }) aufruft. Diese Option verhindert doppelte Zeilen, lässt aber bei Inhaltssänderungen unveränderten veralteten Text unberührt. Für die Synchronisierung des Inhalts zum Zeitpunkt der Bereitstellung ist das Schreiben expliziter Upserts transparenter und funktioniert bei allen Datenbankengines. Greifen Sie nur auf eine rohe INSERT ... ON CONFLICT DO UPDATE-Anweisung zurück, nachdem eine Analyse gezeigt hat, dass Sie tatsächlich die Geschwindigkeit benötigen.

    Schritt 4: Schnelles Scheitern in CI

    Man benötigt keine Datenbank, um den Inhalt zu validieren. Führen Sie dies bei jedem Pull Request durch, der content/ betrifft:

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

    Fehlerhafter Inhalt führt zum Scheitern des Pull Requests – er wird niemals stillschweigend bereitgestellt.

    Die Bereitstellung sieht so aus:

    npx prisma migrate deploy
    npx prisma db seed
    

    Inhaltsaktualisierungen werden zu einer gewöhnlichen Veröffentlichung: Merge, Migrieren, Initialisierung.

    Schritt 5: Wenn sich Ihr Schema ändert

    Eher oder später wird Ihr Zod-Schema weiterentwickelt. Vielleicht fügen Sie ein hint-Feld hinzu. Vielleicht ändern Sie den Namen von codeSnippet in code. Vielleicht wechselt options von einem Array von Zeichenketten zu einem Array von Objekten.

    Egal welche Änderung vorgenommen wird – Ihre vorhandenen JSON-Dateien passen dann nicht mehr zum Schema. Das Seed-Skript gibt einen Fehler aus. Panik ist nicht nötig – behandeln Sie Schema-Änderungen genauso wie Datenbank-Migrationen.

    Fangen Sie damit an, das Schema selbst zu versionieren:

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

    Dann führen Sie einmalig einen Codemod über die Dateien auf der Festplatte durch:

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

    Führen Sie den Codemod aus, speichern Sie die aktualisierten JSON-Dateien ab, weisen Sie das Seed-Skript auf das v2-Schema hin und deployen Sie es. Das ist der gesamte Prozess: Codemod, Speichern, Aktualisieren, Veröffentlichung.

    Es handelt sich dabei um dieselbe Disziplin, die Sie bereits mit prisma migrate anwenden, und es bringt dieselben Vorteile.

    Was Sie gewinnen

    Die Inhaltsprüfung wird zur Code-Prüfung. Die Frage „Ist diese Erklärung tatsächlich korrekt?“ wird durch einen Pull Request mit sichtbarem Diff ersetzt – anstelle einer Slack-Nachricht.

    Ihnen steht kostenlose Validierung zur Verfügung, ohne dass Sie einen eigenen Linter entwickeln müssen. Zod erkennt Tippfehler in Enums, fehlende erforderliche Felder sowie Indexwerte außerhalb des zulässigen Bereichs – das Schema selbst fungiert als Linter.

    Sie vermeiden völlig die Overhead eines CMS. Es muss kein Admin-Panel erstellt werden, es gibt keine separate Editor-Autentifizierung und keinen zweiten Deployment-Zielort, der gewartet werden muss – besonders dann, wenn die Personen, die den Inhalt schreiben, dieselben Entwickler sind, die den Code bereitstellen.

    Die Umgebungen bleiben reproduzierbar. Klonen Sie das Repository, führen Sie Migrationsaufgaben aus und starten Sie die Seed-Daten – jedes Mal erhalten Sie auf jedem Gerät dieselbe Fragebank.

    Massenänderungen werden zu Skripten anstelle von manuellen Klicks. Das Umschlagen von vierzig Fragen von MEDIUM auf HARD erfordert einen einzeiligen sed-Befehl oder ein kurzes Skript, anstatt vierzig einzelne Bearbeitungen in der Admin-Oberfläche.

    Der Fortschritt der Benutzer bleibt auch nach Bereitstellung erhalten. Da Sie stabile IDs in Kombination mit Upserts verwenden, können Sie einen Tippfehler in einer Frage korrigieren, ohne die Verbindung zu den bereits von den Benutzern eingereichten Antworten zu unterbrechen.

    Was Sie aufgeben

    Es ist sinnvoll, frühzeitig über die Kompromisse aufzuklären:

    • Menschen, die keine Ingenieure sind, werden im Allgemeinen nicht gerne mit Git arbeiten. Wenn technisch unbedarfte Redakteure beitragen müssen, benötigen Sie einen CSV-Import-Weg, ein internes Tool oder ein headless CMS, das in JSON exportiert.
    • Es gibt keinen integrierten Workflow für Entwürfe im Gegensatz zu veröffentlichten Inhalten. Was sich auf main befindet, wird verwendet. Wenn Sie Entwürfe benötigen, müssen Sie diese mit Branches abbilden.
  • Mediendateien gehören nicht in JSON-Dateien. Bilder und Videos sollten in einem Objektspeicher gespeichert werden und über URLs referenziert werden.
  • Konflikte bei der Zusammenführung entstehen, wenn mehrere Personen Inhalte bearbeiten. Dies lässt sich verringern, indem der Inhalt in kleine Dateien aufgeteilt wird – sortiert nach Thema oder Schwierigkeitsgrad – anstatt in einer einzigen riesigen questions.json-Datei.
  • Jede Änderung am Inhalt in der Produktion erfordert ein Deployment. Wenn Ihr Anwendungsfall Inhaltsupdates ohne Neudeployment erfordert, eignet sich dieser Ansatz nicht.
  • Wann ist es geeignet?

    Dieses Muster eignet sich gut, wenn Ihr Inhalt strukturiert und wiederholend ist, Ihr Entwicklerteam klein ist, Sie sich irgendwo zwischen MVP und früher Produktion befinden, Änderungen am Inhalt nachvollziehbar sein müssen und Sie bereits Prisma verwenden.

    Es eignet sich schlecht, wenn nicht-technische Redakteure täglich Inhalte veröffentlichen, komplexe Genehmigungsprozesse vorliegen, stark auf Medien zurückgegriffen wird oder die direkte Bearbeitung von Live-Inhalten in der Produktion erforderlich ist.

    Fazit

    Was tatsächlich benötigt wurde, war kein CMS – sondern Inhalte, die denselben Standards wie Code unterliegen: versioniert, validiert, überprüft und über bereits vertrauenswürdige Infrastrukturen bereitgestellt.

    JSON in Git ist die Quelle der Wahrheit. Zod ist der Kontrollmechanismus. Prisma’s Seed-Step dient als Lader. Stable IDs sorgen dafür, dass Benutzerdaten unverändert bleiben, auch wenn sich die darunterliegenden Inhalte ändern.

    Falls es noch keine Benutzer gibt, beginnen Sie einfach mit dem Vorgang des Löschen und Neu laden. Wechseln Sie zu Upserts, sobald Fortschrittsdaten von Bedeutung werden. Fügen Sie CI-Validierungen hinzu, bevor fehlerhafte Enum-Typfe überhaupt in die Staging-Umgebung gelangen. Versionieren Sie Ihr Inhaltschema, bevor es zum zweiten kritischen Änderungsfall kommt.

    Die gesamte Konfiguration ist absichtlich unspektakulär – und genau das ist der Punkt. Bewahren Sie die Begeisterung für das Produkt selbst auf, nicht für den Prozess, der dafür zuständig ist, Ihre Fragen zu speichern.

    Verwandte Artikel

  • Ersatz für TypeScript’s any: Sechs typsichere Muster für häufige Fälle – Lernen Sie praktische, typsichere Alternativen zu TypeScript’s any – einschließlich unbekannter Typen, Generiken, diskriminierter Unionen und ausführlicher Überprüfungen – zur Handhabung unvorhersehbarer Daten.
  • Von Prisma zu Drizzle migrieren: Eine Rückblick nach sechs Monaten – Ein Entwickler teilt praktische Vergleiche und Abwägungen aus dem Umgang mit einer PostgreSQL- TypeScript-Architektur, die von Prisma auf Drizzle ORM umgestellt wurde.