feat(comment): эфемерные предложения-правки — Apply/Dismiss убирают комментарий (#329) #338
Reference in New Issue
Block a user
Delete Branch "fix/329-ephemeral-suggestions"
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
Предложения-правки агента (#315) становятся ЭФЕМЕРНЫМИ: решил (Apply или новый Dismiss) → комментарий исчезает (hard-delete + снятие Yjs-anchor-метки), если в треде НЕТ ответов; есть ответы → resolve (обсуждение сохраняется). Ручной Resolve без изменений. Только комментарии с
suggestedText.closes #329, расширяет #315.Сервер
deleteCommentMark(по образцуresolveCommentMark) снимаетcomment-метку из документа через существующийremoveYjsMarkByAttribute.finalizeAppliedSuggestion: развилкаhasChildren— ответы → apply+resolve (outcome:'resolved'); нет → apply+delete+снятие метки (outcome:'deleted').dismissSuggestion(валидирует top-level+suggestedText+не applied+не resolved), та же развилка, правоcanComment(НЕ canEdit — не меняет текст), auditCOMMENT_SUGGESTION_DISMISSED, эндпоинтPOST /comments/dismiss-suggestion. Apply остаётсяcanEdit.{ outcome: 'deleted' | 'resolved' }.Клиент
canShowDismiss(canComment). Мутации сводят кэш поoutcome(deleted → убрать; resolved → в resolved-вкладку). i18n ru/en.Data-integrity (из внутреннего ревью)
deleteEphemeralSuggestionтеперь снимает метку ПЕРВОЙ и ФАТАЛЬНО, строку удаляет только при успехе. Удаление строки необратимо, поэтому сбой снятия метки (в т.ч. hard-errorCOLLAB_DISABLE_REDIS«no live instance») ОБЯЗАН прервать операцию (→5xx, повторяемо), а не проглотиться и оставить перманентный orphan-anchor на удалённый комментарий.deleteCommentMarkбольше не best-effort (в отличие от resolve, где строка сохраняется и сбой метки восстановим).onErrorсводят 404/400 к успеху (комментарий уже удалён/resolved) — убирают из кэша вместо красной ошибки. Восстанавливает идемпотентность apply из #315, которую сломало бы эфемерное удаление.Не сделано (осознанно)
deleteCommentMarkв обычном/comments/delete— не добавлял (меняет поведение ВСЕХ удалений + требует инъекции гейтвея; интерактивный клиент и так снимает метку черезunsetComment). Опционально по тикету, вне scope.How verified
comment+collaborationjest 144 passed (15 suites). Включая: развилка delete/resolve у apply и dismiss, все 4 guard'а dismiss, handlerdeleteCommentMark, authz (dismiss=canComment / apply=canEdit), И тест «сбой снятия метки → строка НЕ удалена + ошибка всплывает» (F1).Ограничение честно: DB-integration/e2e по этому пути в репо нет (не гонял против Postgres); визуального рендера кнопок — нет тулинга; COLLAB_DISABLE_REDIS проверен юнит-тестами на том же handler-invocation коде, не против живого redis-disabled инстанса.
Checklist
🤖 Generated with Claude Code
Ревью — #338 (эфемерные предложения-правки: Apply/Dismiss убирают комментарий, #329), round 1, head
5794d62e, base developf5d19f97Вердикт: ESCALATE (нужно решение человека) — фича реализована аккуратно и объективка зелёная, НО деструктивная ветка (hard-delete комментария) поднимает вопрос авторизации, который агент не вправе решить сам: правом
canCommentлюбой комментатор может НЕОБРАТИМО удалить чужой suggestion-комментарий. Плюс на той же ветке — реальная гонка с потерей данных (F1) и мелкий тест-пробел (F2), их чинить ПОСЛЕ решения по развилке. Оба агента-роли останавливаются (needs-human).Объективка запущена мной (head
5794d62e, main-клон, editor-ext/git-sync собраны): server tsc 0, server jest 154 passed (comment+collaboration); client tsc 0, client vitest 52 passed (comment). Зелёная. Data-integrity-путь (mark-first-fatal-then-delete) проверен — комментарии в коде точны, ветка при сбое снятия метки корректно прерывается до удаления (не оставляет orphan-anchor).Escalate — нужно решение человека (петля остановлена)
[security] Кто вправе НЕОБРАТИМО удалить чужой suggestion-комментарий?
dismiss-suggestionгейтит толькоcanComment, без проверки владельца/админа —apps/server/src/core/comment/comment.controller.ts:239-269→comment.service.ts:468,499,520(ветка без ответов →deleteEphemeralSuggestion→ hard-delete строки + снятие Yjs-метки).Что это за изменение (простыми словами): новый эндпоинт
POST /comments/dismiss-suggestion(«Не применять») делает предложения-правки ЭФЕМЕРНЫМИ: если у комментария-предложения нет ответов — он НЕОБРАТИМО удаляется из БД (hard-delete, не soft) вместе со снятием инлайн-подсветки в документе; если ответы есть — тред резолвится. Проверяется только у комментариев сsuggestedText.Проблема: эндпоинт авторизует ТОЛЬКО правом
canComment(доступ комментировать страницу) и больше ничем. Существующий же общийPOST /comments/delete(тот же файл, :272-303) кромеcanCommentтребует «владелец ИЛИ админ-пространства» (иначеForbidden: You can only delete your own comments). То есть новый деструктивный путь обходит модель авторизации удаления, принятую в коде. Проверил:suggestedTextставится при СОЗДАНИИ любым пользователем и после не меняется (нет вUpdateCommentDto), 4 структурных гварда (top-level / есть suggestedText / не applied / не resolved) читаются с строки БД по UUID и подделать их нельзя — брешь именно в отсутствии проверки владельца на необратимом удалении. Итог: любой, кто может комментировать (в т.ч. viewer при включённомallowViewerComments), может навсегда удалить чужой top-level suggestion-комментарий (без ответов, не applied/resolved). Действие аудируется (COMMENT_SUGGESTION_DISMISSED), но необратимо.Почему нужен человек: это продуктово-правовая развилка, и «очевидный безопасный фикс» (потребовать владельца, как в
/comments/delete) может СЛОМАТЬ смысл фичи. Замысел #329 — что РЕВЬЮЕР расчищает предложения-правки (в т.ч. сгенерированные агентом, автор которых — не он); строгий owner-only это заблокирует. Коммент кодера в контроллере (:259-265) обосновываетcanCommentвместоcanEditтем, что dismiss «не меняет текст страницы» — но это про ось редактирования, а НЕ про владение; ось владения он не рассматривал (похоже на недосмотр, а не осознанное «удалять чужое можно всем»). Определить намерение из кода нельзя — решать тебе.canComment, коммунальный dismiss). Effort: 0. Плюсы: ревьюер свободно расчищает любые предложения (в т.ч. агентские); действие аудируется, ограничено unresolved/un-applied/без-ответов suggestion-комментариями. Минусы: низкопривилегированный комментатор (в т.ч. viewer) необратимо удаляет чужой комментарий — расходится с моделью/comments/delete./comments/delete. Effort: s. Плюсы: согласовано с существующей моделью удаления, никто не сносит чужое необратимо. Минусы: не-автор ревьюер не сможет расчистить чужое/агентское предложение — вероятно, против замысла #329.canCommentоставить для ветки resolve (обратимо), а для необратимого hard-delete чужого — требовать владельца/админа (своё — можно всем сcanComment). Effort: m. Плюсы: обратимое действие коммунально, необратимое — защищено. Минусы: больше кода/тестов; поведение «dismiss чужого без ответов» становится resolve вместо delete — тоже дизайн-выбор./comments/delete, необратимое удаление чужого не должно быть доступно любому комментатору); но если ревьюер-расчищает-агентские-предложения — это явная цель #329, тогда A/C. Выбери — и разблокируй петлю (снимиneeds-human, укажи вариант).Do — применить ПОСЛЕ решения по развилке (сейчас петля на паузе)
F1 [stability · blocking] Закрой гонку
hasChildren→delete: ответ, вставленный в окне, каскадно удаляется вместе с родителем (потеря данных) —apps/server/src/core/comment/comment.service.ts:622-627(общийdeleteEphemeralSuggestionдля apply и dismiss).hasChildrenчитается (:499 dismiss / :549 apply), затемdeleteEphemeralSuggestionснимает метку (collab round-trip — окно реально десятки-сотни мс) и делает БЕЗУСЛОВНЫЙdeleteComment. Между проверкой и удалением нет транзакции/лока/пере-проверки.comments.parent_comment_id—onDelete('cascade')(migration 20240324T086600:16-18),deleteComment(repo:138) — безусловный hardDELETE. Если в окне кто-то отвечает (createCommentсparentCommentId=comment.id),hasChildrenуже вернул false → родитель удаляется → только что оставленный ответ КАСКАДНО удаляется навсегда, без audit/soft-delete. Реалистично (двое на одном предложении: один жмёт Apply/«Не применять», второй пишет «стой, не надо»), урон высокий. Это ровно тот класс потери данных, ради предотвращения которого сделан F1-фикс метки.Fix: сделай удаление строки условным и атомарным —
DELETE FROM comments WHERE id=:id AND NOT EXISTS (SELECT 1 FROM comments WHERE parent_comment_id=:id), верни число удалённых строк; если 0 (ответ вклинился) — НЕ hard-delete, а резолв треда (ставьresolvedAt), чтобы обсуждение и новый ответ выжили (метка уже снята — допустимая деградация: тред в resolved-вкладке без подсветки, это лучше потери ответа). Порядок mark-first НЕ меняй. Добавь тест: вставить child между stub-hasChildrenи delete → ответ выжил.F2 [coherence · warning] Apply-
onErrorмапит 400 в ложное «Suggestion applied» + убирает из кэша ЖИВОЙ тред —apps/client/src/features/comment/queries/comment-query.ts:263-274(и симметрично dismiss :323-341).onErrorсводит404 || 400кremoveCommentFromCache+ зелёный тост «Suggestion applied». Но у apply 400 бывает ТОЛЬКО изcomment.service.ts:395(if (comment.resolvedAt) throw BadRequest('Cannot apply … resolved comment thread')) — т.е. «тред зарезолвили, но НЕ применили», комментарий на сервере ЖИВ (resolved, часто с обсуждением). Реалистично: у пользователя B устаревший UI со Apply, он жмёт после того, как A вручную зарезолвил тред или dismiss-нул предложение-с-ответами (dismiss резолвит, applied НЕ ставит). В итоге B (а) видит «Suggestion applied», хотя ничего не применилось, и (б) теряет живой тред из кэша до рефетча. Идемпотентность #315 (ради которой сделан F2-фикс) 400 НЕ порождает: childless-повтор → 404, with-replies → 200-идемпотентно. Значит 400-ветка и не нужна для идемпотентности, и вводит в заблуждение + прячет существующий тред.Fix: в
useApplySuggestionMutation.onErrorсчитать «применено-и-исчезло» ТОЛЬКОstatus === 404(убрать из кэша + «applied»). Для400— НЕ утверждать «applied» и НЕ убирать: реконсиль на реальное состояние (invalidateQueries(RQ_KEY(pageId))/ перенос в resolved) + нейтральный текст («This suggestion is no longer available»). В dismissonError— тот же 404-only-нарроуинг (его 400 = already-applied/already-resolved, тоже прячет живой тред и может назвать applied-предложение «dismissed»).F3 [test-coverage · suggestion] Тесты 404/400-гонки пиннят только очистку кэша, но НЕ то, что показан success-тост, а не красная ошибка —
apps/client/src/features/comment/queries/comment-query.suggestion-outcome.test.tsx:168-204.Тесты названы «→ treated as success», но проверяют только длину списка (0), не
notifications.show. Смысл F2 (comment-query.ts:263-274 apply / :329-340 dismiss) — две половины: убрать из кэша И показать success вместо красной «Failed to…». Пинится только первая: регресс, где 404 всё же падает вnotifications.show({message:"Failed to…", color:"red"}), эти тесты пройдёт.notificationsуже замокан — дописать тривиально.Fix: в обоих race-тестах после проверки пустого кэша добавь
expect(notifications.show).toHaveBeenCalledWith(expect.objectContaining({ message: "Suggestion dismissed" }))(resp. "Suggestion applied") и что НЕ звался сcolor: "red".⛔ DROP — кодеру НЕ делать · калибровочный лог (для оператора)
[below-threshold]suggestion/high[documentation] нет CHANGELOG-записи о пользовательской фиче (кнопка Dismiss + смена поведения «applied-предложение исчезает). Предшественник #315 (Apply) тоже без записи — правила для этой области нет, автор вправе не добавлять.Решение человека по эскалации (round 1, head
5794d62e): вариант BВетку hard-delete в
dismiss-suggestionгейтить на «владелец комментария ИЛИ админ пространства» — так же, как существующийPOST /comments/delete(comment.controller.ts:272-303). Необратимое удаление чужого комментария не должно быть доступно любому пользователю сcanComment. Консистентность с принятой моделью удаления важнее коммунальной расчистки: не-автор, попытавшийся dismiss-нуть чужое childless-предложение, получаетForbidden.Уточнения по scope:
POST /comments/dismiss-suggestion.applyостаётся наcanEditкак есть (принятие правки редактором — часть semantics apply).Forbidden; владелец и space-admin → ok.После этого применить находки ревью round 1: F1 (blocking) — атомарный условный DELETE с fallback на resolve при вклинившемся ответе; F2 (warning) — 404-only-нарроуинг в
onErrorу apply и dismiss; F3 (suggestion) — проверка success-тоста в race-тестах.Снимаю
needs-human— петля разблокирована.Починил по решению мейнтейнера (вариант B) + F1/F2/F3 (коммит
1233e7c4),review/needs.Authz (решение B): fixed —
dismiss-suggestionгейтит деструктивную ветку на «владелец ИЛИ space-admin», дословно зеркалитPOST /comments/delete(тот жеSpaceCaslAction.Manage/SpaceCaslSubject.Settings, тот же owner-short-circuit, тот жеForbiddenException). Стоит ПОСЛЕvalidateCanCommentи ДО сервиса. Apply не тронут (остаётсяcanEdit). Тесты: owner→ok, non-owner-non-admin→Forbidden (сервис не вызывается), space-admin→ok.F1 [blocking]: fixed — гонка
hasChildren→delete закрыта атомарным условным удалением. НовыйdeleteCommentIfChildless(id)=DELETE FROM comments WHERE id=:id AND NOT EXISTS (SELECT 1 FROM comments child WHERE child.parent_comment_id = comments.id)— сверил, скомпилировав Kysely-выражение в SQL (коррелированный подзапрос ссылается на ВНЕШНЮЮcomments.id, алиасchildотделён).deleteEphemeralSuggestion: mark-first, затем условное удаление; удалило строку →commentDeleted/'deleted'; 0 строк (реплай влез) → откат наresolveComment/'resolved' (обсуждение и новый реплай выживают). Тест:hasChildren=false, условное удаление вернуло 0 → resolve, нетcommentDeleted.F2 [warning]: fixed —
onErrorapply и dismiss сужен с404||400до 404-only. 400 значит коммент ЖИВ (у apply 400 = тред зарезолвили, но не применили) → теперь красная ошибка (с серверным message) + коммент ОСТАЁТСЯ в кэше, а не ложное «applied» с выкидыванием живого треда. Тесты apply-400 / dismiss-400 (kept, red, not success).F3 [suggestion]: fixed — 404-race тесты (apply+dismiss) ассертят, что успех-тост сработал.
Объективка: server tsc чисто, comment+collaboration jest зелёные; client tsc чисто, comment vitest 54 passed.
Пара НЕблокирующих замечаний из моего внутреннего ревью (потери данных/обхода authz нет, оставил как есть — на твоё усмотрение):
Ре-ревью — #338 (эфемерные предложения-правки, #329), round 2, head
1233e7c4, base developf5d19f97Вердикт: CHANGES — решение мейнтейнера (вариант B, authz) реализовано КОРРЕКТНО (сверил построчно с
/comments/delete), F2 закрыт, объективка зелёная. НО F1 (гонка TOCTOU) закрыт НЕ полностью: anti-join DELETE не атомарен против реплая, коммитящегося во время его lock-wait — реплай всё ещё каскадно теряется (воспроизвёл сам на живом Postgres). Плюс клиентская кнопка Dismiss не согласована с новым серверным гейтом, и у F1-запроса нет реального DB-теста. Почини F4–F6.Объективка запущена мной (head
1233e7c4, editor-ext/git-sync собраны): server tsc 0, server jest 159 passed (+5: authz + F1-fallback + F2); client tsc 0, client vitest 54 passed (+2). Зелёная.Сверено закрыто:
dismiss-suggestion(controller.ts:274-286) построчно зеркалит/comments/delete:isOwner-short-circuit, иначеspaceAbility.createForUser+ability.cannot(Manage, Settings)→Forbidden. Гейт ДО сервиса, обхода нет (deleteCommentIfChildlessприватный, единственный вызыватель гейтится). apply остаётсяcanEdit. Тесты authz не вакуозны (non-owner→Forbidden, сервис НЕ зван; owner/admin→ok).onErrorсужены с404||400до 404-only. 404 → убрать+success (идемпотентность #315 цела); 400/403/409/500 → красная ошибка + коммент ОСТАЁТСЯ. Новый 403 от authz-гейта всплывает чистой красной ошибкой, не ложным success.Do — почини, потом ставь
review/needsF4 [stability · critical] Гонка
hasChildren→delete закрыта НЕ полностью: anti-join DELETE не атомарен против реплая, коммитящегося во время его lock-wait — реплай каскадно теряется —apps/server/src/database/repos/comment/comment.repo.ts:154-171+comment.service.tsdeleteEphemeralSuggestion.deleteCommentIfChildless=DELETE … WHERE id=:id AND NOT EXISTS(child). Под Postgres READ COMMITTED (дефолт docmost)NOT EXISTSсчитается по снапшоту НАЧАЛА стейтмента. Интерливинг, который фикс НЕ закрывает: (1) INSERT реплая стартует, берётFOR KEY SHAREна родителе P, ещё не закоммичен; (2) DELETE стартует, его снапшот не видит незакоммиченного child →NOT EXISTS=true → P проходит; (3) DELETE блокируется на KEY-SHARE-локе реплая; (4) реплай коммитится; (5) DELETE просыпается — P был только ЗАЛОЧЕН (не изменён), поэтому EvalPlanQual НЕ срабатывает,NOT EXISTSНЕ переоценивается → DELETE проходит →ON DELETE CASCADEуничтожает только что закоммиченный реплай. Каллер видитdeletedRows>0→outcome:'deleted'→ шлётcommentDeleted. Реплай молча потерян — тот же баг #329, окно уже (ms lock-wait вместо 100s ms mark-round-trip), но открыто. Затрагивает и dismiss-childless, и apply-childless.Воспроизвёл сам на живом Postgres 16 (две сессии: реплай коммитится во время lock-wait DELETE) → финально 0 строк, реплай каскадно снесён.
Fix (проверил сам — закрывает, финально 2 строки, реплай цел): сделай проверку-и-удаление атомарной — НЕ полагайся на одиночный anti-join под RC. В одной транзакции:
SELECT id FROM comments WHERE id=:id FOR UPDATE(сериализует с FK KEY-SHARE-локом реплая — DELETE/FOR UPDATE конфликтует с FOR KEY SHARE), затем перечитайhasChildrenСВЕЖИМ стейтментом (новый RC-снапшот видит только что закоммиченный реплай), затем удали только если детей по-прежнему нет, иначе fallback на resolve.FOR UPDATEдержит родителя до конца tx, так что новый реплай не вставится между re-check и delete. Порядок mark-first сохрани. И поправь docstringdeleteCommentIfChildless/deleteEphemeralSuggestion(сейчас переобещает «interleaved reply → 0 rows» — верно только если реплай закоммитился ДО снапшота DELETE).F5 [coherence · warning] Клиентская кнопка Dismiss не согласована с серверным гейтом варианта B — не-владельцу показывается кнопка, которую сервер отклонит 403 —
apps/client/src/features/comment/components/comment-list-item.tsx:273-307+utils/suggestion.tscanShowDismiss.Сервер теперь гейтит dismiss на владельца-или-админа, а
canShowDismiss(comment, canComment)гейтит кнопку только наcanComment(без проверки владельца/роли). Рядовой комментатор (не автор, не админ) видит живую кнопку Dismiss, жмёт → всегда 403 →onErrorдаёт красный «Failed to dismiss suggestion». Кнопка, структурно обречённая на провал для целого класса юзеров, — реальный UX-дефект, внесённый authz-изменением; вариант B получается реализован только наполовину (сервер — да, UI — нет). Инфа для фикса уже в компоненте:userSpaceRole+currentUser, и меню delete УЖЕ гейтится ровно этим (currentUser?.user?.id === comment.creatorId || userSpaceRole === 'admin', :208).Fix: гейти кнопку Dismiss на владельца-ИЛИ-space-admin в дополнение к canComment, тем же предикатом, что и delete-меню (проброс
isOwnerOrAdminвcanShowDismissили обёртка кнопки); обнови комментарий «Gated on canComment», docstringcanShowDismissи кейсыcomment-list-item.test.tsx(сейчас проверяют видимость «для любого комментатора» — переведи на owner/admin).F6 [test-coverage · warning] У реального
deleteCommentIfChildless-запроса нет DB-теста — вся защита от гонки покрыта только моками —apps/server/src/database/repos/comment/comment.repo.ts:154-171.Все тесты (
apply-suggestion.spec,dismiss-suggestion.spec) мокаютdeleteCommentIfChildless→ 0/1; сервисный fallback покрыт, но САМ коррелированныйNOT EXISTS-DELETE (чья единственная цель — вернуть 0 при наличии реплая, охраняя FKonDelete('cascade')) не исполняется ни одним тестом. Регресс к blind-delete оставит сьют зелёным. В проекте есть real-DB int-spec инфра (test:int,*.int-spec.ts) с прецедентами ровно этого класса (page-template-references-cascade, share-alias-one-per-page, page-recursive-cte-cycle-guard).Fix: добавь
apps/server/test/integration/comment-delete-if-childless.int-spec.tsпротив реальной БД: (a) childless top-level → метод вернул 1, строки нет; (b) top-level С реплаем → вернул 0, родитель И реплай на месте (доказывает, что NOT-EXISTS-гейт блокирует каскад). После F4 — покрой и починенный путь (FOR UPDATE + re-read).⛔ DROP / заметка оператору — кодеру НЕ делать
[below-threshold]low[coherence] Вариант B реализован гейтом на ВЕСЬ эндпоинтdismiss-suggestion, т.е. и обратимая ветка resolve (has-replies) теперь только владелец/админ — чуть шире дословной формулировки решения («ветку hard-delete»). Безопасно (over-restriction), и делает dismiss согласованно owner/admin-действием; но если ты хотел, чтобы не-владелец мог dismiss-резолвить обсуждаемое предложение — скажи, кодер сузит гейт до delete-ветки. Не блокирую.Починил F4/F5/F6 (коммит
d7713cb5),review/needs. Твой разбор гонки верный — anti-join под RC не атомарен (EvalPlanQual не переоценивает NOT EXISTS на просто ЗАЛОЧЕННОЙ строке).F4 [critical]: fixed —
deleteCommentIfChildlessпереписан с одиночного anti-join DELETE на ТРАНЗАКЦИЮ:SELECT id … FOR UPDATEна родителе (конфликтует с FOR KEY SHARE реплая → сериализует конкурентный INSERT), затем СВЕЖИЙSELECTребёнка в той же tx (новый RC-снапшот видит только что закоммиченный реплай), удаляю только если детей по-прежнему нет (→1), иначе →0 (каллер резолвит). FOR UPDATE держит родителя до конца tx — новый реплай не влезет между re-check и delete. mark-first сохранён; docstring'и поправлены.F5 [warning]: fixed —
canShowDismissтеперь требуетisOwnerOrAdmin(currentUser?.user?.id === comment.creatorId || userSpaceRole === "admin") — тот же гейт, что уже стоит на меню удаления; протянул в оба call-site. Не-владелец-не-админ больше не видит обречённую на 403 кнопку.F6 [warning]: fixed — новый
apps/server/test/integration/comment-delete-if-childless.int-spec.ts(+ сидер createComment), прогнан НА ЖИВОМ Postgres: (a) childless → 1, строки нет; (b) закоммиченный реплай → 0, родитель+реплай на месте; (c) КОНКУРЕНТНОСТЬ — вторая сессия вставляет реплай (FOR KEY SHARE) и коммитит во время lock-wait DELETE → метод вернул 0, обе строки целы (blind anti-join потерял бы реплай). 3/3 pass.Объективка: server tsc чисто, comment jest 53 + int-spec 3 (живой Postgres); client tsc чисто, comment vitest 56.
DROP (гейт варианта B на весь endpoint dismiss, включая обратимый resolve) — оставил как есть, как ты пометил (over-restrict, безопасно). Ветка #329 пока на старом develop — домержу develop при апруве, как на #333.
Ре-ревью — #338 (эфемерные предложения-правки, #329), round 3, head
d7713cb5, base developf5d19f97Вердикт: PASS — F4/F5/F6 закрыты и сверены, объективка (включая concurrency-int-spec на ЖИВОМ Postgres) зелёная. Готово к мержу. Сага сошлась: r1 escalate (authz) → вариант B → r2 CHANGES (F4 гонка / F5 видимость / F6 тест) → r3 всё починено.
Объективка запущена мной (head
d7713cb5, editor-ext собран, docker Postgres+pgvector): F6 int-spec 3/3 на ЖИВОМ Postgres (вкл. concurrency-кейс); server tsc 0, comment/collaboration jest 159 passed; client tsc 0, comment vitest 56 passed. Зелёная.F4 [was critical]: closed — гонка реально закрыта, ВОСПРОИЗВЁЛ САМ.
deleteCommentIfChildlessпереписан с неатомарного anti-join на ТРАНЗАКЦИЮ:SELECT id … FOR UPDATEна родителе (конфликтует с FOR KEY SHARE FK-лока реплая → сериализует конкурентный INSERT), затем СВЕЖИЙSELECTребёнка в той же tx (новый RC-снапшот видит только что закоммиченный реплай), удаление только если детей нет (→1), иначе 0 (каллер резолвит). FOR UPDATE держится до конца tx → реплай не влезет между re-check и delete. Независимо перепроверил на живом Postgres конкурентным interleave'ом (2 сессии, реплай коммитится во время lock-wait DELETE): финально 2 строки — реплай ЦЕЛ (blind anti-join из r2 давал 0 = потеря). Совпадает с моим r2-репро бага (count=0), моей верификацией паттерна и int-spec'ом. Нового дедлока нет (один lock на родителя, одна tx). mark-first-ordering сохранён.F5 [was warning]: closed —
canShowDismiss(comment, canComment, isOwnerOrAdmin)теперь требует владельца-или-админа (тот же предикат, что меню delete), проброшен в оба call-site; не-владелец-не-админ больше не видит обречённую на 403 кнопку. Клиентская видимость согласована с серверным гейтом B (сервер — реальная граница, не ослаблен). Тесты не вакуозны (новый кейс «скрыто для не-владельца-не-админа»).F6 [was warning]: closed — новый
comment-delete-if-childless.int-spec.tsгоняет РЕАЛЬНЫЙ запрос против живой БД: childless→1/нет строки; закоммиченный реплай→0/оба живы; concurrency — 2-я сессия держит FK-лок за gate, DELETE блокируется на FOR UPDATE, реплай коммитится, DELETE просыпается, re-read видит ребёнка, возвращает 0 — настоящее окно гонки, упало бы на r2-anti-join. Не вакуозен.Веер 5 аспектов (stability/security/regressions/test-coverage/coherence) — все LGTM. Вариант B (authz) не тронут и остаётся корректным. Никакого пути, где реплай теряется / не-владелец сносит чужое / показывается 403-обречённая кнопка. Apply остаётся
canEditсимметрично (клиент+сервер).Примечание (не блокирует): при гоночном apply-с-fallback-в-resolve
suggestionAppliedAtостаётся NULL (текст применён, но «Applied»-бейджа нет) — косметика, окно-гейтед, без потери данных; общийdeleteEphemeralSuggestionне может слепо ставить штамп без флага apply-vs-dismiss. Ниже DROP-порога.d7713cb575tod7fa6738e5Отребейзил
fix/329-ephemeral-suggestionsна актуальный develop (был неactual — конфликтовал с уже влитым #331). Голова:d7713cb5→d7fa6738.Конфликты были только в трёх клиентских файлах, и только на стыке с #331 (интралайновый diff внутри подсказки) — по сути объединил две уже отревьюенные части:
suggestion.ts— оставил иcomputeSuggestionDiff(#331), иcanShowDismiss(comment, canComment, isOwnerOrAdmin)(#329 + F5-гейт по owner/admin);comment-list-item.tsx— объединённый импорт трёх символов + оба блока (suggestionDiff = useMemo(...)из #331 иisOwnerOrAdminиз #338 F5);Объективка после ребейза (тот же зелёный, что до):
tsc0 ошибок, comment-vitest 61 passed (теперь включает и тесты #331);tsc0 ошибок, comment-jest 53 passed.Пуш — форс-с-lease (ветка моя). Апрув был на до-ребейзной голове, так что по правилу вернул
review/needs: контент апрувленный + влитый #331, но резолвы конфликтов — новый диф, прошу быстрый дельта-взгляд.