PR #4524 shipped per-thread and section-level notification mute for CommentsV2 after the consolidation plan was written, so the plan had no mention of it. Recorded as decision D7 and specified against the v3 schema. The suppression mechanism collapses: v2 walks ancestors with a recursive CTE capped at 100 (thread-chain.ts), because nesting is a child Thread per comment. In v3 ancestry is `path`, so it becomes one `@>` containment test -- the same GiST lookup the lock check already uses. That deletes thread-chain.ts and closes the cap hole (149 prod chains exceed it today, where mutes stop suppressing) and the client-writable `parentThreadId` trade-off. Two tables, CommentMute + CommentTopicMute. v2 fits both controls in one ThreadMute because a comment's child thread and an entity's root thread are both Thread rows; v3 drops Thread, so the two targets stop sharing an id space. One table with a nullable commentId does not compile -- Postgres forbids a nullable column in a primary key, and the unique-index fallback lets NULLs duplicate. Folding the comment-level mute into CommentV3Reaction is rejected in the doc: ReviewReactions is shared by ten tables, and reactionCount is a trigger- maintained sort key. Also flagged: ThreadMute_threadId_fkey is ON DELETE CASCADE, so Phase 6 teardown would silently delete every mute unless the backfill runs first. Checklist gains items across Phases 0-6, including that the notification rewrite must carry the filter to all 13 notThreadMuted sites and re-point no-unmuteable-comment-processor in the same commit. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
15 KiB
Comments Consolidation — Execution Checklist
Working checklist for comments-consolidation.md — that doc holds the rationale and detail; this one is the order of operations. Check items off as they land; each phase ends with a gate that must be true before the next phase starts.
Schema is the merged design (2026-08-25, Koen's counter-proposal + adjustments): ltree path
set by a BEFORE INSERT trigger with rootId/depth GENERATED from it, replyToId, tombstone
deletion (deletedAt + parentId ON DELETE RESTRICT), comments FK'd to CommentTopic. Deletion
semantics are decided (D6): tombstone with replies, hard-delete childless. Per-thread
notification mute is in scope (D7, added 2026-09-01 after PR #4524 shipped it for CommentsV2).
Phase 0 — Measure & decide
Prod measurements (read-only replica):
- Row counts:
Comment,CommentV2,Thread,CommentReaction,CommentV2Reaction,CommentReport,CommentV2Report - v1 depth distribution — count of
Commentrows whose parent also has a parent (believed 0 reachable; decides whether v1 backfill needs depth handling at all) - Orphaned
Threadcount (all entity FKs null) and orphan comments under them Threadrows pointing at deleted images/challenges (the missing-FK drift)- Per-entityType comment volume (drives flip order and index sizing)
Threadrows for dead surfaces:clubPostId,questionId,answerId- Verify
Thread.modelId/challengeId/model3dIdare truly 1:1 (schema declares@unique, back-relations are lists) - CommentV2 id growth rate (sanity input for the shared-sequence approach)
- Dead-column check:
CommentV2.metadata IS NOT NULLcount andnsfw = truecounts on both tables — if ~0,nsfw/metadatastay out of the new schema - EXPLAIN the capped per-parent reply-count
GROUP BYat prod scale (replaces storedchildCount; must be index-only on(parentId, hidden, …)) - Infra sign-off:
CREATE EXTENSION ltreeon prod ThreadMuterow count + how many map to comment / section / orphaned threads (PR #4524 is dev-only today, so this may be ~0 in prod — confirm rather than assume)
Decisions & prerequisites:
- Deletion semantics decided — D6: tombstone with replies, hard-delete childless (2026-08-25)
ComicChapterinteger surrogate id added (pattern:AppListing.serialId) + migration applied- Flip order confirmed from volume data (default: bounty/article → review/challenge/misc → image/post → comicChapter → model last)
- Mute table naming decided (2026-09-01):
CommentMute+CommentTopicMute, not the*Engagement+ type-enum convention — aFollowstate is speculative and a second table later costs what the enum costs now
Gate: all measurements recorded in the plan doc; deletion decision written down.
Phase 1 — Schema (expand)
CREATE EXTENSION ltreeapplied to prod (signed off in Phase 0)- DDL from the plan doc:
CommentV3(ltreepath, generatedrootId/depth,replyToId,deletedAt, FK toCommentTopic,parentId ON DELETE RESTRICT, CHECKs),CommentTopic,CommentV3Reaction,CommentV3Report CommentMute(@@id([userId, commentId]), FK toCommentV3CASCADE,@@index([commentId])) andCommentTopicMute(@@id([userId, entityType, entityId])) — two tables, because a nullable PK column is illegal in Postgrescommentv3_set_pathBEFORE INSERT trigger- Index set from the plan doc (top ×3, entity, replies ×2 with
hiddensecond,(rootId, depth), GiSTpath,(userId, id DESC),legacyV1Idunique partial) - Shared sequence
comment_shared_id_seq: seed pastmax(CommentV2.id, Comment.id), re-pointCommentandCommentV2id defaults at it - Triggers:
CommentTopic.commentCount(counted set = ALL rows incl. hidden/self, matching today'supdate_thread_comment_count), reaction-count port ofupdate_comment_reaction_count - Prisma shape:
pathasUnsupported("ltree"),rootId/depthasdbgenerated;db:check-generatedclean with it - Clavata trigger
trg_moderation_commentv3+EntityTypeenum valueCommentV3(deploy regenerated client first, then apply enum migration — expand/contract rule) entity-moderation.tsconsumes theCommentV3JobQueue entity typepackages/civitai-db-schema/src/kysely/updated-at-tables.tsgains the new tablespnpm run db:generate+pnpm run db:check-generatedclean- Migration SQL written, committed, applied manually (never
prisma migrate deploy)
Gate: new tables live and empty in prod; client deployed knowing all new types.
Phase 2 — Dual-write (deploy to production first)
Comments AND reactions/reports — everything dual-writes before any backfill runs:
commentsv2.serviceupsert/delete/hide/pin/lock dual-write CommentV3 in-transactioncomment.service(v1) upsert/delete/hide/pin/lock dual-write (vialegacyV1Idresolution)toggleLockCommentsThreadsetsThread.locked+CommentTopic.lockedtogetherreaction.service.ts(comment+commentOld) dual-writesCommentV3Reaction- Report creation dual-writes
CommentV3Report toggleThreadMute→CommentMute,toggleSectionMute→CommentTopicMute(both v2 controls from PR #4524; the section control gains the ability to mute a comment-less entity)user.service.tsaccount deletion deletes from the new tables tooentity-moderation.tsauto-mute hides in both old and new tables- CDC provisioned before enabling:
public.CommentV3in event-engine table subscriptions +postgres.CommentV3Kafka topic created (dedupe against old handlers during overlap) - Deployed to production; new comments/reactions/reports visibly landing in both sides, zero write-path errors
Gate: every comment/reaction/report write path in prod dual-writes; the live edge of CommentV3 is being maintained.
Phase 3 — Backfill & reader re-point
Runs under live dual-write, so every copy job uses skip-existing semantics
(ON CONFLICT (id) DO NOTHING — a dual-written row is always at least as fresh as the copy):
- v2 comment copy job:
threadId → (entityType, entityId)via root chain;parentId= thread'scommentId; inserted in depth order so the path trigger finds each parent (rootId/depthare generated, not computed); ids + timestamps preserved; nullablehidden/locked→ false;replyToIdnull; idempotent + batched + resumable - v1 comment copy job:
entityType='model',legacyV1Idset, ids from shared sequence - Topic copy: one
CommentTopicper entity-bearing Thread (locked carried, count recomputed) - Reaction copy:
CommentV2Reactionas-is,CommentReactionvialegacyV1Id;reactionCountrecomputed at end - Report copy:
CommentV2Reportas-is,CommentReportvialegacyV1Id - Mute copy:
ThreadMutesplits. Thread withcommentId→CommentMuteon that comment's id (v2 ids preserved; note the off-by-one — v2 mutes the comment's CHILD thread); entity-bearing thread with nocommentId→CommentTopicMute; orphaned → skipped and counted - Orphaned-thread comments (and their reactions/reports) skipped, counts logged
- Parity checks: per-entity counts vs
Thread.commentCount, reaction counts vs both source tables, spot checksums,ThreadMutecount vsCommentMute+CommentTopicMute+ skipped - Continuous parity monitor (row-count diff old vs new per day) running
- Reverse-copy script (CommentV3 → old tables, id-matched) written and tested — the Phase 4 write-flip rollback tool
Re-point cross-surface readers at CommentV3 while dual-write keeps it complete (dual-write is one-directional, so any reader left on the old tables goes blind the moment a surface's writes flip — these MUST land before the first Phase 4 write flip):
- All notification
prepareQuerys (pure-v1 types, per-entity v2 types, the UNIONs innew-thread-response+mention.notifications.ts,reaction.notifications.ts,report.notifications.ts) — new notifications writedetails.version: 3 - 🔴 The rewrite CARRIES the mute filter to all 13
notThreadMutedsites:notThreadMutedbecomes aCommentTopicMutePK probe plus aCommentMutejoin onpath @>, andthread-chain.tsis deleted. Dropping it reads as a clean diff and silently un-mutes everyone no-unmuteable-comment-processorre-pointed at the v3 SQL in the same commit — including the deliberatenew-mentionexclusion it pins. Verify it FAILS on a removed filter, not just that it passesgetThreadMuted/getSectionMutedread the same v3 predicate as the suppression (one shared helper, asmuteableThreadsCteis today) so the menu label cannot diverge from behavior- Moderator app reads:
user-account.service.ts,user-lookup.service.ts,user-signals.service.ts,reports.service.ts::commentContextUrl,CommentsPanel.svelte - Creator-studio analytics (
analytics.ts,analytics-detail.ts) - Metrics jobs (
article/bounty/post/model3d.metrics.ts; delete or portpost.metrics-old.ts) daily-challenge-processing.tsjudge gate +getJudgeCommentForImage;api/mod/daily-challenge/re-review.tscollection.service.tsThread-join count;resourceReviewthread.commentCountreads →CommentTopic/comments/v2/[id].tsxpermalink query
Gate: backfill converged; N days of zero parity drift; no cross-surface reader still queries the old tables; reverse-copy proven on a sample.
Phase 4 — Per-surface flips (behind Flipt)
Repeat this block per surface, in the Phase 0 order (model last):
- Read flip on → soak → verify (comments render, counts match, hidden/blocked exclusions hold,
pinned order, deep-links/
targetCommentId, reply pagination, mute menu state matches whether notifications actually arrive) - Reactions read from
CommentV3Reaction - Write flip on → soak → old write path removed for this surface
Surface-specific extras:
- article/post: purge
articleStatCache/postStatCacheafter count recompute - image: re-point the
EntityMetric(metricType='Comment') writer; flipdaily-challenge-processing.tsjudge gate +getJudgeCommentForImagewith it - comicChapter:
ChapterComments.tsxclient shape change (dropsparentThreadId) - model (last): model discussion UI moves onto the unified provider (display depth 1, same
presentation); client reaction entityType
commentOld→comment; v1 replica-lag pinning (getDbWithoutLag('commentModel')) retired or ported commentOldreaction type removed after model flip soaks
Gate: every surface reading+writing CommentV3; old tables receiving no new rows.
Phase 5 — Post-flip cleanup
(The read-side re-points that used to live here run in Phase 3 — see above. What remains requires old write paths to be gone.)
Notifications:
comment.detail-fetcher.tsresolves v3 by id / v2 by id / v1 vialegacyV1Id(permanent shim) — verify against real historical notification rows- Consolidate the per-entity notification queries into one parameterized builder — no longer optional: one builder is one place the mute filter can go missing, instead of 13
Event-engine / metrics:
- One CDC handler on CommentV3 replaces
comments.ts+comment-v2.ts; old handlers retired ModelMetric.commentCountstays distinct commenters — verify post-cutover- Comment-count event continuity verified (feeds
update-user-score.ts, search indexes, stat caches); full search reindex only if a count source moved discontinuously - ClickHouse tracking sends explicit entityType + comment id; readers match old
Comment_*/CommentV2_*literals alongsideCommentV3_*(creator-studio/analytics.ts,analytics-detail.ts)
Main app cleanup:
- Unified exclusion helper (hidden/blocked/blockedBy + owner exemption) replaces the v1/v2 copies
- Reaction enum/controller collapse (
'commentOld'accepted one extra release) ReportEntitycollapse (both values accepted one extra release);ReportModal.tsxgoodContent.reward.tstypeToTable,src/utils/buzz.tslabels,src/utils/string-helpers.tsentriesresourceReview.service.tsdrops its eagerthread: { create: {} }on review creation (topics are lazy)strikes.tsxURL map (+ fix its pre-existing dead/comments/<id>link)- Mute UI ported: per-comment overflow item + section ellipsis beside the top-level composer
(
Comment.tsx,commentv2.utils.ts); copy says "thread" — restate for exact subtree semantics commentv2.routermute procedures move to the v3 router, keeping the sharedcommentv2.muterate-limit budget- Mod endpoints
bulk-delete/remove-as-tostake singlecommentIds(dual-list alias for one release);moderator-endpoint-catalog.generated.tsregenerated
Moderator app cleanup (reads moved in Phase 3; this is the write/UX side):
report-entities.tssingle{ 'CommentV3Report', 'CommentV3' }entryentity-url.tssingle builder;CommentsPanel.sveltesingle checkbox listqueue-thresholds.tskey migration (don't zero thresholds);src/lib/reports.tslabels- Tell mod team:
getCommentFlags()now covers all comments — flag counts rise; model comments appear in creator-studio for the first time - Hand-off: moderator-DB
ModerationQueueMetrics.Comment/CommentV2columns → external job owner
Tooling / tests:
scripts/local-dev/gen_seed.tsrewritten for new tables- ~20 test files' mock shapes updated; SQL-assertion tests (
reports.sql.test.ts,reports.queries.explain.test.ts) re-authored;tests/preview-engagement.spec.tsgreen
Gate: zero code paths query the old tables (grep proves it); notifications + metrics verified continuous across the cutover.
Phase 6 — Contract (teardown)
- 🔴 Verify
CommentMute+CommentTopicMuteare populated BEFORE droppingThread—ThreadMute_threadId_fkeyisON DELETE CASCADE, so the teardown deletes every mute a user ever set, silently - Drop order:
ThreadMute→CommentReaction/CommentV2Reaction/CommentReport/CommentV2Report→Comment/CommentV2→Thread - Drop triggers:
update_thread_comment_count,trg_moderation_comment,trg_moderation_commentv2,comment_reaction_count_update - Remove Thread entries from
schema-drift/drift-baseline.json+ update remediation plan andproduction-plan.test.ts EntityTypevaluesComment/CommentV2left in place (read-time aliases only)job-queue.tsunused mappings deleted; schema reverse-relations cleaned;db:generate+db:check-generatedclean- Plan doc + this checklist marked complete;
docs-drift-reviewover the final diff
Gate: prod schema contains no old comment tables; drift gate green; full unit suite green.