Files
multica/server/pkg/db/queries/issue.sql
T
db86c75e12 MUL-6878: avoid per-row issue status resolution (#7814)
* perf(server): avoid per-row issue status resolution

Co-authored-by: multica-agent <github@multica.ai>

* fix(server): address issue status query review nits

Co-authored-by: multica-agent <github@multica.ai>

---------

Co-authored-by: Sol-Boy <sol-boy@multica-ai.local>
Co-authored-by: multica-agent <github@multica.ai>
2026-08-31 16:00:45 +08:00

578 lines
26 KiB
SQL

-- name: ListIssues :many
-- involves_user_id widens the assignee filter to surface issues where the user
-- is *indirectly* the assignee — via an owned agent or a squad they belong to /
-- lead / have an agent inside. The semantics intentionally exclude direct
-- member assignment (`assignee_type='member' AND assignee_id=involves_user_id`)
-- because that is already the meaning of the `assignee_id` filter (tab 1
-- "Assigned to me"), and the two filters must produce disjoint result sets.
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.last_activity_at, i.number, i.project_id, i.metadata, i.stage, i.properties,
i.revision
FROM issue i
WHERE i.workspace_id = $1
AND (sqlc.narg('status')::text IS NULL OR i.status = sqlc.narg('status'))
AND (sqlc.narg('priority')::text IS NULL OR i.priority = sqlc.narg('priority'))
AND (sqlc.narg('assignee_id')::uuid IS NULL OR i.assignee_id = sqlc.narg('assignee_id'))
AND (sqlc.narg('assignee_ids')::uuid[] IS NULL OR i.assignee_id = ANY(sqlc.narg('assignee_ids')::uuid[]))
AND (sqlc.narg('creator_id')::uuid IS NULL OR i.creator_id = sqlc.narg('creator_id'))
AND (sqlc.narg('project_id')::uuid IS NULL OR i.project_id = sqlc.narg('project_id'))
AND (sqlc.narg('scheduled')::bool IS NULL OR (i.start_date IS NOT NULL OR i.due_date IS NOT NULL))
AND (sqlc.narg('metadata_filter')::jsonb IS NULL OR i.metadata @> sqlc.narg('metadata_filter')::jsonb)
AND (
sqlc.narg('involves_user_id')::uuid IS NULL
-- (1) assignee is an agent owned by the user
OR (i.assignee_type = 'agent' AND i.assignee_id IN (
SELECT a.id FROM agent a
WHERE a.workspace_id = $1
AND a.owner_id = sqlc.narg('involves_user_id')::uuid
))
-- (2)(3)(4) assignee is a squad related to the user — three relations
OR (i.assignee_type = 'squad' AND i.assignee_id IN (
-- (2) the user is a human member of the squad
SELECT sm.squad_id
FROM squad_member sm
JOIN squad s ON s.id = sm.squad_id
WHERE s.workspace_id = $1
AND sm.member_type = 'member'
AND sm.member_id = sqlc.narg('involves_user_id')::uuid
UNION
-- (3) the squad's canonical leader is an agent owned by the user.
-- We read squad.leader_id directly rather than relying on a
-- squad_member row, because the leader copy in squad_member is
-- best-effort (see squad.go AddSquadMember error handling).
SELECT s.id
FROM squad s
JOIN agent a ON a.id = s.leader_id
WHERE s.workspace_id = $1
AND a.workspace_id = $1
AND a.owner_id = sqlc.narg('involves_user_id')::uuid
UNION
-- (4) the squad has an agent member owned by the user
SELECT sm.squad_id
FROM squad_member sm
JOIN squad s ON s.id = sm.squad_id
JOIN agent a ON a.id = sm.member_id
WHERE s.workspace_id = $1
AND sm.member_type = 'agent'
AND a.workspace_id = $1
AND a.owner_id = sqlc.narg('involves_user_id')::uuid
))
)
ORDER BY i.position ASC, i.created_at DESC
LIMIT $2 OFFSET $3;
-- name: GetIssue :one
SELECT * FROM issue
WHERE id = $1;
-- name: GetIssueGCStatus :one
SELECT workspace_id, status, updated_at
FROM issue
WHERE id = $1;
-- name: ListIssueGCStatuses :many
SELECT id, status, updated_at
FROM issue
WHERE workspace_id = sqlc.arg('workspace_id')
AND id = ANY(sqlc.arg('issue_ids')::uuid[]);
-- name: GetIssueInWorkspace :one
SELECT * FROM issue
WHERE id = $1 AND workspace_id = $2;
-- name: LockIssueForChannelMediaBind :one
-- Channel media resolves after /issue creation. Hold a key-share lock while
-- the attachment row is written so a concurrent issue delete cannot land
-- between the workspace-scoped validation and the attachment insert.
SELECT id FROM issue
WHERE id = $1 AND workspace_id = $2
FOR KEY SHARE;
-- name: LockIssueForDescriptionUpdate :one
-- Serialize field-baseline checks and combined attachment binding on the
-- owner row. The handler merges channel media that landed after the editor's
-- submitted base, updates the issue, and binds this request's attachments in
-- the same transaction while holding this lock.
SELECT * FROM issue
WHERE id = $1 AND workspace_id = $2
FOR UPDATE;
-- name: MaterializeIssueChannelMediaMarkdown :one
-- Detached channel media resolves after /issue creation. When the description
-- still equals the exact creation-time base, replace its inline placeholders
-- with the fully composed Markdown so rich-text ordering survives. If a user
-- edited concurrently (or the adapter has no inline layout), append instead;
-- preserving user-authored bytes takes precedence over layout fidelity.
-- This is asynchronous system materialization, not a new user action, so it
-- intentionally preserves last_activity_at while still advancing revision.
UPDATE issue
SET description = CASE
WHEN sqlc.narg('base_description')::text IS NOT NULL
AND COALESCE(description, '') = sqlc.narg('base_description')::text
THEN sqlc.arg('description')::text
WHEN description IS NULL OR description = '' THEN sqlc.arg(markdown)
ELSE description || E'\n\n' || sqlc.arg(markdown)
END,
revision = revision + 1,
updated_at = now()
WHERE id = sqlc.arg(id)
AND workspace_id = sqlc.arg(workspace_id)
RETURNING *;
-- name: LockIssueForDelete :one
-- Issue deletion must collect every attachment URL after it has won the same
-- row-lock race used by channel media binding. FOR UPDATE conflicts with the
-- binder's FOR KEY SHARE: either bind commits first and its URL is collected,
-- or delete commits first and the binder leaves its durable intent for cleanup.
SELECT id FROM issue
WHERE id = $1 AND workspace_id = $2
FOR UPDATE;
-- name: DetachDirectChildIssues :many
UPDATE issue
SET parent_issue_id = NULL,
stage = NULL,
revision = revision + 1,
updated_at = now(),
last_activity_at = GREATEST(COALESCE(last_activity_at, updated_at), now())
WHERE workspace_id = sqlc.arg(workspace_id)
AND parent_issue_id = sqlc.arg(parent_issue_id)
AND NOT COALESCE(id = ANY(sqlc.arg(excluded_issue_ids)::uuid[]), false)
RETURNING *;
-- name: CreateIssue :one
INSERT INTO issue (
workspace_id, title, description, status, priority,
assignee_type, assignee_id, creator_type, creator_id,
parent_issue_id, position, start_date, due_date, number, project_id,
stage, last_activity_at, id
) VALUES (
$1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15,
sqlc.narg('stage'), now(), COALESCE(sqlc.narg('id')::uuid, gen_random_uuid())
) RETURNING *;
-- name: GetIssueByNumber :one
SELECT * FROM issue
WHERE workspace_id = $1 AND number = $2;
-- name: UpdateIssue :one
WITH candidate AS (
SELECT
i.*,
COALESCE(sqlc.narg('title')::text, i.title) AS next_title,
COALESCE(sqlc.narg('description')::text, i.description) AS next_description,
COALESCE(sqlc.narg('status')::text, i.status) AS next_status,
COALESCE(sqlc.narg('priority')::text, i.priority) AS next_priority,
sqlc.narg('assignee_type')::text AS next_assignee_type,
sqlc.narg('assignee_id')::uuid AS next_assignee_id,
CASE
-- An explicit position wins. Cross-column drag-and-drop sends
-- status and position together and means the slot it dropped on.
WHEN sqlc.narg('position')::double precision IS NOT NULL
THEN sqlc.narg('position')::double precision
-- position ranks an issue *within* its (workspace, status)
-- column, so it stops meaning anything the moment the column
-- changes: the value that put the issue on top of Todo lands it
-- below every hand-dragged issue in Done. Re-rank to the top of
-- the destination, the same policy new issues get from
-- NextTopPosition. Keep the two in sync.
--
-- Two status changes running at once can read the same MIN and
-- claim one slot. Every position-ordered query carries a unique
-- final key (created_at DESC, id DESC), so a tie leaves the
-- relative order of simultaneous moves arbitrary but never
-- unstable across pages. Creation avoids the tie by computing its
-- min under the workspace counter lock; a status change holds no
-- such lock and is not worth taking one for.
WHEN i.status IS DISTINCT FROM COALESCE(sqlc.narg('status')::text, i.status)
THEN (
SELECT COALESCE(MIN(target.position), 0) - 1
FROM issue AS target
WHERE target.workspace_id = i.workspace_id
AND target.status = sqlc.narg('status')::text
)
ELSE i.position
END AS next_position,
sqlc.narg('start_date')::date AS next_start_date,
sqlc.narg('due_date')::date AS next_due_date,
sqlc.narg('parent_issue_id')::uuid AS next_parent_issue_id,
sqlc.narg('project_id')::uuid AS next_project_id,
sqlc.narg('stage')::integer AS next_stage
FROM issue AS i
WHERE i.id = $1
AND (sqlc.narg('expected_revision')::bigint IS NULL OR i.revision = sqlc.narg('expected_revision')::bigint)
), changed AS (
SELECT
candidate.*,
ROW(
title, description, status, priority, assignee_type, assignee_id,
position, start_date, due_date, parent_issue_id, project_id, stage
) IS DISTINCT FROM ROW(
next_title, next_description, next_status, next_priority,
next_assignee_type, next_assignee_id, next_position, next_start_date,
next_due_date, next_parent_issue_id, next_project_id, next_stage
) AS did_change,
ROW(
title, description, status, priority, assignee_type, assignee_id,
start_date, due_date, parent_issue_id, project_id, stage
) IS DISTINCT FROM ROW(
next_title, next_description, next_status, next_priority,
next_assignee_type, next_assignee_id, next_start_date, next_due_date,
next_parent_issue_id, next_project_id, next_stage
) AS did_activity
FROM candidate
)
UPDATE issue AS i SET
title = changed.next_title,
description = changed.next_description,
status = changed.next_status,
priority = changed.next_priority,
assignee_type = changed.next_assignee_type,
assignee_id = changed.next_assignee_id,
position = changed.next_position,
start_date = changed.next_start_date,
due_date = changed.next_due_date,
parent_issue_id = changed.next_parent_issue_id,
project_id = changed.next_project_id,
stage = changed.next_stage,
revision = i.revision + changed.did_change::integer,
last_activity_at = CASE WHEN changed.did_activity
THEN GREATEST(COALESCE(i.last_activity_at, i.updated_at), now())
ELSE i.last_activity_at
END,
updated_at = CASE WHEN changed.did_change THEN now() ELSE i.updated_at END
FROM changed
WHERE i.id = changed.id
-- Re-check the precondition on the row version that UPDATE actually locks.
-- Under READ COMMITTED, concurrent statements may both populate candidate
-- from the same snapshot; EvalPlanQual re-evaluates this target-row predicate
-- after waiting for the first writer, leaving the stale writer with 0 rows.
AND (sqlc.narg('expected_revision')::bigint IS NULL OR i.revision = sqlc.narg('expected_revision')::bigint)
RETURNING i.*;
-- name: UpdateIssueStatus :one
-- Workspace_id in the WHERE clause is a SQL-layer tenant guard; see DeleteIssue.
-- Repositioning lives here rather than in the callers (GitHub sync, agent task
-- completion) so a status write cannot land without one: an issue carrying its
-- old column's rank into a new column is the bug this guards against. See the
-- next_position CASE in UpdateIssue for the policy.
UPDATE issue AS i SET
status = $2,
position = CASE WHEN i.status IS DISTINCT FROM $2 THEN (
SELECT COALESCE(MIN(target.position), 0) - 1
FROM issue AS target
WHERE target.workspace_id = i.workspace_id
AND target.status = $2
) ELSE i.position END,
revision = i.revision + CASE WHEN i.status IS DISTINCT FROM $2 THEN 1 ELSE 0 END,
last_activity_at = CASE WHEN i.status IS DISTINCT FROM $2
THEN GREATEST(COALESCE(i.last_activity_at, i.updated_at), now())
ELSE i.last_activity_at
END,
updated_at = now()
WHERE i.id = $1 AND i.workspace_id = $3
RETURNING *;
-- name: CreateIssueWithOrigin :one
INSERT INTO issue (
workspace_id, title, description, status, priority,
assignee_type, assignee_id, creator_type, creator_id,
parent_issue_id, position, start_date, due_date, number, project_id,
origin_type, origin_id, stage, last_activity_at, id
) VALUES (
$1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15,
sqlc.narg('origin_type'), sqlc.narg('origin_id'), sqlc.narg('stage'), now(), COALESCE(sqlc.narg('id')::uuid, gen_random_uuid())
) RETURNING *;
-- name: LockIssueDuplicateKey :exec
SELECT pg_advisory_xact_lock(hashtextextended($1::text, 0));
-- name: FindActiveDuplicateIssue :one
SELECT * FROM issue
WHERE workspace_id = $1
-- Negate only known terminal keys so an unknown legacy key remains active.
AND NOT (status = ANY(sqlc.arg('terminal_status_keys')::text[]))
AND project_id IS NOT DISTINCT FROM sqlc.arg('project_id')::uuid
AND parent_issue_id IS NOT DISTINCT FROM sqlc.arg('parent_issue_id')::uuid
AND lower(btrim(regexp_replace(title, '[[:space:]]+', ' ', 'g'))) = sqlc.arg('normalized_title')
ORDER BY created_at ASC
LIMIT 1;
-- name: FindRecentAutopilotDuplicateIssue :one
SELECT i.* FROM issue i
WHERE i.workspace_id = $1
-- Negate only known terminal keys so an unknown legacy key remains active.
AND NOT (i.status = ANY(sqlc.arg('terminal_status_keys')::text[]))
AND i.origin_type = 'autopilot'
AND i.origin_id = $2
AND i.project_id IS NOT DISTINCT FROM sqlc.arg('project_id')::uuid
AND lower(btrim(regexp_replace(i.title, '[[:space:]]+', ' ', 'g'))) = sqlc.arg('normalized_title')
AND i.created_at >= sqlc.arg('created_after')::timestamptz
AND EXISTS (
SELECT 1
FROM autopilot_run r
WHERE r.issue_id = i.id
AND r.autopilot_id = i.origin_id
AND r.status IN ('issue_created', 'running', 'completed')
)
ORDER BY i.created_at ASC
LIMIT 1;
-- name: DeleteIssue :exec
-- Defense-in-depth: the workspace_id predicate makes the tenant invariant a
-- SQL-layer guarantee rather than a handler-layer one. Handler loaders
-- (loadIssueForUser / GetIssueInWorkspace) already enforce membership today,
-- but a future loader bypass or a new caller skipping the loader would be
-- silently catastrophic without this guard. See incident #1661.
--
-- issue_vcs_pull_request (migration 213) has no FK to issue, so the link rows
-- are not cascaded away. Sweep them here so they go atomically with the issue.
-- The mirrored PR rows themselves belong to the connection, not the issue, so
-- they persist (matching the GitHub link behaviour).
--
-- The sweep MUST route through the same workspace-checked target as the issue
-- delete: deleting links by bare issue_id would drop another tenant's link rows
-- when a caller passes a foreign issue_id with its own workspace_id (the issue
-- itself is correctly untouched, but the links are already gone) — the exact
-- cross-tenant leak the #1661 guard above exists to prevent.
WITH target AS (
SELECT issue.id FROM issue WHERE issue.id = $1 AND issue.workspace_id = $2
),
cleared_vcs_pr_links AS (
DELETE FROM issue_vcs_pull_request WHERE issue_id IN (SELECT target.id FROM target)
)
DELETE FROM issue WHERE issue.id IN (SELECT target.id FROM target);
-- name: ListOpenIssues :many
-- See ListIssues for the semantics of involves_user_id (mirrors the 4-branch
-- filter; member-direct assignment is intentionally excluded).
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.last_activity_at, i.number, i.project_id, i.metadata, i.stage, i.properties,
i.revision
FROM issue i
WHERE i.workspace_id = $1
-- Negate only known terminal keys so an unknown legacy key remains visible.
AND NOT (i.status = ANY(sqlc.arg('terminal_status_keys')::text[]))
AND (sqlc.narg('priority')::text IS NULL OR i.priority = sqlc.narg('priority'))
AND (sqlc.narg('assignee_id')::uuid IS NULL OR i.assignee_id = sqlc.narg('assignee_id'))
AND (sqlc.narg('assignee_ids')::uuid[] IS NULL OR i.assignee_id = ANY(sqlc.narg('assignee_ids')::uuid[]))
AND (sqlc.narg('creator_id')::uuid IS NULL OR i.creator_id = sqlc.narg('creator_id'))
AND (sqlc.narg('project_id')::uuid IS NULL OR i.project_id = sqlc.narg('project_id'))
AND (sqlc.narg('metadata_filter')::jsonb IS NULL OR i.metadata @> sqlc.narg('metadata_filter')::jsonb)
-- properties_filter is a jsonb array of groups, each group an array of
-- containment patterns (built by parsePropertiesFilterParam): the issue
-- must match at least one pattern from EVERY group (AND of ORs). A pattern
-- of the shape {"__none__": "<definitionId>"} is the "no value" marker and
-- matches when the issue's properties are missing that key. The correlated
-- form skips the GIN index, which is fine here: open_only is already an
-- unpaginated workspace scan. The terminal-status predicate intentionally
-- remains a filter rather than a positive index narrowing so unknown legacy
-- status keys stay visible.
AND (
sqlc.narg('properties_filter')::jsonb IS NULL
OR NOT EXISTS (
SELECT 1
FROM jsonb_array_elements(sqlc.narg('properties_filter')::jsonb) AS pf(alternatives)
WHERE NOT EXISTS (
SELECT 1
FROM jsonb_array_elements(pf.alternatives) AS alt(pattern)
WHERE (alt.pattern ? '__none__' AND NOT (i.properties ? (alt.pattern ->> '__none__')))
OR (NOT (alt.pattern ? '__none__') AND i.properties @> alt.pattern)
)
)
)
AND (
sqlc.narg('involves_user_id')::uuid IS NULL
OR (i.assignee_type = 'agent' AND i.assignee_id IN (
SELECT a.id FROM agent a
WHERE a.workspace_id = $1
AND a.owner_id = sqlc.narg('involves_user_id')::uuid
))
OR (i.assignee_type = 'squad' AND i.assignee_id IN (
SELECT sm.squad_id
FROM squad_member sm
JOIN squad s ON s.id = sm.squad_id
WHERE s.workspace_id = $1
AND sm.member_type = 'member'
AND sm.member_id = sqlc.narg('involves_user_id')::uuid
UNION
SELECT s.id
FROM squad s
JOIN agent a ON a.id = s.leader_id
WHERE s.workspace_id = $1
AND a.workspace_id = $1
AND a.owner_id = sqlc.narg('involves_user_id')::uuid
UNION
SELECT sm.squad_id
FROM squad_member sm
JOIN squad s ON s.id = sm.squad_id
JOIN agent a ON a.id = sm.member_id
WHERE s.workspace_id = $1
AND sm.member_type = 'agent'
AND a.workspace_id = $1
AND a.owner_id = sqlc.narg('involves_user_id')::uuid
))
)
ORDER BY i.position ASC, i.created_at DESC;
-- name: CountIssues :one
-- See ListIssues for the semantics of involves_user_id.
SELECT count(*) FROM issue i
WHERE i.workspace_id = $1
AND (sqlc.narg('status')::text IS NULL OR i.status = sqlc.narg('status'))
AND (sqlc.narg('priority')::text IS NULL OR i.priority = sqlc.narg('priority'))
AND (sqlc.narg('assignee_id')::uuid IS NULL OR i.assignee_id = sqlc.narg('assignee_id'))
AND (sqlc.narg('assignee_ids')::uuid[] IS NULL OR i.assignee_id = ANY(sqlc.narg('assignee_ids')::uuid[]))
AND (sqlc.narg('creator_id')::uuid IS NULL OR i.creator_id = sqlc.narg('creator_id'))
AND (sqlc.narg('project_id')::uuid IS NULL OR i.project_id = sqlc.narg('project_id'))
AND (sqlc.narg('scheduled')::bool IS NULL OR (i.start_date IS NOT NULL OR i.due_date IS NOT NULL))
AND (sqlc.narg('metadata_filter')::jsonb IS NULL OR i.metadata @> sqlc.narg('metadata_filter')::jsonb)
AND (
sqlc.narg('involves_user_id')::uuid IS NULL
OR (i.assignee_type = 'agent' AND i.assignee_id IN (
SELECT a.id FROM agent a
WHERE a.workspace_id = $1
AND a.owner_id = sqlc.narg('involves_user_id')::uuid
))
OR (i.assignee_type = 'squad' AND i.assignee_id IN (
SELECT sm.squad_id
FROM squad_member sm
JOIN squad s ON s.id = sm.squad_id
WHERE s.workspace_id = $1
AND sm.member_type = 'member'
AND sm.member_id = sqlc.narg('involves_user_id')::uuid
UNION
SELECT s.id
FROM squad s
JOIN agent a ON a.id = s.leader_id
WHERE s.workspace_id = $1
AND a.workspace_id = $1
AND a.owner_id = sqlc.narg('involves_user_id')::uuid
UNION
SELECT sm.squad_id
FROM squad_member sm
JOIN squad s ON s.id = sm.squad_id
JOIN agent a ON a.id = sm.member_id
WHERE s.workspace_id = $1
AND sm.member_type = 'agent'
AND a.workspace_id = $1
AND a.owner_id = sqlc.narg('involves_user_id')::uuid
))
);
-- name: ListChildIssues :many
-- Order by number ASC so sub-issues display in stable creation order
-- (oldest first), matching how a parent's plan reads top-to-bottom. The
-- position column is computed per-(workspace, status) by NextTopPosition,
-- not relative to siblings, so ordering by it interleaves children
-- unpredictably across batches and statuses; number is a per-workspace
-- monotonic counter and is sibling-stable.
SELECT * FROM issue
WHERE parent_issue_id = $1
ORDER BY number ASC;
-- name: ListChildrenByParents :many
-- Batched variant of ListChildIssues: returns all children for the given
-- parent set in one round trip. Used by Swimlane to avoid an N+1 fan-out
-- (one request per visible parent lane). Result is grouped client-side by
-- parent_issue_id; the workspace filter is also enforced so callers can't
-- enumerate children of parents in workspaces they don't belong to.
-- Within each parent, order by number ASC for the same sibling-stable
-- creation order as ListChildIssues.
SELECT * FROM issue
WHERE workspace_id = sqlc.arg('workspace_id')
AND parent_issue_id = ANY(sqlc.arg('parent_ids')::uuid[])
ORDER BY parent_issue_id, number ASC;
-- name: GetIssueByOrigin :one
-- Finds the issue stamped with a specific (origin_type, origin_id) pair.
-- Used by quick-create completion to deterministically locate the issue
-- produced by a given agent_task_queue.id — robust against concurrent
-- issue creates by the same agent (assignment task + quick-create both
-- running with max_concurrent_tasks > 1).
SELECT * FROM issue
WHERE workspace_id = $1
AND origin_type = $2
AND origin_id = $3
LIMIT 1;
-- name: CountCreatedIssueAssignees :many
-- Count assignees on issues created by a specific user.
SELECT
assignee_type,
assignee_id,
COUNT(*)::bigint as frequency
FROM issue
WHERE workspace_id = $1
AND creator_id = $2
AND creator_type = 'member'
AND assignee_type IS NOT NULL
AND assignee_id IS NOT NULL
GROUP BY assignee_type, assignee_id;
-- name: ChildIssueProgress :many
SELECT parent_issue_id,
COUNT(*)::bigint AS total,
COUNT(*) FILTER (WHERE status = ANY(sqlc.arg('terminal_status_keys')::text[]))::bigint AS done
FROM issue
WHERE workspace_id = $1
AND parent_issue_id IS NOT NULL
GROUP BY parent_issue_id;
-- SearchIssues: moved to handler (dynamic SQL for multi-word search support).
-- name: SetIssueMetadataKey :one
-- Atomically sets a single key in the issue's metadata JSONB. The
-- workspace_id filter is the authorization gate — handler resolves the
-- issue first so this is also the tenant check.
UPDATE issue SET
metadata = jsonb_set(metadata, ARRAY[sqlc.arg('key')::text], sqlc.arg('value')::jsonb),
revision = revision + CASE WHEN metadata -> sqlc.arg('key')::text IS DISTINCT FROM sqlc.arg('value')::jsonb THEN 1 ELSE 0 END,
last_activity_at = CASE
WHEN metadata -> sqlc.arg('key')::text IS DISTINCT FROM sqlc.arg('value')::jsonb
THEN GREATEST(COALESCE(last_activity_at, updated_at), now())
ELSE last_activity_at
END,
updated_at = now()
WHERE id = sqlc.arg('id') AND workspace_id = sqlc.arg('workspace_id')
RETURNING *;
-- name: DeleteIssueMetadataKey :one
-- Atomically removes a single key from the issue's metadata JSONB.
-- Deleting a missing key is a no-op (still returns the row).
UPDATE issue SET
metadata = metadata - sqlc.arg('key')::text,
revision = revision + CASE WHEN metadata ? sqlc.arg('key')::text THEN 1 ELSE 0 END,
last_activity_at = CASE
WHEN metadata ? sqlc.arg('key')::text
THEN GREATEST(COALESCE(last_activity_at, updated_at), now())
ELSE last_activity_at
END,
updated_at = now()
WHERE id = sqlc.arg('id') AND workspace_id = sqlc.arg('workspace_id')
RETURNING *;
-- name: MarkIssueFirstExecuted :one
-- Flips first_executed_at from NULL to now() atomically. Returns the row if
-- this was the first time the issue was executed; no rows otherwise. The
-- analytics issue_executed event fires exactly when this returns a row —
-- retries and re-assignments hit the WHERE clause and no-op.
UPDATE issue
SET first_executed_at = now()
WHERE id = $1 AND first_executed_at IS NULL
RETURNING id, workspace_id, creator_type, creator_id, first_executed_at;
-- name: CountIssuesUpTo :one
-- Bounded count for issue-limit admission and display. Callers pass only the
-- threshold needed for their decision, avoiding a full scan in an oversized
-- workspace.
SELECT COUNT(*)::bigint
FROM (
SELECT 1
FROM issue
WHERE workspace_id = $1
LIMIT sqlc.arg('limit')::bigint
) bounded_issues;