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>
57 KiB
Comments Consolidation Plan
Consolidate the legacy Comment table (model discussions) and the CommentV2 + Thread pair
(everything else) into one comment table that is cheap to page, easy to drill into, and
depth-limited by design.
Execution tracking lives in the companion checklist: comments-consolidation-checklist.md.
Status: proposal, decisions settled 2026-08-24 (Briant):
- Questions & Answers comments are not migrated — dropped with the surface.
- The model page keeps its current UI for now — the migration swaps the storage under it, not the presentation (display depth 1, flat root+replies).
- Hard stored-depth cap of 20 with clamp-to-sibling: approved.
- Reactions/reports unification is in scope (revised 2026-08-24 — originally deferred, but the old join tables FK-reference the old comment tables, so deferring it would block Phase 6 teardown indefinitely).
- The table is named
CommentV3permanently — no post-teardown rename. - Deletion semantics (settled 2026-08-25, via Koen's counter-proposal): tombstone when a
comment has replies, hard-delete when childless, enforced by
deletedAt+parentId ON DELETE RESTRICT. - Per-thread notification mute is in scope (added 2026-09-01, Briant). PR #4524 shipped it for
CommentsV2 after this plan was written; consolidation must carry it rather than rediscover it.
Two tables,
CommentMute+CommentTopicMute— settled 2026-09-01, to be re-reviewed with the rest of the schema before Phase 1 starts. See Thread mute.
The target schema below is the merged design (2026-08-25): Koen's counter-proposal — ltree
path as the single source of tree truth, with rootId/depth generated from it — adopted with
three adjustments: clubPost dropped from the enum (dead surface), CommentTopic.commentCount
kept (hot list-page reads), and the ltree operational constraints documented.
Why the current system is expensive
Two parallel systems. Comment is hard-bound to modelId with a self-referencing parentId;
CommentV2 hangs off a Thread row, and nesting is modeled by giving each parent comment its own
child Thread. Every cross-cutting concern pays twice: notifications (new-thread-response and
mentions are literal SQL UNIONs of both tables), moderation endpoints take { commentIds, commentV2Ids },
reactions/reports each have two join tables, the event-engine has two CDC handlers, and the
moderator app renders "Model comments" and "Other comments" as separate panels.
Thread makes everything indirect. Thread has ~15 nullable entity FK columns, resolved with a
dynamic findUnique({ [${entityType}Id]: entityId }) cast through as unknown as. A nested
comment's own thread carries no entity FK, so answering "what is this comment on?" requires
comment → thread → rootThread → COALESCE across 15 columns. That fallback chain is re-implemented
in at least 9 places (notifications ×4, permalinks, moderator app ×2, event-engine, creator-studio).
"Load more replies" costs a recursive CTE. Because reply pages live in child threads,
getReplyThreads walks Thread.parentThreadId recursively, then fetches comments for the selected
thread ids, then regroups them in JS (commentsv2.reply-threads.ts). It works, but every level of
the tree is a join through a second table, and reply counts require trigger-maintained
Thread.commentCount plus a per-request groupBy for hidden counts.
Target data model
One row per comment. Every row knows its entity, its parent, its root, and its depth — no
resolution chain, no second table on the read path. The DDL is authoritative (ltree is
Unsupported(...) in Prisma — see the notes below):
CREATE EXTENSION IF NOT EXISTS ltree; -- needs infra sign-off on prod
CREATE TYPE "CommentEntityType" AS ENUM (
'model', 'image', 'post', 'article', 'review', 'bounty', 'bountyEntry',
'comicChapter', 'challenge', 'model3d', 'model3dReview', 'appListing'
);
-- Standalone so Comment / CommentV2 id defaults can be re-pointed at it for dual-write.
-- Seeded past max(Comment.id, CommentV2.id) at migration time.
CREATE SEQUENCE comment_shared_id_seq AS integer;
-- Exists to lock an entity (including before any comment exists) and to hold the
-- entity-level count. Required parent of every comment: entity cleanup = delete one row.
CREATE TABLE "CommentTopic" (
"entityType" "CommentEntityType" NOT NULL,
"entityId" integer NOT NULL,
"locked" boolean NOT NULL DEFAULT false,
"commentCount" integer NOT NULL DEFAULT 0, -- trigger-maintained; hot list reads stay O(1)
PRIMARY KEY ("entityType", "entityId")
);
CREATE TABLE "CommentV3" (
"id" integer NOT NULL DEFAULT nextval('comment_shared_id_seq'),
"entityType" "CommentEntityType" NOT NULL,
"entityId" integer NOT NULL,
"parentId" integer, -- NULL = top-level
"replyToId" integer, -- display-only "@user" when the depth clamp re-parented the reply
-- '<rootId>.<...>.<parentId>.<id>'; filled by the BEFORE INSERT trigger
"path" ltree NOT NULL,
"rootId" integer GENERATED ALWAYS AS ((subltree(path, 0, 1)::text)::integer) STORED,
"depth" integer GENERATED ALWAYS AS (nlevel(path) - 1) STORED, -- 0 = top-level
"userId" integer NOT NULL,
"content" text NOT NULL,
"createdAt" timestamptz NOT NULL DEFAULT now(),
"updatedAt" timestamptz NOT NULL DEFAULT now(),
"deletedAt" timestamptz, -- tombstone: had replies, so the row stays and content is cleared
"tosViolation" boolean NOT NULL DEFAULT false,
"hidden" boolean NOT NULL DEFAULT false,
"locked" boolean NOT NULL DEFAULT false, -- refuses new replies anywhere under this row
"pinnedAt" timestamptz,
"reactionCount" integer NOT NULL DEFAULT 0, -- sort key, so stored; maintained by reaction trigger
"legacyV1Id" integer, -- old Comment.id
PRIMARY KEY ("id"),
FOREIGN KEY ("entityType", "entityId") REFERENCES "CommentTopic" ON DELETE CASCADE,
FOREIGN KEY ("parentId") REFERENCES "CommentV3" ("id") ON DELETE RESTRICT, -- a childless-delete that loses a race fails, never orphans
FOREIGN KEY ("replyToId") REFERENCES "CommentV3" ("id") ON DELETE SET NULL,
FOREIGN KEY ("userId") REFERENCES "User" ("id") ON DELETE CASCADE,
CONSTRAINT commentv3_depth_cap CHECK (nlevel(path) <= 21),
CONSTRAINT commentv3_reply_to_needs_parent CHECK ("replyToId" IS NULL OR "parentId" IS NOT NULL),
CONSTRAINT commentv3_reaction_count_nonneg CHECK ("reactionCount" >= 0)
);
-- The one tree trigger: path = parent.path || own id. rootId and depth are GENERATED from
-- path, so no writer — Prisma service, backfill, dual-write, moderator Kysely — can corrupt
-- the tree. Lock checks, entity checks and the topic upsert live in the service.
CREATE FUNCTION commentv3_set_path() RETURNS trigger LANGUAGE plpgsql AS $$
BEGIN
IF NEW."parentId" IS NULL THEN
NEW.path := NEW.id::text::ltree;
ELSE
SELECT p.path || NEW.id::text INTO NEW.path FROM "CommentV3" p WHERE p.id = NEW."parentId";
END IF;
RETURN NEW;
END $$;
CREATE TRIGGER commentv3_set_path
BEFORE INSERT ON "CommentV3" FOR EACH ROW EXECUTE FUNCTION commentv3_set_path();
Schema-review notes (2026-08-25, merged with Koen's counter-proposal):
pathis back — as the single source of tree truth, which changes the earlier calculus. A first draft carriedpath Int[]+ GIN; a review pass dropped it becauserootId/depthcovered the hot paths and every writer would have had to maintain the array. Koen's version inverts the risk:path(ltree) is set by oneBEFORE INSERTtrigger, androotId/depthare GENERATED from it — so no writer population (Prisma service, backfill, dual-write, moderator Kysely) can compute them wrong. Tree integrity moves from N application code paths into the database. The GiST index buys subtree reads, ancestry checks, and the subtree-scoped lock below.- No
childCount— reply counts are queried. With(parentId, hidden, ...)indexes, a capped per-parent reply count for a page of comments is one index-onlyGROUP BY. That re-introduces the per-page count query the plan once celebrated killing, at index-only cost — Phase 0 EXPLAINs it at prod scale before this is final.CommentTopic.commentCountstays stored (our one divergence from Koen's draft): entity-level counts are read per-row on hot list pages (resource-review cards), where count-on-read would mean N subqueries per page. replyToIdpreserves who a clamped reply was answering (display-only,SET NULLon delete, CHECK'd to require a parent) — replaces the earlier "keep an @mention in the content".- Deletion is structural.
deletedAttombstones a comment that has replies;parentId ON DELETE RESTRICTmakes a childless hard-delete that races a new reply fail instead of orphaning. Tombstones stay in indexes and pages — they render as "[deleted]" placeholders, which is the point. CommentTopicis a required parent (FK with CASCADE): the service upserts the topic on first comment (replacing lazy Thread creation 1:1), and entity cleanup becomes "delete the topic row".lockedis subtree-scoped: it refuses new replies anywhere under the row — one ancestor check viapath(@>on the GiST index) at write time. Stronger and simpler than v1's one-level propagation; note it in the moderation section.- ltree operational constraints:
CREATE EXTENSION ltreeneeds infra sign-off on prod, and Prisma models the column asUnsupported("ltree")— path is invisible to the Prisma client, so path operations are raw SQL. The generatedrootId/depthintegers are normal Prisma-readable columns and are what the hot queries use. clubPostis NOT in the enum (it was in Koen's draft) — clubs are dead code and their threads aren't migrated.tosViolation/hidden/lockedstay booleans, not a bitwiseflagsint (considered 2026-08-25, Koen). The repo's bitwise wins come from set-membership queries (nsfwLevel & browsingLevel), and nothing ever queries these three as a set. Booleans keep the(parentId, hidden, …)indexes plain-column (bit-tests need expression indexes the planner only matches on exact expression repeats, with guessed selectivity), keep Prisma/Kysely filters expressible, andADD COLUMN boolean DEFAULT falseis a metadata-only ALTER anyway. Revisit only if moderation states multiply and gain set-style queries.- No
nsfw, nometadata— pending a Phase 0 check.CommentV2.metadataappears in no selector and has no writer we could find; commentnsfwis accepted on upsert input but read nowhere in the v2 UI, and v1's NSFW comment filter is commented out. Phase 0 countsmetadata IS NOT NULLandnsfw = truein prod; if ~0, they stay dropped (and the upsert input'snsfwfield is removed), otherwise they come back as plain columns. CommentTopichas no surrogate id — nothing references it;(entityType, entityId)is the natural PK and saves a sequence plus a second unique index.userIdcascade is a backstop, not the deletion path. User rows are soft-deleted in practice; comment removal on account deletion goes throughuser.service.ts, which is where the tombstone decision (above) gets applied. The FK cascade only fires on a hard User delete.
Reactions and reports get one join table each, replacing the v1/v2 pairs:
model CommentV3Reaction {
id Int @id @default(autoincrement())
commentId Int
comment CommentV3 @relation(fields: [commentId], references: [id], onDelete: Cascade)
userId Int
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
reaction ReviewReactions
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@unique([commentId, userId, reaction])
}
model CommentV3Report {
commentId Int
comment CommentV3 @relation(fields: [commentId], references: [id], onDelete: Cascade)
reportId Int @unique
report Report @relation(fields: [reportId], references: [id], onDelete: Cascade)
@@id([reportId, commentId]) // same shape as CommentV2Report, least churn in report plumbing
@@index([commentId])
}
Notification mutes get two tables, replacing ThreadMute (shipped for CommentsV2 in PR #4524,
commit 017e35fa03) — see Thread mute:
model CommentMute { // one conversation: this comment and its whole subtree
userId Int
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
commentId Int
comment CommentV3 @relation(fields: [commentId], references: [id], onDelete: Cascade)
mutedAt DateTime @default(now())
@@id([userId, commentId])
@@index([commentId])
}
model CommentTopicMute { // the whole section on one entity
userId Int
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
entityType CommentEntityType
entityId Int
mutedAt DateTime @default(now())
@@id([userId, entityType, entityId])
@@index([entityType, entityId])
}
Two tables, not one. A single table keyed (userId, entityType, entityId, commentId) with a
nullable commentId was the first draft and does not compile: Postgres forbids a nullable column in
a PRIMARY KEY. The fallback — a unique index — treats NULLs as distinct, so one user could insert
unlimited duplicate section mutes. PG 17 has UNIQUE NULLS NOT DISTINCT, but Prisma cannot express
it, so the single table costs a hand-written index to save one lookup. Split, both keys are natural,
and a comment-level row stops carrying entityType/entityId it can already read off the comment.
The extra lookup is cheaper than the one it sits beside: every notification processor knows its own
entityType and already has entityId in the query, so the section arm is a single-row PK probe on
constants. Evaluate it first and a section-muted recipient never reaches the subtree test.
Keep userId leading in both, as ThreadMute does and for the reason its migration records: the
check joins from "rows this user muted", so a user with no mutes yields an empty side.
Rejected: folding the comment-level mute into CommentV3Reaction, which is structurally exact
((commentId, userId, <enum>)) and the only table in this design that is. ReviewReactions is
shared by ten reaction tables, so a Mute value lands in the type union and reaction pickers of
image/article/model/post/question/answer as well; reactionCount is trigger-maintained off row
count and is a sort key (commentv3_top_reactions), so a mute row would inflate the visible
count and reorder the page unless the trigger learns to filter; and reactions are public where mutes
are private, so every reaction read path would have to remember to exclude them — the exact
"remember to filter" duplication this consolidation exists to delete. The reaction unique is also
(commentId, userId, reaction), deliberately multi-row per user, where a mute wants at most one.
Reaction rows get fresh ids (nothing durable references a reaction row id); commentId maps 1:1
for v2 and via legacyV1Id for v1. Report rows re-point the same way — the Report row itself
(status, reason, rewards history) is untouched, only the join moves.
CommentTopic is the one Thread responsibility that can't live on a comment row: an entity-level
lock must exist before any comment does (today's toggleLockCommentsThread creates a locked empty
thread), and challenge.service.ts + getCommentCount read an entity-level count. The service
upserts it on first comment (replacing lazy Thread creation 1:1), and every comment FK-references
it — so entity cleanup is "delete the topic row", with comments cascading. That's more integrity
than today, where Thread FKs are SetNull and orphan comments silently.
Indexes
-- Top-level pages, three sort modes (Oldest scans the newest index backward). Deliberately NOT
-- filtered on hidden: hidden rows are rare, a residual filter is cheap, and the same index then
-- serves the moderator hidden-comments view (which would otherwise have no index at all).
-- Tombstoned rows stay in — they render as "[deleted]" placeholders.
CREATE INDEX commentv3_top_newest ON "CommentV3" ("entityType", "entityId", "id" DESC) WHERE "parentId" IS NULL;
CREATE INDEX commentv3_top_reactions ON "CommentV3" ("entityType", "entityId", "reactionCount" DESC, "id" DESC) WHERE "parentId" IS NULL;
CREATE INDEX commentv3_top_pinned ON "CommentV3" ("entityType", "entityId", "pinnedAt" DESC) WHERE "pinnedAt" IS NOT NULL;
-- Entity-wide reads at any depth (hidden counts, moderation)
CREATE INDEX commentv3_entity ON "CommentV3" ("entityType", "entityId");
-- Reply pages and capped reply counts under one parent (hidden second so count branches don't cross)
CREATE INDEX commentv3_replies_newest ON "CommentV3" ("parentId", "hidden", "id");
CREATE INDEX commentv3_replies_reactions ON "CommentV3" ("parentId", "hidden", "reactionCount" DESC, "id" DESC);
-- Auto-expand a page of roots
CREATE INDEX commentv3_root_depth ON "CommentV3" ("rootId", "depth");
-- Subtree reads: re-root, ancestry / lock checks, on-demand totals
CREATE INDEX commentv3_path_gist ON "CommentV3" USING gist ("path");
-- Account deletion, per-user counts, moderation, judge gate
CREATE INDEX commentv3_user ON "CommentV3" ("userId", "id" DESC);
CREATE UNIQUE INDEX commentv3_legacy_v1 ON "CommentV3" ("legacyV1Id") WHERE "legacyV1Id" IS NOT NULL;
The interactions, as queries
Every interaction is one index-backed query on one table:
| Interaction | Query |
|---|---|
| First page of an entity's comments | WHERE entityType/entityId AND parentId IS NULL ORDER BY <sort> LIMIT n (keyset cursor, same 3 sort modes as today) |
| Does this comment have replies? / "Show N replies" | one capped GROUP BY parentId over the page's ids on the (parentId, hidden, …) index — index-only, replaces the childless-comment bookkeeping in reply-threads.ts (Phase 0 EXPLAINs it at prod scale) |
| Load more replies under a comment | WHERE parentId = ? AND id > cursor ORDER BY id LIMIT k (MostReactions replies get their own index) |
| Auto-expand replies d levels deep for a page of roots | WHERE rootId = ANY(pageIds) AND depth <= d AND NOT hidden — no recursive CTE; the existing budget selector (selectReplyThreadsWithinBudget) ports over |
| Drill into / re-root on a deep comment | subtree via path <@ comment.path on the GiST index, or parentId = commentId page-by-page |
| Is any ancestor locked? (write-time check) | WHERE path @> new.path AND locked — one GiST lookup |
| Is this recipient muted at or above this comment? | CommentTopicMute PK probe on (userId, entityType, entityId), else CommentMute joined on path @> c.path — the same GiST lookup as the lock check, replacing v2's recursive ancestor walk |
| Deep-link / notification / moderation "what is this on?" | the row itself carries entityType, entityId, rootId — single-row read, replaces the 15-column COALESCE chain in 9 call sites |
| Entity comment count / locked | one CommentTopic row |
Depth limiting
Today constants.comments.getMaxDepth (10 for article/bounty/challenge, 3 for image/bountyEntry,
5 default) is a UI ceiling: at the ceiling the client re-roots the view on that comment
(setRootThread) and depth in the DB grows unbounded. v1 model comments are a hardcoded two
levels (root + flat replies; replies "to a reply" are just @mentions on the same parent).
The new model makes both real:
- Display depth stays per-surface in
constants.comments, driving auto-expand and the re-root behavior exactly as now. Model comments becomeentityType: 'model'with display depth 1, reproducing today's flat UX — the model page keeps its current presentation (decision 2). - Stored depth gets a hard cap (20, as
CHECK (nlevel(path) <= 21)): the write path clamps a reply that would exceed it to be a sibling (parent = ancestor at cap−1, onesubltreeon the parent's path), recording who was actually answered inreplyToIdfor the "@user" display. Clients never send tree fields — theBEFORE INSERTtrigger derivespathfromparentId, androotId/depthare generated frompath.
Deletion semantics — decided (D6)
Tombstone when a comment has replies; hard-delete when childless. For context: legacy v1
cascades a delete through the whole subtree (destroying other users' replies), while v2 SetNulls
the child thread and quietly orphans them. Neither survives into the new model. Instead:
- A deleted comment with replies gets
deletedAtset; content is blanked and the author hidden at read time; the row keeps rendering as a "[deleted]" placeholder so its replies stay in place. - A childless comment hard-deletes.
parentId ON DELETE RESTRICTmakes this race-proof: a hard-delete that loses a race against a new reply fails (and the service retries as a tombstone) rather than orphaning the reply. - Account deletion tombstones the user's parented comments and hard-deletes the rest — confirm against privacy/GDPR expectations that a tombstone (no content, no author) satisfies erasure; retention of other users' replies is unchanged from v2 either way.
Moderation, blocking, and comment-gating — preserve-behavior checklist
These are service-layer behaviors, so the table swap doesn't break them by construction — but each must be explicitly re-verified on the new service, because they're what a naive port drops.
Moderator actions (all live in the services being rewritten):
- Hide (
toggleHideComment, both systems):hiddencolumn carries over; the read paths'hidden/hiddenCounthandling is already in the query design. - Pin (
togglePinComment): v1 restricts pinning to root comments (raw SQL enforcesparentId IS NULL); v2 pins within any thread page. The new model pins within a comment's sibling page (pinnedAtordered first, as today), which subsumes both — the v1 restriction becomes moot rather than ported. - Lock: three semantics exist today; the new model reproduces one, strengthens one, and
improves one:
(a) entity-level lock (
toggleLockCommentsThread, can pre-exist comments) →CommentTopic.locked; (b) v1's per-comment lock, which propagates to direct children only (updateMany where parentId = id) → per-commentlockedis now subtree-scoped: the write path checksWHERE path @> new.path AND locked(one GiST lookup), refusing replies anywhere under a locked row — a strict superset of v1's reach; (c) known gap being closed: v2's entity lock is only checked when a comment lands in the entity's own thread — a reply to a nested comment checks its parent's child-thread lock, so a locked article can still receive deep replies server-side (the UI hides the button; the API doesn't enforce it). The new write path checksCommentTopic.lockedon every insert (the row'sentityType/entityIdmakes that one lookup), closing the gap. Flag it as an intentional behavior change, not a regression. - TOS violation flows (v1
setTosViolationhandler and v2bulkSetCommentV2TosViolation): both do set-flag → action matching reports → reward reporters → notify the author. The merged service keeps that whole chain, againstCommentV3Report. - Bulk delete/TOS endpoints, the
entity-moderationauto-mute job, the Clavata scan triggers, and strikes/appeals on comments are covered in the phases below.
User blocking — two enforcement points, restated here as behaviors to re-verify:
- Write-time: creating a comment checks
throwIfBlockedByEntityOwner; editing re-resolves owners from the stored comment (getBlockCheckOwnerIdsForCommentinblock-check.service.ts), deliberately not trusting client-supplied entity params. That owner resolution currently walksrootThreadId— it becomes a single-row read, but the don't-trust-the-client property must survive the rewrite. - Read-time: hidden/blocked/blockedBy exclusion lists (
boundExcludedUserIds, including the bind-parameter cap) with the content-owner exemption (isViewerContentOwner— an owner still sees a blocker's comments on their own content, for reporting). Phase 5's "unify exclusion logic" bullet is where this lands; the exemption is the easiest part to lose. Notification queries carry their ownnotBlockedBetweenfilters — preserved through the Phase 5 rewrites.
Preventing users from commenting:
- Mute/ban/onboarding gating is
guardedProcedure(trpc.ts—isOnboarded+isMuted) on bothcomment.upsertandcommentv2.upsert. It's router-level and the routers don't change; the only requirement is that the unified upsert stays behindguardedProcedure. - The
entity-moderationjob's auto-mute also bulk-hides the muted user's recent comments — its dual-table branches are in the Phase 5 cron-jobs section. throwOnBlockedLinkDomain(content blocklist) runs insideupsertCommenton create and edit; carries over with the service.- Verified: there is no per-entity "comments disabled" flag anywhere — entity/comment locks are the only mechanism, so nothing else needs a home in the new model.
ID strategy — the part that keeps history working
Old ids are load-bearing in three places: notification details rows carry a version
discriminator (comment.detail-fetcher.ts splits on it), permalinks (/comments/v2/<id>,
?dialog=commentThread&highlight=<id>), and ClickHouse commentEvents.commentId (which already
mixes both id spaces with no discriminator — pre-existing corruption we can stop, not fix).
Plan:
- CommentV2 rows keep their ids. They're the overwhelming majority and every modern link format points at them.
- One shared sequence during the transition. Seeding CommentV3's sequence "past max" once is
not enough: CommentV2 keeps allocating ids all through dual-write, and flips are per-surface, so
two independent allocators would run in overlapping ranges and collide. Instead, Phase 1 creates
the CommentV3 sequence seeded past
max(CommentV2.id, Comment.id)and re-pointsCommentandCommentV2's id defaults at that same sequence. From then on all three tables draw from one id space — no headroom guessing, nosetvalchoreography at each flip, and dual-written rows carry the same id on both sides by construction. - Legacy v1 rows get new ids, with the original id in
legacyV1Id(unique). The notification detail-fetcher'sversion !== 2branch resolves vialegacyV1Id— that shim is permanent (old notification rows never go away) but tiny. - ClickHouse tracking starts sending
entityType+ new comment id explicitly on every event;model.metrics.ts's dependence onentityId = modelIdfortype='Model'is preserved during transition and re-pointed in Phase 5.
Migration plan
The tRPC contract (commentv2 router shapes) and the client (CommentsProvider, Comment.tsx)
stay as-is — the swap happens inside the service layer, so UI churn is near zero for v2 surfaces.
Phase 0 — measure & decide. Prod queries (blocked from this session, need a human or an
allowed session): row counts for Comment/CommentV2/Thread; v1 depth distribution (rows with
parentId whose parent also has parentId — believed unreachable in UI); Thread orphan count
(all entity FKs null — note Thread.imageId and Thread.challengeId have no FK constraint in
prod, an accepted drift in schema-drift/drift-baseline.json, so orphans pointing at deleted
images/challenges are expected, not hypothetical); per-entityType comment volume; Thread rows for
dead surfaces (clubPostId, questionId/answerId). Two schema facts to confirm:
Thread.modelId/challengeId/model3dId are declared @unique but their back-relations are
lists (Model.threads Thread[] etc.) — verify they are truly 1:1 in prod before assuming one
CommentTopic row per entity.
Prerequisite: ComicChapter needs an integer surrogate id (same pattern as
AppListing.serialId — note Thread.appListingId already references that surrogate, not the TEXT
ULID) because its Thread key is composite (projectId, position).
Phase 1 — schema. CREATE EXTENSION ltree (infra sign-off), then the DDL above: new tables
(CommentV3, CommentTopic, CommentV3Reaction, CommentV3Report, CommentMute,
CommentTopicMute), indexes, CHECK constraints, the commentv3_set_path tree trigger, plus the count triggers
(CommentTopic.commentCount, and a port of update_comment_reaction_count — the trigger on
CommentV2Reaction from migration 20251020155046 — onto CommentV3Reaction). Prisma models
path as Unsupported("ltree") and rootId/depth as dbgenerated — confirm
db:check-generated stays clean with that shape. Two more DB-level items found in the sweep:
- Clavata auto-moderation triggers:
trg_moderation_comment/trg_moderation_commentv2(migration20250521094141) enqueue('ModerationRequest', entityType, id)intoJobQueueon insert/content-update. CommentV3 needs the equivalent trigger, which needs aCommentV3value in theEntityTypepg enum — an additive enum change, so the expand/contract deploy-first rule applies — andentity-moderation.tsmust consume the new value. packages/civitai-db-schema/src/kysely/updated-at-tables.tsis a hand-curated allowlist, not generated: add the new tables or theirupdatedAtsilently never updates under Kysely writers. Deploy the regenerated client before applying the enum-bearing migration writes anything (expand/contract rule in CLAUDE.md). Migrations applied manually as always.
Phase 2 — dual-write, deployed to production first. Both existing comment write paths
(comment.service upsert/delete/hide/pin/lock and commentsv2.service.upsertComment etc.) also
write CommentV3 in the same transaction, and reaction.service.ts (comment and commentOld
entity types) plus report creation dual-write their new tables the same way — commentOld
resolving the target row via legacyV1Id. Two write paths that are easy to miss because they
aren't in the comment services: account deletion (user.service.ts deletes reaction + comment
rows directly — it must delete from the new tables too, or a deleted account's comments resurrect
on flip) and toggleLockCommentsThread (must set Thread.locked and CommentTopic.locked
together, same reasoning as the entity-moderation dual-hide). Note the no-io-in-transaction
guard — the dual write is same-DB SQL, which is fine.
Dual-write ships before the backfill runs: with the live edge already maintained, one
converged backfill pass makes the table complete and it stays complete — no moving-target
window to chase. Before enabling, provision the CDC pipeline for the new table: add
public.CommentV3 to apps/event-engine/src/config/index.ts's table subscription list and create
the postgres.CommentV3 Kafka topic (apps/event-engine/scripts/create-kafka-topics.ts) — a gap
here silently stops comment-driven metrics for rows that only exist in the new table.
Phase 3 — backfill. 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).
Idempotent, batched copy jobs:
- v2 comments:
threadId → (entityType, entityId)via the thread's root chain;parentId= the thread'scommentId. Insert in depth order (parents before children — one recursive pass overparentThreadIdproduces the order) so thecommentv3_set_pathtrigger finds each parent's path;rootId/depthare then generated, not computed by the backfill. Ids andcreatedAt/updatedAtpreserved; nullablehidden/lockedcoalesce to false;replyToIdstays null for migrated rows. - v1 comments: direct copy with
entityType='model',legacyV1Idset, new ids from the shared sequence. - Topics: one
CommentTopicper entity-bearing Thread, copyinglocked;commentCountrecomputed by trigger-equivalent count. The counted set must match today's trigger exactly — all rows, including hidden and self-authored — becausechallenge.service.tsandgetCommentCountread it raw. - Reactions:
CommentV2Reactioncopies with the samecommentId;CommentReactionmaps throughlegacyV1Id. RecomputeCommentV3.reactionCountfrom the copied rows at the end. - Reports:
CommentV2Reportcopies as-is;CommentReportmaps throughlegacyV1Id. TheReportrows themselves don't move. - Skip orphaned-thread comments (unreachable today) and their reactions/reports; log counts.
- Parity checks: per-entity counts vs
Thread.commentCount, reaction counts vs the two source tables, spot checksums.
Once the backfill has converged and parity holds, the cross-surface readers move (see the Phase 4 prerequisite) — still before any write flip.
Rollback story (why reads flip before writes): while writes still land in the old tables, flipping a surface's reads back is instant and lossless — dual-write kept both sides current. Once a surface's writes flip, new rows exist only in CommentV3; rolling that back requires a reverse-copy (CommentV3 → old tables, trivial since ids are shared) — keep that script ready before the first write flip, and don't remove a surface's old write path until it has soaked.
Phase 4 — flip reads, then writes, per surface, behind Flipt. Prerequisite — readers move
first: dual-write makes CommentV3 a complete superset of both old tables, so every cross-surface
reader (notification prepareQuerys, the moderator app, creator-studio analytics, metrics jobs,
the daily-challenge judge gate) is re-pointed at CommentV3 during the dual-write window, before
the first write flip. Dual-write is one-directional (old → new), so once a surface's writes flip
its new comments exist only in CommentV3 — any reader still on the old tables would silently miss
them. With readers moved first, a write flip strands nothing and the old tables go quietly stale.
Order by risk: bounty/bountyEntry
or article first, image/post (highest volume) mid, model comments last (they change system, not
just implementation — the model discussion UI moves onto the unified provider). A surface's flip
includes its reactions and reports: reads of reactions come from CommentV3Reaction, and the model
surface's client switches its reaction entityType from commentOld to comment (its comment ids
are new ids from that point). After each surface's reads and writes are flipped and stable, its old
write path is removed; commentOld is removed once the model surface is flipped.
Per-surface cutover checklist additions from the residue sweep:
- article/post flips: explicitly purge
articleStatCache/postStatCache(src/server/redis/caches.ts~1492-1585) after any comment-count recompute — both are 24h-TTL caches over the metric tables and would serve stale counts for a day otherwise. - image flip:
EntityMetricrows withmetricType='Comment'(surfaced by theEntityMetricImageview) are written on comment create/delete — re-point that writer with the image surface. - comicChapter flip:
ChapterComments.tsxbypasses the comment providers and passesparentThreadIdin its mutation input — the comics client changes shape with its surface flip, unlike every provider-based surface.
Phase 5 — downstream re-point. Ordering note: the read-only consumers in this phase
(notification queries, moderator app reads, creator-studio, metrics jobs, the challenge gate) move
once the Phase 3 backfill converges, before the first Phase 4 write flip — see the Phase 4
prerequisite. What lands after the flips is the cleanup that requires old write paths to be gone
(enum/endpoint collapses, commentOld removal, handler retirement). Notifications and the
moderator app are the two largest consumers and get their own sections below. The rest:
- event-engine: one CDC handler on
CommentV3replacescomments.ts+comment-v2.ts; owner resolution becomes one entity lookup instead of the 15-way COALESCE. Kafka table subscriptions change — coordinate the handler swap with the write flip per surface, or run both handlers with the new one deduping on comment id during the dual-write window. - Metrics jobs (
article/bounty/post/model3d.metrics.ts): count from CommentV3. PreserveModelMetric.commentCount= distinct commenters semantics.question/answer.metrics.tsretire with that surface. - Creator-studio analytics (
analytics.tsall-time tile + per-image series): single-table query; model comments become visible there for the first time (feature win, note it in release comms). - Unify exclusion logic (
boundExcludedUserIds+ hidden/blocked handling) into one shared helper — today it's duplicated between v1 and v2 with subtle drift. - Cron jobs touching the tables directly (beyond the metrics jobs above):
src/server/jobs/entity-moderation.ts— auto-mute cleanup treatsCommentandCommentV2as separate entity types with separate selectors andupdateManyhide branches. Collapses to oneCommentV3branch; during Phases 2–4 it must hide in both old and new tables (a moderation hide applied only to the non-authoritative side would silently unhide on flip).src/server/jobs/daily-challenge-processing.ts— the judge's "already reviewed" gate is a rawThread JOIN CommentV2 WHERE th."imageId" = ... AND cm."userId" = judgein two places; becomes a singleWHERE entityType='image' AND entityId=... AND userId=...lookup (add a covering check that(entityType, entityId, userId)is answerable from the planned indexes — it is via the(userId, id)index only for small per-user sets; if slow, extend(entityType, entityId, id)usage or add(entityType, entityId, userId)). Its judge-comment writes already go throughupsertComment, so dual-write covers them;getJudgeCommentForImageincommentsv2.service.tsis the matching read to port. Flip these with theimagesurface.src/server/jobs/job-queue.ts—EntityType.Comment/CommentV2mappings are marked// unused; delete them in Phase 6 rather than porting.src/server/jobs/update-user-score.ts— touches comments only viaArticleMetric.commentCountand ClickHouseentityMetricEvents(metricType='commentCount'), both downstream of the metrics/CDC work above; no direct change, but its inputs must not gap during the CDC handler swap — include comment-count continuity in the Phase 5 event-engine verification.
- Reaction/report consumers:
reaction.service.tsdrops thecommentOldbranch;goodContent.reward.ts'stypeToTableand the buzz-tip labels insrc/utils/buzz.tscollapse to one mapping;ReportEntity.Comment/ReportEntity.CommentV2collapse to one value inreport-helpers.ts+report.service.ts(accept both from clients for one release), andReportModal.tsxlists one comment entity. The reaction entity enum (src/server/schema/reaction.schema.ts— contains both'comment'and'commentOld') and its twin switch inreaction.controller.ts(which also emitsComment_*vsCommentV2_*ClickHouse events) collapse too; keep both input values accepted for a release since it's a breaking tRPC input change, and note ClickHouse history keeps the old event-type literals forever — readers (apps/creator-studio/src/lib/server/analytics.ts:329,analytics-detail.ts) must match old literals alongsideCommentV3_*. Client twin: thecommentOldkey insrc/components/Reaction/Reactions.tsx. - Raw-SQL stragglers found in the full sweep, each a contained rewrite:
src/server/services/resourceReview.service.ts(owns its Thread — creates one eagerly on review create and readsthread.commentCountthroughresourceReview.selector.tsintoResourceReviewCard/ResourceReviewDetail; replace with lazyCommentTopic+COALESCE(0), a client-visible shape change),src/server/services/collection.service.ts(~2662, Thread-join count),src/server/metrics/post.metrics-old.ts(live sibling ofpost.metrics.ts— migrate or delete),src/pages/api/mod/daily-challenge/re-review.ts,src/server/notifications/report.notifications.ts(CASE over both report join tables), and the Q&A thread_countreads inanswer.controller.ts/question.controller.ts(retire with the surface, decision 1). - Entity-label/URL maps:
src/utils/string-helpers.ts(commentV2/CommentV2→ 'Comment' entries),src/pages/moderator/strikes.tsxentity→URL map, andsrc/server/utils/moderator-endpoint-catalog.generated.ts(regenerate). Thesrc/pages/comments/v2/[id].tsxpermalink redirect keeps working because v2 ids are preserved — just re-point its query; moderator tooling and strike links depend on that route. - Moderator app extras beyond the section above:
queue-thresholds.ts(commentV2threshold key andreport:commentV2mapping — renaming the key silently zeroes thresholds),src/lib/reports.tslabel maps. The moderator database (tracked schemaapps/moderator/prisma/schema.prisma) has siblingComment Int?/CommentV2 Int?columns onModerationQueueMetrics— populated by an external Retool-era job, nothing in the monorepo writes them, so collapsing them is a moderator-DB DDL change coordinated with whoever owns that job; low priority, tail-end of Phase 5. - Tooling:
scripts/local-dev/gen_seed.tshand-builds Thread/CommentV2 rows — rewrite with the new tables or local dev seeding breaks;scripts/metric-migration/metric-backfill-config.tsis the comment-count backfill tool (beware its line-96chMetricType: 'Comment'vs'commentCount'inconsistency). - Search indexes (
images/metrics-images/models/articles/bounties) only consume denormalizedcommentCountmetrics, never the tables — safe as long as the metric feeds don't gap; if a count source changes discontinuously, schedule a full reindex rather than incremental.
Notifications impact (Phase 5 detail)
src/server/notifications/comment.notifications.ts defines ~16 notification types. On the current
schema they fall into three shapes; all three simplify:
- Pure-v1 (
new-comment,new-comment-response,new-comment-nested, plus the"Comment"join inreaction.notifications.ts): move from"Comment"+parentIdself-joins toCommentV3 WHERE entityType = 'model'. The permalink they emit (?dialog=commentThread&highlight=<id>) must carry the new id — the client dialog reads it against the live table, so nothing else changes. - Per-entity v2 (
new-image-comment,new-post-comment,new-article-comment,new-bounty-comment,new-bounty-entry-comment,new-challenge-comment,new-app-listing-comment,new-review-response, the threemodel3dtypes,new-comment-reply): each is today aCommentV2 → Threadjoin on one entity FK (reply variants double-join through the child thread'scommentId). All becomeWHERE entityType = X AND parentId IS [NOT] NULLon one table — near-mechanical rewrites, and candidates for one parameterized query builder instead of a dozen near-copies. - Both-table UNIONs (
new-thread-responsehere, andmention.notifications.ts): the UNION disappears; thread-participant fanout becomesARRAY_AGG(userId) ... WHERE parentId = c.parentId(siblings) instead of one arm each forparentId(v1) andthreadId(v2).
Two things must hold through the transition:
- Historical rows are forever.
Notification.detailscarriesversion: 2(absent = v1) and old comment ids.comment.detail-fetcher.tskeeps its split permanently:version === 2resolves by id directly (ids preserved), otherwise vialegacyV1Id. New notifications writeversion: 3with new ids so the fetcher stays unambiguous. - Cutover alignment. Notification
prepareQuerys run offlastSentwindows against whichever table they query. Because CommentV3 is complete during dual-write, the simple safe order is: rewrite all notification queries against CommentV3 once, during the dual-write window, before any surface's write flip. No per-surface flag coupling needed — a query on CommentV3 sees every comment regardless of which side wrote it. The only forbidden state is a query still on an old table after that surface's writes flipped.
Also fix in passing: reaction.notifications.ts links ?dialog=commentThread&commentId=<rootId> —
verify the root-resolution logic against rootId on the new row (it currently self-joins to find
the v1 root).
Thread mute (Phase 5 detail)
Added 2026-08-31, after this plan was written: PR #4524 (commit 017e35fa03) shipped per-thread
notification mute for CommentsV2. It is merged and its migration is applied to dev only. The plan
above predates it, so everything in this section is new scope, not a restatement.
What v2 has today:
ThreadMute (userId, threadId), FK'd toThreadwithON DELETE CASCADEon both sides.muteableThreadsCteinsrc/server/common/thread-chain.ts— aWITH RECURSIVEancestor walk upThread.commentId → CommentV2.threadId, capped atMAX_THREAD_CHAIN_DEPTH = 100.notThreadMuted(recipient, threadId)inbase.notifications.ts, applied at 13 sites incomment.notifications.ts, producer-side.- Two controls: per-comment (overflow menu) via
toggleThreadMute, and section-level (beside the top-level composer) viatoggleSectionMute, which is the only writer that targets an entity-root thread. getThreadMuted/getSectionMutedread the same shared CTE so the menu label and the suppression cannot disagree.- A convention guard,
no-unmuteable-comment-processor, wired intotest:lint-rules.
What consolidation changes
The recursive walk disappears. v2 has to climb thread-by-thread because nesting is modeled as a
child Thread per comment. In CommentV3 ancestry is path, so suppression is one @> GiST
containment test — the identical shape as the lock check, and a natural row in the interactions
table above. That deletes thread-chain.ts outright and takes three properties with it:
- The depth cap stops being a correctness hole.
MAX_THREAD_CHAIN_DEPTHis a bound on work, not a proof; past itnotThreadMutedfails open and sends. Measured on prod 2026-08-27: 149 chains are past the cap, where a mute silently stops suppressing today. A containment test has no cap, and the stored depth cap of 20 makes the question moot regardless. - The
parentThreadIdexclusion becomes unnecessary. The v2 walk deliberately refuses that edge because it is written from client input, so trusting it would let a replier steer their reply into a chain the recipient muted and have the notification dropped — a suppression operated by someone other than its owner. The cost is 3,110 orphaned threads (0.057% of all threads) whose mutes go unrecognised.CommentV3.parentIdis server-derived andpathis set by a DB trigger, so there is no client-steerable edge and no trade to make. - The section mute stops depending on lazy row creation.
toggleSectionMutetoday returnshasThread: falseand does nothing on an entity with no comments yet.CommentTopicMutekeys off(entityType, entityId)and needs no row to exist, so it works before the first comment.
The 13 filter sites ride on the notification rewrite. Phase 3 re-points every notification
prepareQuery at CommentV3, and Phase 5 offers to collapse the per-entity queries into one
parameterized builder. Both must carry the mute filter — a rewrite that drops it reads as a clean
diff and silently un-mutes every user who set a mute, with no failing test unless the guard is
re-pointed in the same change. Do the collapse because of this rather than as optional cleanup:
one builder is one place the filter can be missing.
no-unmuteable-comment-processor must be re-pointed in lockstep. It asserts over the v2 SQL
text. Left alone it either fails on the rewrite or, worse, passes vacuously against v3 SQL it no
longer recognises. It also pins one deliberate exclusion — new-mention is intentionally not
muteable — which is a product decision the rewrite must preserve, not a gap.
🔴 ThreadMute cascades to nothing in Phase 6. The teardown drops Thread, and
ThreadMute_threadId_fkey is ON DELETE CASCADE — so unless mutes are migrated first, teardown
deletes every mute a user ever set, quietly and unrecoverably. The backfill runs in Phase 3 with the
rest, and Phase 6 must verify the new table is populated before the drop, not after.
Backfilling mutes
ThreadMute.threadId maps three ways, and only two of them work:
- Comment-level — thread's
commentIdis set: that comment's new id is theCommentMute.commentId(v2 ids are preserved, so this is identity). Note the off-by-one: v2 mutes the comment's child thread, so the mute belongs on the comment that thread hangs off, not on the thread's own parent. - Section-level — thread has an entity FK and no
commentId: becomes aCommentTopicMuterow on(userId, entityType, entityId). - Orphaned — neither: nothing to map to. Skip and log the count, same policy as orphaned-thread comments in Phase 3.
Volume is small (the feature is days old and dev-only), so this is a one-shot copy, not a batched resumable job. It still needs the parity check, because the failure mode is invisible to the user until a notification they muted arrives.
Open questions
- Naming — settled 2026-09-01 as
CommentMute/CommentTopicMute. The alternative wasCommentEngagement/CommentTopicEngagementwith a type enum, matching the repo's ten*Engagementtables (ChallengeEngagementships a single-value enum, so precedent covers one state) and leaving room for aFollow. Rejected: aFollowstate is speculative — the ticket's "mute/unfollow" is two words for one feature — and if it is ever wanted, a second table costs then what the enum costs now, so nothing is bought by deciding early. Whichever pair is used, keep both halves in the same lane:*Engagementis named for the id it holds, soCommentEngagementon the section table (which holdsentityType/entityId) would read as the comment-level one. - Does drilling in / re-rooting change what "this thread" means to a user? v2's mute anchors on a thread the user can see in the UI. In v3, subtree semantics are exact — muting a comment silences everything under it forever, including replies added after a re-root. That is the same behavior, but it is worth stating in the UI copy, which today says "thread".
- Is per-notification-type mute wanted? Out of scope here; noted because a
CommentMuterow is all-or-nothing per subtree and adding a type dimension later is a PK change.
Moderator app impact (Phase 5 detail)
apps/moderator (Kysely, direct DB) has the v1/v2 split baked into six modules; consolidation
deletes about half of each:
user-account.service.ts:getComments()(v1) andgetCommentsV2()(v2, hand-builtcoalesceoverTHREAD_TARGETSthat omits challenge/comicChapter/model3d/appListing — those render with a null entity today) merge into one query readingentityType/entityIdoff the row. The merge fixes the missing-entity bug for free; keep the surfaces list exhaustive via the enum.user-lookup.service.ts:COUNT_SOURCEScollapses "Model comments" + "Image comments" into one CommentV3 count (optionally grouped by entityType);getCommentFlags()extends from v1-only to all comments — a behavior change to flag to the mod team, since flag counts will rise.user-signals.service.ts::getCommentBurst: two-table sum becomes one count.reports.service.ts::commentContextUrl: the 10-branch Thread-FKCASE+rootThreadIdfallback becomes a singleentityTypeswitch on the row.report-entities.ts: the two{ reportTable, table }entries collapse to one{ 'CommentV3Report', 'CommentV3' }entry once the report backfill lands (Phase 3 mapsCommentReportrows throughlegacyV1Id, so no legacy-id handling survives here).entity-url.ts+retool/user-lookup/CommentsPanel.svelte:modelCommentUrl()/commentV2Url()collapse to one builder; the "Model comments"/"Other comments" panels and the separatecommentIds/commentV2Idscheckbox groups become one list. The backing mod endpoints (src/pages/api/mod/comment/bulk-delete.ts,remove-as-tos.ts) accept a singlecommentIdslist; keep the old dual-list shape as an accepted alias for one release so the moderator app and main app can deploy independently.
Sequencing: the moderator app's read rewrite lands during the dual-write window, before the
first write flip (CommentV3 is complete then, and dual-write is one-directional — waiting until
after flips would leave mod tooling blind to new comments on flipped surfaces). Its old-table code
paths are deleted with Phase 6. Pre-existing dead link to note while in there:
src/pages/moderator/strikes.tsx links v1 comments to /comments/<id>, a route that doesn't exist.
Phase 6 — contract. Drop Thread (and its 15 FK columns' reverse relations), Comment,
CommentV2, CommentReaction, CommentV2Reaction, CommentReport, CommentV2Report; drop the
triggers update_thread_comment_count, trg_moderation_comment/trg_moderation_commentv2, and
comment_reaction_count_update. Drop order: reaction/report join tables first (they FK-reference
the comment tables), then Comment/CommentV2, then Thread. Also:
- Remove the two
Threadentries (missing-foreign-key|Thread|imageId/challengeId) fromschema-drift/drift-baseline.jsonand update the remediation plan + itsproduction-plan.test.ts, or the drift gate fails on a table that no longer exists. - Leave the
Comment/CommentV2values in theEntityTypepg enum permanently. Removing a pg enum value requires a type rewrite across every column using it, and historicalUserStrike,Appeal,JobQueue,EntityCollaborator, andModerationRulerows carry those values. Instead: new rows writeCommentV3, and readers alias old values (CommentV2→ id-direct,Comment→ vialegacyV1Id). Same policy for persisted strings elsewhere (notificationdetails.version, ClickHouse event types, moderation queue keys).
What we get to delete
Thread entirely; the recursive CTE in getReplyThreads; the dynamic
as unknown as Prisma.ThreadWhereUniqueInput casts (5 sites); the 15-column COALESCE chain (9
sites); the v1/v2 UNIONs in notifications; one CDC handler; the duplicate reaction and report join
tables (four tables become two); the commentOld reaction type and one ReportEntity value; the { commentIds, commentV2Ids } dual-id API shape; the duplicated exclusion logic; the
per-page reply-count GROUP BY; src/server/common/thread-chain.ts and its 100-deep ancestor walk,
which path @> replaces for both the lock check and the mute check.
Explicitly not migrated
clubPostthreads — clubs are dead code; rows (if any) are left behind and dropped with Thread.question/answer— decided (decision 1): not migrated; rows drop with Thread, andQuestionAnswerComments.tsx+question/answer.metrics.tsretire alongside.- Comments attached to orphaned Threads (all entity FKs null) — unreachable today.
Sweep coverage (2026-08-24)
Two thorough residue sweeps (DB-level and app-level) ran over the whole workspace. Everything they
found is folded into the phases above. Categories checked and verified empty — so future
readers don't re-sweep them: CSAM services, mod-activity logging, chat, webhooks, email templates,
RSS/sitemaps, api/testing/* debug endpoints, signals, notification-cache, feature flags,
Prisma-mapped views (all commentCount* view fields source from *Metric tables),
prisma/programmability/, @no-type slim-schema handling, packages/civitai-db-queries,
packages/civitai-db, and the apps/notifications/auth/orchestrator-gateway/storage apps.
The string-typed entityType tables (BuzzTip, ModActivity, File, Link, EntityAccess,
etc.) have no code writing comment values into them today. One deliberate non-match:
StickerSurface = 'chat' | 'comment' in src/shared/utils/sticker-token.ts is a UI-surface union,
not a table reference — leave it alone in any rename sweep. Likewise the root
prisma/schema.prisma (gitignored, stale by ~1,500 lines vs the generated one in
packages/civitai-db-schema/prisma/) still contains Comment/CommentV2/Thread definitions —
it is a leftover build artifact, not residue; don't chase or edit it.
Test blast radius: ~20 unit/integration files hardcode CommentV2/Thread shapes (services,
notifications, permalink, controllers, event-engine handler tests) — mostly mechanical mock-shape
rewrites. Three need judgment, not find-and-replace: the two moderator SQL-assertion tests
(reports.sql.test.ts, reports.queries.explain.test.ts assert exact SQL text and query plans)
and schema-drift/.../production-plan.test.ts. One E2E gate: tests/preview-engagement.spec.ts
drives commentv2.upsert/delete end-to-end and keeps working as long as the tRPC contract stays
(the plan keeps it).