Files
Claude c4b6140334 perf(issues): page the comment timeline by root comment
Before: GET /issues/{id}/timeline read every comment and activity of the
issue (up to the hard cap) in one response, and the paging params only
changed the response wrapper. The issue page fetched, parsed and held the
whole timeline no matter how long the conversation was.

After: `roots` (1..200) pages by root comment, newest first, with a
(created_at, id) cursor in `next_before` / `next_before_id`. Each page carries
every reply of its roots, so a thread is never split, plus the activities of
the page's time window; `has_more_before` is exact. `around=<commentId>` lands
on the page that starts at the comment's thread root. Requests without
`roots` (including the legacy limit/before/after/around) behave exactly as
before, so installed clients still receive the whole timeline. The issue page
loads the newest 50 roots, fetches an older page when the list scrolls to its
top without moving the viewport, and walks older pages for deep links and
in-page find.

How: three new sqlc queries (roots before a cursor, replies of a set of roots
via a recursive walk, activities in a time window) feed the new
listTimelinePage path. The web cache becomes an infinite query whose newest
page is pages[0]; older pages are its next pages. Realtime and optimistic
patches go through updateTimelinePages in core, which applies a flat-list edit
across pages. Replies per page are capped at the comment hard cap: trailing
threads move to the next page, and only a single oversized thread is cut and
reported through X-Timeline-Truncated.

Co-Authored-By: Claude Fable 5.1 <[email protected]>
Claude-Session: https://claude.ai/code/session_017deksZDSzZBA4QSqZZoBip
2026-10-21 03:24:28 +00:00

295 lines
8.9 KiB
Go

// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.31.1
// source: activity.sql
package db
import (
"context"
"github.com/jackc/pgx/v5/pgtype"
)
const countAssigneeChangesByActor = `-- name: CountAssigneeChangesByActor :many
SELECT
details->>'to_type' as assignee_type,
details->>'to_id' as assignee_id,
COUNT(*)::bigint as frequency
FROM activity_log
WHERE workspace_id = $1
AND actor_id = $2
AND actor_type = 'member'
AND action = 'assignee_changed'
AND details->>'to_type' IS NOT NULL
AND details->>'to_id' IS NOT NULL
GROUP BY details->>'to_type', details->>'to_id'
`
type CountAssigneeChangesByActorParams struct {
WorkspaceID pgtype.UUID `json:"workspace_id"`
ActorID pgtype.UUID `json:"actor_id"`
}
type CountAssigneeChangesByActorRow struct {
AssigneeType interface{} `json:"assignee_type"`
AssigneeID interface{} `json:"assignee_id"`
Frequency int64 `json:"frequency"`
}
// Count how many times a user assigned each target via assignee_changed activities.
func (q *Queries) CountAssigneeChangesByActor(ctx context.Context, arg CountAssigneeChangesByActorParams) ([]CountAssigneeChangesByActorRow, error) {
rows, err := q.db.Query(ctx, countAssigneeChangesByActor, arg.WorkspaceID, arg.ActorID)
if err != nil {
return nil, err
}
defer rows.Close()
items := []CountAssigneeChangesByActorRow{}
for rows.Next() {
var i CountAssigneeChangesByActorRow
if err := rows.Scan(&i.AssigneeType, &i.AssigneeID, &i.Frequency); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const createActivity = `-- name: CreateActivity :one
INSERT INTO activity_log (
workspace_id, issue_id, actor_type, actor_id, action, details, id
) VALUES ($1, $2, $3, $4, $5, $6, COALESCE($7::uuid, gen_random_uuid()))
RETURNING id, workspace_id, issue_id, actor_type, actor_id, action, details, created_at
`
type CreateActivityParams struct {
WorkspaceID pgtype.UUID `json:"workspace_id"`
IssueID pgtype.UUID `json:"issue_id"`
ActorType pgtype.Text `json:"actor_type"`
ActorID pgtype.UUID `json:"actor_id"`
Action string `json:"action"`
Details []byte `json:"details"`
ID pgtype.UUID `json:"id"`
}
func (q *Queries) CreateActivity(ctx context.Context, arg CreateActivityParams) (ActivityLog, error) {
row := q.db.QueryRow(ctx, createActivity,
arg.WorkspaceID,
arg.IssueID,
arg.ActorType,
arg.ActorID,
arg.Action,
arg.Details,
arg.ID,
)
var i ActivityLog
err := row.Scan(
&i.ID,
&i.WorkspaceID,
&i.IssueID,
&i.ActorType,
&i.ActorID,
&i.Action,
&i.Details,
&i.CreatedAt,
)
return i, err
}
const getActivity = `-- name: GetActivity :one
SELECT id, workspace_id, issue_id, actor_type, actor_id, action, details, created_at FROM activity_log
WHERE id = $1
`
func (q *Queries) GetActivity(ctx context.Context, id pgtype.UUID) (ActivityLog, error) {
row := q.db.QueryRow(ctx, getActivity, id)
var i ActivityLog
err := row.Scan(
&i.ID,
&i.WorkspaceID,
&i.IssueID,
&i.ActorType,
&i.ActorID,
&i.Action,
&i.Details,
&i.CreatedAt,
)
return i, err
}
const getLastIssueStatusChange = `-- name: GetLastIssueStatusChange :one
SELECT actor_type, actor_id FROM activity_log
WHERE issue_id = $1 AND action = 'status_changed'
ORDER BY created_at DESC, id DESC
LIMIT 1
`
type GetLastIssueStatusChangeRow struct {
ActorType pgtype.Text `json:"actor_type"`
ActorID pgtype.UUID `json:"actor_id"`
}
// Who last changed an issue's status, and to what: names the mover when an
// agent's status change is refused because the issue left its step (MUL-7420).
func (q *Queries) GetLastIssueStatusChange(ctx context.Context, issueID pgtype.UUID) (GetLastIssueStatusChangeRow, error) {
row := q.db.QueryRow(ctx, getLastIssueStatusChange, issueID)
var i GetLastIssueStatusChangeRow
err := row.Scan(&i.ActorType, &i.ActorID)
return i, err
}
const hasSquadLeaderNoActionEvaluationForTask = `-- name: HasSquadLeaderNoActionEvaluationForTask :one
SELECT EXISTS (
SELECT 1
FROM activity_log
WHERE issue_id = $1
AND actor_type = 'agent'
AND actor_id = $2
AND action = 'squad_leader_evaluated'
AND details->>'outcome' = 'no_action'
AND details->>'task_id' = $3::text
) AS exists
`
type HasSquadLeaderNoActionEvaluationForTaskParams struct {
IssueID pgtype.UUID `json:"issue_id"`
AgentID pgtype.UUID `json:"agent_id"`
TaskID string `json:"task_id"`
}
func (q *Queries) HasSquadLeaderNoActionEvaluationForTask(ctx context.Context, arg HasSquadLeaderNoActionEvaluationForTaskParams) (bool, error) {
row := q.db.QueryRow(ctx, hasSquadLeaderNoActionEvaluationForTask, arg.IssueID, arg.AgentID, arg.TaskID)
var exists bool
err := row.Scan(&exists)
return exists, err
}
const listActivitiesForIssue = `-- name: ListActivitiesForIssue :many
SELECT id, workspace_id, issue_id, actor_type, actor_id, action, details, created_at FROM (
SELECT id, workspace_id, issue_id, actor_type, actor_id, action, details, created_at FROM activity_log
WHERE issue_id = $1
ORDER BY created_at DESC, id DESC
LIMIT $2
) AS recent
ORDER BY created_at ASC, id ASC
`
type ListActivitiesForIssueParams struct {
IssueID pgtype.UUID `json:"issue_id"`
Limit int32 `json:"limit"`
}
// The NEWEST $2 activities for an issue, returned in chronological order.
//
// The cap has to bite at the OLD end, not the new one. The inner query takes
// the window with the keyset ordering (created_at DESC, id DESC), which
// idx_activity_log_issue_keyset (migration 068) satisfies without a sort step —
// it is not an index-only scan, since the index does not cover the columns
// SELECT * needs, so the heap is still read for the rows in the window. The
// outer query re-sorts ascending so every caller keeps the chronological
// contract it already had.
//
// Capping with ORDER BY created_at ASC instead discards the newest rows, which
// made a busy issue's timeline appear to stop at some point in the past with no
// indication anything was missing. Activity is machine-paced (description
// autosave, every agent run, status/assignee changes), so this was reachable in
// normal use, not only on pathological issues (MUL-5492).
func (q *Queries) ListActivitiesForIssue(ctx context.Context, arg ListActivitiesForIssueParams) ([]ActivityLog, error) {
rows, err := q.db.Query(ctx, listActivitiesForIssue, arg.IssueID, arg.Limit)
if err != nil {
return nil, err
}
defer rows.Close()
items := []ActivityLog{}
for rows.Next() {
var i ActivityLog
if err := rows.Scan(
&i.ID,
&i.WorkspaceID,
&i.IssueID,
&i.ActorType,
&i.ActorID,
&i.Action,
&i.Details,
&i.CreatedAt,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const listActivitiesForIssueBetween = `-- name: ListActivitiesForIssueBetween :many
SELECT id, workspace_id, issue_id, actor_type, actor_id, action, details, created_at FROM (
SELECT id, workspace_id, issue_id, actor_type, actor_id, action, details, created_at FROM activity_log
WHERE issue_id = $1
AND ($2::boolean = FALSE OR created_at >= $3::timestamptz)
AND ($4::boolean = FALSE OR created_at < $5::timestamptz)
ORDER BY created_at DESC, id DESC
LIMIT $6
) AS recent
ORDER BY created_at ASC, id ASC
`
type ListActivitiesForIssueBetweenParams struct {
IssueID pgtype.UUID `json:"issue_id"`
HasFrom bool `json:"has_from"`
FromAt pgtype.Timestamptz `json:"from_at"`
HasTo bool `json:"has_to"`
ToAt pgtype.Timestamptz `json:"to_at"`
RowLimit int32 `json:"row_limit"`
}
// The NEWEST @row_limit activities of an issue inside a time window, returned in
// chronological order. The window is [@from_at, @to_at): the lower bound is
// inclusive and applies when @has_from is TRUE, the upper bound is exclusive and
// applies when @has_to is TRUE.
//
// Powers the root-paged timeline, where activities are partitioned by TIME
// rather than by thread: each page owns the interval from its oldest root up to
// the previous page's oldest root, so adjacent pages tile the timeline with no
// gap and no overlap. The cap bites at the OLD end for the same reason as
// ListActivitiesForIssue.
func (q *Queries) ListActivitiesForIssueBetween(ctx context.Context, arg ListActivitiesForIssueBetweenParams) ([]ActivityLog, error) {
rows, err := q.db.Query(ctx, listActivitiesForIssueBetween,
arg.IssueID,
arg.HasFrom,
arg.FromAt,
arg.HasTo,
arg.ToAt,
arg.RowLimit,
)
if err != nil {
return nil, err
}
defer rows.Close()
items := []ActivityLog{}
for rows.Next() {
var i ActivityLog
if err := rows.Scan(
&i.ID,
&i.WorkspaceID,
&i.IssueID,
&i.ActorType,
&i.ActorID,
&i.Action,
&i.Details,
&i.CreatedAt,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}