Files
multica/server/pkg/db/generated/project.sql.go
T
446080bd57 MUL-6243 feat(issue-status): per-workspace custom issue statuses over 7 canonical categories (#7065)
* feat(issue-status): per-workspace custom issue statuses over 7 canonical categories (MUL-6243)

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

* fix(issue-status): address review — normalize all status consumers, close archive race, gate rollout, split PK index

Review round 1 (MUL-6243 / PR #7065):

1. Behavior normalization was incomplete. Adds issue_effective_status() as a
   SQL mirror of issuestatus.Effective so the six SQL consumers the Go resolver
   cannot reach (duplicate guard, open-issue list, sub-issue progress, project
   stats, completed-inbox archive, open_only) resolve categories too. Wires the
   resolver into the task-failure sweeper and the stage-barrier helpers.

2. Archive TOCTOU. Archive now runs its in-use census and the archive itself in
   one transaction under an exclusive catalog lock; writes that target a custom
   status take the shared side. Built-in writes stay lock-free — a built-in can
   never be archived.

3. Rollout safety. Creating a custom status is gated behind the default-off
   CustomIssueStatuses flag, so a value old pods cannot interpret cannot exist
   until the fleet is updated. Readers ship ungated and are safe.

4. Primary key index. Follows the repo convention (327-329): table without a
   PK, unique index built CONCURRENTLY in its own migration, attached via
   PRIMARY KEY USING INDEX, and registered for invalid-index cleanup.

5. Status normalization vs stored value. Write paths now persist the canonical
   key from Resolve, so 'HUMAN_REVIEW' no longer validates and then fails the
   column format constraint as a 500.

Also fixes the two CLI tests failing in CI: they asserted the pre-MUL-6243
contract that an unknown-but-well-formed status is rejected locally.

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

* fix(issue-status): close the archive race in both orderings; resolve categories in batch barrier and CLI progress

Review round 2 (MUL-6243 / PR #7065):

1. The archive race was only half closed. Handlers resolved the status BEFORE
   opening a transaction, so an archive could commit in that window and the
   write would still land on an archived key. assertIssueStatusStillActive now
   takes the shared catalog lock and RE-RESOLVES inside the caller's
   transaction, which is what actually makes the status provably active at
   write time. Applied to create, update, batch, and both description-merge
   branches, which previously bypassed the guard entirely. A losing writer gets
   409 rather than a silent partial update.

2. The batch stage-barrier prefilter still compared literals, so a batch that
   moved the last child onto a custom done status left childDoneCompleted empty
   and skipped the parent notification. It now resolves categories first.

3. multica issue children --output json counted only literal done/cancelled.
   ListChildIssues now emits status_category and the CLI reads it, falling back
   to status for older backends.

TestArchiveCommitsInsideTheWriteRaceWindow interleaves the two operations so the
writer validates against an active status and the archive commits before the
write. Verified it fails without the re-resolve (issue stranded on the archived
status) and passes with it.

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

* refactor(issue-status): address review nits — honest race-test naming, one catalog read per list

Review round 3 nits (MUL-6243 / PR #7065):

1. TestArchiveRaceBothOrderings claimed more than it proved. Its 'archive wins'
   cases archived first and only then sent the request, which the pre-flight
   check alone rejects. Split into TestWritesRejectAnArchivedStatus (the
   sequential case, named for what it is) and TestArchiveRefusesAfterACommitted
   Write, and extended TestArchiveCommitsInsideTheWriteRaceWindow — the only
   test that truly interleaves archive between pre-flight and write — from
   create alone to all five write endpoints. Re-verified by deleting the
   in-transaction re-resolve: 4 of 5 fail with issues stranded on the archived
   status (create is guarded in the service layer and checked separately).

2. ListChildIssues / ListChildrenByParents issued one category lookup per
   custom-status child. issuestatus.Resolver now amortizes that to a single
   catalog read per request, lazily on the first custom key, so built-in
   statuses still cost zero queries however long the list is.

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

---------

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

335 lines
8.6 KiB
Go

// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.31.1
// source: project.sql
package db
import (
"context"
"github.com/jackc/pgx/v5/pgtype"
)
const countIssuesByProject = `-- name: CountIssuesByProject :one
SELECT count(*) FROM issue
WHERE project_id = $1
`
func (q *Queries) CountIssuesByProject(ctx context.Context, projectID pgtype.UUID) (int64, error) {
row := q.db.QueryRow(ctx, countIssuesByProject, projectID)
var count int64
err := row.Scan(&count)
return count, err
}
const createProject = `-- name: CreateProject :one
INSERT INTO project (
workspace_id, title, description, icon, status,
lead_type, lead_id, priority, start_date, due_date
) VALUES (
$1, $2, $3, $4, $5, $6, $7, $8, $9, $10
) RETURNING id, workspace_id, title, description, icon, status, lead_type, lead_id, created_at, updated_at, priority, start_date, due_date
`
type CreateProjectParams struct {
WorkspaceID pgtype.UUID `json:"workspace_id"`
Title string `json:"title"`
Description pgtype.Text `json:"description"`
Icon pgtype.Text `json:"icon"`
Status string `json:"status"`
LeadType pgtype.Text `json:"lead_type"`
LeadID pgtype.UUID `json:"lead_id"`
Priority string `json:"priority"`
StartDate pgtype.Date `json:"start_date"`
DueDate pgtype.Date `json:"due_date"`
}
func (q *Queries) CreateProject(ctx context.Context, arg CreateProjectParams) (Project, error) {
row := q.db.QueryRow(ctx, createProject,
arg.WorkspaceID,
arg.Title,
arg.Description,
arg.Icon,
arg.Status,
arg.LeadType,
arg.LeadID,
arg.Priority,
arg.StartDate,
arg.DueDate,
)
var i Project
err := row.Scan(
&i.ID,
&i.WorkspaceID,
&i.Title,
&i.Description,
&i.Icon,
&i.Status,
&i.LeadType,
&i.LeadID,
&i.CreatedAt,
&i.UpdatedAt,
&i.Priority,
&i.StartDate,
&i.DueDate,
)
return i, err
}
const deleteProject = `-- name: DeleteProject :exec
DELETE FROM project WHERE id = $1 AND workspace_id = $2
`
type DeleteProjectParams 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) DeleteProject(ctx context.Context, arg DeleteProjectParams) error {
_, err := q.db.Exec(ctx, deleteProject, arg.ID, arg.WorkspaceID)
return err
}
const getProject = `-- name: GetProject :one
SELECT id, workspace_id, title, description, icon, status, lead_type, lead_id, created_at, updated_at, priority, start_date, due_date FROM project
WHERE id = $1
`
func (q *Queries) GetProject(ctx context.Context, id pgtype.UUID) (Project, error) {
row := q.db.QueryRow(ctx, getProject, id)
var i Project
err := row.Scan(
&i.ID,
&i.WorkspaceID,
&i.Title,
&i.Description,
&i.Icon,
&i.Status,
&i.LeadType,
&i.LeadID,
&i.CreatedAt,
&i.UpdatedAt,
&i.Priority,
&i.StartDate,
&i.DueDate,
)
return i, err
}
const getProjectInWorkspace = `-- name: GetProjectInWorkspace :one
SELECT id, workspace_id, title, description, icon, status, lead_type, lead_id, created_at, updated_at, priority, start_date, due_date FROM project
WHERE id = $1 AND workspace_id = $2
`
type GetProjectInWorkspaceParams struct {
ID pgtype.UUID `json:"id"`
WorkspaceID pgtype.UUID `json:"workspace_id"`
}
func (q *Queries) GetProjectInWorkspace(ctx context.Context, arg GetProjectInWorkspaceParams) (Project, error) {
row := q.db.QueryRow(ctx, getProjectInWorkspace, arg.ID, arg.WorkspaceID)
var i Project
err := row.Scan(
&i.ID,
&i.WorkspaceID,
&i.Title,
&i.Description,
&i.Icon,
&i.Status,
&i.LeadType,
&i.LeadID,
&i.CreatedAt,
&i.UpdatedAt,
&i.Priority,
&i.StartDate,
&i.DueDate,
)
return i, err
}
const getProjectIssueStats = `-- name: GetProjectIssueStats :many
SELECT project_id,
count(*)::bigint AS total_count,
count(*) FILTER (WHERE issue_effective_status(workspace_id, status) IN ('done', 'cancelled'))::bigint AS done_count
FROM issue
WHERE project_id = ANY($1::uuid[])
GROUP BY project_id
`
type GetProjectIssueStatsRow struct {
ProjectID pgtype.UUID `json:"project_id"`
TotalCount int64 `json:"total_count"`
DoneCount int64 `json:"done_count"`
}
func (q *Queries) GetProjectIssueStats(ctx context.Context, projectIds []pgtype.UUID) ([]GetProjectIssueStatsRow, error) {
rows, err := q.db.Query(ctx, getProjectIssueStats, projectIds)
if err != nil {
return nil, err
}
defer rows.Close()
items := []GetProjectIssueStatsRow{}
for rows.Next() {
var i GetProjectIssueStatsRow
if err := rows.Scan(&i.ProjectID, &i.TotalCount, &i.DoneCount); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const listProjects = `-- name: ListProjects :many
SELECT id, workspace_id, title, description, icon, status, lead_type, lead_id, created_at, updated_at, priority, start_date, due_date FROM project
WHERE workspace_id = $1
AND ($2::text IS NULL OR status = $2)
AND ($3::text IS NULL OR priority = $3)
ORDER BY created_at DESC
`
type ListProjectsParams struct {
WorkspaceID pgtype.UUID `json:"workspace_id"`
Status pgtype.Text `json:"status"`
Priority pgtype.Text `json:"priority"`
}
func (q *Queries) ListProjects(ctx context.Context, arg ListProjectsParams) ([]Project, error) {
rows, err := q.db.Query(ctx, listProjects, arg.WorkspaceID, arg.Status, arg.Priority)
if err != nil {
return nil, err
}
defer rows.Close()
items := []Project{}
for rows.Next() {
var i Project
if err := rows.Scan(
&i.ID,
&i.WorkspaceID,
&i.Title,
&i.Description,
&i.Icon,
&i.Status,
&i.LeadType,
&i.LeadID,
&i.CreatedAt,
&i.UpdatedAt,
&i.Priority,
&i.StartDate,
&i.DueDate,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const lockProjectForChatSessionCreate = `-- name: LockProjectForChatSessionCreate :one
SELECT id FROM project
WHERE id = $1 AND workspace_id = $2
FOR KEY SHARE
`
type LockProjectForChatSessionCreateParams struct {
ID pgtype.UUID `json:"id"`
WorkspaceID pgtype.UUID `json:"workspace_id"`
}
// Conflicts with project deletion so a chat session cannot commit a soft
// project reference after the delete transaction has swept existing sessions.
func (q *Queries) LockProjectForChatSessionCreate(ctx context.Context, arg LockProjectForChatSessionCreateParams) (pgtype.UUID, error) {
row := q.db.QueryRow(ctx, lockProjectForChatSessionCreate, arg.ID, arg.WorkspaceID)
var id pgtype.UUID
err := row.Scan(&id)
return id, err
}
const lockProjectForDelete = `-- name: LockProjectForDelete :one
SELECT id FROM project
WHERE id = $1 AND workspace_id = $2
FOR UPDATE
`
type LockProjectForDeleteParams struct {
ID pgtype.UUID `json:"id"`
WorkspaceID pgtype.UUID `json:"workspace_id"`
}
// Serializes project deletion with chat-session creation. The handler locks,
// clears every soft chat reference, and deletes the project in one transaction.
func (q *Queries) LockProjectForDelete(ctx context.Context, arg LockProjectForDeleteParams) (pgtype.UUID, error) {
row := q.db.QueryRow(ctx, lockProjectForDelete, arg.ID, arg.WorkspaceID)
var id pgtype.UUID
err := row.Scan(&id)
return id, err
}
const updateProject = `-- name: UpdateProject :one
UPDATE project SET
title = COALESCE($2, title),
description = $3,
icon = $4,
status = COALESCE($5, status),
priority = COALESCE($6, priority),
lead_type = $7,
lead_id = $8,
start_date = $9,
due_date = $10,
updated_at = now()
WHERE id = $1
RETURNING id, workspace_id, title, description, icon, status, lead_type, lead_id, created_at, updated_at, priority, start_date, due_date
`
type UpdateProjectParams struct {
ID pgtype.UUID `json:"id"`
Title pgtype.Text `json:"title"`
Description pgtype.Text `json:"description"`
Icon pgtype.Text `json:"icon"`
Status pgtype.Text `json:"status"`
Priority pgtype.Text `json:"priority"`
LeadType pgtype.Text `json:"lead_type"`
LeadID pgtype.UUID `json:"lead_id"`
StartDate pgtype.Date `json:"start_date"`
DueDate pgtype.Date `json:"due_date"`
}
func (q *Queries) UpdateProject(ctx context.Context, arg UpdateProjectParams) (Project, error) {
row := q.db.QueryRow(ctx, updateProject,
arg.ID,
arg.Title,
arg.Description,
arg.Icon,
arg.Status,
arg.Priority,
arg.LeadType,
arg.LeadID,
arg.StartDate,
arg.DueDate,
)
var i Project
err := row.Scan(
&i.ID,
&i.WorkspaceID,
&i.Title,
&i.Description,
&i.Icon,
&i.Status,
&i.LeadType,
&i.LeadID,
&i.CreatedAt,
&i.UpdatedAt,
&i.Priority,
&i.StartDate,
&i.DueDate,
)
return i, err
}