package platform import ( "encoding/json" "errors" "fmt" ) const canvasOperationsProtocol = 1 type canvasOperation struct { ID string `json:"id"` Type string `json:"type"` CanvasID string `json:"canvas_id"` ElementID string `json:"element_id"` Record json.RawMessage `json:"record,omitempty"` Patch map[string]json.RawMessage `json:"patch,omitempty"` } type canvasOperationBatch struct { ProtocolVersion int `json:"protocol_version"` RequestID string `json:"request_id"` ExpectedVersions map[string]int64 `json:"expected_versions"` Operations []canvasOperation `json:"operations"` } var errInvalidCanvasOperation = errors.New("invalid_canvas_operation") // applyCanvasOperations stages a complete batch. Input snapshots are never mutated. // HTTP/auth/version/idempotency are checked by the transaction coordinator. func applyCanvasOperations(initial map[string]canvasSnapshot, operations []canvasOperation) (map[string]canvasSnapshot, error) { if len(operations) == 0 || len(operations) > 2500 { return nil, errInvalidCanvasOperation } documents := make(map[string]map[string]json.RawMessage, len(initial)) for id, snapshot := range initial { var document map[string]json.RawMessage if json.Unmarshal(snapshot.Document, &document) != nil { return nil, errInvalidCanvasOperation } documents[id] = document } seen := map[string]bool{} for _, operation := range operations { if !uuidSlugPattern.MatchString(operation.ID) || seen[operation.ID] || !canvasID.MatchString(operation.ElementID) { return nil, errInvalidCanvasOperation } seen[operation.ID] = true document := documents[operation.CanvasID] if document == nil { return nil, errInvalidCanvasOperation } collection := "" switch operation.Type { case "node.create", "node.patch", "node.delete": collection = "nodes" case "edge.create", "edge.patch", "edge.delete": collection = "edges" default: return nil, errInvalidCanvasOperation } var records []map[string]json.RawMessage if json.Unmarshal(document[collection], &records) != nil { return nil, errInvalidCanvasOperation } index := -1 for i, record := range records { var id string _ = json.Unmarshal(record["id"], &id) if id == operation.ElementID { index = i break } } switch operation.Type { case "node.create", "edge.create": if index >= 0 || len(operation.Record) == 0 || len(operation.Patch) != 0 { return nil, fmt.Errorf("%w: create conflict", errInvalidCanvasOperation) } var record map[string]json.RawMessage var id string if json.Unmarshal(operation.Record, &record) != nil || json.Unmarshal(record["id"], &id) != nil || id != operation.ElementID { return nil, errInvalidCanvasOperation } records = append(records, record) case "node.delete", "edge.delete": if index < 0 || len(operation.Record) != 0 || len(operation.Patch) != 0 { return nil, fmt.Errorf("%w: missing delete target", errInvalidCanvasOperation) } records = append(records[:index], records[index+1:]...) case "node.patch", "edge.patch": if index < 0 || len(operation.Record) != 0 || len(operation.Patch) == 0 { return nil, errInvalidCanvasOperation } for field, value := range operation.Patch { allowed := field == "data" || collection == "nodes" && (field == "position" || field == "width" || field == "height") || collection == "edges" && (field == "source" || field == "target" || field == "sourceHandle" || field == "targetHandle") if !allowed { return nil, errInvalidCanvasOperation } if field == "data" { // Merge individual data fields; a geometry-only patch never rolls back titles/assets. var before, patch map[string]json.RawMessage if json.Unmarshal(records[index][field], &before) != nil || json.Unmarshal(value, &patch) != nil || patch == nil { return nil, errInvalidCanvasOperation } if before == nil { before = map[string]json.RawMessage{} } for key, item := range patch { if string(item) == "null" { delete(before, key) } else { before[key] = item } } encoded, err := json.Marshal(before) if err != nil { return nil, err } records[index][field] = encoded } else { records[index][field] = append(json.RawMessage(nil), value...) } } } encoded, err := json.Marshal(records) if err != nil { return nil, err } document[collection] = encoded } result := make(map[string]canvasSnapshot, len(initial)) for id, document := range documents { raw, err := json.Marshal(document) if err != nil { return nil, err } normalized, valid := normalizeCanvas(raw) if !valid { return nil, errInvalidCanvasOperation } snapshot := initial[id] snapshot.Document = normalized snapshot.Version++ result[id] = snapshot } return result, nil }