Traktuj treść jak kod: pipeline siewania danych z Git do Postgresa
Treść jako kod: JSON śledzone przez Git, weryfikowane za pomocą Zod i wprowadzane do PostgreSQL przez Prisma, co zastępuje system CMS pipeline’em do seedowania danych, który nie może je uszkodzić.
Załóżmy, że chcemy stworzyć aplikację do quizów z pytaniami wielokrotnego wyboru, fragmentami kodu, wyjaśnieniami oraz oceną poziomu trudności. Tego typu treści zmieniają się dość często, ale nie w taki sposób, który wymagałby edycji w niesprzyjających godzinach.
Oczywistym pierwszym wyborem może być CMS takie jak Sanity lub Strapi. Jednak zanim się na nie decydujemy, warto najpierw określić dokładne wymagania:
- Pełna historia zmian dla każdej edycji treści
- Możliwość sprawdzenia zmian przed ich wdrożeniem
- Walidacja, która powoduje awarię procesu budowania, a nie wdrożonej aplikacji
- Brak konieczności używania dodatkowej infrastruktury dla wersji MVP
- Proces pracy odpowiadający temu, w jaki sposób już dostarczamy kod
Biorąc pod uwagę te potrzeby, przechowywanie treści bezpośrednio w Git ma większy sens niż dodawanie CMS.
Podejście to wykorzystuje pliki JSON, schematy Zod, skrypt seed Prisma oraz PostgreSQL do przechowywania danych w czasie wykonywania aplikacji. Proces wygląda następująco:
JSON → Zod → Seed (upsert) → Postgres → API
Celowo jest to rozwiązanie niespecjalnie ekscytujące. Nudny proces oznacza proces, któremu można zaufać.
Dlaczego nie „po prostu użyć CMS”?
Pliatformy CMS sprawdzają się w sytuacjach, gdy osoby niebędące specjalistami technicznymi publikują treści codziennie, gdy potrzebne są wersje robocze i role uprawnień, lub gdy struktura danych zmienia się w sposób nieprzewidywalny.
Jednak w przypadku ustrukturyzowanych treści tworzonych przez samych inżynierów — banki pytań, dane początkowe, procesy onboardingu, poziomy cenowe — wprowadzenie CMS zazwyczaj powoduje dodatkowe problemy:
- Kolejną usługę, którą trzeba hostować i zabezpieczać
- Kolejny schemat, który trzeba utrzymywać w zgodności z aplikacją
- Kolejną lukę, przez którą mogą przedostać się nieprawidłowe dane
W rzeczywistości nie potrzebowano platformy publikacyjnej — potrzebna była ścieżka przetwarzania treści:
autor → weryfikacja → przegląd → wdrożenie → sianie → serwowanie
Git już obsługuje pierwsze cztery kroki. Jedynym brakującym elementem była niezawodna metoda importowania treści do bazy danych.
Architektura
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 |
Niezaprzeczalna zasada: aplikacja nigdy nie powinna odczytywać plików JSON w czasie wykonywania w środowisku produkcyjnym. JSON istnieje tylko jako dane wejściowe podczas wdrożenia. Postgres pozostaje warstwą, która faktycznie obsługuje zapytania.
Dzięki temu korzystasz z zalet procesu pracy Git bez przekształcania bazy danych w coś, co jedynie przekazuje pliki.
Krok 1: Zacznij od Zod, a nie JSON
Zanim napiszesz jakąkolwiek treść, zdefiniuj umowę, której musi ona spełniać.
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>;
Wyróżnia się kilka celowych decyzji projektowych:
- Pole
idznajduje się bezpośrednio w pliku z treścią — to właśnie sprawia, że ponowna instalacja jest bezpieczna. Klucze główne generowane przez bazę danych to jedynie szczegóły implementacyjne; to stabilny identyfikator taki jakjs-closures-loop-001jest tym, od czego faktycznie zależy zapisany postęp użytkownika. - Zamiast surowych ciągów znaków używane są enumy, co zapobiega niejednolitej pisowni takiej jak
js,JSczyjavascriptw różnych plikach. - Funkcja
.refine()obsługuje reguły walidacji obejmujące kilka pól — coś, czego nie może zapewnić prosta ograniczenie typumin(), na przykład utrzymanie indeksu odpowiedzi w określonych granicach. - Każdy schemat waliduje cały plik JSON jako jeden zbiór, a nie pojedyncze rekordy.
W rezultacie treść ta ma wiążącą umowę, a nie tylko jakąś konwencję udokumentowaną gdzieś, czego nikt nie czyta.
Krok 2: Tworzenie nudnego JSON
[
{
"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."
}
]
Format jest celowo prosty: jednoznaczne typy, czytelne różnice między wersjami oraz brak sporów dotyczących skomplikowanych przypadków parsowania. Jeśli autorzy treści będą chcieli pisać w formacie Markdown lub YAML, można wygenerować JSON z tych formatów na etapie przygotowawczym — sam skrypt inicjalizacyjny powinien pozostać prosty i przewidywalny.
Jeśli chodzi konkretnie o tekst z formatowaniem, należy przechowywać surowy ciąg znaków w bazie danych — w formacie Markdown, prostego tekstu lub dowolnym innym, którym autorzy się czują komfortowo — i renderować go tam, gdzie aplikacja ma go wyświetlić. Renderowanie do HTML w momencie inicjalizacji wiąże cię z jedną konkretną biblioteką renderowania i powoduje problemy przy migracji, jeśli kiedykolwiek się jej pozbyjesz. Zachowaj źródło w takim stanie, w jakim jest, i renderuj je tylko tam, gdzie jest to rzeczywiście konieczne.
Krok 3: Uzupełnianie danych za pomocą upsertów, a nie całkowitego usuwania
Póki nie masz jeszcze prawdziwych użytkowników, wyczyszczenie tabeli za pomocą deleteMany i ponowne wypełnienie jej za pomocą createMany jest w porządku. Gdy rekordy użytkowników zaczną odwoływać się do wierszy zawartości, przejdź na upserty oparte na stabilnym identyfikatorze.
Społeczny model Prisma:
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
}
Skrypt do uzupełniania danych:
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());
Tutaj istotne są trzy kwestie:
questionsFileSchema.parse(...)całkowicie zapobiega dostaniu się niepoprawnego JSON do bazy danych.findUnique({ where: { externalId: q.id } })porównuje się z identyfikatorem samej zawartości, a nie z wewnętrznym kluczem głównym bazy danych.- Zamiast usuwania, aktualizacja zachowuje odpowiedzi lub postępy, które użytkownik już ma powiązane z danym rekordem.
Możesz zapytać, dlaczego nie użyć po prostu createMany({ skipDuplicates: true }). Ta opcja zapobiega duplikatom wierszy, ale nie modyfikuje starych tekstów po zmianie ich treści. Aby zsynchronizować zawartość podczas wdrażania, użycie wyraźnych operacji upsert jest bardziej przejrzyste i działa we wszystkich silnikach baz danych. Użyj surowego polecenia INSERT ... ON CONFLICT DO UPDATE tylko wtedy, gdy analiza wykazuje rzeczywistą potrzebę przyspieszenia.
Krok 4: Szybka reakcja w CI
Aby zweryfikować zawartość, nie potrzebujesz bazy danych. Uruchom to przy każdej prośbie o pull request dotyczącej katalogu 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
Zepsuta zawartość powoduje odrzucenie pull request – nigdy nie trafia do użytkowników w tajemnicy.
Proces wdrażania wygląda następująco:
npx prisma migrate deploy
npx prisma db seed
Aktualizacje zawartości stają się zwykłym wydaniem: łączenie, migracja, wypełnianie początkowe.
Krok 5: Gdy zmienia się schemat
Ranо czy późno schemat Zod ulegnie zmianie. Być może dodasz pole hint. Być może przemienisz nazwę codeSnippet na code. Być może options zmieni się z tablicy ciągów znaków na tablicę obiektów.
Niezależnie od zmiany, istniejące pliki JSON przestaną odpowiadać schematowi. Skrypt seed rzuca błąd. Nie panikuj — traktuj zmiany schematu tak samo jak migracje bazy danych.
Zacznij od wersjonowania samego schematu:
// schemas/question.v1.ts — old shape
// schemas/question.v2.ts — new shape
// schemas/question.ts — export latest as `questionSchema`
Następnie przeprowadź jednorazową operację codemod na plikach na dysku:
// 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));
}
Uruchom codemod, zapisz zaktualizowany plik JSON, skieruj skrypt seed na schemat v2 i wdroż go. To cały proces: codemod, zapis, aktualizacja, wdrożenie.
To ta sama dyscyplina, którą już stosujesz z prisma migrate, i przynosi te same korzyści.
Co zyskujesz
Przegląd treści staje się przeglądem kodu. Pytanie „Czy to wyjaśnienie jest rzeczywiście poprawne?” zamienia się w prośbę o połączenie z repozytorium zawierającą widoczne różnice, zamiast wiadomości w Slacku.
Otrzymujesz walidację za darmo, bez konieczności tworzenia własnego narzędzia do sprawdzania kodu. Zod wykrywa błędy pisowni w typach enumeracyjnych, brakujące pola wymagane oraz indeksy poza zakresem — sama struktura schematu pełni rolę narzędzia do sprawdzania kodu.
Pomijasz całkowicie dodatkowe obciążenia związane z CMS. Nie trzeba tworzyć panelu administracyjnego, nie ma osobnej autoryzacji dla edytorów ani drugiego celu implementacji do utrzymywania — szczególnie gdy osoby piszące treści to ci sami inżynierowie, którzy dostarczają kod.
Środowiska pozostają powtarzalne. Sklonuj repozytorium, uruchom migracje i procedurę inicjalizacji, a za każdym razem na każdym komputerze otrzymasz identyczny zbiór pytań.
Zmiany masowe przekształcają się w skrypty zamiast w ręczne kliknięcia. Przeklasyfikowanie czterdziestu pytań z MEDIUM na HARD to jednozdaniowy polecenie sed lub krótki skrypt, a nie czterdzieści oddzielnych edycji w interfejsie administracyjnym.
Postępy użytkowników przetrwają aktualizacje. Dzięki użyciu stabilnych identyfikatorów w połączeniu z mechanizmem upsert można poprawić błąd ortograficzny w pytaniu bez zerwania powiązań z odpowiedziami już dostarczonymi przez użytkowników.
Czego tracisz
Warto jasno przedstawić zalety i wady:
- Ludzie, którzy nie są inżynierami, zazwyczaj nie będą lubić pracy w Git. Jeśli redaktorzy niebędący specjalistami technicznymi chcą przyczynić się do pracy, będziesz potrzebował sposobu na import z pliku CSV, narzędzia wewnętrznego lub bezinterfejsowego CMS eksportującego dane do formatu JSON.
- Nie ma wbudowanego procesu pracy nad wersjami roboczymi i publikowanymi treściami. To, co znajduje się w gałęzi
main, jest tym, co zostaje użyte. Jeśli potrzebujesz wersji roboczych, będziesz musiał to zrealizować za pomocą gałęzi.
questions.json.Kiedy go stosować
To rozwiązanie nadaje się wtedy, gdy treść jest ustrukturyzowana i powtarzalna, zespół inżynieryjny jest mały, znajdujesz się pomiędzy fazą MVP a wczesną produkcją, zmiany w treści muszą być śledzone, a Ty już używasz Prismy.
Nie sprawdza się dobrze, gdy codziennie publikują redaktorzy niebędący specjalistami, istnieją złożone łańcuchy zatwierdzania, duże uzależnienie od mediów lub konieczność edycji treści na żywo bezpośrednio w środowisku produkcyjnym.
Główny wniosek
Rzeczywiście potrzebne nie było CMS — potrzebna była treść spełniająca te same standardy co kod: wersjonowana, walidowana, sprawdzana i wdrażana za pomocą już zaufanej infrastruktury.
JSON w Git to źródło prawdy. Zod pełni rolę bramkarza. Krok seed w Prismie służy do ładowania danych. Stable IDs zapewniają integralność danych użytkowników mimo zmian w treści.
Jeśli jeszcze nie ma użytkowników, zacznij od prostego metody seed polegającego na usunięciu starych danych i ich ponownym załadowaniu. Przejdź na operacje upsert, gdy dane postępów staną się istotne. Dodaj walidację CI, zanim jakiś błąd w typie enum dotrze do środowiska staging. Wersjonuj schemat swojej treści, zanim pojawi się druga istotna zmiana.
Cała ta konfiguracja jest celowo pozbawiona efektowności — i to właśnie jest jej cechą charakterystyczną. Zachowaj ekscytację na sam produkt, a nie na system odpowiedzialny za przechowywanie Twoich pytań.
Powiązane artykuły
any w TypeScript: Sześć bezpiecznych wariantów dla częstych przypadków — Poznaj praktyczne, bezpieczne pod względem typów alternatywy dla any w TypeScript — w tym rozwiązania oparte na nieznanych typach, generykach, zjednoczeniach dyskryminowanych oraz pełnych sprawdzeniach — służące do obsługi nieprzewidywalnych danych.