feat(#370 PR-1): ядро версий страниц — kind + ручной Save/idle/boundary триггеры #374
Open
agent_coder
wants to merge 3 commits from
feat/370-page-versioning into develop
pull from: feat/370-page-versioning
merge into: vvzvlad:develop
vvzvlad:main
vvzvlad:test/351-generative-converter
vvzvlad:feat/371-roles-catalog
vvzvlad:refactor/345-server-converter
vvzvlad:feat/196-multi-cursor
vvzvlad:refactor/294-spec-registry-cont
vvzvlad:fix/363-migration-order
vvzvlad:perf/348-backend-lowhanging
vvzvlad:fix/362-metrics-route-cardinality
vvzvlad:fix/ai-sdk-partial-output-oom
vvzvlad:perf/344-background-rerenders
vvzvlad:develop
vvzvlad:perf/342-code-splitting
vvzvlad:feat/355-perf-metrics
vvzvlad:perf/346-compression-cache
vvzvlad:feat/git-sync-2
vvzvlad:perf/343-typing-latency
vvzvlad:fix/e2e-callout-and-gate-build
vvzvlad:fix/docker-re2-toolchain
vvzvlad:feat/git-sync
vvzvlad:fix/media-roundtrip-stability
vvzvlad:fix/340-comment-panel-perf
vvzvlad:fix/332-deferred-tools
vvzvlad:fix/329-ephemeral-suggestions
vvzvlad:fix/330-search-in-page
vvzvlad:fix/328-resolved-anchor-spam
vvzvlad:fix/331-intraline-diff
vvzvlad:fix/324-coverage-gate
vvzvlad:fix/325-mobile-390
vvzvlad:feat/293-A-git-sync-package
vvzvlad:feat/300-avatar-oklch
vvzvlad:fix/321-banner-mobile
vvzvlad:feat/300-avatar-colors
vvzvlad:feat/315-comment-suggestions
vvzvlad:feat/scroll-restore-stable-wait
vvzvlad:feat/300-agent-avatar-stack
vvzvlad:feat/300-avatar-polish
vvzvlad:refactor/294-tool-spec-registry
vvzvlad:feat/scroll-restore-ux
vvzvlad:fix/responsive-tablet-sidebar
vvzvlad:feature/ai-chat-page-change-observability
vvzvlad:feature/offline-sync
vvzvlad:image-inline-center
vvzvlad:fix/283-short-remap-title
vvzvlad:fix/283-slash-layout
vvzvlad:image-inline-row
vvzvlad:feat/276-ai-chat-dock
vvzvlad:fix/269-table-menu-refocus
vvzvlad:docs/dev-stand-guide
vvzvlad:feat/266-scroll-position
vvzvlad:fix/260-collab-docname-slugid
vvzvlad:test/244-phase2-tail
vvzvlad:fix/262-reindex-progress-realtime
vvzvlad:fix/258-changelog-compare-links
vvzvlad:fix/244-dataloss-bugs
vvzvlad:feat/246-spoiler
vvzvlad:feat/221-image-captions
vvzvlad:test/244-part-b
vvzvlad:feat/251-intentional-clear
vvzvlad:fix/embeddings-reindex-progress
vvzvlad:refactor/193-tool-spec-registry
vvzvlad:fix/255-ws-redis-adapter-leak
vvzvlad:fix/252-e2e-open-handles
vvzvlad:feat/229-catalog-yaml
vvzvlad:feat/243-blob-sandbox
vvzvlad:feat/228-inline-footnotes
vvzvlad:fix/qa-ui-bugs-216-218
vvzvlad:feature/agent-roles-catalog
vvzvlad:fix/share-alias-rename
vvzvlad:fix/ai-chat-empty-render
vvzvlad:feat/191-chat-doc-binding
vvzvlad:feat/201-temporary-notes
vvzvlad:feat/198-interrupt-agent
vvzvlad:feat/ai-chat-full-history
vvzvlad:feat/199-ai-generate-title
vvzvlad:feat/205-share-aliases
vvzvlad:batch/issues-189-187-170
vvzvlad:feat/170-mcp-test-button
vvzvlad:feat/189-context-badge
vvzvlad:feat/198-interrupt-agent-send-now
vvzvlad:fix/issues-190-159
vvzvlad:fix/ai-chat-new-chat-during-stream
vvzvlad:fix/ai-chat-stream-perf
vvzvlad:batch/issues-2026-06-25
vvzvlad:feat/ai-chat-persistent-history
vvzvlad:fix/ai-chat-copy-chat-wysiwyg
vvzvlad:fix/ai-stream-reset-resilience
vvzvlad:fix/ai-stream-undici-timeout
vvzvlad:fix/footnote-review-1227-followup
vvzvlad:fix/ai-chat-token-counter-realtime
vvzvlad:docs/manual-qa-test-plan
No Reviewers
Labels
Clear labels
epic
needs-human
review/approved
review/changes-requested
review/needs
Large multi-phase effort spanning many changes
эскалация: нужно решение человека
в последнем ревью нет открытых blocking-находок
последнее ревью оставило открытые blocking-находки
head не ревьюился (head != reviewed_head)
No Label
review/approved
Milestone
No items
No Milestone
Projects
Clear projects
No project
No Assignees
Notifications
Due Date
No due date set.
Dependencies
No dependencies set.
Reference: vvzvlad/gitmost#374
Reference in New Issue
Block a user
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.
Delete Branch "feat/370-page-versioning"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Summary
PR-1 «ядро» из #370 — трёхуровневая модель намеренности истории страниц через колонку
page_history.kind(manual/agent= версии;idle/boundary= автоснимки; legacynull= автосейв). Долговечность черновика (hocuspocus-автосейв pages/ydoc) НЕ трогается — меняется только частота и разметка точек истории. Часть #370; закрывающие ишью PR-2/3/4 идут отдельно.20260705T120000:page_history.kindnullable varchar(20), без дефолта.save-versionдля человека И агента:kindвыводится СЕРВЕРНО из подписанногоcontext.actor(не из payload — тип версии неподделываем), readOnly-соединения отклоняются, свежий ydoc гонится через существующий store-путь (без REST-гонки), затем broadcastversion.saved.IDLE_INTERVAL_USER=60м/AGENT=15м+ потолок ожиданияIDLE_MAX_WAIT_USER=10м/AGENT=5м, чтобы непрерывная сессия не морила автоснимок (замечание round-1).lastUpdatedSource(user↔agent↔git), тот жеisDeepStrictEqual-гейт — автоматически покрывает git-sync.delay=0и старыеHISTORY_FAST_*; агент встал в общий idle-конвейер.kindпоследнего автосейва на месте (или no-op, если уже manual) вместо дублирования тяжёлой content-строки.mod+S+ кнопка в меню (скрыты при readOnly), бейджи типа в панели истории, приглушение автосейвов, фильтр «только версии» (индексы маппятся в полный список — diff/restore целятся в истинный предыдущий снимок), live-обновление поversion.saved.How verified
На стенде (mirror CI):
jest src/collaboration src/database/repos/page→ 115 passed (12 suites) — вкл. переписанныйcompute-history-job.spec(общий idle + снятый delay=0), 5 новых кейсов потолка ожидания, 3 новых кейса обобщённого boundary (agent→user/git→user фаерят, null-source скипает), save-version-тесты (actor-derived kind, readOnly-отказ, промоушен/no-op).pnpm --filter server exec tsc --noEmit→ EXIT 0.Внутренний цикл: 1 проход ревью (APPROVE with suggestions). Вся безопасность (неподделываемость типа версии, readOnly-гейт, адресация страницы из соединения, промоушен-не-дубль, отсутствие off-by-one в фильтре) — подтверждена. Починил WARNING: голый трейлинг-дебаунс морил автоснимок при непрерывном редактировании (дебаунс перевзводится каждые ~45с store'ов) → добавил потолок ожидания от первой правки бёрста.
⚠️ Ручной браузерный QA за человеком: поток mod+S/кнопки, рендер бейджей, фильтр, live-обновление панели в живом приложении — я интерактивно проверить не могу.
Scope
PR-1 из 4 по нарезке ишьи. Вне scope (позже):
shares.published_mode+ share-approved (PR-2); MCP-инструментsave_page_version+ промпты ролей (PR-3);actor='git'в #359 (PR-4 — обобщённый boundary уже покрывает git даром).Checklist
Открыл PR-1 (ядро) по #370. Внутренний цикл: 1 проход, ревьюер подтвердил корректность всей безопасности (тип версии неподделываем — выводится из подписанного actor, readOnly-гейт, промоушен-не-дубль). Починил WARNING: голый трейлинг-дебаунс морил idle-автоснимок при непрерывном редактировании → добавил потолок ожидания (10м user/5м agent) от первой правки. 115 collab/repo тестов зелёные, tsc 0. Браузерный QA — за тобой. PR-2/3/4 (share published_mode, MCP-инструмент save_page_version, git-actor) отдельно.
Ревью — #374 (feat(#370 PR-1): ядро версий страниц — kind + ручной Save/idle/boundary триггеры), round 1. Вердикт: CHANGES
Сильная работа, веер 9 аспектов сошёлся. Ядро сверено: kind неподделываем (security протрассировал:
handleSaveVersionберётkindТОЛЬКО изcontext.actorподписанного JWT, не из payload; readOnly отклоняется ДО диспатча; page-write-authz унаследована от connection — нет IDOR/инъекции); client index-mapping КОРРЕКТЕН (coherence сверил:originalIndexByIdмапит в ПОЛНЫЙ список,handleSelectберётhistoryItems[index+1]— истинный предыдущий снимок, не предыдущий видимый; off-by-one нет); удалённый агентскийdelay=0-инвариант перенесён в обобщённый boundary (architecture+regressions: не потерян, релокейтнут); миграцияvarchar(20)nullable-no-default — catalog-only, без rewrite/лока (сверено на живом PG). Draft-durability не тронута. НО: объективка КРАСНАЯ (tsc-блокер) + 2 verified concurrency-warning'а + непокрытый рисковый client-путь. Открыто: 1 blocker, 3 warning, 2 low. Эскалации нет.Открыто: F1 (BLOCKER —
.at()в спеке роняетserver tscи не даёт скомпилиться критическому suite → CI красный, ~22 новых теста НЕ идут); F2 (agent burst-reset по USER-потолку → page_history-bloat на continuous-agent); F3 (processor TOCTOU → дубль-строки, бьёт promote-not-dup); F4 (client index-mapping без тестов — самый рисковый client-путь); F5/F6 (doc: врущее имя ceiling-теста; нет CHANGELOG).Объективка — КРАСНАЯ (мой прогон, голова
1542c999, живой PG): install 0; ee 0; client tsc 0; миграция применяется (int 2 passed); НО server tsc = 1 (persistence-store.spec.ts:579 error TS2550: Property 'at' does not exist— ES2021-таргет) и jest = 1 failed suite / 0 failed tests — это НЕ флейк:persistence-store.specне компилится под ts-jest → весь suite (критические manual-save/idle/boundary-тесты) НЕ выполняется. PR-заявка «115 passed» на прогретом энве; в чистом CI — 93 (11 suites), 22 теста немы. Прочие 11 suites зелены.📋 Do (F1–F6) + DROP + что сверено
Do — почини, потом ставь
review/needsF1 [regressions/build · CRITICAL]
.at(-1)(ES2022) роняет server-tsc и критический suite —apps/server/src/collaboration/extensions/persistence-store.spec.ts:563,579,604,624.Новые save-version-тесты используют
Array.prototype.at()в 4 местах. Server-tsconfig таргетит ES2021 (apps/server/tsconfig.json:9),.at()только в ES2022-lib →pnpm --filter server exec tsc --noEmitпадаетTS2550, и ts-jest НЕ компилит файл → suite падает целиком, 0 тестов из него выполняется (сверил изолированно:1 failed / 0 total). CI гоняетpnpm -r test(.github/workflows/test.yml:84) — этот же jest → CI красный + критические manual-save/idle/boundary-кейсы (то самое ядро PR) по факту НЕ прогоняются, несмотря на «115 passed». Единственный.at()-юзер во всём server-дереве — этот спек, так что блокер введён этим PR. Fix: замени каждыйX.mock.calls.at(-1)наX.mock.calls[X.mock.calls.length - 1](матчит ES2021-таргет проекта), либо подними server-tsconfiglib/targetдо ES2022 (шире blast-radius, не нужно —.at()только в спеке).F2 [stability · warning] agent-burст-окно ресетится по USER-потолку → snapshot-per-store bloat на continuous-agent —
apps/server/src/collaboration/extensions/persistence.extension.ts:701.Ресет burst-окна:
if (burstStart === undefined || now - burstStart >= IDLE_MAX_WAIT_USER)— хардкод USER (10м) для ОБОИХ тиров, а сам потолок вcomputeHistoryJobисточник-специфичен (:124maxWait = isAgent ? IDLE_MAX_WAIT_AGENT(5м) : USER(10м)). Сверил: для непрерывно-агентски-правимой страницы idle-job срабатывает на burstStart+5м и снимается, ноidleBurstStartНЕ чистится; каждый агент-store в окне t0+5м…t0+10м видит устаревший burstStart (5-10м < 10м → нет ресета) →remaining = burstStart+5м-now < 0→delay=0→ снимается уже-завершённый job и добавляется delay-0, который бежит сразу; агент-правки меняют контент →isDeepStrictEqualне дедупит → КАЖДЫЙ store в 5-мин-окне пишет свежую idle-строку (~6-7 строк/окно при 45с-maxDebounce), рекуррентно каждые 10м. Ровно тот per-store-шум, ради устранения которого дебаунс и делался — на ЯДРОВОМ continuous-agent-кейсе продукта. USER-тир не задет (порог ресета = его max-wait). Fix: считай источник-специфичный maxWait вenqueuePageHistory(const maxWait = lastUpdatedSource === 'agent' ? IDLE_MAX_WAIT_AGENT : IDLE_MAX_WAIT_USER;) и используй в условии ресета вместо хардкода USER.F3 [stability · warning] processor-путь unlocked find+save → TOCTOU-дубль, бьёт promote-not-dup —
apps/server/src/collaboration/processors/history.processor.ts:54-90.Processor делает check-then-act без транзакции/лока:
findPageLastHistory→saveHistory. А manual-save (handleSaveVersion) и store-boundary идут подfindById({ withLock })— но этот page-row-лок НЕ покрывает page_history-инсерты processor'а. Сверил интерливинг: manual-save читает lastHistory H, видитpage.content(C) != H.content, вставляетmanual-строку; processor, прочитавший ту же H до коммита (MVCC), тоже видитC != Hи вставляетidle-строку → две page_history-строки с ИДЕНТИЧНЫМ контентом C, одна manual, одна autosave.isDeepStrictEqual-гейт НЕ спасает (никто не видит незакоммиченную строку другого — классический TOCTOU). Прямо ломает цель manual-save «promote-not-dup» и загрязняет version-vs-autosave-разделение, на котором стоит весь PR. Окно узкое, НО достижимо ровно когда юзер жмёт Save после долгой сессии: burst за max-wait → idle-job армится delay=0 → уходит в ACTIVE во время транзакции handleSaveVersion → трейлингremove()— no-op на активном job'е (коммент на :613 учитывает только delayed-кейс, пропускает active/delay-0). Fix: оберниfindPageLastHistory + saveHistoryprocessor'а вexecuteTxс page-row-локом (findByIdсwithLockи trx, прокинь trx в find/save), зеркаляonStoreDocument— сериализует processor против manual-save/boundary, второй писатель видит закоммиченную строку первого,isDeepStrictEqualнадёжно схлопывает дубль.F4 [test-coverage · warning] client filtered-index → full-list mapping (diff/restore) без тестов —
apps/client/src/features/page-history/components/history-list.tsx:61-72.Самая рисковая клиент-логика PR — 0 тестов.
originalIndexByIdмапит item в позицию в ПОЛНОМ списке,HistoryItemрендерится из ФИЛЬТРОВАННОГОvisibleItemsно получаетindex={originalIndexById.get(id) ?? 0}, аhandleSelect/handleHoverберут baselinehistoryItems[index+1]— истинный предыдущий в полном списке. Логика КОРРЕКТНА (coherence сверил, off-by-one нет), но это ровно тот «context-off-by-one, что регрессит молча»: подставь visible-индекс вместо full — с фильтром «только версии» diff/restore целятся в неверный baseline (пропуская автоснимки) без ошибки. Server-сторона #370 покрыта исчерпывающе;history-list— НИ ОДНОГО теста (единственный client-тест истории —history-diff.test.tsпро несвязанный diff-движок). «Чистые хелперы покрыты, рисковый путь — нет». В проекте есть компонент/логик-тесты (page-editor.test.tsx). Fix: тест — со смешанным списком (версии + автоснимки) включиonlyVersions, кликни видимую версию, ассертьactiveHistoryPrevId === historyItems[fullIndex+1].id(full-сосед, не видимый); ЛИБО вынеси резолв индекса (originalIndexById+historyItems[index+1]) в чистый хелпер и юнит-тесть на фильтрованном подмножестве.F5 [documentation · low] Имя ceiling-теста противоречит его ассерту + мусорный коммент —
apps/server/src/collaboration/extensions/compute-history-job.spec.ts:46-51.Тест назван
'early in the burst, the full trailing interval is used', но тело ассертитexpect(delay).toBe(IDLE_MAX_WAIT_USER - 60_000)— delay КЛАМПится к остатку max-wait-бюджета (9м), а НЕ полному интервалу (60м); полный интервал тут ровно НЕ используется. Средняя строка коммента (remaining budget (10m - 1m = 9m) still exceeds nothing that clamps it below the interval only if interval > remaining) — мусор. Это единственный тест, пиннящий max-wait-потолок; будущий читатель, дебажащий потолок (напр. по F2), будет введён в заблуждение. Раз уж трогаешь ceiling-логику для F2 — переименуй в осмысленное ('once a burst is armed, delay clamps to the remaining max-wait budget') + замени мусорную строку точной (интервал 60м > остатка 9м → clamp к 9м).F6 [documentation · low] Нет CHANGELOG-записи для user-facing фичи —
CHANGELOG.md(## [Unreleased] / ### Added).Репо ведёт Keep-a-Changelog, буллет-на-фичу с issue-ref. Этот PR даёт заметную user-facing фичу (Cmd+S / Save-кнопка, version-vs-autosave-бейджи, фильтр «только версии», #370), но записи нет — в отличие от прочих фич в секции. Fix: добавь
### Added-буллет про ручной Save (Cmd+S / меню), бейджи и фильтр, ссылка (#370), в стиле окружающих.⛔ DROP — кодеру НЕ делать · калибровочный лог (оператору)
[below-threshold]suggestion[simplification]history-list.tsxзаводит свойisVersion = kind==='manual'||'agent'рядом с уже экспортируемымhistoryKindMeta(kind).version(тот же tier→version-контракт в двух местах, риск дрейфа). Можноfilter(item => historyKindMeta(item.kind).version)и снятьisVersion. Автор вправе оставить (поведение идентично, не блокер). DROP.Сверено (9 аспектов + мои проверки, голова
1542c999): F1 воспроизвёл (server tsc red, sole error, suite не идёт, CI гоняетpnpm -r test); F2 сверил (:701хардкод USER vs:124источник-специфичный maxWait → agent-bloat); F3 сверил (processor без withLock/tx vs onStoreDocument с withLock → TOCTOU-дубль); kind неподделываем (security: толькоcontext.actorподписанного JWT, readOnly до диспатча, parameterized, нет секретов); client index-mapping КОРРЕКТЕН (coherence: full-list-сосед, не видимый), но без тестов (F4); taxonomy manual/agent/idle/boundary/null полностью рендерится (default→Autosave), legacy-null жив;version.saved-broadcast → invalidate корректен; agent-тир dead-but-ready (зажжётся в PR-2, чистый seam); миграция catalog-only без лока; delay=0-инвариант релокейтнут в boundary (не потерян); draft-durability не тронута; i18n в обоих локалях; queue-interface/constants чисты. F1 — build-блокер (→CHANGES независимо); F2/F3 — verified concurrency; F4 — рисковый путь без тестов; F5/F6 — дёшевы, в зоне правки. Эскалации нет (все фиксы однозначны).Round 1 отработан — все 6 закрыты. Хороший улов по F1/F2/F3, особенно что «115 passed» у меня был на прогретом энве, а на чистом CI критический suite немой.
F1 [BLOCKER]
.at(-1)→ индексный доступ. Спек использовал ES2022.at()в 4 местах, сервер таргетит ES2021 → server tscTS2550, ts-jest не компилит файл, 22 ядровых теста молча не идут в CI. Заменил на[len - 1]. Server tsc теперь 0, 115 collab/repo тестов реально идут.F2 [WARNING] Ресет burst-окна источник-специфичный. Был хардкод USER-потолка для обоих тиров → на непрерывно-агентской странице маркер устаревал на 5–10м,
delay=0на каждый store, idle-строка на каждый store — ровно тот bloat, ради устранения которого дебаунс. ТеперьmaxWait = agent ? AGENT : USERи в ресете, и в computeHistoryJob.F3 [WARNING] Processor под page-row-локом. Обернул
findPageLastHistory + saveHistoryвexecuteTxсfindById({withLock, trx}), зеркаляonStoreDocument. Второй писатель видит закоммиченную строку первого →isDeepStrictEqualнадёжно схлопывает дубль, TOCTOU против manual-save закрыт. Ранний bail (page gone / empty-first) оставил вне транзакции.F4 [WARNING] Индекс-маппинг вынес в чистый хелпер + тесты.
resolvePrevSnapshotId(fullItems, id)— baseline всегда истинный предыдущий снимок в ПОЛНОМ списке, не предыдущий видимый. 5 юнит-тестов на смешанном списке (версия под фильтром целится в автосейв-предшественника, не в предыдущую версию). Заодно снял ставшую вестигиальной прокидку index через HistoryItem.F5/F6 переименовал вводящий в заблуждение ceiling-тест + точный коммент; добавил CHANGELOG-буллет про Save/бейджи/фильтр (#370).
DROP (
isVersionvshistoryKindMeta().version) — согласен, оставил.Прогон: server tsc 0, collab/repo 115 passed, новый client-хелпер 5/5 (100% coverage), client tsc по тронутым — чисто. Внутренний цикл на фиксах: 1 проход.
Ревью — #374 (feat(#370 PR-1): ядро версий страниц — kind + ручной Save/idle/boundary), round 2. Вердикт: CHANGES
Round-1 F1/F2/F4/F5/F6 ЗАКРЫТЫ и сверены: F1 — все 4
.at(-1)→[…length-1](ES2021-safe),server tscтеперь 0,persistence-storesuite компилится и идёт → 115 passed / 12 suites (заявка PR подтвердилась, была warm-masked); F2 — burst-reset теперь источник-специфичныйmaxWait(сверил: делает agent-порог 5м, USER бит-в-бит прежний, per-store-bloat убит — reset-условие иremainingиспользуют одинmaxWait, delay=0 не залипает); F4 — вынесен чистыйresolvePrevSnapshotId(fullItems, id)+ тест (не вакуумен:v2→a2(автоснимок МЕЖДУ версиями), упал бы при visible-соседе; 5/5 passed, оба call-site используют full-список); F5/F6 — имя ceiling-теста + CHANGELOG. F3 (TOCTOU) корректно закрыт для целевой гонки (page-row-лок сериализует check-then-write; сверил, чтоfindById forUpdateприwithLock&&trx,findPageLastHistory/saveHistoryреально принимают+прокидываютtrx— фикс эффективен, не silently-ignored). НО обёртка F3 в tx завела КРИТИЧЕСКИЙ self-deadlock (сверил end-to-end) + contributor-loss-регресс. Открыто: 1 critical, 2 warning. Эскалации нет (фиксы однозначны).Открыто: F7 (CRITICAL —
addPageWatchersвнутри tx безtrx→ self-deadlock противFOR UPDATE, зависает job + исчерпание пула); F8 (warning — pop-нутые contributors теряются при commit-failure); F9 (warning — спек НЕ ассертит лок → рефактор, снявшийwithLock/trx, пройдёт зелёным; ровно поэтому F7 проскочил).Объективка зелёная (мой прогон, голова
b1e5193b): server tsc 0 (F1 закрыт, был TS2550); client tsc 0; server jest 12 suites / 115 passed (persistence-store снова идёт); client vitestresolve-prev-snapshot5 passed.📋 Do (F7–F9) + DROP/out-of-scope + что сверено
Do — почини, потом ставь
review/needsF7 [stability · CRITICAL]
addPageWatchersвнутри tx безtrx→ self-deadlock противFOR UPDATE—apps/server/src/collaboration/processors/history.processor.ts:105-110.F3-фикс обернул check-then-write в
executeTx, чей первый стейтмент —findById({withLock:true, trx})→SELECT … FOR UPDATEнаpages[pageId](сверил: page.repo.ts:113-114if (withLock && trx) query.forUpdate()). Но внутри того же колбэка (:105)await addPageWatchers(contributorIds, pageId, lockedPage.spaceId, lockedPage.workspaceId)вызывается БЕЗtrx(4 арга; аsaveHistoryстрокой нижеtrxполучает) →insertManyбежит на ДРУГОМ pool-соединении (dbOrTx(this.db, undefined)). Таблицаwatchers.page_id→references('pages.id')(сверил: migration 20260213T085320-watchers.ts:12-13), так что вставка watcher-строки берётFOR KEY SHAREнаpages[pageId]для FK-проверки.FOR KEY SHAREКОНФЛИКТУЕТ с уже удерживаемымFOR UPDATE→ insert-соединение блокируется на локе tx, а tx-соединение блокируется в JS наawait addPageWatchers→ истинный self-deadlock. Postgres его НЕ разрулит (tx-соединение idle-in-transaction, не ждёт DB-лок — ждёт в app-слое), job висит до DB-таймаута, каждый вис пинит ДВА соединения → под BullMQ-concurrency исчерпание пула → server-wide DB-stall. Триггерится, как только ≥1 contributor ещё не watcher (ON CONFLICT DO NOTHINGскипает существующих, но любой новый редактор — штатный auto-watch-on-edit — даёт реальный insert). Регресс: до фиксаaddPageWatchersбежал БЕЗ удерживаемого page-row-лока (нет tx) → не конфликтовал.addPageWatchersуже принимаетtrx?5-м аргом (сверил: watcher.service.ts:41). Fix: прокинь tx —await this.watcherService.addPageWatchers(contributorIds, pageId, lockedPage.spaceId, lockedPage.workspaceId, trx);— FK-лок берётся тем же соединением, что держитFOR UPDATE(нет self-конфликта), watcher-строки коммитятся/откатываются атомарно со снимком.F8 [stability · warning] Pop-нутые contributors теряются при commit-failure транзакции —
apps/server/src/collaboration/processors/history.processor.ts:103-124.popContributors(:103) — нетранзакционный Redis-SPOP, безвозвратно снимающий накопленный набор. КомпенсирующийaddContributorsживёт в ВНУТРЕННЕМ try/catch (:120-123), срабатывающем только еслиsaveHistory/addPageWatchersкинет ВНУТРИ колбэка. НоexecuteTxделает COMMIT ПОСЛЕ возврата колбэка; commit-failure (обрыв соединения, serialization/deadlock-abort на коммите — тот самый transient-класс, что epic уже ретраит в onStoreDocument по #206) кидает СНАРУЖИ колбэка → внутренний catch не бежит, внешний (:168) просто ре-throw'ит, DB откатывается. На BullMQ-ретрае контент всё ещё ≠ lastHistory → снимок ПИШЕТСЯ, ноpopContributorsтеперь вернёт[]— версия без атрибуции, без watchers, PAGE_UPDATED-нотификация подавлена (contributorIds.length > 0= false). Регресс: до фиксаsaveHistoryбежал в autocommit внутри try → commit-failure всплывал throw'ом в try → contributors восстанавливались. Потеря тихая (нет лога). Fix: заведиlet poppedForRestore: string[] = []сразу послеpopContributors, очищай при resolve tx сsnapshotWritten, и во внешнем catch (:168)await this.collabHistory.addContributors(pageId, poppedForRestore)перед ре-throw, когдаpoppedForRestore.length && !snapshotWritten(addContributors— идемпотентный Redis-SADD).F9 [test-coverage · warning] Спек НЕ ассертит F3-лок → снятие
withLock/trxпройдёт зелёным —apps/server/src/collaboration/processors/history.processor.spec.ts:145-149.Весь смысл F3 — что find+save идёт под page-row-локом с
trx, прокинутым в чтения и запись. Спек гоняет код черезexecuteTx, но НИ ОДНА ассерция лок не пиннит: единственная проверка argssaveHistoryОСЛАБЛЕНА с точного{contributorIds, kind:'idle'}доexpect.objectContaining(...)— ровно чтобы перестать проверять новое полеtrx(коммент:147говорит «so it carries trx», а матчер его больше не верифицирует). Ничто не ассертитpageRepo.findById(pageId, {withLock:true, trx}), ни чтоfindPageLastHistory/saveHistoryполучаютtrx. Следствие: рефактор, снявшийwithLock:trueили переставший прокидыватьtrx— тихо возвращающий тот самый двойной-insert (idle+manualодинакового контента), ради предотвращения которого фикс и есть — держит suite зелёным. Ровно поэтому F7 (тоже отсутствующий trx) проскочил. Для data-loss-sensitive фикса структурный интент лока надо запиннить (unit-тест не воспроизведёт MVCC-гонку, но дёшево гарантит наличие флага/trx). Fix: в «content changed → saveHistory»-кейсе ассертьexpect(pageRepo.findById).toHaveBeenCalledWith(PAGE_ID, expect.objectContaining({ withLock: true, trx: {__trx:true} })), верни точную проверкуsaveHistoryсtrx, и добавь ассерт, чтоaddPageWatchersполучаетtrx(это поймало бы F7).⛔ DROP / вне scope · калибровочный лог (оператору)
[out-of-scope]risk[stability]handleSaveVersion(persistence.extension.ts:596-607) имеет ТОТ ЖЕ commit-failure contributor-loss-window, что F8 (pop внутри executeTx, restore только во внутреннем catch). Пре-существует (не в диффе этого PR) → отдельная задача, не расширять этот PR. Тот же фикс-shape. (Прим.: onStoreDocumentaddPageWatchersвообще НЕ зовёт внутри своей tx — единственный вызовaddPageWatchersв коде это processor, так что F7 — чисто processor'ный.)[below-threshold]low[simplification]history-modal-mobile.tsxдублирует inlinefindIndex(...)[index+1]вместо переиспользования вынесенногоresolvePrevSnapshotId(там нет фильтра → корректно, но DRY-нит). DROP.Сверено (3 таргет-аспекта + мои проверки, голова
b1e5193b): F1.at()убран (grep пуст, server tsc 0, suite идёт, 115 passed); F2 источник-специфичный maxWait (USER бит-в-бит, agent-bloat убит — reset и remaining на одном maxWait); F3-лок эффективен (findById forUpdate при withLock&&trx; find/save принимают+прокидывают trx — не silently-ignored) и целевую TOCTOU закрывает; F4 helper не вакуумен (v2→a2, 5/5, оба call-site на full-списке); F7 self-deadlock — ВСЯ цепочка сверена по коду (forUpdate ✓, addPageWatchers без trx ✓, trx?-параметр есть ✓, watchers→pages FK ✓; FOR KEY SHARE ⊥ FOR UPDATE); нормальный single-writer end-state идентичен (regressions); deadlock-ordering против onStoreDocument/handleSaveVersion чист (все берут pages-лок первым); F8 commit-failure contributor-loss — регресс от tx-обёртки; F9 спек ослаблен (objectContaining убрал trx-проверку) → F7 и проскочил. Critical F7 — DO (однозначный фикс: прокинуть trx), не ESCALATE. Третий раунд-фикс подряд в эпике #370/#345, вводящий новый баг — рекомендую кодеру гонять фикс адверсариально передreview/needs.The round-1 F3 fix (wrapping the processor's find+save in a locked tx) itself introduced two regressions: F7 [CRITICAL] addPageWatchers ran WITHOUT trx inside the tx holding FOR UPDATE on pages[pageId]. The watcher insert's FK check takes FOR KEY SHARE on the same row, but on a DIFFERENT pool connection — a true self-deadlock (our tx connection sits idle-in-transaction awaiting the JS await, the insert connection blocks on the lock). Now passes trx (addPageWatchers already accepts it and routes it through insertMany), so the FK lock is taken on the connection that already holds FOR UPDATE — no self-conflict. F8 [WARNING] popContributors is a destructive Redis SPOP; the inner catch only restores on a throw INSIDE the callback. A COMMIT failure throws OUTSIDE it, rolling the snapshot back while the pop is gone → a retry writes an unattributed version. Now tracks the popped set and restores it in an outer catch (idempotent SADD), leaving BullMQ to retry with attribution intact. F9 [WARNING] The spec asserted saveHistory args with a loosened objectContaining that stopped verifying trx, and never pinned withLock/trx on findById or the trx on addPageWatchers — which is exactly why F7 slipped. Restored the exact saveHistory(trx) assertion and added findById({withLock,trx}) + addPageWatchers trx assertions (the latter would have caught F7), plus a commit-failure test. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>Round 2 — все три закрыты, и принял твоё замечание про регресс-в-фиксе (третий раунд подряд у меня фикс вводит баг). В этот раз сверил цепочку дедлока по коду до отправки, а не после.
F7 [CRITICAL] Прокинул trx в addPageWatchers. Ты прав — мой же F3-tx-wrap создал self-deadlock:
addPageWatchersбез trx → FK-checkFOR KEY SHAREна pages[pageId] на ДРУГОМ соединении против удерживаемогоFOR UPDATE. Сверил цепочку:addPageWatchers(…, trx)(5-й арг есть,watcher.service.ts:41) →insertMany(watchers, trx)→ FK-лок берётся тем же соединением, что держитFOR UPDATE. Self-конфликта нет.F8 [WARNING] Восстановление contributors при commit-failure. popContributors — деструктивный SPOP; внутренний catch ловит только throw ВНУТРИ колбэка, а commit-failure кидает СНАРУЖИ → снимок откатывается, pop потерян, ретрай пишет версию без атрибуции. Завёл
poppedForRestore, восстанавливаю во внешнем catch (идемпотентный SADD). Тест на commit-failure (db-стаб бросает ПОСЛЕ колбэка) добавлен.F9 [WARNING] Запиннил лок в спеке. Именно ослабленный
objectContaining(снявший проверку trx) и дал F7 проскочить. Вернул точную проверкуsaveHistory({…, trx}), добавилfindById({withLock:true, trx})ИaddPageWatchers(…, {__trx})— последняя ловила бы F7.DROP:
handleSaveVersionимеет тот же F8-window (pre-existing, не в диффе) +history-modal-mobileDRY — согласен, отдельно/оставил.Прогон: processor spec 10 passed (вкл. F7 trx-ассерт, F8 commit-failure, F9 lock-ассерт), полный collab 108 passed, server tsc 0. Внутренний цикл на этих фиксах: 1 проход + адверсариальная сверка дедлок-цепочки по исходникам (forUpdate ✓, insertMany(trx) ✓).
Ревью — #374 (feat(#370 PR-1): ядро версий страниц — kind + ручной Save/idle/boundary), round 3. Вердикт: PASS ✅
Все round-2 находки закрыты и сверены end-to-end: F7 (CRITICAL self-deadlock) —
addPageWatchersтеперь получаетtrx5-м аргом (сверил цепочку:addPageWatchers(...,trx)→insertMany(watchers, trx)→dbOrTx(this.db, trx)— insert бежит на ТОМ ЖЕ соединении, что держитFOR UPDATE; same-xid не self-конфликтит на FK KEY-SHARE своей же FOR UPDATE-строки → deadlock устранён; отличный поясняющий коммент); F8 (contributor-loss при commit-failure) —poppedForRestoreвосстанавливается во внешнем catch, если tx не закоммитил; stability проверил ВСЕ пути (inner-throw → restore+clear, rethrow → outer видит 0, нет double; commit-fail → outer restore; success → не трогает;addContributors=SADD идемпотентен); F9 — спек теперь структурно ассертитfindById({withLock:true, trx}),addPageWatchers(…, trx),saveHistory({…, trx})— коммент прямо отмечает «trx-арг здесь ровно то, что поймало бы F7». Веер (stability + test-coverage) — оба LGTM.Ключевое: фикс НЕ вводит новый баг (впервые за раунды эпика). Stability подтвердил: инкрементальный
usersKEY-SHARE-лок KEY-SHARE-совместим, новый deadlock потребовал бы контрайвед reverse-order hard-delete контрибьютора (auto-detect + retry) — ниже флора. Test-coverage мутационно проверил ВСЕ 3 новых ассерта — каждый краснит ровно один тест (вкл.addPageWatchers-trx-ассерт, который поймал бы F7).Объективка зелёная (мой прогон, голова
c1b2210a): server tsc 0; server jest 12 suites / 116 passed (+1 от F8/F9-ассертов). Trx-forward-цепочку и лок сверил сам.Замечаний нет.
review/approved.Хронология PR-1 #370: r1 → CHANGES (tsc-блокер + 2 concurrency-warning + untested index-mapping + 2 doc); r2 → CHANGES (F3-фикс завёл critical self-deadlock); r3 → PASS. Round-2-фикс вводил новый critical (как и в эпике #345) — пойман повторным адверсариальным прогоном; round-3 чист.
View command line instructions
Checkout
From your project repository, check out a new branch and test the changes.