Clusker

Совместная правка, при которой файл остаётся оригиналом

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

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

Совместная работа тянет в обратную сторону. Двоим, набирающим текст на одной странице, нужно общее состояние, которое сливает нажатия, — и файлом, переписываемым на каждую букву, оно быть не может. Вот как мы завели одно, не отказавшись от другого.

Граница во времени

Правило «файл — оригинал» мы не отменили, а дали ему границу во времени. Пока документ закрыт, оригинал — файл, и сверка работает как раньше. Пока документ открыт для совместной работы, оригинал — общая комната, документ Yjs, а файл получает снимки.

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

Четыре выкатки — по риску для данных

Эта работа меняет носитель правды, авторизацию, жизненный цикл процесса и редактор. Резать её по слоям значило бы поставить самое опасное вперёд, поэтому мы резали по тому, что потеряется, если выкатка сломается.

  • Первая — холст без комнаты. Один писатель, сохранение прежней ручкой. Откат кода оставляет файл побайтово тем же.
  • Вторая — комната без общего состояния. Люди на одной странице видят лица друг друга в шапке, а содержимого через комнату не идёт ни байта. Сломается — пропадут кружки, а не текст, а апгрейд до WebSocket, авторизация, выселение и потолки тем временем проверяются на живом трафике.
  • Третья — холст поверх комнаты. Хороший первый носитель общего состояния: самодостаточный JSON, без истории редактора и без трудных вопросов про отмену.
  • Четвёртая — текст. Самый рискованный кусок и последний: здесь может сломаться набор текста.

Yjs — да, сервер комнат — нет

Общее состояние живёт на Yjs, синхронизация — на y-protocols, том самом протоколе, которым говорит любой сервер Yjs. Написать свой значило бы завести второй диалект одного протокола. А вот Hocuspocus, привычный сервер для комнат Yjs, мы не взяли.

К тому моменту, как приехало общее состояние, всё, ради чего обычно берут сервер комнат, уже работало на проде: маршрут WebSocket внутри того же Fastify, та же проверка сессии, проверка Origin, перепроверка прав, коды закрытия, выселение, потолки и порядок при выключении. Hocuspocus принёс бы свою авторизацию, свой жизненный цикл и свою адресацию: имя документа внутри сообщения, один сокет на много документов. У нас наоборот: один документ, один адрес, права проверены до апгрейда.

Комната живёт в том же процессе, на том же порту, за тем же nginx. Ни лишнего контейнера, ни демона, ни Redis.

Порядок записи

комната (Y.Doc) → снимок состояния в базе → блоки → markdown-файл
  • Правка попадает в комнату сразу.
  • Состояние комнаты пишется в базу с задержкой 800 мс — тем же числом, что у обычного автосохранения.
  • Блоки и файл — раз в 5 секунд и обязательно при закрытии комнаты. За файлом следит наблюдатель, переписывать его на каждую букву нельзя.
  • Строка документа перечитывается перед каждой записью. Иначе переименование страницы при открытой комнате оставило бы файл-сироту по старому пути.

Одной защите пришлось переехать. Проверка «не меняли ли файл снаружи» стояла в HTTP-маршруте, а комната этот маршрут обходит, поэтому проверка переехала внутрь общей функции записи. Иначе снимок, попавший между внешней правкой и срабатыванием наблюдателя, мог бы стереть её без следа.

Один писатель за раз

ИИ-агенты через MCP ходят в тот же API, что и приложение. Поэтому, пока комната жива, запись блоков в обход неё получает 409 room_open. Слить markdown от агента с CRDT нельзя, а молча выиграть у человека, который прямо сейчас набирает текст, — ещё хуже. Наблюдатель за файлами подчиняется тому же правилу: при живой комнате он помечает конфликт и берёт текущую версию из комнаты, а не из отстающего кэша.

Отмена отменяет только своё

Раньше ⌘ Z на холсте возвращал снимок всего холста. В общей комнате этот снимок содержит и узлы соседа, и вернуть его целиком значило бы отменить чужую работу вместе со своей. Теперь в комнате отмену ведёт Y.UndoManager, привязанный к источнику ваших собственных правок, — и на холсте, и в тексте.

Две дыры, которых не было в плане

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

Вторую показал только живой прогон. Браузер шлёт первый кадр синхронизации сразу после открытия сокета, пока сервер ещё загружает документ. Слушатель заводился слишком поздно, кадр пропадал, и обе стороны ждали друг друга. Теперь ранние кадры копятся в очереди.

Что проверено, а что нет

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

Проверяли мы это на смоук-сервере двумя WebSocket-соединениями, на холсте и на текстовой странице. На холсте прогон покрыл обмен правками и слияние, доезд до файла, отказ 409, закрытие комнаты без удвоения, правку вслепую после обрыва и конфликт от внешней правки файла. Двумя людьми в двух настоящих окнах браузера мы это ещё не прогоняли. Это следующая проверка, и пока она не пройдена, большего мы не обещаем.

Читают также

Все записи

Журнал изменений4 мин чтения

Что вышло в начале сентября

Граф связей у документов, совместная правка страниц и холстов, комментарии с markdown и семь починок по нашему же смотру.

Дизайн4 мин чтения

Мы сняли 29 подписей из 55 на телефоне

Интерфейс начал объяснять себя мелким серым текстом. За один смотр мы сняли большую часть — и переписали правила, из-за которых этот текст там появился.