Files
multica/server/pkg/db/generated/comment.sql.go
Naiyuan Qing 78789e8da8 fix(comments): lock thread root to serialize concurrent resolves
Under READ COMMITTED, two concurrent resolves of different comments in
the same thread could each miss the other's uncommitted resolution in
ClearOtherThreadResolutions and commit two resolved rows. Take a FOR
UPDATE lock on the thread root in its own statement before clearing, so
the loser blocks and its clear re-runs on a fresh snapshot that sees the
winner's committed resolution.

Also updates the stale loadCommentForActor docstring that still
described the backend as a plain setter with frontend-derived
resolution — the single-resolution invariant is now enforced on write.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 14:30:01 +08:00

1219 lines
39 KiB
Go

// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.31.1
// source: comment.sql
package db
import (
"context"
"github.com/jackc/pgx/v5/pgtype"
)
const clearOtherThreadResolutions = `-- name: ClearOtherThreadResolutions :many
WITH RECURSIVE root_of AS (
-- Walk up from the target to its thread root.
SELECT c.id, c.parent_id
FROM comment c
WHERE c.id = $1 AND c.issue_id = $2 AND c.workspace_id = $3
UNION ALL
SELECT p.id, p.parent_id
FROM comment p
JOIN root_of r ON p.id = r.parent_id
),
thread_root AS (
SELECT id FROM root_of WHERE parent_id IS NULL LIMIT 1
),
descendants AS (
-- Expand back down from the root over the whole subtree. Cycle-safe under
-- the PK constraint (a comment cannot be its own ancestor).
SELECT c.id
FROM comment c
JOIN thread_root tr ON c.id = tr.id
UNION
SELECT c.id
FROM comment c
JOIN descendants d ON c.parent_id = d.id
WHERE c.issue_id = $2 AND c.workspace_id = $3
)
UPDATE comment SET
resolved_at = NULL,
resolved_by_type = NULL,
resolved_by_id = NULL,
updated_at = now()
WHERE comment.id IN (SELECT id FROM descendants)
AND comment.id <> $1
AND comment.resolved_at IS NOT NULL
RETURNING id, issue_id, author_type, author_id, content, type, created_at, updated_at, parent_id, workspace_id, resolved_at, resolved_by_type, resolved_by_id
`
type ClearOtherThreadResolutionsParams struct {
TargetID pgtype.UUID `json:"target_id"`
IssueID pgtype.UUID `json:"issue_id"`
WorkspaceID pgtype.UUID `json:"workspace_id"`
}
// Single-resolution invariant: a thread has at most one resolved comment.
// Resolving @target_id makes it the sole resolution, so this clears resolved_at
// on every OTHER currently-resolved comment in the same thread (the root of
// @target_id plus every descendant). The handler runs this in the SAME tx as
// ResolveComment so the replace is atomic — a crash can never leave two
// resolutions or zero. Scope is the thread only (id IN descendants AND
// id <> @target_id), never the whole issue. Returns each cleared row so the
// handler can emit a comment:unresolved event per row; granular realtime
// consumers replace a single comment in place and would otherwise keep
// displaying the stale resolution. Callers MUST run LockCommentThreadRoot
// first in the same tx so concurrent resolves in one thread serialize.
func (q *Queries) ClearOtherThreadResolutions(ctx context.Context, arg ClearOtherThreadResolutionsParams) ([]Comment, error) {
rows, err := q.db.Query(ctx, clearOtherThreadResolutions, arg.TargetID, arg.IssueID, arg.WorkspaceID)
if err != nil {
return nil, err
}
defer rows.Close()
items := []Comment{}
for rows.Next() {
var i Comment
if err := rows.Scan(
&i.ID,
&i.IssueID,
&i.AuthorType,
&i.AuthorID,
&i.Content,
&i.Type,
&i.CreatedAt,
&i.UpdatedAt,
&i.ParentID,
&i.WorkspaceID,
&i.ResolvedAt,
&i.ResolvedByType,
&i.ResolvedByID,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const countComments = `-- name: CountComments :one
SELECT count(*) FROM comment
WHERE issue_id = $1 AND workspace_id = $2
`
type CountCommentsParams struct {
IssueID pgtype.UUID `json:"issue_id"`
WorkspaceID pgtype.UUID `json:"workspace_id"`
}
func (q *Queries) CountComments(ctx context.Context, arg CountCommentsParams) (int64, error) {
row := q.db.QueryRow(ctx, countComments, arg.IssueID, arg.WorkspaceID)
var count int64
err := row.Scan(&count)
return count, err
}
const countNewCommentsSince = `-- name: CountNewCommentsSince :one
SELECT count(*) FROM comment
WHERE issue_id = $1
AND workspace_id = $2
AND created_at > $3
AND id <> $4
AND NOT (author_type = 'agent' AND author_id = $5)
`
type CountNewCommentsSinceParams struct {
IssueID pgtype.UUID `json:"issue_id"`
WorkspaceID pgtype.UUID `json:"workspace_id"`
Since pgtype.Timestamptz `json:"since"`
AnchorID pgtype.UUID `json:"anchor_id"`
AuthorID pgtype.UUID `json:"author_id"`
}
// Counts comments on an issue created strictly after @since, ACROSS THE WHOLE
// ISSUE (every thread, not just the triggering one). Excludes the triggering
// comment itself (@anchor_id — its body is already injected into the prompt)
// and any authored by the given agent (@author_id), so a chatty agent does not
// inflate its own new-comment count. The agent is steered to read the
// triggering thread first (see BuildNewCommentsHint), but the count is
// issue-wide so it knows the full catch-up volume. Feeds the daemon claim
// response without shipping comment bodies.
func (q *Queries) CountNewCommentsSince(ctx context.Context, arg CountNewCommentsSinceParams) (int64, error) {
row := q.db.QueryRow(ctx, countNewCommentsSince,
arg.IssueID,
arg.WorkspaceID,
arg.Since,
arg.AnchorID,
arg.AuthorID,
)
var count int64
err := row.Scan(&count)
return count, err
}
const createComment = `-- name: CreateComment :one
INSERT INTO comment (issue_id, workspace_id, author_type, author_id, content, type, parent_id)
VALUES ($1, $2, $3, $4, $5, $6, $7)
RETURNING id, issue_id, author_type, author_id, content, type, created_at, updated_at, parent_id, workspace_id, resolved_at, resolved_by_type, resolved_by_id
`
type CreateCommentParams struct {
IssueID pgtype.UUID `json:"issue_id"`
WorkspaceID pgtype.UUID `json:"workspace_id"`
AuthorType string `json:"author_type"`
AuthorID pgtype.UUID `json:"author_id"`
Content string `json:"content"`
Type string `json:"type"`
ParentID pgtype.UUID `json:"parent_id"`
}
func (q *Queries) CreateComment(ctx context.Context, arg CreateCommentParams) (Comment, error) {
row := q.db.QueryRow(ctx, createComment,
arg.IssueID,
arg.WorkspaceID,
arg.AuthorType,
arg.AuthorID,
arg.Content,
arg.Type,
arg.ParentID,
)
var i Comment
err := row.Scan(
&i.ID,
&i.IssueID,
&i.AuthorType,
&i.AuthorID,
&i.Content,
&i.Type,
&i.CreatedAt,
&i.UpdatedAt,
&i.ParentID,
&i.WorkspaceID,
&i.ResolvedAt,
&i.ResolvedByType,
&i.ResolvedByID,
)
return i, err
}
const deleteComment = `-- name: DeleteComment :exec
DELETE FROM comment WHERE id = $1 AND workspace_id = $2
`
type DeleteCommentParams struct {
ID pgtype.UUID `json:"id"`
WorkspaceID pgtype.UUID `json:"workspace_id"`
}
// Defense-in-depth: workspace_id is a SQL-layer tenant guard. See DeleteIssue.
func (q *Queries) DeleteComment(ctx context.Context, arg DeleteCommentParams) error {
_, err := q.db.Exec(ctx, deleteComment, arg.ID, arg.WorkspaceID)
return err
}
const getComment = `-- name: GetComment :one
SELECT id, issue_id, author_type, author_id, content, type, created_at, updated_at, parent_id, workspace_id, resolved_at, resolved_by_type, resolved_by_id FROM comment
WHERE id = $1
`
func (q *Queries) GetComment(ctx context.Context, id pgtype.UUID) (Comment, error) {
row := q.db.QueryRow(ctx, getComment, id)
var i Comment
err := row.Scan(
&i.ID,
&i.IssueID,
&i.AuthorType,
&i.AuthorID,
&i.Content,
&i.Type,
&i.CreatedAt,
&i.UpdatedAt,
&i.ParentID,
&i.WorkspaceID,
&i.ResolvedAt,
&i.ResolvedByType,
&i.ResolvedByID,
)
return i, err
}
const getCommentInWorkspace = `-- name: GetCommentInWorkspace :one
SELECT id, issue_id, author_type, author_id, content, type, created_at, updated_at, parent_id, workspace_id, resolved_at, resolved_by_type, resolved_by_id FROM comment
WHERE id = $1 AND workspace_id = $2
`
type GetCommentInWorkspaceParams struct {
ID pgtype.UUID `json:"id"`
WorkspaceID pgtype.UUID `json:"workspace_id"`
}
func (q *Queries) GetCommentInWorkspace(ctx context.Context, arg GetCommentInWorkspaceParams) (Comment, error) {
row := q.db.QueryRow(ctx, getCommentInWorkspace, arg.ID, arg.WorkspaceID)
var i Comment
err := row.Scan(
&i.ID,
&i.IssueID,
&i.AuthorType,
&i.AuthorID,
&i.Content,
&i.Type,
&i.CreatedAt,
&i.UpdatedAt,
&i.ParentID,
&i.WorkspaceID,
&i.ResolvedAt,
&i.ResolvedByType,
&i.ResolvedByID,
)
return i, err
}
const getThreadRoot = `-- name: GetThreadRoot :one
WITH RECURSIVE root_of AS (
SELECT c.id, c.parent_id
FROM comment c
WHERE c.id = $1 AND c.workspace_id = $2
UNION ALL
SELECT p.id, p.parent_id
FROM comment p
JOIN root_of r ON p.id = r.parent_id
)
SELECT c.id, c.issue_id, c.author_type, c.author_id, c.content, c.type, c.created_at, c.updated_at, c.parent_id, c.workspace_id, c.resolved_at, c.resolved_by_type, c.resolved_by_id FROM comment c
WHERE c.id = (SELECT id FROM root_of WHERE parent_id IS NULL LIMIT 1)
`
type GetThreadRootParams struct {
CommentID pgtype.UUID `json:"comment_id"`
WorkspaceID pgtype.UUID `json:"workspace_id"`
}
// Returns the thread-root comment for @comment_id by walking parent_id up to
// the row whose parent_id IS NULL. For a root comment it returns that comment
// itself. Used when callers need thread-level behavior while parent_id remains
// the exact direct parent of a reply. Cycle-safe under the PK constraint (a
// comment cannot be its own ancestor).
func (q *Queries) GetThreadRoot(ctx context.Context, arg GetThreadRootParams) (Comment, error) {
row := q.db.QueryRow(ctx, getThreadRoot, arg.CommentID, arg.WorkspaceID)
var i Comment
err := row.Scan(
&i.ID,
&i.IssueID,
&i.AuthorType,
&i.AuthorID,
&i.Content,
&i.Type,
&i.CreatedAt,
&i.UpdatedAt,
&i.ParentID,
&i.WorkspaceID,
&i.ResolvedAt,
&i.ResolvedByType,
&i.ResolvedByID,
)
return i, err
}
const hasAgentCommentedSince = `-- name: HasAgentCommentedSince :one
SELECT EXISTS (
SELECT 1 FROM comment
WHERE issue_id = $1
AND author_type = 'agent'
AND author_id = $2
AND created_at >= $3
) AS commented
`
type HasAgentCommentedSinceParams struct {
IssueID pgtype.UUID `json:"issue_id"`
AuthorID pgtype.UUID `json:"author_id"`
Since pgtype.Timestamptz `json:"since"`
}
func (q *Queries) HasAgentCommentedSince(ctx context.Context, arg HasAgentCommentedSinceParams) (bool, error) {
row := q.db.QueryRow(ctx, hasAgentCommentedSince, arg.IssueID, arg.AuthorID, arg.Since)
var commented bool
err := row.Scan(&commented)
return commented, err
}
const hasAgentRepliedInThread = `-- name: HasAgentRepliedInThread :one
SELECT count(*) > 0 AS has_replied FROM comment
WHERE parent_id = $1 AND author_type = 'agent' AND author_id = $2
`
type HasAgentRepliedInThreadParams struct {
ParentID pgtype.UUID `json:"parent_id"`
AgentID pgtype.UUID `json:"agent_id"`
}
// Returns true if the given agent has posted a reply in the thread rooted at
// the specified parent comment. Used to detect agent participation in a
// member-started thread so that follow-up member replies still trigger the agent.
func (q *Queries) HasAgentRepliedInThread(ctx context.Context, arg HasAgentRepliedInThreadParams) (bool, error) {
row := q.db.QueryRow(ctx, hasAgentRepliedInThread, arg.ParentID, arg.AgentID)
var has_replied bool
err := row.Scan(&has_replied)
return has_replied, err
}
const listCommentsForIssue = `-- name: ListCommentsForIssue :many
SELECT id, issue_id, author_type, author_id, content, type, created_at, updated_at, parent_id, workspace_id, resolved_at, resolved_by_type, resolved_by_id FROM comment
WHERE issue_id = $1 AND workspace_id = $2
ORDER BY created_at ASC, id ASC
LIMIT $3
`
type ListCommentsForIssueParams struct {
IssueID pgtype.UUID `json:"issue_id"`
WorkspaceID pgtype.UUID `json:"workspace_id"`
Limit int32 `json:"limit"`
}
// All comments for an issue in chronological order, capped at $3 (DB safety
// net). Issue p99 is ~30 comments, max ever observed in prod is ~1.1k, so
// the handler-side cap of 2000 is purely defensive.
func (q *Queries) ListCommentsForIssue(ctx context.Context, arg ListCommentsForIssueParams) ([]Comment, error) {
rows, err := q.db.Query(ctx, listCommentsForIssue, arg.IssueID, arg.WorkspaceID, arg.Limit)
if err != nil {
return nil, err
}
defer rows.Close()
items := []Comment{}
for rows.Next() {
var i Comment
if err := rows.Scan(
&i.ID,
&i.IssueID,
&i.AuthorType,
&i.AuthorID,
&i.Content,
&i.Type,
&i.CreatedAt,
&i.UpdatedAt,
&i.ParentID,
&i.WorkspaceID,
&i.ResolvedAt,
&i.ResolvedByType,
&i.ResolvedByID,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const listCommentsSinceForIssue = `-- name: ListCommentsSinceForIssue :many
SELECT id, issue_id, author_type, author_id, content, type, created_at, updated_at, parent_id, workspace_id, resolved_at, resolved_by_type, resolved_by_id FROM comment
WHERE issue_id = $1 AND workspace_id = $2 AND created_at > $3
ORDER BY created_at ASC, id ASC
LIMIT $4
`
type ListCommentsSinceForIssueParams struct {
IssueID pgtype.UUID `json:"issue_id"`
WorkspaceID pgtype.UUID `json:"workspace_id"`
CreatedAt pgtype.Timestamptz `json:"created_at"`
Limit int32 `json:"limit"`
}
// Comments created strictly after $3 in chronological order, capped at $4.
// Powers the CLI's `--since` agent-polling flow.
func (q *Queries) ListCommentsSinceForIssue(ctx context.Context, arg ListCommentsSinceForIssueParams) ([]Comment, error) {
rows, err := q.db.Query(ctx, listCommentsSinceForIssue,
arg.IssueID,
arg.WorkspaceID,
arg.CreatedAt,
arg.Limit,
)
if err != nil {
return nil, err
}
defer rows.Close()
items := []Comment{}
for rows.Next() {
var i Comment
if err := rows.Scan(
&i.ID,
&i.IssueID,
&i.AuthorType,
&i.AuthorID,
&i.Content,
&i.Type,
&i.CreatedAt,
&i.UpdatedAt,
&i.ParentID,
&i.WorkspaceID,
&i.ResolvedAt,
&i.ResolvedByType,
&i.ResolvedByID,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const listRecentThreadCommentsForIssue = `-- name: ListRecentThreadCommentsForIssue :many
WITH RECURSIVE membership(id, root_id, comment_created_at) AS (
-- Each root maps to itself.
SELECT c.id, c.id AS root_id, c.created_at
FROM comment c
WHERE c.issue_id = $1
AND c.workspace_id = $2
AND c.parent_id IS NULL
UNION ALL
-- Each descendant inherits its parent's root_id.
SELECT c.id, m.root_id, c.created_at
FROM comment c
JOIN membership m ON c.parent_id = m.id
WHERE c.issue_id = $1
AND c.workspace_id = $2
),
thread_stats AS (
SELECT root_id, MAX(comment_created_at)::timestamptz AS last_activity_at
FROM membership
GROUP BY root_id
),
picked AS (
SELECT ts.root_id, ts.last_activity_at
FROM thread_stats ts
WHERE (
$3::boolean = FALSE
OR (ts.last_activity_at, ts.root_id) < ($4::timestamptz, $5::uuid)
)
ORDER BY ts.last_activity_at DESC, ts.root_id DESC
LIMIT $6
)
SELECT c.id, c.issue_id, c.author_type, c.author_id, c.content, c.type,
c.created_at, c.updated_at, c.parent_id, c.workspace_id,
c.resolved_at, c.resolved_by_type, c.resolved_by_id,
p.root_id AS thread_root_id,
p.last_activity_at AS thread_last_activity_at
FROM picked p
JOIN membership m ON m.root_id = p.root_id
JOIN comment c ON c.id = m.id
ORDER BY p.last_activity_at ASC, p.root_id ASC, c.created_at ASC, c.id ASC
`
type ListRecentThreadCommentsForIssueParams struct {
IssueID pgtype.UUID `json:"issue_id"`
WorkspaceID pgtype.UUID `json:"workspace_id"`
HasCursor bool `json:"has_cursor"`
BeforeAt pgtype.Timestamptz `json:"before_at"`
BeforeID pgtype.UUID `json:"before_id"`
ThreadLimit int32 `json:"thread_limit"`
}
type ListRecentThreadCommentsForIssueRow struct {
ID pgtype.UUID `json:"id"`
IssueID pgtype.UUID `json:"issue_id"`
AuthorType string `json:"author_type"`
AuthorID pgtype.UUID `json:"author_id"`
Content string `json:"content"`
Type string `json:"type"`
CreatedAt pgtype.Timestamptz `json:"created_at"`
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
ParentID pgtype.UUID `json:"parent_id"`
WorkspaceID pgtype.UUID `json:"workspace_id"`
ResolvedAt pgtype.Timestamptz `json:"resolved_at"`
ResolvedByType pgtype.Text `json:"resolved_by_type"`
ResolvedByID pgtype.UUID `json:"resolved_by_id"`
ThreadRootID pgtype.UUID `json:"thread_root_id"`
ThreadLastActivityAt pgtype.Timestamptz `json:"thread_last_activity_at"`
}
// Returns the N most recently active threads (root + every descendant) rather
// than the N most recent rows. A thread's "last activity" is MAX(created_at)
// over its whole subtree; threads are ranked by (last_activity_at DESC,
// root_id DESC) and the top N are expanded.
//
// Why thread-grouped instead of row-recent: with row-recent the newest 20
// comments can come from 8 different threads — the agent sees 8 unrelated
// tails. With thread-grouped the agent sees N complete conversational arcs,
// which matches how a human reads an issue (#2340).
//
// Response ordering:
//
// threads: (thread_last_activity_at ASC, root_id ASC)
// in-thread: (created_at ASC, id ASC)
//
// So the oldest-active thread appears first and the most recently-active
// thread is at the tail, closest to "now" in an agent prompt.
//
// Cursor scrolls back through threads. When @has_cursor=TRUE only threads
// with (last_activity_at, root_id) < (@before_at, @before_id) are eligible.
// The cursor is a THREAD cursor — both values identify a thread (its last
// activity timestamp and its root comment id), not a single row.
//
// The recursive `membership` CTE labels each comment with its thread root by
// walking down from every root. It does not assume any maximum nesting depth,
// which preserves correctness even if the schema ever allows reply-of-reply
// (the agent path in TaskService.createAgentComment collapses to root today,
// but the user-facing CreateComment handler does not enforce it).
func (q *Queries) ListRecentThreadCommentsForIssue(ctx context.Context, arg ListRecentThreadCommentsForIssueParams) ([]ListRecentThreadCommentsForIssueRow, error) {
rows, err := q.db.Query(ctx, listRecentThreadCommentsForIssue,
arg.IssueID,
arg.WorkspaceID,
arg.HasCursor,
arg.BeforeAt,
arg.BeforeID,
arg.ThreadLimit,
)
if err != nil {
return nil, err
}
defer rows.Close()
items := []ListRecentThreadCommentsForIssueRow{}
for rows.Next() {
var i ListRecentThreadCommentsForIssueRow
if err := rows.Scan(
&i.ID,
&i.IssueID,
&i.AuthorType,
&i.AuthorID,
&i.Content,
&i.Type,
&i.CreatedAt,
&i.UpdatedAt,
&i.ParentID,
&i.WorkspaceID,
&i.ResolvedAt,
&i.ResolvedByType,
&i.ResolvedByID,
&i.ThreadRootID,
&i.ThreadLastActivityAt,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const listRootCommentsForIssue = `-- name: ListRootCommentsForIssue :many
WITH RECURSIVE selected_roots AS (
SELECT c.id, c.created_at
FROM comment c
WHERE c.issue_id = $1
AND c.workspace_id = $2
AND c.parent_id IS NULL
ORDER BY c.created_at ASC, c.id ASC
LIMIT $3
),
membership(id, root_id, comment_created_at) AS (
SELECT sr.id, sr.id AS root_id, sr.created_at
FROM selected_roots sr
UNION ALL
SELECT c.id, m.root_id, c.created_at
FROM comment c
JOIN membership m ON c.parent_id = m.id
WHERE c.issue_id = $1
AND c.workspace_id = $2
),
thread_stats AS (
SELECT root_id,
(COUNT(*) - 1)::int AS reply_count,
MAX(comment_created_at)::timestamptz AS last_activity_at
FROM membership
GROUP BY root_id
)
SELECT c.id, c.issue_id, c.author_type, c.author_id, c.content, c.type,
c.created_at, c.updated_at, c.parent_id, c.workspace_id,
c.resolved_at, c.resolved_by_type, c.resolved_by_id,
ts.reply_count AS reply_count,
ts.last_activity_at AS last_activity_at
FROM selected_roots sr
JOIN comment c ON c.id = sr.id
JOIN thread_stats ts ON ts.root_id = sr.id
ORDER BY c.created_at ASC, c.id ASC
`
type ListRootCommentsForIssueParams struct {
IssueID pgtype.UUID `json:"issue_id"`
WorkspaceID pgtype.UUID `json:"workspace_id"`
RowLimit int32 `json:"row_limit"`
}
type ListRootCommentsForIssueRow struct {
ID pgtype.UUID `json:"id"`
IssueID pgtype.UUID `json:"issue_id"`
AuthorType string `json:"author_type"`
AuthorID pgtype.UUID `json:"author_id"`
Content string `json:"content"`
Type string `json:"type"`
CreatedAt pgtype.Timestamptz `json:"created_at"`
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
ParentID pgtype.UUID `json:"parent_id"`
WorkspaceID pgtype.UUID `json:"workspace_id"`
ResolvedAt pgtype.Timestamptz `json:"resolved_at"`
ResolvedByType pgtype.Text `json:"resolved_by_type"`
ResolvedByID pgtype.UUID `json:"resolved_by_id"`
ReplyCount int32 `json:"reply_count"`
LastActivityAt pgtype.Timestamptz `json:"last_activity_at"`
}
// Top-level comments only, in issue chronological order, each annotated with
// per-thread orientation stats: reply_count (number of descendants) and
// last_activity_at (MAX(created_at) over the whole subtree). This powers
// `comment list --roots-only` so agents can not only orient around the global
// discussion but also triage which thread to drill into (biggest / most
// recently active) before fetching any specific reply thread.
//
// `selected_roots` picks the roots we will actually return first (the chrono
// page of size @row_limit), so the recursive `membership` walk only expands
// those threads' subtrees instead of every thread in the issue. membership
// labels each comment with its thread root by walking down from the selected
// roots, so the counts stay correct even if the schema ever allows
// reply-of-reply (the write path collapses to root today, but does not enforce
// it). Mirrors ListRecentThreadCommentsForIssue's stats CTE.
func (q *Queries) ListRootCommentsForIssue(ctx context.Context, arg ListRootCommentsForIssueParams) ([]ListRootCommentsForIssueRow, error) {
rows, err := q.db.Query(ctx, listRootCommentsForIssue, arg.IssueID, arg.WorkspaceID, arg.RowLimit)
if err != nil {
return nil, err
}
defer rows.Close()
items := []ListRootCommentsForIssueRow{}
for rows.Next() {
var i ListRootCommentsForIssueRow
if err := rows.Scan(
&i.ID,
&i.IssueID,
&i.AuthorType,
&i.AuthorID,
&i.Content,
&i.Type,
&i.CreatedAt,
&i.UpdatedAt,
&i.ParentID,
&i.WorkspaceID,
&i.ResolvedAt,
&i.ResolvedByType,
&i.ResolvedByID,
&i.ReplyCount,
&i.LastActivityAt,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const listRootCommentsSinceForIssue = `-- name: ListRootCommentsSinceForIssue :many
WITH RECURSIVE selected_roots AS (
SELECT c.id, c.created_at
FROM comment c
WHERE c.issue_id = $1
AND c.workspace_id = $2
AND c.parent_id IS NULL
AND c.created_at > $3
ORDER BY c.created_at ASC, c.id ASC
LIMIT $4
),
membership(id, root_id, comment_created_at) AS (
SELECT sr.id, sr.id AS root_id, sr.created_at
FROM selected_roots sr
UNION ALL
SELECT c.id, m.root_id, c.created_at
FROM comment c
JOIN membership m ON c.parent_id = m.id
WHERE c.issue_id = $1
AND c.workspace_id = $2
),
thread_stats AS (
SELECT root_id,
(COUNT(*) - 1)::int AS reply_count,
MAX(comment_created_at)::timestamptz AS last_activity_at
FROM membership
GROUP BY root_id
)
SELECT c.id, c.issue_id, c.author_type, c.author_id, c.content, c.type,
c.created_at, c.updated_at, c.parent_id, c.workspace_id,
c.resolved_at, c.resolved_by_type, c.resolved_by_id,
ts.reply_count AS reply_count,
ts.last_activity_at AS last_activity_at
FROM selected_roots sr
JOIN comment c ON c.id = sr.id
JOIN thread_stats ts ON ts.root_id = sr.id
ORDER BY c.created_at ASC, c.id ASC
`
type ListRootCommentsSinceForIssueParams struct {
IssueID pgtype.UUID `json:"issue_id"`
WorkspaceID pgtype.UUID `json:"workspace_id"`
Since pgtype.Timestamptz `json:"since"`
RowLimit int32 `json:"row_limit"`
}
type ListRootCommentsSinceForIssueRow struct {
ID pgtype.UUID `json:"id"`
IssueID pgtype.UUID `json:"issue_id"`
AuthorType string `json:"author_type"`
AuthorID pgtype.UUID `json:"author_id"`
Content string `json:"content"`
Type string `json:"type"`
CreatedAt pgtype.Timestamptz `json:"created_at"`
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
ParentID pgtype.UUID `json:"parent_id"`
WorkspaceID pgtype.UUID `json:"workspace_id"`
ResolvedAt pgtype.Timestamptz `json:"resolved_at"`
ResolvedByType pgtype.Text `json:"resolved_by_type"`
ResolvedByID pgtype.UUID `json:"resolved_by_id"`
ReplyCount int32 `json:"reply_count"`
LastActivityAt pgtype.Timestamptz `json:"last_activity_at"`
}
// Top-level comments created strictly after @since, each annotated with the
// same reply_count / last_activity_at stats as ListRootCommentsForIssue. The
// @since filter narrows which roots are returned; the stats are still computed
// over each selected thread's full subtree (so a freshly created root with no
// replies reports reply_count 0 and last_activity_at = its own created_at).
// selected_roots applies the @since + @row_limit cut up front so the recursive
// membership walk only touches the subtrees of the roots we actually return.
func (q *Queries) ListRootCommentsSinceForIssue(ctx context.Context, arg ListRootCommentsSinceForIssueParams) ([]ListRootCommentsSinceForIssueRow, error) {
rows, err := q.db.Query(ctx, listRootCommentsSinceForIssue,
arg.IssueID,
arg.WorkspaceID,
arg.Since,
arg.RowLimit,
)
if err != nil {
return nil, err
}
defer rows.Close()
items := []ListRootCommentsSinceForIssueRow{}
for rows.Next() {
var i ListRootCommentsSinceForIssueRow
if err := rows.Scan(
&i.ID,
&i.IssueID,
&i.AuthorType,
&i.AuthorID,
&i.Content,
&i.Type,
&i.CreatedAt,
&i.UpdatedAt,
&i.ParentID,
&i.WorkspaceID,
&i.ResolvedAt,
&i.ResolvedByType,
&i.ResolvedByID,
&i.ReplyCount,
&i.LastActivityAt,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const listThreadCommentsForIssue = `-- name: ListThreadCommentsForIssue :many
WITH RECURSIVE root_of AS (
-- Walk up from the anchor until parent_id IS NULL.
SELECT c.id, c.parent_id
FROM comment c
WHERE c.id = $2 AND c.issue_id = $3 AND c.workspace_id = $4
UNION ALL
SELECT p.id, p.parent_id
FROM comment p
JOIN root_of r ON p.id = r.parent_id
),
thread_root AS (
SELECT id FROM root_of WHERE parent_id IS NULL LIMIT 1
),
descendants AS (
-- Start from the root, then keep adding any comment whose parent is
-- already in the set. Cycle-safe under PK constraint (a comment cannot
-- be its own ancestor).
SELECT c.id, c.issue_id, c.author_type, c.author_id, c.content, c.type,
c.created_at, c.updated_at, c.parent_id, c.workspace_id,
c.resolved_at, c.resolved_by_type, c.resolved_by_id
FROM comment c
JOIN thread_root tr ON c.id = tr.id
UNION
SELECT c.id, c.issue_id, c.author_type, c.author_id, c.content, c.type,
c.created_at, c.updated_at, c.parent_id, c.workspace_id,
c.resolved_at, c.resolved_by_type, c.resolved_by_id
FROM comment c
JOIN descendants d ON c.parent_id = d.id
WHERE c.issue_id = $3 AND c.workspace_id = $4
)
SELECT id, issue_id, author_type, author_id, content, type,
created_at, updated_at, parent_id, workspace_id,
resolved_at, resolved_by_type, resolved_by_id
FROM descendants
ORDER BY created_at ASC, id ASC
LIMIT $1
`
type ListThreadCommentsForIssueParams struct {
RowLimit int32 `json:"row_limit"`
AnchorID pgtype.UUID `json:"anchor_id"`
IssueID pgtype.UUID `json:"issue_id"`
WorkspaceID pgtype.UUID `json:"workspace_id"`
}
type ListThreadCommentsForIssueRow struct {
ID pgtype.UUID `json:"id"`
IssueID pgtype.UUID `json:"issue_id"`
AuthorType string `json:"author_type"`
AuthorID pgtype.UUID `json:"author_id"`
Content string `json:"content"`
Type string `json:"type"`
CreatedAt pgtype.Timestamptz `json:"created_at"`
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
ParentID pgtype.UUID `json:"parent_id"`
WorkspaceID pgtype.UUID `json:"workspace_id"`
ResolvedAt pgtype.Timestamptz `json:"resolved_at"`
ResolvedByType pgtype.Text `json:"resolved_by_type"`
ResolvedByID pgtype.UUID `json:"resolved_by_id"`
}
// Returns the root of the thread containing @anchor_id plus every descendant
// (recursive — supports real reply-to-reply nesting). @anchor_id may itself be
// a root or any reply in the thread. Output is chronological so it can be fed
// straight to the agent.
func (q *Queries) ListThreadCommentsForIssue(ctx context.Context, arg ListThreadCommentsForIssueParams) ([]ListThreadCommentsForIssueRow, error) {
rows, err := q.db.Query(ctx, listThreadCommentsForIssue,
arg.RowLimit,
arg.AnchorID,
arg.IssueID,
arg.WorkspaceID,
)
if err != nil {
return nil, err
}
defer rows.Close()
items := []ListThreadCommentsForIssueRow{}
for rows.Next() {
var i ListThreadCommentsForIssueRow
if err := rows.Scan(
&i.ID,
&i.IssueID,
&i.AuthorType,
&i.AuthorID,
&i.Content,
&i.Type,
&i.CreatedAt,
&i.UpdatedAt,
&i.ParentID,
&i.WorkspaceID,
&i.ResolvedAt,
&i.ResolvedByType,
&i.ResolvedByID,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const listThreadCommentsForIssuePaged = `-- name: ListThreadCommentsForIssuePaged :many
WITH RECURSIVE root_of AS (
SELECT c.id, c.parent_id
FROM comment c
WHERE c.id = $1 AND c.issue_id = $2 AND c.workspace_id = $3
UNION ALL
SELECT p.id, p.parent_id
FROM comment p
JOIN root_of r ON p.id = r.parent_id
),
thread_root AS (
SELECT id FROM root_of WHERE parent_id IS NULL LIMIT 1
),
descendants AS (
SELECT c.id, c.issue_id, c.author_type, c.author_id, c.content, c.type,
c.created_at, c.updated_at, c.parent_id, c.workspace_id,
c.resolved_at, c.resolved_by_type, c.resolved_by_id
FROM comment c
JOIN thread_root tr ON c.id = tr.id
UNION
SELECT c.id, c.issue_id, c.author_type, c.author_id, c.content, c.type,
c.created_at, c.updated_at, c.parent_id, c.workspace_id,
c.resolved_at, c.resolved_by_type, c.resolved_by_id
FROM comment c
JOIN descendants d ON c.parent_id = d.id
WHERE c.issue_id = $2 AND c.workspace_id = $3
),
reply_page AS (
SELECT d.id, d.issue_id, d.author_type, d.author_id, d.content, d.type,
d.created_at, d.updated_at, d.parent_id, d.workspace_id,
d.resolved_at, d.resolved_by_type, d.resolved_by_id
FROM descendants d
WHERE d.id NOT IN (SELECT id FROM thread_root)
AND (
$4::boolean = FALSE
OR (d.created_at, d.id) < ($5::timestamptz, $6::uuid)
)
ORDER BY d.created_at DESC, d.id DESC
LIMIT $7
)
SELECT id, issue_id, author_type, author_id, content, type,
created_at, updated_at, parent_id, workspace_id,
resolved_at, resolved_by_type, resolved_by_id
FROM (
SELECT d.id, d.issue_id, d.author_type, d.author_id, d.content, d.type,
d.created_at, d.updated_at, d.parent_id, d.workspace_id,
d.resolved_at, d.resolved_by_type, d.resolved_by_id
FROM descendants d
JOIN thread_root tr ON d.id = tr.id
UNION ALL
SELECT id, issue_id, author_type, author_id, content, type,
created_at, updated_at, parent_id, workspace_id,
resolved_at, resolved_by_type, resolved_by_id
FROM reply_page
) combined
ORDER BY created_at ASC, id ASC
`
type ListThreadCommentsForIssuePagedParams struct {
AnchorID pgtype.UUID `json:"anchor_id"`
IssueID pgtype.UUID `json:"issue_id"`
WorkspaceID pgtype.UUID `json:"workspace_id"`
HasCursor bool `json:"has_cursor"`
BeforeAt pgtype.Timestamptz `json:"before_at"`
BeforeID pgtype.UUID `json:"before_id"`
ReplyLimit int32 `json:"reply_limit"`
}
type ListThreadCommentsForIssuePagedRow struct {
ID pgtype.UUID `json:"id"`
IssueID pgtype.UUID `json:"issue_id"`
AuthorType string `json:"author_type"`
AuthorID pgtype.UUID `json:"author_id"`
Content string `json:"content"`
Type string `json:"type"`
CreatedAt pgtype.Timestamptz `json:"created_at"`
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
ParentID pgtype.UUID `json:"parent_id"`
WorkspaceID pgtype.UUID `json:"workspace_id"`
ResolvedAt pgtype.Timestamptz `json:"resolved_at"`
ResolvedByType pgtype.Text `json:"resolved_by_type"`
ResolvedByID pgtype.UUID `json:"resolved_by_id"`
}
// Same root-walk + descendants expansion as ListThreadCommentsForIssue, but
// returns root + only the @reply_limit most recent replies (per the
// (created_at, id) composite key). When @has_cursor=TRUE only replies with
// (created_at, id) < (@before_at, @before_id) are eligible — that is the
// cursor for scrolling *within* a thread.
//
// Root is unconditional: it is included regardless of @reply_limit (even 0)
// and regardless of the cursor. A reader landing on a long thread needs the
// root for the "what is this thread about" context, even if every reply has
// been paginated past.
//
// Reply selection happens DESC (newest replies first) so the cursor walks
// toward older replies; the outer SELECT then re-sorts the combined output
// ASC so the body stays chronological (oldest → newest), matching every
// other comment list path.
func (q *Queries) ListThreadCommentsForIssuePaged(ctx context.Context, arg ListThreadCommentsForIssuePagedParams) ([]ListThreadCommentsForIssuePagedRow, error) {
rows, err := q.db.Query(ctx, listThreadCommentsForIssuePaged,
arg.AnchorID,
arg.IssueID,
arg.WorkspaceID,
arg.HasCursor,
arg.BeforeAt,
arg.BeforeID,
arg.ReplyLimit,
)
if err != nil {
return nil, err
}
defer rows.Close()
items := []ListThreadCommentsForIssuePagedRow{}
for rows.Next() {
var i ListThreadCommentsForIssuePagedRow
if err := rows.Scan(
&i.ID,
&i.IssueID,
&i.AuthorType,
&i.AuthorID,
&i.Content,
&i.Type,
&i.CreatedAt,
&i.UpdatedAt,
&i.ParentID,
&i.WorkspaceID,
&i.ResolvedAt,
&i.ResolvedByType,
&i.ResolvedByID,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const lockCommentThreadRoot = `-- name: LockCommentThreadRoot :one
WITH RECURSIVE root_of AS (
SELECT c.id, c.parent_id
FROM comment c
WHERE c.id = $1 AND c.issue_id = $2 AND c.workspace_id = $3
UNION ALL
SELECT p.id, p.parent_id
FROM comment p
JOIN root_of r ON p.id = r.parent_id
)
SELECT c.id
FROM comment c
WHERE c.id = (SELECT id FROM root_of WHERE parent_id IS NULL LIMIT 1)
FOR UPDATE
`
type LockCommentThreadRootParams struct {
TargetID pgtype.UUID `json:"target_id"`
IssueID pgtype.UUID `json:"issue_id"`
WorkspaceID pgtype.UUID `json:"workspace_id"`
}
// Serializes resolves within a thread. Under READ COMMITTED, two concurrent
// resolves of different comments in the same thread could each miss the
// other's uncommitted resolution in ClearOtherThreadResolutions and commit two
// resolved rows. Locking the thread root in its OWN statement (before the
// clear) makes the loser block here; once the winner commits, the loser's
// clear runs as a new statement with a fresh snapshot that sees the winner's
// committed resolution and clears it. The lock must be a separate statement —
// folding FOR UPDATE into the clear's CTE would not help, because the blocked
// statement keeps its pre-wait snapshot for all other rows.
func (q *Queries) LockCommentThreadRoot(ctx context.Context, arg LockCommentThreadRootParams) (pgtype.UUID, error) {
row := q.db.QueryRow(ctx, lockCommentThreadRoot, arg.TargetID, arg.IssueID, arg.WorkspaceID)
var id pgtype.UUID
err := row.Scan(&id)
return id, err
}
const resolveComment = `-- name: ResolveComment :one
UPDATE comment SET
resolved_at = COALESCE(resolved_at, now()),
resolved_by_type = COALESCE(resolved_by_type, $2),
resolved_by_id = COALESCE(resolved_by_id, $3),
updated_at = CASE WHEN resolved_at IS NULL THEN now() ELSE updated_at END
WHERE id = $1
RETURNING id, issue_id, author_type, author_id, content, type, created_at, updated_at, parent_id, workspace_id, resolved_at, resolved_by_type, resolved_by_id
`
type ResolveCommentParams struct {
ID pgtype.UUID `json:"id"`
ResolvedByType pgtype.Text `json:"resolved_by_type"`
ResolvedByID pgtype.UUID `json:"resolved_by_id"`
}
// Idempotent: re-resolving keeps the original resolved_at + resolver. Always
// returns the row so the handler can surface the canonical state.
func (q *Queries) ResolveComment(ctx context.Context, arg ResolveCommentParams) (Comment, error) {
row := q.db.QueryRow(ctx, resolveComment, arg.ID, arg.ResolvedByType, arg.ResolvedByID)
var i Comment
err := row.Scan(
&i.ID,
&i.IssueID,
&i.AuthorType,
&i.AuthorID,
&i.Content,
&i.Type,
&i.CreatedAt,
&i.UpdatedAt,
&i.ParentID,
&i.WorkspaceID,
&i.ResolvedAt,
&i.ResolvedByType,
&i.ResolvedByID,
)
return i, err
}
const unresolveComment = `-- name: UnresolveComment :one
UPDATE comment SET
resolved_at = NULL,
resolved_by_type = NULL,
resolved_by_id = NULL,
updated_at = CASE WHEN resolved_at IS NOT NULL THEN now() ELSE updated_at END
WHERE id = $1
RETURNING id, issue_id, author_type, author_id, content, type, created_at, updated_at, parent_id, workspace_id, resolved_at, resolved_by_type, resolved_by_id
`
// Idempotent: a no-op clear (already unresolved) just returns the row.
func (q *Queries) UnresolveComment(ctx context.Context, id pgtype.UUID) (Comment, error) {
row := q.db.QueryRow(ctx, unresolveComment, id)
var i Comment
err := row.Scan(
&i.ID,
&i.IssueID,
&i.AuthorType,
&i.AuthorID,
&i.Content,
&i.Type,
&i.CreatedAt,
&i.UpdatedAt,
&i.ParentID,
&i.WorkspaceID,
&i.ResolvedAt,
&i.ResolvedByType,
&i.ResolvedByID,
)
return i, err
}
const updateComment = `-- name: UpdateComment :one
UPDATE comment SET
content = $2,
updated_at = now()
WHERE id = $1
RETURNING id, issue_id, author_type, author_id, content, type, created_at, updated_at, parent_id, workspace_id, resolved_at, resolved_by_type, resolved_by_id
`
type UpdateCommentParams struct {
ID pgtype.UUID `json:"id"`
Content string `json:"content"`
}
func (q *Queries) UpdateComment(ctx context.Context, arg UpdateCommentParams) (Comment, error) {
row := q.db.QueryRow(ctx, updateComment, arg.ID, arg.Content)
var i Comment
err := row.Scan(
&i.ID,
&i.IssueID,
&i.AuthorType,
&i.AuthorID,
&i.Content,
&i.Type,
&i.CreatedAt,
&i.UpdatedAt,
&i.ParentID,
&i.WorkspaceID,
&i.ResolvedAt,
&i.ResolvedByType,
&i.ResolvedByID,
)
return i, err
}