Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 09c78d3ab8 | |||
| b2ae48e250 |
@@ -628,3 +628,7 @@ Legacy inline images must be migrated through an explicit editor action: upload
|
|||||||
unchanged bytes with idempotency keys, then save the same node IDs/geometry with
|
unchanged bytes with idempotency keys, then save the same node IDs/geometry with
|
||||||
managed asset references and the current canvas version. Opening a canvas does
|
managed asset references and the current canvas version. Opening a canvas does
|
||||||
not migrate it or cause an implicit PATCH.
|
not migrate it or cause an implicit PATCH.
|
||||||
|
|
||||||
|
#### Atomic member drop positions
|
||||||
|
|
||||||
|
`POST B/subgraphs/{frame}/members` additionally accepts optional `positions`, an object with exactly one `{x,y}` entry for each `node_ids` item, expressed in the **source board's local coordinates**. Both coordinates are required, finite and within ±1,000,000. Capabilities advertise `subgraphs.member_positions: true`. Omission preserves legacy behavior. Positions, ownership, edge/port conversion, versions and undo receipt commit in one transaction; no preceding coordinate PATCH is needed. Validation failures or stale versions perform zero writes. The positions participate in the idempotency payload digest; retries must keep the entire body and key unchanged. Undo restores the pre-drop snapshot, including original positions.
|
||||||
|
|||||||
@@ -47,7 +47,7 @@ func (s *service) canvasCapabilities(w http.ResponseWriter, r *http.Request, pro
|
|||||||
"contract_version": "canvas-v2-2026-11-06",
|
"contract_version": "canvas-v2-2026-11-06",
|
||||||
"supported_schema_versions": []int{1, 2},
|
"supported_schema_versions": []int{1, 2},
|
||||||
"edge_features": response{"appearance": true, "markers": true, "bezier_chain": true, "animation_configuration": true, "execution_events": true},
|
"edge_features": response{"appearance": true, "markers": true, "bezier_chain": true, "animation_configuration": true, "execution_events": true},
|
||||||
"subgraphs": response{"create": true, "migrate": true, "undo": true, "members": true, "dissolve": true, "connections": true},
|
"subgraphs": response{"create": true, "migrate": true, "undo": true, "members": true, "dissolve": true, "connections": true, "member_positions": true},
|
||||||
"ai": ai,
|
"ai": ai,
|
||||||
"assets": response{"previews": true, "preview_max_edge": 1024, "originals_preserved": true},
|
"assets": response{"previews": true, "preview_max_edge": 1024, "originals_preserved": true},
|
||||||
"limits": response{"max_depth": 8, "max_nodes": 500, "max_edges": 2000, "max_frame_ports": 4000, "max_curve_knots": 64, "max_snapshot_bytes": canvasLimit, "max_active_tasks": 128, "max_run_steps": 64, "max_execution_depth": 32, "max_input_images": 4, "max_input_image_bytes": 20 << 20, "max_coordinate": 1000000},
|
"limits": response{"max_depth": 8, "max_nodes": 500, "max_edges": 2000, "max_frame_ports": 4000, "max_curve_knots": 64, "max_snapshot_bytes": canvasLimit, "max_active_tasks": 128, "max_run_steps": 64, "max_execution_depth": 32, "max_input_images": 4, "max_input_image_bytes": 20 << 20, "max_coordinate": 1000000},
|
||||||
|
|||||||
@@ -423,7 +423,8 @@ func TestOnboardingAndSharing(t *testing.T) {
|
|||||||
}
|
}
|
||||||
parentID := migrationBoard["id"].(string)
|
parentID := migrationBoard["id"].(string)
|
||||||
memberPath := migrationPath + "/subgraphs/f/members"
|
memberPath := migrationPath + "/subgraphs/f/members"
|
||||||
moveCommand := response{"source_board_id": membersSource, "target_board_id": membersTarget, "node_ids": []string{"a"}, "expected_versions": map[string]int64{parentID: 5, membersSource: 1, membersTarget: 1}}
|
moveCommand := response{"source_board_id": membersSource, "target_board_id": membersTarget, "node_ids": []string{"a"}, "positions": map[string]memberDropPosition{"a": {X: 40, Y: 50}}, "expected_versions": map[string]int64{parentID: 5, membersSource: 1, membersTarget: 1}}
|
||||||
|
call("POST", memberPath, tokens[0], response{"source_board_id": membersSource, "target_board_id": membersTarget, "node_ids": []string{"a"}, "positions": response{"wrong": response{"x": 1, "y": 2}}, "expected_versions": map[string]int64{parentID: 5, membersSource: 1, membersTarget: 1}}, 400, "", accept, "eeeeeeee-1111-4111-8111-111111111111")
|
||||||
movedMembers := call("POST", memberPath, tokens[0], moveCommand, 200, "", accept, "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb")
|
movedMembers := call("POST", memberPath, tokens[0], moveCommand, 200, "", accept, "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb")
|
||||||
memberRetry := call("POST", memberPath, tokens[0], moveCommand, 200, "", accept, "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb")
|
memberRetry := call("POST", memberPath, tokens[0], moveCommand, 200, "", accept, "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb")
|
||||||
if movedMembers["transaction_id"] != memberRetry["transaction_id"] {
|
if movedMembers["transaction_id"] != memberRetry["transaction_id"] {
|
||||||
@@ -435,6 +436,10 @@ func TestOnboardingAndSharing(t *testing.T) {
|
|||||||
targetRead := call("GET", targetCanvasPath, tokens[0], nil, 200, "", accept)
|
targetRead := call("GET", targetCanvasPath, tokens[0], nil, 200, "", accept)
|
||||||
sourceJSON, _ := json.Marshal(sourceRead["document"])
|
sourceJSON, _ := json.Marshal(sourceRead["document"])
|
||||||
targetJSON, _ := json.Marshal(targetRead["document"])
|
targetJSON, _ := json.Marshal(targetRead["document"])
|
||||||
|
dropPosition := canvasNodesByID(targetJSON)["a"]["position"].(map[string]any)
|
||||||
|
if dropPosition["x"] != float64(140) || dropPosition["y"] != float64(250) {
|
||||||
|
t.Fatalf("atomic drop geometry %v", dropPosition)
|
||||||
|
}
|
||||||
if canvasNodeData(sourceJSON)["a"] != nil || canvasNodeData(targetJSON)["a"] == nil {
|
if canvasNodeData(sourceJSON)["a"] != nil || canvasNodeData(targetJSON)["a"] == nil {
|
||||||
t.Fatal("member move duplicated or lost ownership")
|
t.Fatal("member move duplicated or lost ownership")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,73 @@
|
|||||||
|
package platform
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"math"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Positions are expressed in the source board's local coordinates. The existing
|
||||||
|
// ownership transaction converts them into destination coordinates exactly once.
|
||||||
|
type memberDropPosition struct {
|
||||||
|
X float64 `json:"x"`
|
||||||
|
Y float64 `json:"y"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func validMemberDropPositions(ids []string, positions map[string]memberDropPosition) bool {
|
||||||
|
if positions == nil {
|
||||||
|
return true
|
||||||
|
} // Backward-compatible old clients.
|
||||||
|
if len(positions) != len(ids) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
seen := map[string]bool{}
|
||||||
|
for _, id := range ids {
|
||||||
|
p, ok := positions[id]
|
||||||
|
if !ok || seen[id] || math.IsNaN(p.X) || math.IsNaN(p.Y) || math.IsInf(p.X, 0) || math.IsInf(p.Y, 0) || math.Abs(p.X) > 1e6 || math.Abs(p.Y) > 1e6 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
seen[id] = true
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
func positionMemberDrop(raw json.RawMessage, positions map[string]memberDropPosition) (json.RawMessage, error) {
|
||||||
|
var doc map[string]any
|
||||||
|
if err := json.Unmarshal(raw, &doc); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
found := 0
|
||||||
|
nodes, ok := doc["nodes"].([]any)
|
||||||
|
if !ok {
|
||||||
|
return nil, errGraphOwnership
|
||||||
|
}
|
||||||
|
for _, v := range nodes {
|
||||||
|
n, ok := v.(map[string]any)
|
||||||
|
if !ok {
|
||||||
|
return nil, errGraphOwnership
|
||||||
|
}
|
||||||
|
id, _ := n["id"].(string)
|
||||||
|
if p, ok := positions[id]; ok {
|
||||||
|
n["position"] = map[string]any{"x": p.X, "y": p.Y}
|
||||||
|
found++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if found != len(positions) {
|
||||||
|
return nil, errGraphOwnership
|
||||||
|
}
|
||||||
|
return json.Marshal(doc)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *memberDropPosition) UnmarshalJSON(raw []byte) error {
|
||||||
|
var fields map[string]json.RawMessage
|
||||||
|
if err := json.Unmarshal(raw, &fields); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if len(fields) != 2 || fields["x"] == nil || fields["y"] == nil || string(fields["x"]) == "null" || string(fields["y"]) == "null" {
|
||||||
|
return fmt.Errorf("invalid member position")
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(fields["x"], &p.X); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return json.Unmarshal(fields["y"], &p.Y)
|
||||||
|
}
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
package platform
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"math"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestMemberDropPositions(t *testing.T) {
|
||||||
|
raw := memberFixture(t)
|
||||||
|
source := "11111111-1111-4111-8111-111111111111"
|
||||||
|
target := "22222222-2222-4222-8222-222222222222"
|
||||||
|
original := string(raw[source])
|
||||||
|
positions := map[string]memberDropPosition{"a": {X: 40, Y: 50}}
|
||||||
|
positioned, err := positionMemberDrop(raw[source], positions)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
raw[source] = positioned
|
||||||
|
moved, err := moveMembersDocuments("parent", source, target, []string{"a"}, raw)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
pos := canvasNodesByID(moved[target])["a"]["position"].(map[string]any)
|
||||||
|
if pos["x"] != float64(140) || pos["y"] != float64(250) {
|
||||||
|
t.Fatalf("final drop position %v", pos)
|
||||||
|
}
|
||||||
|
if original == string(positioned) {
|
||||||
|
t.Fatal("position not applied")
|
||||||
|
}
|
||||||
|
if _, err := positionMemberDrop(positioned, map[string]memberDropPosition{"missing": {}}); err == nil {
|
||||||
|
t.Fatal("missing member accepted")
|
||||||
|
}
|
||||||
|
if !validMemberDropPositions([]string{"a"}, nil) || !validMemberDropPositions([]string{"a"}, positions) {
|
||||||
|
t.Fatal("valid positions rejected")
|
||||||
|
}
|
||||||
|
for _, bad := range []map[string]memberDropPosition{{}, {"other": {}}, {"a": {X: 1e6 + 1}}, {"a": {Y: math.NaN()}}, {"a": {X: math.Inf(1)}}} {
|
||||||
|
if validMemberDropPositions([]string{"a"}, bad) {
|
||||||
|
t.Fatalf("bad positions accepted %v", bad)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, bad := range []string{`{"x":1}`, `{"x":null,"y":1}`, `{"x":1,"y":2,"z":3}`} {
|
||||||
|
var p memberDropPosition
|
||||||
|
if json.Unmarshal([]byte(bad), &p) == nil {
|
||||||
|
t.Fatal("malformed position accepted")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -22,10 +22,11 @@ func (s *service) moveSubgraphMembers(w http.ResponseWriter, r *http.Request, pr
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
var input struct {
|
var input struct {
|
||||||
Source string `json:"source_board_id"`
|
Source string `json:"source_board_id"`
|
||||||
Target string `json:"target_board_id"`
|
Target string `json:"target_board_id"`
|
||||||
Nodes []string `json:"node_ids"`
|
Nodes []string `json:"node_ids"`
|
||||||
Expected map[string]int64 `json:"expected_versions"`
|
Expected map[string]int64 `json:"expected_versions"`
|
||||||
|
Positions map[string]memberDropPosition `json:"positions,omitempty"`
|
||||||
}
|
}
|
||||||
if !decode(w, r, &input) {
|
if !decode(w, r, &input) {
|
||||||
return nil
|
return nil
|
||||||
@@ -40,6 +41,10 @@ func (s *service) moveSubgraphMembers(w http.ResponseWriter, r *http.Request, pr
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if !validMemberDropPositions(input.Nodes, input.Positions) {
|
||||||
|
reject(w, 400, "invalid_subgraph_request")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
if len(input.Expected) == 0 {
|
if len(input.Expected) == 0 {
|
||||||
reject(w, 428, "version_required")
|
reject(w, 428, "version_required")
|
||||||
return nil
|
return nil
|
||||||
@@ -194,6 +199,14 @@ func (s *service) moveSubgraphMembers(w http.ResponseWriter, r *http.Request, pr
|
|||||||
before = append(before, 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}
|
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)
|
moved, err := moveMembersDocuments(parent, input.Source, input.Target, input.Nodes, raw)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if err == errGraphOwnership {
|
if err == errGraphOwnership {
|
||||||
|
|||||||
Reference in New Issue
Block a user