Спрытнае адрабатаванне кантэнту як коду: пайплайн для запуску дадзейнаў з Git у Postgres
Паказвае, як заменіць CMS на JSON, яке караўнаецца за дапамою Git, верыфікацыю Zod і аператыў upsert у Prisma, каб безпечна запрацаваць структураваны контэнт у Postgres.
Уявіце стварэнне прыемкі з пытаннямі з калькуляцый, фрагментамі коду, адказамі та рангамі сложнасці. Такія матэрыялы досыта часта змінююцца, але не так, каб было неабходна жывая рэдагування у некалькі часы.
Вочыважным першым выборам можа быць CMS на кшталт Sanity або Strapi. Але перш чым выбіраць якой-небудзь з іх, корыстна спачатку узгадаць фактычныя трэбаванні:
- Полная історыя змян для кожной правкі матэрыялу
- Магчымасць перагляду змян прытым, якія не падаюць у продакшн
- Праверкі, якія зупіняюць процес стварэння, а не падаюць у продакшн
- Няхайка додатковай інфраструктуры для MVP
- Рабочы процес, який падобаецца таму, як вы вже дастаеце код
З урахоўваннем гэтых трэбаванняй, зберагчыць матэрыялы безпасцей у Git мае больш сенс, чым дадаваць CMS.
У гэтым падходзе для зберагчыка дадзеных у час роботы выкорыстоўваюцца файлы JSON, схемы Zod, скрыпт Prisma seed і база дадзеных 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 }). Гэты варыянт запобегае дублікацыі рэядкоў, але не прабывае змяніць застарэлы тэкст, калі змieniaецца ўжо існуючы контэнт. Для сінхронізаціі контэнту пад час развяртання болей прозрачным є спосаб з выразнымі операцыямі 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: Калі змieniaецца ваша схема
Раней чым пазней ваша схема 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`
Потым запрацаваце кодамод над файламі на дыску адной раз:
// 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));
}
Запрацаваце кодамод, зафіксуйце апдэйтаваны JSON, наставьце скрыпт seed на схему v2 і разверніце. Гэта весь процес: кодамод, зафіксаванне, апдэйт, розповсюджэнне.
Это тая ж сама дысціпліна, якую вы вжо прыменяеце з prisma migrate, і яна дае тыя ж рэзультаты.
Што вы отрымаеце
Адзінаковая перагляд зместу стаецца адзінаковай перагляд коду. Запытанне «Чы гэтаяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяяя
Масавыя змены выконваюцца за дапамою скрыптав, а не праз ручныя клікі. Переканалаванне чатырохдзесят пытанняў з MEDIUM на HARD адбываецца за дапамою адной лініі каманды sed чыра скрыпта, а не праз чатырохдзесят окалічных правак у адмінистрацыйным інтерфейсе.
Прагрэс ужоў застаёцца пасля запуску. Будучы выкарыстанымі стабільныя 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 — укладаючы неканфірмаваныя дадзеныя, гэнерыкі, дискримінаваныя аюніі і выключныя пераконтроўкі — для обработкі неперапрацаваных дадзеных.