Результаты работы: подписки на уведомления и авторство правок

Отчёт по одному заходу работы: NEXTDOCS-24 (подписки на уведомления) и всё, что из него выросло — авторство правок, чекпоинты истории, переработка настроек. Затронуто пять репозиториев: api, crdt, frontend, text-converter, deploy.

1. Подписки на уведомления (NEXTDOCS-24)

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

Модель взята у GitHub: одно состояние на пару (человек, объект), решает ближайшее явное вдоль цепочки вложенности.

состояние

что означает

all

слышу всё, что происходит в объекте

mentions

тишина, кроме обращений по имени

ignore

ничего

строки нет

умолчание: слышу то, в чём участвую

Как решается, кому отправлять

flowchart TD
    E["Событие про объект"] --> I{"Неотключаемое?<br/>упоминание, ответ на мой<br/>комментарий, мой доступ"}
    I -->|да| S["Доставить"]
    I -->|нет| P{"Явная строка<br/>на самом объекте?"}
    P -->|"all"| S
    P -->|"mentions / ignore"| X["Молчать"]
    P -->|"нет строки"| C{"Явная строка<br/>на контейнере?"}
    C -->|"есть"| R["Применить её"]
    C -->|"нет"| D{"Я участник?"}
    D -->|да| S
    D -->|нет| X

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

Цепочки вложенности

flowchart BT
    PG["страница"] --> KB["база знаний"] --> ORG["организация"]
    TK["задача"] --> TP["проект трекера"] --> ORG
    RP["репозиторий"] --> ORG

Подписка на контейнер накрывает всё под ним; явная строка на объекте перебивает контейнер.

Миграции

2. Авторство правок

page_updated — единственное уведомление, автора которого никто api не передаёт. Правка из браузера уходит на collab-сервер, тот её склеивает по debounce и публикует rag.update — id проекта и id страницы, без человека.

Раньше collab-сервер угадывал: сканировал awareness-карту документа и брал первое состояние. Это верно ровно пока в документе один человек.

Путь от нажатия клавиши до уведомления

sequenceDiagram
    participant B as Браузер
    participant N as NATS
    participant C as collab (crdt)
    participant DB as Postgres
    participant A as api

    B->>N: awareness { user.id }
    B->>N: Yjs-апдейт (несёт clientID)
    N->>C: awareness + апдейт
    Note over C: clientID из апдейта<br/>сопоставляется с человеком<br/>из awareness
    C->>DB: page_updates (actor_*, actors[])
    C-->>N: rag.update (через 30 с тишины)
    N->>A: rag.update
    A->>DB: RecentPageEditors
    A-->>B: page_updated — кто и сколько ещё

Client id внутри Yjs-апдейта — единственная авторская метка, которую несёт сам Yjs, а awareness ключуется тем же id. Декодирование апдейта превращает «документ, полный людей» в тех, кто действительно печатал, со счётчиком вклада у каждого.

Почему автор не один

Один store накрывает всё окно debounce — до 30 секунд общего набора. Двое, печатающих одновременно, попадают в одну строку, а актор-колонки вмещают одного.

flowchart LR
    subgraph W["одно окно debounce (до 30 с)"]
        A1["Ada: 3 структуры"]
        B1["Bob: 9 структур"]
        A2["Ada: 5 структур"]
    end
    W --> ROW["одна строка page_updates"]
    ROW --> COL["actor_* = Bob<br/>(написал больше)"]
    ROW --> ARR["actors[] = Bob:9, Ada:8<br/>по убыванию вклада"]

«По чьему указанию»

extractActor раньше выбрасывал вызывающего: id прошивался константой, присланный X-Actor-Id игнорировался, а JWT-пользователь — который RAG-агент пересылает именно потому, что действует за человека — отбрасывался намеренно. Все агентские правки в системе имели одного автора.

Теперь заголовок называет агента, а токен — того, кто попросил:

{
  "id": "documentator",
  "name": "Documentation Agent",
  "type": "agent",
  "on_behalf_of": { "id": "…", "name": "Bob" }
}

on_behalf_of: null — настоящий ответ «Nextdocs сделал сам»: расписание, вебхук, автопулл. Хранится как отсутствие, а не как выдуманный системный пользователь.

3. Чекпоинты и история

Снепшоты на путях api

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

Это делал только collab-сервер. В api не было ни одного INSERT в page_snapshots вне легаси-пути и ни одного в task_snapshots вообще — страница, которую правят только агенты, не имела чекпоинтов после seed'а.

flowchart LR
    U1["update 1"] --> U2["…"] --> U50["update 50"]
    U50 --> S1["snapshot<br/>actors: накопительно"]
    S1 --> U51["update 51"] --> U99["…"] --> U100["update 100"]
    U100 --> S2["snapshot<br/>actors = S1 + новые"]

Зачем авторы на снепшоте, если сегодня он ничего не теряет: первый же шаг любой ретенции — «удалить апдейты старше последнего снепшота», и он унесёт с собой всё авторство ниже чекпоинта.

Панель истории

page_change_log пишет строку на сессию правки, и пишет её в момент закрытия сессии. Версия существует раньше и знает всех. Поэтому:

Сворачивание записей по версии раньше оставляло самую свежую и считало остальные («2 edits, one version») — то есть отвечало на вопрос, которого никто не задавал. Теперь имена объединяются, а строка рисует лицо на редактора: три максимум, дальше счётчик, весь список — в модалке за ними.

4. Интерфейс

Настройки и контролы разъехались по трём поверхностям, и это пришлось развести:

flowchart TD
    subgraph PROF["Профиль — про человека"]
        P1["Account"]
        P2["Appearance: тема, фон сайдбара, оглавление"]
        P3["Notifications: отклонения + автоподписка"]
    end
    subgraph PRJ["Настройки проекта — про место"]
        J1["Private"]
        J2["Locales and versions → Add"]
        J3["Notifications базы знаний"]
    end
    subgraph DOC["Тулбар документа — про открытую страницу"]
        D1["Подписка на страницу"]
        D2["Активность"]
        D3["Share"]
    end

5. Баги, найденные по дороге

Самая ценная часть: ни один из этих не проявлялся как ошибка.

что

как выглядело

причина

Гонка в onChange

у пачки правок оставался один автор

Hocuspocus не ждёт хук: все обработчики читают одинаковый финальный текст, и все кроме первого отбрасываются guard'ом «ничего не изменилось»

Двойное кодирование actors

правка с двумя авторами читалась как правка с одним

${JSON.stringify(x)}::jsonb — драйвер сериализует json-параметры сам, значение легло jsonb-строкой

Порядок в actors

основным автором становился не тот

массив шёл в порядке прихода, а читатели берут первый элемент

Двойное кодирование metadata

длительность сессии не рисовалась ни разу с марта

то же самое в logContentEdit; починено, 225 строк распакованы миграцией 000048

ResponseError без текста

409 «Variant slug already exists» показывался как «Try again»

super() без сообщения → e.message пустая у всех ошибок api

Install GitHub App

предлагалось для пустого проекта, zip и Confluence

shouldInstall инициализируется true, а отвечает на него только проверка репозитория, которую эти пути не запускают

Распухший JWT

вход валится с 500 через прокси

у аккаунта в 154 организациях токен 21.9 КБ и не влезает в 16 КБ заголовка

Три первых нашёл тест на двух одновременных редакторов — и не нашёл бы, будь тестовых пользователей двое: нужна была третья личность, «двое пишут, третий смотрит».

6. Проверки

7. Порядок выката

api должен стартовать раньше crdt.

Новый collab-сервер пишет колонку actors и зовёт page_snapshot_actors. Если он поднимется до того, как api применит 000046/000047, каждый store будет падать — и падать молча: адаптер ловит ошибку в лог. Снаружи всё живое, правки просто не сохраняются.

Необратимое: 000044 дропает task_watchers после перелива. Down-миграция восстановит только task-строки; подписки на репозитории и организации возвращать неоткуда.

8. Осознанно не сделано