Ставтеся до контенту як до коду: конвеєр для внесення даних з Git у Postgres
Показує, як замінити CMS на JSON із відстеженням у Git, перевірку за допомогою Zod та операції Prisma upsert для безпечного внесення структурованого контенту до 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є тим, від чого насправді залежить збережений прогрес користувача. - Замість сирих рядків використовуються енумерації, що запобігає появі непослідовностей у написанні, таких як
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: Заповнення даними за допомогою upserts, а не повного стирання
Оскільки у вас ще немає справжніх користувачів, можна без проблем стерти таблицю за допомогою deleteMany та заповнити її знову через createMany. Як тільки записи користувачів почнуть посилатися на рядки контенту, потрібно перейти на використання upserts з ключем, заснованим на стабільному ідентифікаторі.
Спрощена модель 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 }). Цей варіант запобігає дублюванню рядків, але залишає застарілий текст недоторканим після змін контенту. Для синхронізації контенту під час розгортання більш зрозумілим є використання прямих операцій upsert, які працюють у будь-яких двигунах баз даних. Вдаватися до формули 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-файли більше не відповідатимуть схемі. Скрипт seed викине помилку. Не панікуйте — ставтесь до змін схеми контенту точно так само, як до міграцій бази даних.
Почніть з версіонування самої схеми:
// 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, налаштуйте скрипт seed на схему v2 та розгорніть її. Ось весь процес: codemod, збереження, оновлення, розгортання.
Це та сама дисципліна, яку ви вже застосовуєте з prisma migrate, і результат буде таким самим.
Що ви отримуєте
Перевірка контенту перетворюється на перевірку коду. Питання «Чи справді це пояснення правильне?» стає запитом на приєднання з видимою різницею між версіями коду замість повідомлення в Slack.
Ви отримуєте перевірку безкоштовно, не створюючи власного інструменту для перевірки коду. Zod виявляє опечатки в переліках значень, відсутні обов’язкові поля та індекси, що виходять за межі допустимого діапазону — сама схема виступає у ролі інструменту перевірки коду.
Ви повністю уникаєте зайвих складнощів, пов’язаних із CMS. Не потрібно створювати панель керування, налаштовувати автентифікацію редакторів чи підтримувати додаткові цілі для розгортання — особливо коли ті самі інженери, які пишуть контент, є тими, хто розгортає код.
Середовища залишаються відтворюваними. Клонуйте репозиторій, виконайте міграції та ініціалізацію даних, і кожного разу на будь-якому пристрої ви отримаєте абсолютно ідентичну базу запитань.
Масові зміни перетворюються на скрипти замість ручних кліків. Переатрибуція сорока запитань з MEDIUM на HARD — це однорядкова команда sed або короткий скрипт, а не сорок окремих edits у інтерфейсі адміністратора.
Прогрес користувачів зберігається після розгортання. Оскільки ви використовуєте стабільні ID у поєднанні з операціями upsert, ви можете виправити помилку в запитанні, не руйнуючи посилання на відповіді, які користувачі вже надіслали.
Що ви втрачаєте
Варто чесно розповісти про компроміси:
- Люди, які не є інженерами, зазвичай не будуть задоволені роботою в Git. Якщо технічно некваліфіковані редактори потребують внеску, вам знадобиться шлях імпорту CSV, внутрішній інструмент або CMS без графічного інтерфейсу, який експортує дані у форматі JSON.
- Немає вбудованої робочої процедури для різниці між чернетками та опублікованим контентом. Те, що знаходиться у розділі
main, і є тим, що використовується. Якщо вам потрібні чернетки, доведеться створювати для цього гілки.
questions.json.Коли його використовувати
Ця схема добре підходить, коли ваш контент структурований та повторюваний, ваша інженерна команда невелика, ви знаходитеся між MVP та раннім етапом продакшену, зміни до контенту потребують відстеження, і ви вже використовуєте Prisma.
Це погано підходить, коли щодня працюють нетехнічні редактори, існують складні ланцюги схвалення, велика залежність від медіа чи потреба безпосередньо редагувати контент у промисловому середовищі.
Висновок
Насправді потрібна була не CMS — а контент, який мав би відповідати тим самим стандартам, що й код: мати версії, проходити перевірку, аналізуватися та розгортатися через інфраструктуру, якій вже довіряють.
JSON у Git є джерелом істини. Zod виконує функцію контролера. Крок seed у Prisma є засобом завантаження даних. Stable IDs забезпечують цілісність даних користувачів під час змін контенту.
Якщо користувачів ще немає, почніть з простого методу очищення та повторного завантаження даних. Перейдіть на метод upsert, як тільки дані про прогрес почнуть мати значення. Додайте перевірку CI, щоб помилки в enum ніколи не потрапляли у стадію тестування. Створіть версії схеми контенту ще до того, як виникне друга критична зміна.
Уся ця конфігурація навмисно не є привабливою — і саме в цьому суть. Збережіть захоплення для самого продукту, а не для системи, яка відповідає за зберігання ваших запитань.
Пов’язані статті
- Поширені проблеми JavaScript та TypeScript, які тихо ламають код — пояснює тонкі нюанси JavaScript та TypeScript — від порівнянь з NaN до асинхронного таймінгу та примусової заміни типів — які спричиняють помилки, незважаючи на зовнішню правильність коду.
any у TypeScript: Шість безпечних за типом патернів для поширених ситуацій — Дізнайтеся про практичні, безпечні за типом альтернативи any у TypeScript — включаючи типи unknown, генеріки, дискриміновані союзи та повні перевірки — для роботи з непередбачуваними даними.