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.
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
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
idbefindet sich direkt im Inhalt der Datei – dadurch sind Neuveröffentlichungen sicher. Von der Datenbank generierte Primärschlüssel sind lediglich Implementierungsdetails; ein stabiler Identifikator wiejs-closures-loop-001ist 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,JSoderjavascriptnicht in verschiedene Dateien eindringen können. .refine()kümmert sich um Validierungsregeln, die mehrere Felder betreffen – etwas, was eine einfachemin()-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:
questionsFileSchema.parse(...)verhindert, dass fehlerhaftes JSON überhaupt in die Datenbank gelangt.findUnique({ where: { externalId: q.id } })vergleicht anhand der eigenen ID des Inhalts, nicht anhand der internen Primärschlüssel der Datenbank.- 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
mainbefindet, wird verwendet. Wenn Sie Entwürfe benötigen, müssen Sie diese mit Branches abbilden.
questions.json-Datei.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
- Häufige Fehler bei JavaScript und TypeScript, die den Code heimlich zerstören – Erklärt subtile Probleme bei JavaScript und TypeScript, von NaN-Vergleichen über asynchrone Zeitplanung bis hin zur Typumwandlung, die trotz scheinbar korrekter Ausführung zu Fehlern führen.
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.