Strona główna / Artykuły / Traktuj treść jak kod: pipeline siewania danych z Git do Postgresa

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.

2258 słów

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.