package platform import ( "encoding/json" "net/http" "sort" "time" ) func (s *service) dissolveSubgraph(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 } key := canvasIdempotencyKey(r) frame := r.PathValue("frame") if !uuidSlugPattern.MatchString(key) || !canvasID.MatchString(frame) { reject(w, 400, "invalid_subgraph_request") return nil } var input struct { ExpectedVersions map[string]int64 `json:"expected_versions"` } if !decode(w, r, &input) { return nil } if len(input.ExpectedVersions) == 0 { reject(w, 428, "version_required") return nil } tx, err := s.db.BeginTx(r.Context(), nil) if err != nil { return err } defer tx.Rollback() workspace, board := r.PathValue("workspace"), r.PathValue("material") _, role, err := lockMaterial(r.Context(), tx, project, workspace, board, account) if err != nil { return err } if role == "viewer" { reject(w, 403, "forbidden") return nil } action := "dissolve_subgraph/" + frame hash := payloadDigest(input) done, err := transactionRetry(w, r, tx, project, account, board, action, key, hash) if err != nil { return err } if done { return nil } var child 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, board, frame).Scan(&child) if err != nil { return err } relation, err := readSubgraphRelation(r, tx, project, workspace, child) if err != nil { return err } archivedRelation := relation archivedRelation.Archived = true changes := []subgraphRelationChange{{Before: relation, After: archivedRelation}} participants := []string{board, child} rows, err := tx.QueryContext(r.Context(), `SELECT child_board_id::text FROM canvas_subgraphs WHERE project=$1 AND workspace_id=$2 AND parent_board_id=$3 AND archived_at IS NULL ORDER BY child_board_id`, project, workspace, child) if err != nil { return err } nested := []string{} for rows.Next() { var id string if err = rows.Scan(&id); err != nil { rows.Close() return err } nested = append(nested, id) } err = rows.Err() rows.Close() if err != nil { return err } for _, id := range nested { before, err := readSubgraphRelation(r, tx, project, workspace, id) if err != nil { return err } after := before after.Parent = board changes = append(changes, subgraphRelationChange{Before: before, After: after}) participants = append(participants, id) } parentBefore, err := loadCanvas(r, tx, project, workspace, board) if err != nil { return err } childBefore, err := loadCanvas(r, tx, project, workspace, child) if err != nil { return err } members := []string{} for node := range canvasNodeData(childBefore.Document) { members = append(members, node) } repair, err := planAncestorPortRepair(r, tx, project, workspace, board, child, account, members, parentBefore.Document) if err != nil { return err } if repair != nil { participants = append(participants, repair.Ancestor) } sort.Strings(participants) if len(input.ExpectedVersions) != len(participants) { reject(w, 428, "version_required") return nil } snapshots := map[string]canvasSnapshot{} beforeSnapshots := []canvasSnapshot{} for _, id := range participants { _, 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 _, exists := input.ExpectedVersions[id]; !exists { reject(w, 428, "version_required") return nil } if input.ExpectedVersions[id] != snapshot.Version { reject(w, 412, "version_conflict") return nil } snapshots[id] = snapshot beforeSnapshots = append(beforeSnapshots, snapshot) } parent, childSnapshot := snapshots[board], snapshots[child] parentDoc, childDoc, err := dissolveDocuments(parent.Document, childSnapshot.Document, frame) if err != nil { if err == errGraphOwnership { reject(w, 409, "ownership_conflict") } else { reject(w, 413, "graph_limit_exceeded") } return nil } if repair != nil { documents := map[string]json.RawMessage{board: parentDoc, repair.Ancestor: snapshots[repair.Ancestor].Document} if err = applyAncestorPortRepair(repair, board, board, documents); err != nil { return err } parentDoc = documents[board] ancestor := snapshots[repair.Ancestor] ancestor.Document = documents[repair.Ancestor] snapshots[repair.Ancestor] = ancestor } parent.Document = parentDoc childSnapshot.Document = childDoc snapshots[board] = parent snapshots[child] = childSnapshot // Reparent nested children first. The archived child keeps a recovery record, // while live ownership immediately follows the nodes flattened into the parent. for i := len(changes) - 1; i >= 0; i-- { if err = writeSubgraphRelation(r, tx, project, workspace, changes[i].After); err != nil { return err } } updated := []canvasSnapshot{} versions := map[string]int64{} for _, id := range participants { snapshot := snapshots[id] 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[board], "snapshots": updated, "archived_board_ids": []string{child}, "undo_receipt": response{"transaction_id": id, "valid_for_seconds": 604800, "expires_at": receiptExpiry}} encoded, _ := json.Marshal(result) beforeJSON, _ := json.Marshal(beforeSnapshots) versionsJSON, _ := json.Marshal(versions) relationsJSON, _ := 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, board, id, account, action, key, hash, encoded, beforeJSON, versionsJSON, relationsJSON) if err != nil { return err } if err = tx.Commit(); err != nil { return err } setVersion(w, versions[board]) reply(w, 200, result) return nil }