Files
multica/server/internal/handler/issue_table_rows.go
T
14c2e4e831 MUL-6243 feat(issue-status): board fetches by category; archive retires without migration (#7084)
* feat(issue-status): board fetches by category; archive retires without migration (MUL-6243)

Product decisions from the issue thread:

1. Board fetches by CATEGORY, not per status. PAGINATED_STATUSES becomes
   PAGINATED_CATEGORIES and the cache buckets by category, so the board's
   fan-out stays fixed at 7 parallel requests however many custom statuses a
   workspace defines — instead of one more request per status added. Adds
   status_category / status_categories filters to the issue list endpoints,
   backed by the existing issue_effective_status() SQL function.

3. Archiving retires a status from FUTURE use only. It no longer refuses while
   issues still carry it: those issues keep their status and keep resolving to
   their category's behavior (Effective ignores archived_at), while Resolve
   still rejects the status so nothing new can be assigned to it. The exclusive
   catalog lock stays — it is what makes "no NEW assignment after archive"
   exact rather than approximate.

Also lands the frontend catalog foundation: packages/core/issue-statuses with
useIssueStatuses(wsId) (React Query, keyed by wsId per the state rules) and a
pure buildIssueStatusCatalog for non-React callers, plus IssueStatusCategory as
a distinct type from the status key and status_category on issue payloads.

NOT included, and deliberately: widening IssueStatus to string. That change
forces every Record<IssueStatus, …> presentation lookup in packages/views to
resolve a key to its category first, and it is the first task of the follow-up
PR. Everything here is the groundwork that makes it mechanical.

Co-authored-by: multica-agent <[email protected]>

* fix(issue-status): restore the status index, make category authoritative, keep archived resolvable

Review round 1 on #7084 (MUL-6243):

1. Category filtering wrapped issue.status in issue_effective_status(), which
   makes the (workspace_id, status) index unusable — and the board switched to
   it unconditionally, so every workspace paid for it immediately. Categories
   are now expanded to concrete status keys (issuestatus.ExpandCategories) and
   filtered with an indexed status = ANY(...). Measured on a 100k-row table
   with the production index: seq scan cost 27013 -> index-only scan cost 6.07.
   A workspace with no custom statuses expands each category to exactly its own
   key, reproducing the pre-feature query. Archived keys are included, since
   issues left on them still belong in their category's column.

2. status_category was only filled for built-ins, so a custom-status issue
   arriving over the websocket was silently dropped by addIssueToBuckets and a
   cross-category update was skipped by the cache coordinator, drifting
   off-window totals permanently. List, grouped, get, create, update and batch
   responses now carry an authoritative category via a per-request Resolver
   (archived included), and the client invalidates instead of no-oping when a
   category is unresolvable.

3. The catalog was fetched without archived entries, so after archiving a
   status the issues still on it lost their name and colour and had their
   category guessed as todo — contradicting the decision that archiving leaves
   existing issues untouched. Resolution now includes archived entries;
   activeStatuses / inCategory expose only assignable ones to pickers.

Co-authored-by: multica-agent <[email protected]>

* fix(issue-status): category on every payload exit, one resolver per request, no same-category duplication

Review round 2 on #7084 (MUL-6243):

1. Category fill was incomplete and N+1. The create WEBSOCKET payload was
   unfilled — only the creator's HTTP response was, which is too late for the
   other tabs the event exists to serve. GetIssue, the open_only list, search,
   the duplicate-conflict response, the table rows, the GitHub PR update and
   the onboarding shim were also unfilled. Grouped and batch called the
   single-response helper per row, so each custom-status row hit the catalog
   separately.

   Every exit now goes through newStatusCategoryFiller, a request-scoped
   closure over ONE Resolver: built-in statuses still cost no query, and a page
   of custom-status rows costs one catalog read rather than one per row.

2. A status KEY change within one category duplicated the card and inflated the
   total. patchIssueInBuckets compared patch.status (a key) against loc.status
   (a bucket CATEGORY), so in_review -> a custom human_review took the
   cross-bucket path, then deleted from and re-inserted into the same bucket
   from a pre-delete snapshot. It now resolves the destination category first
   and compares category to category; same-category changes replace in place
   and leave the total alone.

   The destination category is resolved from the patch alone — the merged issue
   inherits the previous status_category, which would have kept a genuinely
   moved card in its old column. patchNeedsInvalidation lets the loaded branch
   of the cache coordinator invalidate instead of silently no-oping on a status
   it cannot resolve.

Also drops the unrelated share_link.sql.go churn from an sqlc version
difference, per the review nit.

The four cache cases were verified to fail against the previous logic (card
duplicated, total +1) before the fix was restored.

Co-authored-by: multica-agent <[email protected]>

* fix(issue-status): normalize status_category on every cache write; one filler per create

Review round 3 on #7084 (MUL-6243):

Must-fix — a cached entity kept the STALE status_category after a status
change. The server now sends a category on every issue, so a cached entity is
{status: "todo", status_category: "todo"}; an optimistic patch carries only
{status: "done"}, and a bare {...issue, ...patch} moved the card to the done
bucket while the entity still claimed todo. A single update self-heals from the
server response, but the batch API returns only {updated: n} and does not
refetch bucketed lists, so the inconsistency was permanent — and the next
off-window count then decremented the wrong bucket, recreating total drift.

normalizeStatusPatch now rewrites status_category to match status before any
cache write, applied at applyIssueChange's entry (covering the flat, table and
detail spreads), in patchIssueInBuckets, and in the children mutation. An
unresolvable custom key drops the inherited category rather than keeping it;
patchNeedsInvalidation still routes it to a refetch.

Test fixtures now carry status_category for built-ins, matching what the server
actually sends — without that the regression was invisible.

Non-blocking items also addressed:
- CreateIssue builds one filler and shares it between the broadcast payload and
  the HTTP response, so a custom-status create reads the catalog once.
- Background events via IssueToMap now resolve an authoritative category
  (IssueToMapWithCategory) for the task, issue and autopilot publishers. The
  channel router keeps plain IssueToMap and says why: it always creates with the
  built-in "todo", where key IS category.

The "one resolver serves a whole page" assertion was REMOVED rather than kept:
pg_stat counters lag, so a delta around one request passes even with a per-row
filler. Verified that directly before dropping it. The comment now states what
is and is not pinned instead of implying coverage that does not exist.

Co-authored-by: multica-agent <[email protected]>

* fix(issue-status): address review nits

- Normalize the single-update children optimistic patch too, matching the batch
  path. It self-healed on onSettled, but it broke the entity/bucket invariant
  for the duration of the request.
- Rename TestListEndpointsCarryStatusCategory: the old name claimed it covered
  "without per row queries", which the test explicitly does not assert.
- Drop the unrelated gofmt churn in empty_claim_cache.go.
- Add a background-event assertion: IssueToMap leaves a custom status
  uncategorized, IssueToMapWithCategory resolves it — the reason the publishers
  use the latter.

Co-authored-by: multica-agent <[email protected]>

---------

Co-authored-by: Bohan-J <[email protected]>
Co-authored-by: multica-agent <[email protected]>
2026-08-18 03:00:21 +08:00

489 lines
16 KiB
Go

package handler
import (
"context"
"fmt"
"log/slog"
"net/http"
"strconv"
"strings"
"time"
"github.com/jackc/pgx/v5/pgtype"
"github.com/multica-ai/multica/server/internal/logger"
"github.com/multica-ai/multica/server/internal/util"
db "github.com/multica-ai/multica/server/pkg/db/generated"
)
type issueTableRowResponse struct {
Issue IssueResponse `json:"issue"`
DirectChildCount int64 `json:"direct_child_count"`
}
type issueTableRowsResponse struct {
QueryFingerprint string `json:"query_fingerprint"`
GroupKey *string `json:"group_key"`
ParentID *string `json:"parent_id"`
Total int64 `json:"total"`
Rows []issueTableRowResponse `json:"rows"`
BranchTotal int64 `json:"branch_total"` // Current page size; retained for response compatibility.
NextCursor *string `json:"next_cursor"`
}
type resolvedIssueTableSort struct {
expression string
direction string
castType string
nullsLast bool
}
func (sort resolvedIssueTableSort) orderBy() string {
orderBy := sort.expression + " " + strings.ToUpper(sort.direction)
if sort.nullsLast {
orderBy += " NULLS LAST"
}
return orderBy + ", i.created_at DESC, i.id DESC"
}
func (sort resolvedIssueTableSort) cursorPredicate(w http.ResponseWriter, cursor *issueTableCursor, addArg func(any) string) (string, bool) {
if cursor == nil {
return "TRUE", true
}
createdAt, err := time.Parse(time.RFC3339Nano, cursor.RowCreatedAt)
if err != nil || cursor.RowID == "" {
writeError(w, http.StatusBadRequest, "invalid cursor")
return "", false
}
rowID, err := util.ParseUUID(cursor.RowID)
if err != nil {
writeError(w, http.StatusBadRequest, "invalid cursor")
return "", false
}
createdRef := addArg(createdAt)
idRef := addArg(rowID)
tie := fmt.Sprintf("(i.created_at < %s::timestamptz OR (i.created_at = %s::timestamptz AND i.id < %s::uuid))", createdRef, createdRef, idRef)
if cursor.SortIsNull {
if !sort.nullsLast || cursor.SortValue != nil {
writeError(w, http.StatusBadRequest, "invalid cursor")
return "", false
}
return fmt.Sprintf("(%s IS NULL AND %s)", sort.expression, tie), true
}
if cursor.SortValue == nil {
writeError(w, http.StatusBadRequest, "invalid cursor")
return "", false
}
switch sort.castType {
case "integer":
if _, err := strconv.ParseInt(*cursor.SortValue, 10, 64); err != nil {
writeError(w, http.StatusBadRequest, "invalid cursor")
return "", false
}
case "double precision", "numeric":
if _, err := strconv.ParseFloat(*cursor.SortValue, 64); err != nil {
writeError(w, http.StatusBadRequest, "invalid cursor")
return "", false
}
case "timestamptz":
if _, err := time.Parse(time.RFC3339Nano, *cursor.SortValue); err != nil {
if _, postgresErr := time.Parse("2006-01-02 15:04:05.999999999Z07", *cursor.SortValue); postgresErr != nil {
writeError(w, http.StatusBadRequest, "invalid cursor")
return "", false
}
}
case "date":
if _, err := time.Parse("2006-01-02", *cursor.SortValue); err != nil {
writeError(w, http.StatusBadRequest, "invalid cursor")
return "", false
}
}
valueRef := addArg(*cursor.SortValue)
valueExpr := fmt.Sprintf("%s::%s", valueRef, sort.castType)
comparison := ">"
if sort.direction == "desc" {
comparison = "<"
}
predicate := fmt.Sprintf("(%s %s %s OR (%s = %s AND %s))", sort.expression, comparison, valueExpr, sort.expression, valueExpr, tie)
if sort.expression == "i.position" {
// The exact mixed-direction keyset predicate is not itself indexable.
// This redundant lower bound lets PostgreSQL start the default position
// index at the cursor instead of filtering every preceding index entry.
predicate = fmt.Sprintf("(%s >= %s AND %s)", sort.expression, valueExpr, predicate)
}
if sort.nullsLast {
predicate = fmt.Sprintf("(%s IS NULL OR %s)", sort.expression, predicate)
}
return predicate, true
}
func (h *Handler) issueTableOrderBy(w http.ResponseWriter, r *http.Request, workspaceID string, sortRequest issueTableSortRequest) (resolvedIssueTableSort, bool) {
sortField := strings.TrimSpace(sortRequest.Field)
if sortField == "" {
sortField = "position"
}
resolved := resolvedIssueTableSort{
expression: "i.position",
direction: "asc",
castType: "double precision",
}
switch sortField {
case "position":
case "title":
resolved.expression = "i.title"
resolved.castType = "text"
case "created_at", "updated_at":
resolved.expression = "i." + sortField
resolved.castType = "timestamptz"
case "start_date", "due_date":
resolved.expression = "i." + sortField
resolved.castType = "date"
resolved.nullsLast = true
case "status":
resolved.expression = "CASE i.status WHEN 'backlog' THEN 0 WHEN 'todo' THEN 1 WHEN 'in_progress' THEN 2 WHEN 'in_review' THEN 3 WHEN 'done' THEN 4 WHEN 'blocked' THEN 5 WHEN 'cancelled' THEN 6 ELSE 7 END"
resolved.castType = "integer"
case "priority":
resolved.expression = "CASE i.priority WHEN 'urgent' THEN 0 WHEN 'high' THEN 1 WHEN 'medium' THEN 2 WHEN 'low' THEN 3 ELSE 4 END"
resolved.castType = "integer"
default:
expr, handled, err := h.propertySortExpr(r, workspaceID, sortField)
if !handled {
writeError(w, http.StatusBadRequest, "invalid query.sort.field")
return resolvedIssueTableSort{}, false
}
if err != nil {
if strings.HasPrefix(err.Error(), "resolve sort property:") {
slog.Warn("resolve table sort property failed", append(logger.RequestAttrs(r), "error", err)...)
writeIssueTableQueryFailure(w, r, "failed to resolve table sort")
} else {
writeError(w, http.StatusBadRequest, err.Error())
}
return resolvedIssueTableSort{}, false
}
if expr != "" {
resolved.expression = expr
resolved.castType = "text"
if strings.Contains(expr, "::numeric") {
resolved.castType = "numeric"
}
resolved.nullsLast = true
}
}
direction := strings.ToLower(strings.TrimSpace(sortRequest.Direction))
if direction == "" {
direction = "asc"
}
if direction != "asc" && direction != "desc" {
writeError(w, http.StatusBadRequest, "invalid query.sort.direction")
return resolvedIssueTableSort{}, false
}
if sortField != "position" && resolved.expression != "i.position" {
resolved.direction = direction
}
return resolved, true
}
func normalizeIssueTableGroupKey(w http.ResponseWriter, group issueTableGroupSpec, groupKey *string) (*string, bool) {
if group.Kind == "none" {
if groupKey != nil && strings.TrimSpace(*groupKey) != "" {
writeError(w, http.StatusBadRequest, "group_key must be empty when group.kind=none")
return nil, false
}
return nil, true
}
if groupKey == nil || strings.TrimSpace(*groupKey) == "" {
writeError(w, http.StatusBadRequest, "group_key is required")
return nil, false
}
normalized := strings.TrimSpace(*groupKey)
return &normalized, true
}
func (h *Handler) ListIssueTableRows(w http.ResponseWriter, r *http.Request) {
if h.DB == nil {
writeError(w, http.StatusInternalServerError, "database is unavailable")
return
}
baseHandler := h
var request issueTableRowsRequest
if !decodeIssueTableJSON(w, r, &request) {
return
}
r, cancel := withIssueTableQueryTimeout(r)
defer cancel()
snapshot, tx, err := h.beginIssueTableSnapshot(r.Context())
if err != nil {
slog.Warn("ListIssueTableRows snapshot failed", append(logger.RequestAttrs(r), "error", err)...)
writeIssueTableQueryFailure(w, r, "failed to start table query")
return
}
committed := false
defer func() {
if !committed {
_ = tx.Rollback(context.Background())
}
}()
h = snapshot
limit, cursor, ok := normalizeIssueTablePage(w, request.Page)
if !ok {
return
}
groupKey, ok := normalizeIssueTableGroupKey(w, request.Group, request.GroupKey)
if !ok {
return
}
if !request.Hierarchy.Enabled && request.ParentID != nil {
writeError(w, http.StatusBadRequest, "parent_id requires hierarchy.enabled=true")
return
}
compiled, ok := h.compileIssueTableQuery(w, r, request.Query)
if !ok {
return
}
if !issueTableCursorMatches(w, cursor, compiled.fingerprint, groupKey, request.ParentID) {
return
}
branchIdentity := issueTableGroupIdentity(request.Group) + ":hierarchy=" + strconv.FormatBool(request.Hierarchy.Enabled)
if cursor != nil && cursor.BranchIdentity != branchIdentity {
writeJSON(w, http.StatusConflict, map[string]any{
"error": "cursor_query_mismatch",
"message": "cursor does not belong to this table branch",
})
return
}
group, ok := h.resolveIssueTableGroup(w, r, compiled.workspaceID, request.Group, true)
if !ok {
return
}
resolvedSort, ok := h.issueTableOrderBy(w, r, util.UUIDToString(compiled.workspaceID), request.Query.Sort)
if !ok {
return
}
args := append([]any(nil), compiled.args...)
addArg := func(value any) string {
args = append(args, value)
return "$" + strconv.Itoa(len(args))
}
predicateKey := ""
if groupKey != nil {
predicateKey = *groupKey
}
groupPredicate, ok := group.predicate(w, predicateKey, addArg)
if !ok {
return
}
branchPredicate := "TRUE"
if request.Hierarchy.Enabled {
if request.ParentID == nil {
// Keep parent membership as a scalar lookup. NOT EXISTS is equivalent
// semantically, but PostgreSQL turns it into a full hash anti-join and
// loses the ordered workspace index before LIMIT.
branchPredicate = "i.parent_issue_id IS NULL OR (SELECT parent.id FROM membership parent WHERE parent.id = i.parent_issue_id) IS NULL"
} else {
parentUUID, err := util.ParseUUID(*request.ParentID)
if err != nil {
writeError(w, http.StatusBadRequest, "invalid parent_id")
return
}
parentRef := addArg(parentUUID)
branchPredicate = fmt.Sprintf("i.parent_issue_id = %s::uuid AND EXISTS (SELECT 1 FROM membership parent WHERE parent.id = %s::uuid)", parentRef, parentRef)
}
}
cursorPredicate, ok := resolvedSort.cursorPredicate(w, cursor, addArg)
if !ok {
return
}
limitRef := addArg(limit + 1)
// Pick the requested page before computing hierarchy metadata. The old query
// materialized every matching issue and aggregated every parent before LIMIT,
// which made a 51-row page spill the entire workspace membership to disk.
// NOT MATERIALIZED lets PostgreSQL push parent_id/id predicates into issue and
// use the parent indexes for child branches and per-page child counts.
ctePrefix := "WITH "
pageSource := "issue"
pagePredicate := fmt.Sprintf("(%s) AND (%s)", compiled.where, groupPredicate)
if request.Hierarchy.Enabled {
ctePrefix += fmt.Sprintf(`membership AS NOT MATERIALIZED (
SELECT i.*
FROM issue i
WHERE %s AND (%s)
), `, compiled.where, groupPredicate)
pageSource = "membership"
pagePredicate = branchPredicate
} else if group.kind == "compound" &&
group.primary != nil &&
group.primary.kind == "parent" &&
group.secondaryFiltered {
visibleRef := addArg(group.secondaryValues)
ctePrefix += fmt.Sprintf(`membership AS NOT MATERIALIZED (
SELECT i.*
FROM issue i
WHERE %s
), promoted_parents AS (
SELECT DISTINCT child.parent_issue_id AS id
FROM membership child
WHERE child.parent_issue_id IS NOT NULL
AND child.status = ANY(%s::text[])
), `, compiled.where, visibleRef)
pageSource = "membership"
pagePredicate = fmt.Sprintf(`(%s) AND NOT (
i.parent_issue_id IS NULL AND
EXISTS (SELECT 1 FROM promoted_parents p WHERE p.id = i.id)
)`, groupPredicate)
}
cte := fmt.Sprintf(`%spage AS MATERIALIZED (
SELECT i.*, (%s)::text AS table_sort_key
FROM %s i
WHERE (%s) AND %s
ORDER BY %s
LIMIT %s
)`, ctePrefix, resolvedSort.expression, pageSource, pagePredicate, cursorPredicate, resolvedSort.orderBy(), limitRef)
childCountExpr := "0::bigint"
if request.Hierarchy.Enabled {
childCountExpr = "(SELECT COUNT(*)::bigint FROM membership child WHERE child.parent_issue_id = i.id)"
}
query := fmt.Sprintf(`%s
SELECT i.id, i.workspace_id, i.title, i.description, i.status, i.priority,
i.assignee_type, i.assignee_id, i.creator_type, i.creator_id,
i.parent_issue_id, i.position, i.start_date, i.due_date, i.created_at,
i.updated_at, i.number, i.project_id, i.metadata, i.stage, i.properties,
%s AS direct_child_count, i.table_sort_key
FROM page i
ORDER BY %s`, cte, childCountExpr, resolvedSort.orderBy())
rows, err := h.DB.Query(r.Context(), query, args...)
if err != nil {
slog.Warn("ListIssueTableRows query failed", append(logger.RequestAttrs(r), "error", err)...)
writeIssueTableQueryFailure(w, r, "failed to list table rows")
return
}
defer rows.Close()
type scannedRow struct {
issue db.ListIssuesRow
childCount int64
sortKey pgtype.Text
}
scanned := make([]scannedRow, 0, limit+1)
for rows.Next() {
var row scannedRow
if err := rows.Scan(
&row.issue.ID,
&row.issue.WorkspaceID,
&row.issue.Title,
&row.issue.Description,
&row.issue.Status,
&row.issue.Priority,
&row.issue.AssigneeType,
&row.issue.AssigneeID,
&row.issue.CreatorType,
&row.issue.CreatorID,
&row.issue.ParentIssueID,
&row.issue.Position,
&row.issue.StartDate,
&row.issue.DueDate,
&row.issue.CreatedAt,
&row.issue.UpdatedAt,
&row.issue.Number,
&row.issue.ProjectID,
&row.issue.Metadata,
&row.issue.Stage,
&row.issue.Properties,
&row.childCount,
&row.sortKey,
); err != nil {
writeIssueTableQueryFailure(w, r, "failed to list table rows")
return
}
scanned = append(scanned, row)
}
if err := rows.Err(); err != nil {
writeIssueTableQueryFailure(w, r, "failed to list table rows")
return
}
rows.Close()
// Only the ungrouped root head consumes a query-wide total. Group headers
// get their exact totals from /groups; child branches and continuation pages
// must not pay for a full-membership COUNT.
var total int64
if cursor == nil && request.Group.Kind == "none" && request.ParentID == nil {
if err := h.DB.QueryRow(r.Context(), fmt.Sprintf("SELECT COUNT(*)::bigint FROM issue i WHERE %s", compiled.where), compiled.args...).Scan(&total); err != nil {
slog.Warn("ListIssueTableRows total count failed", append(logger.RequestAttrs(r), "error", err)...)
writeIssueTableQueryFailure(w, r, "failed to count table rows")
return
}
}
var nextCursor *string
if len(scanned) > limit {
scanned = scanned[:limit]
last := scanned[len(scanned)-1]
var sortValue *string
if last.sortKey.Valid {
value := last.sortKey.String
sortValue = &value
}
nextCursor = encodeIssueTableCursor(issueTableCursor{
Version: 1,
QueryFingerprint: compiled.fingerprint,
GroupKey: groupKey,
ParentID: request.ParentID,
BranchIdentity: branchIdentity,
SortValue: sortValue,
SortIsNull: !last.sortKey.Valid,
RowCreatedAt: last.issue.CreatedAt.Time.UTC().Format(time.RFC3339Nano),
RowID: util.UUIDToString(last.issue.ID),
})
}
// The row window, optional root total and cursor are the authoritative snapshot. Commit
// it before the best-effort display enrichment below: PostgreSQL aborts a
// transaction after any statement error, so running getIssuePrefix or
// labelsByIssue inside the snapshot would turn their intentionally tolerated
// failures into a fatal Commit error for the entire page.
if err := tx.Commit(r.Context()); err != nil {
slog.Warn("ListIssueTableRows snapshot commit failed", append(logger.RequestAttrs(r), "error", err)...)
writeIssueTableQueryFailure(w, r, "failed to finish table query")
return
}
committed = true
prefix := baseHandler.getIssuePrefix(r.Context(), compiled.workspaceID)
issueIDs := make([]pgtype.UUID, len(scanned))
for index, row := range scanned {
issueIDs[index] = row.issue.ID
}
labelsByIssue := baseHandler.labelsByIssue(r.Context(), compiled.workspaceID, issueIDs)
// One Resolver for the page — see newStatusCategoryFiller. (MUL-6243)
fillTableRow := baseHandler.newStatusCategoryFiller(r.Context(), compiled.workspaceID)
responseRows := make([]issueTableRowResponse, len(scanned))
for index, row := range scanned {
issue := issueListRowToResponse(row.issue, prefix)
fillTableRow(&issue)
labels := labelsByIssue[issue.ID]
if labels == nil {
labels = []LabelResponse{}
}
issue.Labels = &labels
responseRows[index] = issueTableRowResponse{
Issue: issue,
DirectChildCount: row.childCount,
}
}
response := issueTableRowsResponse{
QueryFingerprint: compiled.fingerprint,
GroupKey: groupKey,
ParentID: request.ParentID,
Total: total,
Rows: responseRows,
BranchTotal: int64(len(responseRows)),
NextCursor: nextCursor,
}
writeJSON(w, http.StatusOK, response)
}