Strona główna / Artykuły

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ć.

Wydawca: REACTAPP.TOP10 min czytania2258 słów

Źródła: Content as Data: JSON in Git → Zod → Prisma Seed

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
  • Kolejna zmiana kontekstu z dala od edytora
  • 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 id znajduje 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 jak js-closures-loop-001 jest 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, JS czy javascript w różnych plikach.
    • Funkcja .refine() obsługuje reguły walidacji obejmujące kilka pól — coś, czego nie może zapewnić prosta ograniczenie typu min(), 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:

    1. questionsFileSchema.parse(...) całkowicie zapobiega dostaniu się niepoprawnego JSON do bazy danych.
    2. findUnique({ where: { externalId: q.id } }) porównuje się z identyfikatorem samej zawartości, a nie z wewnętrznym kluczem głównym bazy danych.
    3. 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.
  • Zasoby multimedialne nie powinny znajdować się w plikach JSON. Obrazy i filmy powinny być przechowywane w systemie magazynowania obiektów, z których można korzystać za pomocą adresów URL.
  • Konflikty przy łączeniu plików stanowią poważne zagrożenie, gdy kilka osób edytuje treść. Aby temu zapobiec, należy podzielić treść na małe pliki – uporządkowane według tematu lub poziomu trudności – zamiast tworzyć jeden ogromny plik questions.json.
  • Każda zmiana w treści w środowisku produkcyjnym wymaga jej rozruchu. Jeśli Twój scenariusz wymaga aktualizacji treści bez ponownego rozruchu, ten podejście nie jest odpowiednie.
  • 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

  • Zamiana 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.
  • Migracja z Prisma na Drizzle: Sześciomiesięczna refleksja — Programista dzieli się rzeczywistymi wynikami testów i kompromisami związanymi ze zmianą stacku TypeScript opartego na PostgreSQL z Prisma na Drizzle ORM.