Олег Карнауховweb developerОбсудить проект

Блог

Черновики и двадцать версий: история правок без базы данных

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

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

Черновик — это не флаг в интерфейсе

Черновик у меня означает буквально: адрес не отвечает никому, кроме автора. Не «страница есть, но скрыта в списке», а 404 для постороннего и полный вид для того, кто вошёл в панель.

Важно, что фильтрует не хранилище. Хранилище отдаёт всё — панели нужны и черновики. Решение «показывать ли» принимает страница, и это правильное место: одно и то же хранилище обслуживает и сайт, и админку.

История: копия перед записью

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

app/server/history.tsts
/** ISO, с приведёнными к дефисам символами, которые не любит файловая система.
    Сортируется по времени как строка — на это и опирается список версий. */
function idFor(when: Date) {
  return when.toISOString().replace(/[:.]/g, "-");
}

export async function keepVersion(kind: Kept, slug: string, document: unknown) {
  const folder = historyDir(kind, slug);
  await mkdir(folder, { recursive: true });

  const file = join(folder, `${idFor(new Date())}.json`);
  const temporary = `${file}.${process.pid}.tmp`;
  await writeFile(temporary, `${JSON.stringify(document, null, 2)}\n`, "utf8");
  await rename(temporary, file);

  await prune(kind, slug);
}
Имя файла — это и есть дата. Сортировка по имени даёт сортировку по времени, поэтому отдельного индекса не нужно.

Почему версия накладывается, а не пишется как есть

Версия, сохранённая до появления поля, ничего про это поле не знает. Записать её напрямую значит оставить документ без поля до следующей правки — то есть тихо сломать страницу спустя недели после отката.

Поэтому восстановление накладывает версию на то, чем документ является сейчас. И отдельно: адрес не принадлежит версии. Slug — это место файла на диске и часть адреса страницы; вернуть старый slug из истории означало бы переименовать сущность, чего кнопка «вернуть» не обещала.

Что должно быть у отката, чтобы им можно было пользоваться

  • Из откаченного состояния можно откатиться обратноуже так
  • Версию можно посмотреть до того, как применятьуже так
  • Восстановление не меняет адрес сущностиуже так
  • Число версий ограничено, иначе каталог растёт молчауже так
  • Удаление сущности убирает и её историюуже так