Files
b7cdd76ad8 MUL-7349: feat(server): add the duplicate-mark column and keep it correct (1/2) (#8691)
* feat(server): add the duplicate-mark column and keep it correct (MUL-7349)

Expand step for marking an issue as a duplicate of another. A duplicate
is an ordinary cancelled issue that records which issue it duplicates;
there is no duplicate status. This release adds the column and the code
that maintains it, and nothing writes a pointer yet.

- A mark survives only a write that leaves an already cancelled issue
  cancelled. Every other write drops it, so reopening removes a mark and
  cancelling again does not bring one back. Both status-writing queries
  carry the rule, which covers the UI, batch updates and background
  writers.
- Deleting an issue clears the pointers of its duplicates in the same
  transaction, the way deleting a parent detaches its children.

Shipping this before marking is what makes marking safe to roll back: the
release a rollback lands on already clears the pointer. Tests seed a mark
the way the next release will write it.

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

* fix(server): compute issue updates from the locked row (MUL-7349)

UpdateIssue built its next_* values in a CTE that read the row before
the UPDATE took its row lock. A write that waited there behind another
transaction then resumed with the values it had read before the wait and
wrote them back, so an ordinary priority or status edit could restore a
duplicate mark the other transaction had just cleared — by reopening the
issue or by deleting its original — and did_change/did_activity were
decided against the pre-wait row as well.

Lock the row in the CTE instead, so every computed value comes from the
row the statement is about to write. Tests hold the clearing transaction
open and wait for the update to block on the row lock, so the
interleaving is deterministic; both fail without this change.

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

---------

Co-authored-by: J <[email protected]>
Co-authored-by: multica-agent <[email protected]>
2026-09-23 14:59:47 +08:00

394 lines
12 KiB
Go

// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.31.1
// source: issue_property.sql
package db
import (
"context"
"github.com/jackc/pgx/v5/pgtype"
)
const countActiveIssueProperties = `-- name: CountActiveIssueProperties :one
SELECT COUNT(*) FROM issue_property
WHERE workspace_id = $1 AND archived_at IS NULL
`
func (q *Queries) CountActiveIssueProperties(ctx context.Context, workspaceID pgtype.UUID) (int64, error) {
row := q.db.QueryRow(ctx, countActiveIssueProperties, workspaceID)
var count int64
err := row.Scan(&count)
return count, err
}
const countIssuesUsingPropertyOptions = `-- name: CountIssuesUsingPropertyOptions :many
SELECT opt::text AS option_id, COUNT(i.id) AS usage_count
FROM unnest($1::text[]) AS opt
LEFT JOIN issue i
ON i.workspace_id = $2::uuid
AND (i.properties -> $3::text) ? opt
GROUP BY opt
HAVING COUNT(i.id) > 0
`
type CountIssuesUsingPropertyOptionsParams struct {
OptionIds []string `json:"option_ids"`
WorkspaceID pgtype.UUID `json:"workspace_id"`
PropertyKey string `json:"property_key"`
}
type CountIssuesUsingPropertyOptionsRow struct {
OptionID string `json:"option_id"`
UsageCount int64 `json:"usage_count"`
}
// Usage census for specific option ids of one property. jsonb `?` matches
// both value shapes: array element for multi_select, string equality for
// select. Only options with at least one referencing issue come back.
func (q *Queries) CountIssuesUsingPropertyOptions(ctx context.Context, arg CountIssuesUsingPropertyOptionsParams) ([]CountIssuesUsingPropertyOptionsRow, error) {
rows, err := q.db.Query(ctx, countIssuesUsingPropertyOptions, arg.OptionIds, arg.WorkspaceID, arg.PropertyKey)
if err != nil {
return nil, err
}
defer rows.Close()
items := []CountIssuesUsingPropertyOptionsRow{}
for rows.Next() {
var i CountIssuesUsingPropertyOptionsRow
if err := rows.Scan(&i.OptionID, &i.UsageCount); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const createIssueProperty = `-- name: CreateIssueProperty :one
INSERT INTO issue_property (workspace_id, name, type, description, icon, config, position)
SELECT $1::uuid,
$2::text,
$3::text,
$4::text,
$5::text,
$6::jsonb,
COALESCE((SELECT MAX(position) FROM issue_property WHERE workspace_id = $1::uuid), 0) + 1
RETURNING id, workspace_id, name, type, description, config, position, archived_at, created_at, updated_at, icon
`
type CreateIssuePropertyParams struct {
WorkspaceID pgtype.UUID `json:"workspace_id"`
Name string `json:"name"`
Type string `json:"type"`
Description string `json:"description"`
Icon string `json:"icon"`
Config []byte `json:"config"`
}
// New definitions append to the end of the catalog: position = max + 1.
func (q *Queries) CreateIssueProperty(ctx context.Context, arg CreateIssuePropertyParams) (IssueProperty, error) {
row := q.db.QueryRow(ctx, createIssueProperty,
arg.WorkspaceID,
arg.Name,
arg.Type,
arg.Description,
arg.Icon,
arg.Config,
)
var i IssueProperty
err := row.Scan(
&i.ID,
&i.WorkspaceID,
&i.Name,
&i.Type,
&i.Description,
&i.Config,
&i.Position,
&i.ArchivedAt,
&i.CreatedAt,
&i.UpdatedAt,
&i.Icon,
)
return i, err
}
const deleteIssuePropertyValue = `-- name: DeleteIssuePropertyValue :one
UPDATE issue
SET properties = properties - $1::text,
revision = revision + CASE WHEN properties ? $1::text THEN 1 ELSE 0 END,
last_activity_at = CASE
WHEN properties ? $1::text
THEN GREATEST(COALESCE(last_activity_at, updated_at), now())
ELSE last_activity_at
END,
updated_at = CASE WHEN properties ? $1::text THEN now() ELSE updated_at END
WHERE id = $2::uuid AND workspace_id = $3::uuid
RETURNING id, workspace_id, title, description, status, priority, assignee_type, assignee_id, creator_type, creator_id, parent_issue_id, acceptance_criteria, context_refs, position, due_date, created_at, updated_at, number, project_id, origin_type, origin_id, first_executed_at, start_date, metadata, stage, properties, revision, last_activity_at, triage_state, duplicate_of_issue_id
`
type DeleteIssuePropertyValueParams struct {
Key string `json:"key"`
ID pgtype.UUID `json:"id"`
WorkspaceID pgtype.UUID `json:"workspace_id"`
}
func (q *Queries) DeleteIssuePropertyValue(ctx context.Context, arg DeleteIssuePropertyValueParams) (Issue, error) {
row := q.db.QueryRow(ctx, deleteIssuePropertyValue, arg.Key, arg.ID, arg.WorkspaceID)
var i Issue
err := row.Scan(
&i.ID,
&i.WorkspaceID,
&i.Title,
&i.Description,
&i.Status,
&i.Priority,
&i.AssigneeType,
&i.AssigneeID,
&i.CreatorType,
&i.CreatorID,
&i.ParentIssueID,
&i.AcceptanceCriteria,
&i.ContextRefs,
&i.Position,
&i.DueDate,
&i.CreatedAt,
&i.UpdatedAt,
&i.Number,
&i.ProjectID,
&i.OriginType,
&i.OriginID,
&i.FirstExecutedAt,
&i.StartDate,
&i.Metadata,
&i.Stage,
&i.Properties,
&i.Revision,
&i.LastActivityAt,
&i.TriageState,
&i.DuplicateOfIssueID,
)
return i, err
}
const getIssueProperty = `-- name: GetIssueProperty :one
SELECT id, workspace_id, name, type, description, config, position, archived_at, created_at, updated_at, icon FROM issue_property
WHERE id = $1 AND workspace_id = $2
`
type GetIssuePropertyParams struct {
ID pgtype.UUID `json:"id"`
WorkspaceID pgtype.UUID `json:"workspace_id"`
}
func (q *Queries) GetIssueProperty(ctx context.Context, arg GetIssuePropertyParams) (IssueProperty, error) {
row := q.db.QueryRow(ctx, getIssueProperty, arg.ID, arg.WorkspaceID)
var i IssueProperty
err := row.Scan(
&i.ID,
&i.WorkspaceID,
&i.Name,
&i.Type,
&i.Description,
&i.Config,
&i.Position,
&i.ArchivedAt,
&i.CreatedAt,
&i.UpdatedAt,
&i.Icon,
)
return i, err
}
const listIssueProperties = `-- name: ListIssueProperties :many
SELECT p.id, p.workspace_id, p.name, p.type, p.description, p.config, p.position, p.archived_at, p.created_at, p.updated_at, p.icon,
(
SELECT COUNT(*) FROM issue i
WHERE i.workspace_id = p.workspace_id
AND i.properties ? p.id::text
)::bigint AS usage_count
FROM issue_property p
WHERE p.workspace_id = $1::uuid
AND ($2::bool OR p.archived_at IS NULL)
ORDER BY p.position ASC, LOWER(p.name) ASC
`
type ListIssuePropertiesParams struct {
WorkspaceID pgtype.UUID `json:"workspace_id"`
IncludeArchived bool `json:"include_archived"`
}
type ListIssuePropertiesRow struct {
ID pgtype.UUID `json:"id"`
WorkspaceID pgtype.UUID `json:"workspace_id"`
Name string `json:"name"`
Type string `json:"type"`
Description string `json:"description"`
Config []byte `json:"config"`
Position float64 `json:"position"`
ArchivedAt pgtype.Timestamptz `json:"archived_at"`
CreatedAt pgtype.Timestamptz `json:"created_at"`
UpdatedAt pgtype.Timestamptz `json:"updated_at"`
Icon string `json:"icon"`
UsageCount int64 `json:"usage_count"`
}
// usage_count = number of issues in the workspace that currently carry a
// value for this property. `properties ? id` is a seq scan today; fine at
// the 20-definition / small-workspace scale this feature targets.
func (q *Queries) ListIssueProperties(ctx context.Context, arg ListIssuePropertiesParams) ([]ListIssuePropertiesRow, error) {
rows, err := q.db.Query(ctx, listIssueProperties, arg.WorkspaceID, arg.IncludeArchived)
if err != nil {
return nil, err
}
defer rows.Close()
items := []ListIssuePropertiesRow{}
for rows.Next() {
var i ListIssuePropertiesRow
if err := rows.Scan(
&i.ID,
&i.WorkspaceID,
&i.Name,
&i.Type,
&i.Description,
&i.Config,
&i.Position,
&i.ArchivedAt,
&i.CreatedAt,
&i.UpdatedAt,
&i.Icon,
&i.UsageCount,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const setIssuePropertyValue = `-- name: SetIssuePropertyValue :one
UPDATE issue
SET properties = jsonb_set(properties, ARRAY[$1::text], $2::jsonb, true),
revision = revision + CASE WHEN properties -> $1::text IS DISTINCT FROM $2::jsonb THEN 1 ELSE 0 END,
last_activity_at = CASE
WHEN properties -> $1::text IS DISTINCT FROM $2::jsonb
THEN GREATEST(COALESCE(last_activity_at, updated_at), now())
ELSE last_activity_at
END,
updated_at = CASE WHEN properties -> $1::text IS DISTINCT FROM $2::jsonb THEN now() ELSE updated_at END
WHERE id = $3::uuid AND workspace_id = $4::uuid
RETURNING id, workspace_id, title, description, status, priority, assignee_type, assignee_id, creator_type, creator_id, parent_issue_id, acceptance_criteria, context_refs, position, due_date, created_at, updated_at, number, project_id, origin_type, origin_id, first_executed_at, start_date, metadata, stage, properties, revision, last_activity_at, triage_state, duplicate_of_issue_id
`
type SetIssuePropertyValueParams struct {
Key string `json:"key"`
Value []byte `json:"value"`
ID pgtype.UUID `json:"id"`
WorkspaceID pgtype.UUID `json:"workspace_id"`
}
// Single-key atomic write: concurrent writers on different property keys
// never clobber each other. Unlike SetIssueMetadataKey, a no-op still updates
// and returns the issue row.
func (q *Queries) SetIssuePropertyValue(ctx context.Context, arg SetIssuePropertyValueParams) (Issue, error) {
row := q.db.QueryRow(ctx, setIssuePropertyValue,
arg.Key,
arg.Value,
arg.ID,
arg.WorkspaceID,
)
var i Issue
err := row.Scan(
&i.ID,
&i.WorkspaceID,
&i.Title,
&i.Description,
&i.Status,
&i.Priority,
&i.AssigneeType,
&i.AssigneeID,
&i.CreatorType,
&i.CreatorID,
&i.ParentIssueID,
&i.AcceptanceCriteria,
&i.ContextRefs,
&i.Position,
&i.DueDate,
&i.CreatedAt,
&i.UpdatedAt,
&i.Number,
&i.ProjectID,
&i.OriginType,
&i.OriginID,
&i.FirstExecutedAt,
&i.StartDate,
&i.Metadata,
&i.Stage,
&i.Properties,
&i.Revision,
&i.LastActivityAt,
&i.TriageState,
&i.DuplicateOfIssueID,
)
return i, err
}
const updateIssueProperty = `-- name: UpdateIssueProperty :one
UPDATE issue_property SET
name = COALESCE($3, name),
description = COALESCE($4, description),
icon = COALESCE($5, icon),
config = COALESCE($6, config),
archived_at = CASE WHEN $7::bool THEN $8 ELSE archived_at END,
updated_at = now()
WHERE id = $1 AND workspace_id = $2
RETURNING id, workspace_id, name, type, description, config, position, archived_at, created_at, updated_at, icon
`
type UpdateIssuePropertyParams struct {
ID pgtype.UUID `json:"id"`
WorkspaceID pgtype.UUID `json:"workspace_id"`
Name pgtype.Text `json:"name"`
Description pgtype.Text `json:"description"`
Icon pgtype.Text `json:"icon"`
Config []byte `json:"config"`
ArchivedSet bool `json:"archived_set"`
ArchivedAt pgtype.Timestamptz `json:"archived_at"`
}
// `type` is deliberately immutable — changing it would silently invalidate
// existing values. archived_set/archived_at implement tri-state semantics:
// archived_set=false leaves archived_at untouched.
func (q *Queries) UpdateIssueProperty(ctx context.Context, arg UpdateIssuePropertyParams) (IssueProperty, error) {
row := q.db.QueryRow(ctx, updateIssueProperty,
arg.ID,
arg.WorkspaceID,
arg.Name,
arg.Description,
arg.Icon,
arg.Config,
arg.ArchivedSet,
arg.ArchivedAt,
)
var i IssueProperty
err := row.Scan(
&i.ID,
&i.WorkspaceID,
&i.Name,
&i.Type,
&i.Description,
&i.Config,
&i.Position,
&i.ArchivedAt,
&i.CreatedAt,
&i.UpdatedAt,
&i.Icon,
)
return i, err
}