Совместная правка, при которой файл остаётся оригиналом
Как мы добавили совместную работу в документы, которые живут 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, закрытие комнаты без удвоения, правку вслепую после обрыва и конфликт от внешней правки файла. Двумя людьми в двух настоящих окнах браузера мы это ещё не прогоняли. Это следующая проверка, и пока она не пройдена, большего мы не обещаем.