Files
foundry-core/internal/platform/subgraph_members_http.go
T
codex-bot 09c78d3ab8
Foundry Go checks / check (push) Successful in 8m18s
Foundry Go checks / check (pull_request) Successful in 6m16s
Accept final member positions in ownership transactions
2026-11-06 22:46:46 +08:00

267 lines
8.1 KiB
Go

package platform
import (
"encoding/json"
"net/http"
"sort"
"time"
)
func (s *service) moveSubgraphMembers(w http.ResponseWriter, r *http.Request, project, account string) error {
if r.Method != http.MethodPost {
reject(w, 405, "method_not_allowed")
return nil
}
if !acceptsCanvasV2(r) {
reject(w, 409, "canvas_schema_upgrade_required")
return nil
}
frame, key := r.PathValue("frame"), canvasIdempotencyKey(r)
if !canvasID.MatchString(frame) || !uuidSlugPattern.MatchString(key) {
reject(w, 400, "invalid_subgraph_request")
return nil
}
var input struct {
Source string `json:"source_board_id"`
Target string `json:"target_board_id"`
Nodes []string `json:"node_ids"`
Expected map[string]int64 `json:"expected_versions"`
Positions map[string]memberDropPosition `json:"positions,omitempty"`
}
if !decode(w, r, &input) {
return nil
}
if !uuidSlugPattern.MatchString(input.Source) || !uuidSlugPattern.MatchString(input.Target) || input.Source == input.Target || len(input.Nodes) == 0 || len(input.Nodes) > 500 {
reject(w, 400, "invalid_subgraph_request")
return nil
}
for _, node := range input.Nodes {
if !canvasID.MatchString(node) {
reject(w, 400, "invalid_subgraph_request")
return nil
}
}
if !validMemberDropPositions(input.Nodes, input.Positions) {
reject(w, 400, "invalid_subgraph_request")
return nil
}
if len(input.Expected) == 0 {
reject(w, 428, "version_required")
return nil
}
workspace, parent := r.PathValue("workspace"), r.PathValue("material")
tx, err := s.db.BeginTx(r.Context(), nil)
if err != nil {
return err
}
defer tx.Rollback()
_, role, err := lockMaterial(r.Context(), tx, project, workspace, parent, account)
if err != nil {
return err
}
if role == "viewer" {
reject(w, 403, "forbidden")
return nil
}
action := "move_members/" + frame
hash := payloadDigest(input)
done, err := transactionRetry(w, r, tx, project, account, parent, action, key, hash)
if err != nil {
return err
}
if done {
return nil
}
var associated string
err = tx.QueryRowContext(r.Context(), `SELECT child_board_id::text FROM canvas_subgraphs WHERE project=$1 AND workspace_id=$2 AND parent_board_id=$3 AND frame_id=$4 AND archived_at IS NULL`, project, workspace, parent, frame).Scan(&associated)
if err != nil {
return err
}
if input.Source != associated && input.Target != associated {
reject(w, 409, "ownership_conflict")
return nil
}
participants := map[string]bool{parent: true, input.Source: true, input.Target: true}
for _, board := range []string{input.Source, input.Target} {
if board == parent {
continue
}
relation, err := readSubgraphRelation(r, tx, project, workspace, board)
if err != nil {
return err
}
if relation.Parent != parent || relation.Archived {
reject(w, 409, "ownership_conflict")
return nil
}
}
sourceSnapshot, err := loadCanvas(r, tx, project, workspace, input.Source)
if err != nil {
return err
}
sourceNodes := canvasNodeData(sourceSnapshot.Document)
changes := []subgraphRelationChange{}
for _, node := range input.Nodes {
data := sourceNodes[node]
if data == nil {
reject(w, 409, "ownership_conflict")
return nil
}
sub, _ := data["subgraph"].(map[string]any)
child, _ := sub["boardId"].(string)
if child == "" {
continue
}
relation, err := readSubgraphRelation(r, tx, project, workspace, child)
if err != nil {
return err
}
if relation.Parent != input.Source || relation.Frame != node || relation.Archived {
reject(w, 409, "ownership_conflict")
return nil
}
if child == input.Target {
reject(w, 409, "subgraph_cycle")
return nil
}
after := relation
after.Parent = input.Target
changes = append(changes, subgraphRelationChange{Before: relation, After: after})
rows, err := tx.QueryContext(r.Context(), `WITH RECURSIVE descendants(id,depth) AS(SELECT $3::uuid,0 UNION ALL SELECT s.child_board_id,d.depth+1 FROM descendants d JOIN canvas_subgraphs s ON s.project=$1 AND s.workspace_id=$2 AND s.parent_board_id=d.id AND s.archived_at IS NULL WHERE d.depth<8) SELECT id::text FROM descendants`, project, workspace, child)
if err != nil {
return err
}
for rows.Next() {
var id string
if err = rows.Scan(&id); err != nil {
break
}
if id == input.Target {
rows.Close()
reject(w, 409, "subgraph_cycle")
return nil
}
participants[id] = true
}
if err == nil {
err = rows.Err()
}
rows.Close()
if err != nil {
return err
}
}
parentSnapshot, err := loadCanvas(r, tx, project, workspace, parent)
if err != nil {
return err
}
repair, err := planAncestorPortRepair(r, tx, project, workspace, parent, input.Source, account, input.Nodes, parentSnapshot.Document)
if err != nil {
return err
}
if repair != nil {
participants[repair.Ancestor] = true
}
if len(input.Expected) != len(participants) {
reject(w, 428, "version_required")
return nil
}
ids := []string{}
for id := range participants {
ids = append(ids, id)
}
sort.Strings(ids)
snapshots := map[string]canvasSnapshot{}
before := []canvasSnapshot{}
for _, id := range ids {
expected, exists := input.Expected[id]
if !exists {
reject(w, 428, "version_required")
return nil
}
_, role, err := lockMaterial(r.Context(), tx, project, workspace, id, account)
if err != nil {
return err
}
if role == "viewer" {
reject(w, 403, "forbidden")
return nil
}
snapshot, err := loadCanvas(r, tx, project, workspace, id)
if err != nil {
return err
}
if snapshot.Version != expected {
reject(w, 412, "version_conflict")
return nil
}
snapshots[id] = snapshot
before = append(before, snapshot)
}
raw := map[string]json.RawMessage{parent: snapshots[parent].Document, input.Source: snapshots[input.Source].Document, input.Target: snapshots[input.Target].Document}
if input.Positions != nil {
positioned, err := positionMemberDrop(raw[input.Source], input.Positions)
if err != nil {
reject(w, 409, "ownership_conflict")
return nil
}
raw[input.Source] = positioned
}
moved, err := moveMembersDocuments(parent, input.Source, input.Target, input.Nodes, raw)
if err != nil {
if err == errGraphOwnership {
reject(w, 409, "ownership_conflict")
} else {
reject(w, 413, "graph_limit_exceeded")
}
return nil
}
if repair != nil {
moved[repair.Ancestor] = snapshots[repair.Ancestor].Document
if err = applyAncestorPortRepair(repair, parent, input.Target, moved); err != nil {
return err
}
}
for _, change := range changes {
if err = writeSubgraphRelation(r, tx, project, workspace, change.After); err != nil {
return err
}
}
updated := []canvasSnapshot{}
versions := map[string]int64{}
for _, id := range ids {
snapshot := snapshots[id]
if document, exists := moved[id]; exists {
snapshot.Document = document
}
snapshot.Version++
if err = s.saveCanvas(r, tx, project, workspace, snapshot); err != nil {
return err
}
updated = append(updated, snapshot)
versions[id] = snapshot.Version
snapshots[id] = snapshot
}
var id string
var receiptExpiry time.Time
err = tx.QueryRowContext(r.Context(), `SELECT gen_random_uuid()::text,now()+interval '7 days'`).Scan(&id, &receiptExpiry)
if err != nil {
return err
}
result := response{"transaction_id": id, "parent": snapshots[parent], "snapshots": updated, "undo_receipt": response{"transaction_id": id, "valid_for_seconds": 604800, "expires_at": receiptExpiry}}
encoded, _ := json.Marshal(result)
beforeJSON, _ := json.Marshal(before)
versionsJSON, _ := json.Marshal(versions)
changesJSON, _ := json.Marshal(changes)
_, err = tx.ExecContext(r.Context(), `INSERT INTO canvas_transactions(project,workspace_id,board_id,id,account_id,action,idempotency_key,payload_hash,response_status,response_body,before_state,after_versions,relation_changes) VALUES($1,$2,$3,$4,$5,$6,$7,$8,200,$9,$10,$11,$12)`, project, workspace, parent, id, account, action, key, hash, encoded, beforeJSON, versionsJSON, changesJSON)
if err != nil {
return err
}
if err = tx.Commit(); err != nil {
return err
}
setVersion(w, versions[parent])
reply(w, 200, result)
return nil
}