Рассматриваем контент как код: конвейер для загрузки данных из Git в Postgres
Показывает, как заменить CMS на JSON с отслеживанием в Git, проверку с помощью Zod и операции upsert в Prisma для безопасной загрузки структурированного контента в Postgres.
Представьте, что вы создаёте приложение для тестов с вопросами с множественным выбором, фрагментами кода, пояснениями и оценкой уровня сложности. Такой контент довольно часто меняется, но не настолько, чтобы требовать редактирования в нерабочее время.
Очевидным первым вариантом может показаться 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());
Здесь важны три момента:
questionsFileSchema.parse(...)полностью исключает некорректный JSON из базы данных.findUnique({ where: { externalId: q.id } })выполняет поиск по собственному ID контента, а не по внутреннему первичному ключу базы данных.- Обновление вместо удаления позволяет сохранить любые ответы или прогресс, уже связанные с этой записью у пользователя.
Вы можете спросить, почему не просто использовать 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, и становится основной версией. Если вам нужны черновики, придется использовать ветки для их хранения.
questions.json.Когда использовать
Этот подход хорошо подходит, когда ваш контент структурирован и повторяется, у вас небольшая инженерная команда, вы находитесь на этапе между MVP и ранним продакшеном, изменения к контенту должны быть отслеживаемы, и вы уже используете Prisma.
Этот подход плохо справляется в ситуациях, когда ежедневно работают неспециалисты-редакторы, существуют сложные цепочки утверждения, большая зависимость от медиафайлов или необходимость прямой редактирования контента в режиме работы системы.
Вывод
На самом деле требовалась не CMS — а контент, соответствующий тем же стандартам, что и код: с версионированием, проверкой качества, ревью и развертыванием через уже надежную инфраструктуру.
JSON в Git является источником правды. Zod выполняет функцию контроля качества. Шаг seed в Prisma отвечает за загрузку данных. Stable IDs обеспечивают целостность пользовательских данных при изменениях контента под ними.
Если пока нет пользователей, начните с простого метода очистки и повторной загрузки данных. Перейдите на операции типа upsert, как только начнут важными становиться данные о прогрессе. Добавьте проверки в рамках CI, чтобы ошибки в перечислениях не добрались до этапа тестирования. Сделайте версионирование схемы контента до того, как появятся вторые критические изменения.
Вся эта конфигурация сознательно выглядит непривлекательно — и именно в этом суть. Оставьте восторг для самого продукта, а не для механизма хранения ваших вопросов.
Похожие статьи
- Распространённые ошибки в JavaScript и TypeScript, которые тайно ломают код — рассматриваются тонкие проблемы в JavaScript и TypeScript, от сравнений с NaN до асинхронных задач и принудительного преобразования типов, которые вызывают баги, несмотря на кажущуюся корректность кода.
any в TypeScript: шесть безопасных по типу паттернов для распространённых случаев — Узнайте о практических, безопасных по типу альтернативах any в TypeScript — включая использование типов unknown, генериков, дискриминированных союзов и полных проверок — для обработки непредсказуемых данных.