Главная / Статьи / Рассматриваем контент как код: конвейер для загрузки данных из Git в Postgres

Рассматриваем контент как код: конвейер для загрузки данных из Git в Postgres

Показывает, как заменить CMS на JSON с отслеживанием в Git, проверку с помощью Zod и операции upsert в Prisma для безопасной загрузки структурированного контента в Postgres.

2258 слов

Представьте, что вы создаёте приложение для тестов с вопросами с множественным выбором, фрагментами кода, пояснениями и оценкой уровня сложности. Такой контент довольно часто меняется, но не настолько, чтобы требовать редактирования в нерабочее время.

Очевидным первым вариантом может показаться CMS вроде Sanity или Strapi. Однако прежде чем обращаться к ним, стоит чётко определить реальные требования:

  • Полная история изменений для каждой правки контента
  • Возможность проверить изменения перед их развертыванием
  • Механизм проверки, прерывающий процесс сборки, а не развертывание
  • Отсутствие необходимости в дополнительной инфраструктуре для MVP
  • Рабочий процесс, соответствующий тому, как вы уже развёртываете код

Учитывая эти требования, хранение контента непосредственно в Git имеет больше смысла, чем использование CMS.

В этом подходе для хранения данных во время выполнения используются файлы JSON, схемы Zod, скрипт сеяния Prisma и PostgreSQL. Поток обработки выглядит следующим образом:

JSON → Zod → Seed (upsert) → Postgres → API

Это сделано намеренно просто — чем проще поток, тем больше вероятность его надежности.

Почему бы просто не использовать CMS?

Платформы CMS оправдывают себя тогда, когда неспециалисты ежедневно публикуют контент, когда требуется возможность хранения черновиков и настройка прав доступа, или когда структура данных меняется непредсказуемо.

Однако для структурированного контента, создаваемого самими инженерами — банков вопросов, исходных данных, процедур адаптации пользователей, уровней ценообразования — использование CMS обычно приводит к появлению дополнительных проблем:

  • Еще одного сервиса, который необходимо хостить и защищать
  • Еще одной схемы, которую нужно синхронизировать с приложением
  • Еще одного места, через которое могут проникнуть некорректные данные
  • Ещё один смена контекста, уводящая вас подальше от редактора
  • На самом деле требовалась не платформа для публикации — а цепочка обработки контента:

    автор → проверка → рецензирование → развертывание → загрузка → обслуживание

    Git уже обрабатывает первые четыре этапа. Единственным недостающим элементом был надёжный способ импорта контента в базу данных.

    Архитектура

    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 |
    

    Непреложное правило здесь: ваше приложение никогда не должно читать файлы JSON во время выполнения в производственной среде. JSON существует только как входные данные во время развертывания. Postgres остаётся слоем, который фактически обрабатывает запросы.

    Это позволяет воспользоваться преимуществами рабочего процесса Git, не превращая базу данных в простой прокси для файлов.

    Шаг 1: Начните с Zod, а не с JSON

    Прежде чем писать любой контент, определите требования, которым он должен соответствовать.

    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>;
    

    Выделяются несколько осознанных решений в дизайне:

    • Поле id находится непосредственно в самом файле с контентом — именно это обеспечивает безопасность повторных развертываний. Основные ключи, генерируемые базой данных, являются лишь деталями реализации; стабильный идентификатор вроде js-closures-loop-001 — вот на чем действительно зависит сохраненный пользователем прогресс.
    • Вместо обычных строк используются enum, что предотвращает появление неоднородных форматов написания, таких как js, JS или javascript, в разных файлах.
    • Метод .refine() обрабатывает правила валидации, касающиеся нескольких полей — то, что простое ограничение min() выразить не может, например, поддержание индекса ответа в пределах допустимых значений.
    • Каждая схема валидирует целый JSON-файл как единственный массив, а не по отдельным записям.

    В результате у вашего контента появляется юридически обязывающий договор, а не просто какая-то конвенция, зафиксированная где-то и никем не читаемая.

    Шаг 2: Создание скучного 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."
      }
    ]
    

    Формат выбран намеренно простым: однозначные типы, четкие отличия между версиями и отсутствие споров по поводу редких случаев парсинга. Если авторы контента в будущем захотят писать в форматах Markdown или YAML, вы можете сгенерировать JSON из этих форматов на этапе предварительной обработки — сам скрипт должен оставаться простым и предсказуемым.

    Что касается форматированного текста, то необходимо хранить исходную строку в базе данных — в формате Markdown, обычного текста или любом другом формате, с которым комфортно работают авторы — и отображать её там, где это нужно в приложении. Преобразование в HTML на этапе создания скрипта привязывает вас к определенной библиотеке отображения и создает проблемы при последующей замене библиотеки. Храните исходный текст в неизменном виде и отображайте его только там, где это действительно необходимо.

    Шаг 3: Заполнение данных с использованием операций upsert, а не полного удаления

    Поскольку у вас пока нет реальных пользователей, ничего страшного в том, чтобы очистить таблицу с помощью deleteMany и заново заполнить её через createMany. Как только записи пользователей начнут ссылаться на строки с контентом, перейдите на операции upsert, основанные на стабильном идентификаторе.

    Упрощённая модель 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
    }
    

    Скрипт заполнения данных:

    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());
    

    Здесь важны три момента:

    1. questionsFileSchema.parse(...) полностью исключает некорректный JSON из базы данных.
    2. findUnique({ where: { externalId: q.id } }) выполняет поиск по собственному ID контента, а не по внутреннему первичному ключу базы данных.
    3. Обновление вместо удаления позволяет сохранить любые ответы или прогресс, уже связанные с этой записью у пользователя.

    Вы можете спросить, почему не просто использовать createMany({ skipDuplicates: true }). Этот вариант предотвращает дублирование строк, но оставляет устаревший текст нетронутым при изменении контента. Для синхронизации контента во время развертывания более понятным решением является использование явных операций обновления или вставки, которые работают с любыми движками баз данных. Обращайтесь к прямой команде INSERT ... ON CONFLICT DO UPDATE только тогда, когда анализ покажет, что вам действительно необходима такая скорость.

    Шаг 4: Быстрое обнаружение ошибок в CI

    Для проверки контента база данных не требуется. Выполняйте эту процедуру для каждого pull request, касающегося папки 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
    

    Некорректный контент приводит к отклонению pull request — он никогда не будет развернут без проблем.

    Процесс развертывания выглядит следующим образом:

    npx prisma migrate deploy
    npx prisma db seed
    

    Обновления контента превращаются в обычную процедуру выпуска: слияние, миграция, заполнение начальными данными.

    Шаг 5: При изменении схемы

    Рано или поздно схема Zod будет развиваться. Возможно, вы добавите поле hint. Возможно, переименуете codeSnippet в code. Возможно, options изменится с массива строк на массив объектов.

    Какой бы ни была изменение, существующие JSON-файлы больше не соответствуют схеме. Скрипт запуска выдаст ошибку. Не паникуйте — относитесь к изменениям схемы контента точно так же, как к миграциям базы данных.

    Начните с версионирования самой схемы:

    // schemas/question.v1.ts — old shape
    // schemas/question.v2.ts — new shape
    // schemas/question.ts   — export latest as `questionSchema`
    

    Затем выполните однократную обработку файлов на диске с помощью codemod:

    // 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));
    }
    

    Запустите codemod, сохраните обновленные JSON-файлы, настройте скрипт запуска на схему v2 и разверните изменения. Вот весь процесс: codemod, сохранение, обновление, развертывание.

    Это та же дисциплина, которую вы уже применяете с prisma migrate, и результат будет таким же.

    Что вы получаете

    Проверка контента превращается в проверку кода. Вопрос «Действительно ли это объяснение верное?» заменяется запросом на слияние с видимым отчетом о различиях вместо сообщения в Slack.

    Вы получаете проверку качества бесплатно, без необходимости создания собственного инструмента для выявления ошибок. Zod обнаруживает опечатки в перечислениях, отсутствие обязательных полей и индексы, выходящие за пределы допустимого диапазона — сама схема выполняет функцию инструмента для выявления ошибок.

    Вы полностью избавляетесь от лишних сложностей, связанных с CMS. Не нужно создавать панель управления, настраивать аутентификацию редакторов или поддерживать дополнительные цели развертывания — особенно когда те же инженеры, которые пишут контент, отвечают за развертывание кода.

    Среды остаются воспроизводимыми. Достаточно скопировать репозиторий, выполнить миграции и запустить процедуру инициализации, чтобы каждый раз на любом устройстве получать идентичный набор вопросов.

    Массовые изменения выполняются с помощью скриптов вместо ручных кликов. Переотнесение сорока вопросов из категории MEDIUM в категорию HARD можно сделать с помощью одной строки команды sed или короткого скрипта, а не сорока отдельных правок в интерфейсе администратора.

    Прогресс пользователя сохраняется после развертывания. Благодаря использованию стабильных идентификаторов в сочетании с операциями обновления или добавления данных, можно исправить опечатку в вопросе без разрыва связи с ответами, уже отправленными пользователями.

    Что придется отказаться

    Стоит честно рассказать о возможных компромиссах:

    • Люди, которые не являются инженерами, обычно не будут охотно работать с Git. Если не технические редакторы должны вносить изменения, потребуется способ импорта данных в формате CSV, внутренний инструмент или CMS без графического интерфейса, экспортирующий данные в формате JSON.
    • Встроенной системы работы с черновиками и опубликованными версиями нет. То, что находится в репозитории main, и становится основной версией. Если вам нужны черновики, придется использовать ветки для их хранения.
  • Медиафайлы не должны находиться внутри JSON-файлов. Изображения и видео следует хранить в системах объектного хранилища, к которым делается ссылка через URL.
  • Конфликты при слиянии возникают, когда несколько человек редактируют контент. Чтобы этого избежать, разделяйте контент на небольшие файлы — организованные по темам или уровню сложности — вместо одного огромного файла questions.json.
  • Любые изменения контента в продакшене требуют его развертывания. Если ваша ситуация предполагает обновление контента без повторного развертывания, этот подход не подходит.
  • Когда использовать

    Этот подход хорошо подходит, когда ваш контент структурирован и повторяется, у вас небольшая инженерная команда, вы находитесь на этапе между MVP и ранним продакшеном, изменения к контенту должны быть отслеживаемы, и вы уже используете Prisma.

    Этот подход плохо справляется в ситуациях, когда ежедневно работают неспециалисты-редакторы, существуют сложные цепочки утверждения, большая зависимость от медиафайлов или необходимость прямой редактирования контента в режиме работы системы.

    Вывод

    На самом деле требовалась не CMS — а контент, соответствующий тем же стандартам, что и код: с версионированием, проверкой качества, ревью и развертыванием через уже надежную инфраструктуру.

    JSON в Git является источником правды. Zod выполняет функцию контроля качества. Шаг seed в Prisma отвечает за загрузку данных. Stable IDs обеспечивают целостность пользовательских данных при изменениях контента под ними.

    Если пока нет пользователей, начните с простого метода очистки и повторной загрузки данных. Перейдите на операции типа upsert, как только начнут важными становиться данные о прогрессе. Добавьте проверки в рамках CI, чтобы ошибки в перечислениях не добрались до этапа тестирования. Сделайте версионирование схемы контента до того, как появятся вторые критические изменения.

    Вся эта конфигурация сознательно выглядит непривлекательно — и именно в этом суть. Оставьте восторг для самого продукта, а не для механизма хранения ваших вопросов.

    Похожие статьи

  • Замена any в TypeScript: шесть безопасных по типу паттернов для распространённых случаев — Узнайте о практических, безопасных по типу альтернативах any в TypeScript — включая использование типов unknown, генериков, дискриминированных союзов и полных проверок — для обработки непредсказуемых данных.
  • Миграция с Prisma на Drizzle: обзор за шесть месяцев — Разработчик делятся реальными результатами тестирования и соотношением преимуществ и недостатков при переходе от стека PostgreSQL с TypeScript на ORM Drizzle.