Traktuj treść jak kod: pipeline siewania danych z Git do Postgresa
Pokazuje, jak zastąpić CMS plikami JSON monitorowanymi przez Git, walidacją Zod oraz operacjami Prisma upsert, aby bezpiecznie wprowadzać ustrukturyzowany treść do Postgresa.
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
- Powszechne błędy JavaScript i TypeScript, które cichoczynnie niszczą kod — Wyjaśnia subtelne pułapki w JavaScript i TypeScript — od porównań z NaN po kwestie związane z asynchronicznym działaniem i przymusową konwersją typów — które powodują błędy mimo pozornie poprawnego kodu.
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.