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

Журнал сообщений и история комнаты

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

После принятия сообщения сервер до сих пор сразу рассылал его и забывал. Новый участник комнаты видел только события, появившиеся после подключения. Добавим журнал «Линии»: для каждой комнаты сохраним последние двести сообщений, а при входе отправим историю перед событием joined. Это позволит продолжать разговор без зависимости от того, какие вкладки были открыты в момент отправки.

Хранилище этого урока находится в памяти одного процесса. Оно не является базой данных и теряет содержимое после перезапуска. Снимок stages/09 имеет статус not_run; сохранение, чтение и ответы ниже описаны как ожидаемые. История создаёт новую прикладную гарантию только в пределах живого процесса, поэтому интерфейс и README не обещают долговечность переписки.

Запись получает номер комнаты

У каждой комнаты появляется собственный счётчик seq. Сервер увеличивает его при создании записи и включает номер в событие. Первая запись комнаты 17 получает номер 1; первая запись комнаты 18 тоже получает номер 1. Номер имеет смысл вместе с roomId, поэтому нельзя сравнивать события разных комнат и заключать, какое из них было раньше по общему времени системы.

Кроме номера создаём epoch — случайный идентификатор текущего процесса. После перезапуска последовательность снова начнётся с единицы, но эпоха будет другой. Если клиент позже принесёт прежний курсор, сервер сможет понять, что число относится к утраченному журналу. Без эпохи одинаковое значение seq скрывало бы два разных жизненных цикла и могло заставить интерфейс пропустить новые сообщения как уже виденные.

Полный файл store.js содержит журнал, добавление записи и формирование снимка. В девятом состоянии подавление повторов выключено аргументом обработчика, а snapshot() вызывается без курсора; остальные ветви станут активными в следующих шагах.

import { randomUUID } from 'node:crypto';
import { roomIds } from './contract.js';

export const epoch = randomUUID();
const rooms = new Map(roomIds.map(id => [id, { seq: 0, messages: [] }]));
const accepted = new Map();

export function findAccepted(actorId, command) {
  return accepted.get(`${actorId}|${command.roomId}|${command.clientMessageId}`);
}

export function appendMessage(actor, command, suppressDuplicates) {
  const room = rooms.get(command.roomId);
  const message = {
    version: 1,
    type: 'message',
    epoch,
    roomId: command.roomId,
    seq: ++room.seq,
    messageId: randomUUID(),
    clientMessageId: command.clientMessageId,
    actorId: actor.id,
    text: command.text,
    createdAt: new Date().toISOString()
  };
  room.messages.push(message);
  if (room.messages.length > 200) room.messages.shift();
  if (suppressDuplicates) {
    const key = `${actor.id}|${command.roomId}|${command.clientMessageId}`;
    accepted.set(key, message);
    if (accepted.size > 2000) accepted.delete(accepted.keys().next().value);
  }
  return message;
}

export function snapshot(roomId, cursor) {
  const room = rooms.get(roomId);
  const first = room.messages[0]?.seq || 1;
  const reset = !cursor || cursor.epoch !== epoch ||
    cursor.after > room.seq || cursor.after < first - 1;
  return {
    version: 1,
    type: 'history',
    roomId,
    epoch,
    reset,
    messages: reset ? room.messages :
      room.messages.filter(message => message.seq > cursor.after),
    through: room.seq
  };
}

Массив хранит записи в порядке добавления. При превышении двухсот удаляем первую, сохраняя более новые. Счётчик комнаты при этом не уменьшается: удаление из окна истории не меняет идентичность оставшихся событий. Если в комнате было 230 сообщений, журнал содержит номера 31–230. Клиенту важно знать границу доступного окна, а не предполагать, что индекс массива совпадает с номером сообщения.

История предшествует новым событиям

При обработке join сервер сначала меняет подписку сокета, затем отправляет history, затем joined. В текущем однопроцессном коде обработчик синхронный: внутри добавления и выдачи снимка нет await, способного передать управление другой операции. Это позволяет ясно определить последовательность. При переходе к асинхронной базе потребуется отдельно согласовать снимок и подписку, иначе между ними может потеряться событие или появиться дубль.

Ожидаемый ответ истории выглядит так. Эпоха и содержимое условные; пример показывает договор, а не прочитанные данные выполненной программы.

{
  "version": 1,
  "type": "history",
  "roomId": "ticket-17",
  "epoch": "current-process-id",
  "reset": true,
  "messages": [],
  "through": 0
}

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

Когда reset равен true, браузер очищает текущий список и наполняет его сообщениями снимка. Это замена представления, а не повторное добавление всех строк. Если каждый вход просто дописывал историю в конец списка, человек видел бы одинаковую переписку несколько раз. При этом сообщения истории не должны объявляться как десятки «новых» событий: позднее доступный интерфейс отделит загрузку журнала от живого уведомления.

Запись и рассылка имеют разный результат

Обработчик сначала вызывает appendMessage(), затем отправляет ack и рассылает событие. Поэтому журнал содержит принятую запись даже если сокет оператора закроется перед получением. Новый вход в комнату сможет прочитать эту запись из истории. Однако если завершится сам серверный процесс, журнал исчезнет. Мы улучшили независимость сообщения от конкретного подключения, но ещё не обеспечили независимость от процесса.

Время createdAt остаётся полезным для чтения и будущего оформления. Оно не участвует в сортировке журнала: несколько сообщений могут получить близкое время, а часы процесса могут корректироваться. Номер назначается в месте добавления и прямо отражает порядок операций данного сервера. Случайный messageId сохраняет идентичность записи, но также не задаёт её положение в комнате.

Рассмотрим ожидаемую ситуацию. Алиса отправляет две фразы, оператор ещё не вошёл. После выбора комнаты оператор получает обе строки в исходном порядке, затем его форма становится доступной. Если оператор переподключится в этом состоянии вручную, снова придёт полный снимок, а не только изменения. Это правильное поведение девятого этапа: оптимизация продолжения по курсору пока не включена.

Ограничение в двести сообщений предотвращает бесконечный рост одного журнала, однако число комнат в этом проекте тоже фиксировано. Если разрешить создание произвольных комнат, общий объём памяти станет зависеть от их количества. Нужно ограничивать не только длину отдельного массива, но и количество предметных объектов, размер каждой записи и срок хранения. Локальная модель даёт понятную границу; большая система требует явной политики хранения.

Журнал не должен выдаваться до авторизации. Седьмой урок уже разместил canEnter() перед ветвью входа, и это расположение сохраняется. Если перенести выдачу истории в отдельный HTTP-маршрут, там необходимо повторить серверную проверку прав: наличие защищённого WebSocket не защищает другой маршрут автоматически. Каждый способ чтения обращения имеет собственную границу доступа.

При выдаче истории записи должны оставаться неизменными относительно своего messageId. Если приложение позже добавит редактирование или удаление, ему потребуется новый договор: событие изменения с номером журнала либо версия конкретного сообщения. Простое исправление текста в старом массиве не уведомит подключённые браузеры и разрушит сравнение повторной команды с прежним результатом. Поэтому в этой серии принятая реплика не редактируется, а дополнительные операции не прячутся под типом send. Отдельное предметное действие объясняет пользователю результат и позволяет восстановлению применить его в правильном порядке. Этот выбор удерживает журнал последовательностью принятых фактов, а не изменяемым DOM, случайно сохранённым на сервере.

Модель кадров и открытых соединений опирается на ws, а структура журнала является нашим прикладным решением. После этого состояния запись отделена от рассылки и имеет номер внутри комнаты. Дальше используем эти номера вместе с идентификаторами команд, чтобы повтор после неопределённого ack не создавал вторую запись в сохранённом окне.