Перейти к содержанию

Серверный revision и журнал изменений

В предыдущей главе браузер получил стабильные операции. Теперь создадим серверную сторону «Блокнота»: она проверяет исходную revision, сохраняет принятый вариант и помнит ответ для повторного opId. Результат урока — понятный HTTP контракт, по которому две независимые локальные базы могут согласовывать изменяемые заметки без доверия к часам устройств.

Конечный сервер находится в server.mjs архива, использует Node.js 24 и встроенные модули. Npm зависимости ему не нужны. Автор не запускал этот процесс и не отправлял HTTP запросы. JSON файл здесь служит небольшой учебной реализацией; его границы отличаются от транзакционной СУБД и будут названы прямо, чтобы ожидаемый учебный результат не превратился в обещание производственной надёжности.

Владелец определяется до обработки операции

Каждое обращение к /api/ сначала сопоставляет Bearer строку с учебным аккаунтом. Classroom-alice обозначает Alice, classroom-bob — Bob. Сервер не принимает произвольный account из тела push. Если клиент добавит туда чужое имя, оно не станет основанием получить доступ к чужим заметкам: выбор области данных уже сделан сервером по сессии.

Это демонстрация границы контракта, а не готовая система входа. Обе строки известны читателю и не являются секретными паролями. Реальному приложению нужны проверенные сессии, HTTPS, управление доступом и завершение полномочий. В локальной лаборатории процесс слушает loopback и не публикуется в интернет. Ошибка 401 останавливает синхронизацию, сохраняя локальную работу для дальнейшего решения.

Состояние аккаунта содержит notes, results, changes и seq. Notes хранит текущие канонические заметки, results — ответы на opId, changes — последовательность принятых версий, seq — позицию в этом журнале. Разделение помогает отличить чтение текущей записи от доставки того, что изменилось с прежнего cursor.

Проверка исходной ревизии

Push принимает ограниченный набор полей и проверяет типы, длины строк, допустимые идентификаторы и целую неотрицательную baseRevision. Приложение не должно переносить произвольный объект из JSON прямо в своё внутреннее состояние. Даже локальная демонстрация полезнее, если неверный payload получает определённый отказ, а не создаёт неожиданную структуру notes.

Если заметки ещё нет, сервер рассматривает её как revision ноль. Сравнение old.revision и op.baseRevision происходит до изменения. При совпадении новый вариант получает old.revision плюс один. При несовпадении возвращаются status conflict и текущая каноническая note. Конфликт не повышает revision и не становится событием changes, поскольку прикладное изменение не принято.

Следующий фрагмент взят из mutate; data, old и op уже определены в полном файле. Он показывает центральную развилку, а не заменяет серверную программу.

if (old.revision !== op.baseRevision) {
  result = { status: 'conflict', note: old };
} else {
  const note = {
    id: op.noteId, title: op.title, body: op.body,
    deleted: op.deleted, revision: old.revision + 1,
    lastOpId: op.opId
  };
  data.notes[note.id] = note;
  data.seq++;
  data.changes.push({ seq: data.seq, ...note });
  result = { status: 'accepted', note };
}

Возьмём два устройства, которые прочитали revision три. Первое отправляет текст «HTTP и кеширование», сервер принимает его как четыре. Второе отправляет «HTTP и cookies» с baseRevision три. Оно получит конфликт с четвёртой редакцией. Сервер не выбирает победителя по времени ноутбука и не стирает принятую работу автоматически.

Ответ сохраняется вместе с результатом

До проверки revision сервер смотрит ledger results по opId. Если идентификатор уже известен и fingerprint совпадает, возвращается прежний result. Важно делать это раньше нового сравнения revision: повтор принятой операции всё ещё содержит старую baseRevision, но её устаревание вызвано самой этой операцией. Превратить такой повтор в конфликт означало бы потерять смысл идемпотентности.

После первого вычисления результата ledger сохраняет его независимо от accepted или conflict. Повтор conflicted операции возвращает тот же ответ. Если клиент хочет применить ручное объединение, ему нужен новый opId и новая baseRevision. Так сервер не переписывает историю решения одного намерения из-за того, что окружающие заметки продолжили изменяться.

В памяти создаётся structuredClone всего учебного состояния. Изменения записываются в временный файл, затем rename заменяет state.json, и только после этого новый снимок становится текущим state процесса. Обработчик push ждёт завершения этого этапа прежде, чем вернуть ответ. При ошибке записи успешное подтверждение не отправляется.

Однако rename не равен полной транзакционной и аварийной гарантии базы данных. Пример не выполняет fsync и не координирует несколько процессов. Запуск двух серверов над одним каталогом способен потерять изменения. Поэтому все mutation проходят одну Promise очередь в единственном процессе, а README запрещает совместную запись несколькими процессами. Это честная граница небольшой лаборатории.

Cursor относится к журналу аккаунта

Pull получает cursor и отдаёт не больше пятидесяти событий с seq больше этого значения. NextCursor берётся из последнего отданного события; hasMore сообщает о продолжении. Если ничего не изменилось, cursor остаётся прежним. Нельзя увеличить его до текущего конца журнала, одновременно вернув только первую страницу: оставшиеся события тогда никогда не попадут к клиенту.

Revision и seq отличаются. Две заметки могут обе иметь revision два, но их события стоят в разных местах общего журнала. Cursor относится к порядку доставки всех заметок одного аккаунта. Поэтому он не вычисляется как максимальная revision локальных объектов. Это особенно важно, когда одно устройство долго работало только с одним документом, а другие заметки менялись отдельно.

Отрицательный, дробный или опережающий сервер cursor получает 409. Возможная причина — клиент сохранил позицию прежнего журнала, а server data были потеряны. В лаборатории нет автоматического сброса такого состояния: сначала нужен экспорт локальной работы и согласованное восстановление. Тихий возврат к нулю мог бы скрыть потерю серверной истории и смешать несовместимые поколения данных.

Ожидаемые ответы и предел хранения

После первого push новой заметки ожидается accepted с revision один. Повтор того же JSON должен вернуть ту же revision. Изменённый payload с прежним opId получает 422. Другая операция с baseRevision ноль к уже существующей заметке получает conflict. Эти наблюдения связаны с конкретными ветками кода, а не с временем ожидания или предполагаемой скоростью сервера.

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

Теперь сервер может принять намерение, отличить повтор от новой правки и предоставить последовательное получение изменений. Следующая глава соединит его с outbox: клиент должен подтвердить именно отправленный снимок и при этом сохранить текст, который пользователь успел изменить во время HTTP запроса.

Оглавление серии