fix(comment): transactional childless-delete race fix + client dismiss gate + DB int-spec (#329 review round 2)

F4 [critical] — the anti-join `DELETE … WHERE NOT EXISTS(child)` was still racy
under Postgres READ COMMITTED: a reply INSERT holds FOR KEY SHARE on the parent;
the DELETE's start snapshot doesn't see the uncommitted child (NOT EXISTS true),
blocks on the reply's lock, and when the reply commits the parent was only LOCKED
(not modified) so EvalPlanQual does NOT re-check → the DELETE proceeds and CASCADE
destroys the just-committed reply. Replaced with a transaction: SELECT the parent
FOR UPDATE (conflicts with the reply's FOR KEY SHARE → serializes the concurrent
reply), re-check for a child with a FRESH statement in the same tx (a new RC
snapshot sees a just-committed reply), delete only if still childless (return 1)
else return 0 (caller resolves). The FOR UPDATE lock is held to end-of-tx so no
reply can insert between the re-check and the delete. Signature unchanged, so the
service + its mocked unit tests are untouched; docstrings updated.

F5 [warning] — the client Dismiss button was gated only on canComment, but the
server now gates dismiss on owner-or-space-admin, so a non-owner non-admin saw a
button the server 403s. `canShowDismiss` now also requires
`isOwnerOrAdmin = currentUser?.user?.id === comment.creatorId || userSpaceRole ===
"admin"` (the same gate the comment delete-menu already uses); threaded into both
call sites.

F6 [warning] — added a REAL-DB int-spec
(apps/server/test/integration/comment-delete-if-childless.int-spec.ts, + a
createComment seeder): (a) childless → returns 1, row gone; (b) committed reply →
returns 0, parent+reply survive; (c) CONCURRENCY — a second connection inserts a
reply (FOR KEY SHARE) and commits mid-operation while deleteCommentIfChildless
blocks on FOR UPDATE → asserts it returns 0 and both rows survive (a blind
anti-join would lose the reply here). Ran against live Postgres — 3/3 pass.

server tsc clean; comment jest 53 + int-spec 3 (live Postgres) pass. client tsc
clean; comment vitest 56 pass.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
claude code agent 227
2026-07-04 19:04:25 +03:00
parent e6d8eda8e5
commit d7fa6738e5
7 changed files with 313 additions and 57 deletions
@@ -623,17 +623,18 @@ export class CommentService {
* bug #329 targets). Let the exception propagate (→ 5xx); the operation is
* then repeatable with row + mark still consistent.
*
* RACE (#338 F1): the caller read `hasChildren` BEFORE the (slow) mark
* RACE (#338 F4): the caller read `hasChildren` BEFORE the (slow) mark
* removal, so a reply can land in that window. `comments.parent_comment_id` is
* ON DELETE CASCADE, so an unconditional delete here would cascade-destroy the
* just-added reply forever. Instead we use `deleteCommentIfChildless`, which
* re-checks childlessness inside the delete statement. If it removes the row
* (outcome 'deleted') we broadcast the deletion as before. If it removes 0
* rows (a reply interleaved) we do NOT hard-delete we resolve the thread
* instead (outcome 'resolved'), preserving the discussion and the new reply.
* The anchor mark is already gone by then, an accepted degradation: the thread
* lands in the resolved tab without its inline highlight — far better than
* losing a reply.
* re-checks childlessness under a FOR UPDATE lock inside a transaction (a plain
* anti-join DELETE is NOT race-safe under READ COMMITTED — see the repo method
* docstring). If it removes the row (outcome 'deleted') we broadcast the
* deletion as before. If it removes 0 rows (a reply interleaved) we do NOT
* hard-delete — we resolve the thread instead (outcome 'resolved'), preserving
* the discussion and the new reply. The anchor mark is already gone by then, an
* accepted degradation: the thread lands in the resolved tab without its inline
* highlight — far better than losing a reply.
*/
private async deleteEphemeralSuggestion(
comment: Comment,