Результаты работы: подписки на уведомления и авторство правок
Отчёт по одному заходу работы: NEXTDOCS-24 (подписки на уведомления) и всё, что из него выросло — авторство правок, чекпоинты истории, переработка настроек. Затронуто пять репозиториев: api, crdt, frontend, text-converter, deploy.
1. Подписки на уведомления (NEXTDOCS-24)
До этого уведомления были обязательными: получателей выбирал api, у читателя права голоса не было, и единственным выходом из шумного проекта был почтовый фильтр.
Модель взята у GitHub: одно состояние на пару (человек, объект), решает ближайшее явное вдоль цепочки вложенности.
состояние | что означает |
|---|---|
| слышу всё, что происходит в объекте |
| тишина, кроме обращений по имени |
| ничего |
строки нет | умолчание: слышу то, в чём участвую |
Как решается, кому отправлять
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
Подписка на контейнер накрывает всё под ним; явная строка на объекте перебивает контейнер.
Миграции
000044— таблицаnotification_subscriptions, переливtask_watchersв неё и дроп старой: обе хранили один факт под разными именами.000045— личный тумблер автоподписки. Комментарий к задаче подписывает на неё, и это стоит сохранить, но не стоит навязывать: тот, кто отвечает на один вопрос в пятидесяти задачах за день, к вечеру подписан на пятьдесят задач.
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/>по убыванию вклада"]
000046— колонкаactorsвpage_updatesиtask_updates, с бэкфиллом из актор-колонок.ops— размер записи в Yjs-структурах. Приходит изtext-converter: он единственный на этом пути держит настоящийY.Doc, api видит непрозрачный блоб.Отсутствующий счётчик читается как одна единица, а не ноль — иначе агентская запись оседала бы позади любого, кто однажды набрал один символ.
«По чьему указанию»
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 + новые"]
000047— колонкаactorsна снепшотах. Семантика накопительная, как и само содержимое снепшота: все, кто участвовал вплоть доup_to_update_id. Считается инкрементально — список прошлого чекпоинта плюс апдейты с тех пор, — поэтому цена равна интервалу, а не истории.Правило слияния живёт в SQL (
merge_actors), потому что писателя два и они на разных языках: collab-сервер на TypeScript, api на Go. Одно правило в двух языках — это правило, которое разъедется.
Зачем авторы на снепшоте, если сегодня он ничего не теряет: первый же шаг любой ретенции — «удалить апдейты старше последнего снепшота», и он унесёт с собой всё авторство ниже чекпоинта.
Панель истории
page_change_log пишет строку на сессию правки, и пишет её в момент закрытия сессии. Версия существует раньше и знает всех. Поэтому:
«что произошло», тип актора и длительность — из change_log;
«кто» — из
version_actors, то есть из авторов версии.
Сворачивание записей по версии раньше оставляло самую свежую и считало остальные («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
Экран документа получил общую шапку — он был единственным без неё, и главным. Высоты перестали мерить вьюпорт вручную (
calc(100vh-62px)и родственники) и заполняют родителя; именно это и позволило вставить шапку, ничего не пересчитывая.Тема, фон сайдбара и оглавление переехали из настроек проекта в профиль: ни одна из них не принадлежит проекту, который случайно был открыт в момент переключения.
Создание локали переехало в настройки проекта — это про то, чем проект является, а не про переключение между тем, что уже есть.
Контрол подписки получил глаз вместо второго колокольчика: колокольчик в шапке — это инбокс, куда приходит отправленное, а контрол решает, что будет отправлено.
5. Баги, найденные по дороге
Самая ценная часть: ни один из этих не проявлялся как ошибка.
что | как выглядело | причина |
|---|---|---|
Гонка в | у пачки правок оставался один автор | Hocuspocus не ждёт хук: все обработчики читают одинаковый финальный текст, и все кроме первого отбрасываются guard'ом «ничего не изменилось» |
Двойное кодирование | правка с двумя авторами читалась как правка с одним |
|
Порядок в | основным автором становился не тот | массив шёл в порядке прихода, а читатели берут первый элемент |
Двойное кодирование | длительность сессии не рисовалась ни разу с марта | то же самое в |
| 409 «Variant slug already exists» показывался как «Try again» |
|
| предлагалось для пустого проекта, zip и Confluence |
|
Распухший JWT | вход валится с 500 через прокси | у аккаунта в 154 организациях токен 21.9 КБ и не влезает в 16 КБ заголовка |
Три первых нашёл тест на двух одновременных редакторов — и не нашёл бы, будь тестовых пользователей двое: нужна была третья личность, «двое пишут, третий смотрит».
6. Проверки
Go:
build,vet, юнит-тесты; новый интеграционный тест на чекпоинты (50 записей → чекпоинт с накопительными авторами иon_behalf_of) и наRecentPageEditors(строка безactors, строка с двумя авторами, строка за окном). Сами скипаются без базы.e2e (
deploy/e2e-tests): 10 кейсов на подписки, 2 на авторство — сквозняк от нажатия клавиши до уведомления, с проверкой, что имена доходят до панели истории и что редактор не получает уведомления о собственном наборе.vitest: 12 кейсов на строку истории и рельсу настроек.
Глазами в браузере: рельса профиля, имена подписок вместо
#108, аватарки в истории, шапка,Main EN DEFAULT, ровные отступы тулбара (1432 → 1470 → 1508, по 38 пикселей).
7. Порядок выката
apiдолжен стартовать раньшеcrdt.Новый collab-сервер пишет колонку
actorsи зовётpage_snapshot_actors. Если он поднимется до того, как api применит000046/000047, каждыйstoreбудет падать — и падать молча: адаптер ловит ошибку в лог. Снаружи всё живое, правки просто не сохраняются.
Необратимое: 000044 дропает task_watchers после перелива. Down-миграция восстановит только task-строки; подписки на репозитории и организации возвращать неоткуда.
8. Осознанно не сделано
Бэкфилл исторических строк GitHub-sync. Новые записи пишутся правильно (актор — синхронизация, человек в
on_behalf_of), старые сохраняют прежнюю форму.Инициатор через цепочку
gpt-doc. Цепочка запуска его не несёт вовсе, поэтому его записи читаются как «сделано автоматически».Агент в задачах.
actorOfв task-хендлерах не смотрит наX-Actor-Type, хотя агент его присылает: тело задачи, написанное агентом, записывается человеком, чей токен он использовал. 19 вызовов и смена смыслаtask_change_log.actor_id— отдельное решение.Shareи presence в шапку. Тулбар документа живёт в правой панели, скрытой ниже 1280px, поэтому на узком окне подписаться на страницу нельзя — ровно как и нажатьShare.Ретенция и компакция. Их нет. Когда появятся, компакция обязана объединять
actors, а не брать шапку первой строки: иначе схлопывание истории потеряет соавторов безвозвратно. Правило записано комментарием там, где живёт слияние.