Files
foundry-core/internal/platform/canvas_model_migration.go
codex-bot b85e8bd2ca
Foundry Go checks / check (push) Failing after 2m29s
Foundry Go checks / check (pull_request) Failing after 2m31s
Add canonical canvas rows, stable identity migration and model operations
2026-11-07 22:26:06 +08:00

283 lines
10 KiB
Go

package platform
import (
"encoding/json"
"fmt"
"strings"
)
type modelIdentity struct {
SourceCanvasID string `json:"sourceCanvasId"`
Structure string `json:"structure"`
SourceID string `json:"sourceId"`
ElementID string `json:"elementId"`
}
type modelMigration struct {
Model canvasModel
Identities []modelIdentity
NewCanvases []string
}
type modelSpace struct{ X, Y, Scale float64 }
// No source document is changed. IDs are allocated once inside the eventual
// migration transaction and the complete mapping is stored with its receipt.
func migrateCanvasModel(root string, snapshots map[string]canvasSnapshot, relations []subgraphRelation, allocate func() string) (modelMigration, error) {
result := modelMigration{Model: emptyModel(root), Identities: []modelIdentity{}, NewCanvases: []string{}}
documents, nodes := map[string]map[string]any{}, map[string]map[string]any{}
identities, children, synthetic := map[string]string{}, map[string]string{}, map[string]bool{}
used := map[string]bool{}
for board := range snapshots {
used[board] = true
}
nextID := func() (string, error) {
id := allocate()
if !uuidSlugPattern.MatchString(id) || used[id] {
return "", errCanvasModel
}
used[id] = true
return id, nil
}
identityKey := func(board, structure, id string) string { return board + "/" + structure + "/" + id }
for _, board := range sortedKeys(snapshots) {
if !uuidSlugPattern.MatchString(board) || snapshots[board].Version < 1 {
return result, errCanvasModel
}
normalized, valid := normalizeCanvas(snapshots[board].Document)
if !valid {
return result, errCanvasModel
}
var document map[string]any
if json.Unmarshal(normalized, &document) != nil {
return result, errCanvasModel
}
documents[board] = document
result.Model.Canvases[board] = modelCanvas{ID: board}
result.Model.Versions[board] = snapshots[board].Version + 1
for _, collection := range []string{"nodes", "edges"} {
structure := "node"
if collection == "edges" {
structure = "connection"
}
for _, value := range document[collection].([]any) {
record := value.(map[string]any)
old := record["id"].(string)
id, err := nextID()
if err != nil {
return result, err
}
identities[identityKey(board, structure, old)] = id
result.Identities = append(result.Identities, modelIdentity{SourceCanvasID: board, Structure: structure, SourceID: old, ElementID: id})
if structure == "node" {
nodes[canvasNodeKey(board, old)] = record
}
}
}
}
for _, relation := range relations {
if relation.Archived {
continue
}
key := canvasNodeKey(relation.Parent, relation.Frame)
frame := nodes[key]
if frame == nil || documents[relation.Child] == nil || children[key] != "" || relation.Child == root {
return result, errCanvasModel
}
data := frame["data"].(map[string]any)
sub, _ := data["subgraph"].(map[string]any)
if data["kind"] != "frame" || sub["boardId"] != relation.Child || result.Model.Canvases[relation.Child].Parent != nil {
return result, errCanvasModel
}
children[key] = relation.Child
result.Model.Canvases[relation.Child] = modelCanvas{ID: relation.Child, Parent: &modelParent{CanvasID: relation.Parent, EntryElementID: identities[identityKey(relation.Parent, "node", relation.Frame)]}}
}
// Ordinary legacy frameId groups also become actual canvases, even if empty.
for _, key := range sortedKeys(nodes) {
node := nodes[key]
data := node["data"].(map[string]any)
if data["kind"] != "frame" {
continue
}
if _, linked := data["subgraph"]; linked && children[key] == "" {
return result, errCanvasModel
}
if children[key] != "" {
continue
}
id, err := nextID()
if err != nil {
return result, err
}
board := strings.SplitN(key, "/", 2)[0]
children[key], synthetic[key] = id, true
result.Model.Canvases[id] = modelCanvas{ID: id, Parent: &modelParent{CanvasID: board, EntryElementID: identities[identityKey(board, "node", node["id"].(string))]}}
result.Model.Versions[id] = 1
result.NewCanvases = append(result.NewCanvases, id)
}
for _, board := range sortedKeys(documents) {
for index, value := range documents[board]["nodes"].([]any) {
node := value.(map[string]any)
original := node["data"].(map[string]any)
data := map[string]any{}
for key, value := range original {
data[key] = value
}
kind := data["kind"].(string)
owner := board
geometry := legacyModelGeometry(node, kind)
if frameID, exists := data["frameId"].(string); exists {
frame := nodes[canvasNodeKey(board, frameID)]
child := children[canvasNodeKey(board, frameID)]
if frame == nil || child == "" {
return result, errCanvasModel
}
position := frame["position"].(map[string]any)
frameData := frame["data"].(map[string]any)
scale := modelContentScale(frameData)
geometry["x"] = (geometry["x"].(float64) - position["x"].(float64) - 24) / scale
geometry["y"] = (geometry["y"].(float64) - position["y"].(float64) - 36) / scale
geometry["width"], geometry["height"] = geometry["width"].(float64)/scale, geometry["height"].(float64)/scale
owner = child
}
for _, key := range []string{"kind", "rotation", "frameId", "subgraph", "ports"} {
delete(data, key)
}
id := identities[identityKey(board, "node", node["id"].(string))]
result.Model.Elements[id] = map[string]any{"id": id, "incarnation": float64(1), "ownerCanvasId": owner, "componentId": kind, "dataVersion": float64(1), "structure": "node", "geometry": geometry, "data": data, "orderKey": fmt.Sprintf("%s/%012d", board, index)}
}
}
spaces, err := modelSpaces(result.Model)
if err != nil {
return result, err
}
resolve := func(board, node, handle, direction string) (map[string]any, error) {
seen := map[string]bool{}
for depth := 0; depth <= 8; depth++ {
key := canvasNodeKey(board, node)
legacy := nodes[key]
if legacy == nil || seen[key+"/"+handle] {
return nil, errCanvasModel
}
seen[key+"/"+handle] = true
data := legacy["data"].(map[string]any)
if data["kind"] != "frame" || !strings.HasPrefix(handle, "port:") {
endpoint := map[string]any{"elementId": identities[identityKey(board, "node", node)]}
if handle != "" {
endpoint["portId"] = handle
}
return endpoint, nil
}
ports, _ := data["ports"].([]any)
found := false
for _, value := range ports {
port := value.(map[string]any)
if port["id"] == strings.TrimPrefix(handle, "port:") && port["direction"] == direction {
node, handle = modelString(port["nodeId"]), modelString(port["handle"])
if !synthetic[key] {
board = children[key]
}
found = true
break
}
}
if !found {
return nil, errCanvasModel
}
}
return nil, errCanvasModel
}
for _, board := range sortedKeys(documents) {
for index, value := range documents[board]["edges"].([]any) {
edge := value.(map[string]any)
source, err := resolve(board, modelString(edge["source"]), modelString(edge["sourceHandle"]), "output")
if err != nil {
return result, err
}
target, err := resolve(board, modelString(edge["target"]), modelString(edge["targetHandle"]), "input")
if err != nil {
return result, err
}
owner, err := modelCommonCanvas(result.Model, modelString(result.Model.Elements[source["elementId"].(string)]["ownerCanvasId"]), modelString(result.Model.Elements[target["elementId"].(string)]["ownerCanvasId"]))
if err != nil {
return result, err
}
from, to := spaces[board], spaces[owner]
projectEdgePath(edge, from.Scale/to.Scale, (from.X-to.X)/to.Scale, (from.Y-to.Y)/to.Scale)
data, _ := edge["data"].(map[string]any)
if data == nil {
data = map[string]any{}
}
path := data["path"]
delete(data, "path")
id := identities[identityKey(board, "connection", edge["id"].(string))]
result.Model.Elements[id] = map[string]any{"id": id, "incarnation": float64(1), "ownerCanvasId": owner, "componentId": "connection", "dataVersion": float64(1), "structure": "connection", "source": source, "target": target, "path": path, "data": data, "orderKey": fmt.Sprintf("%s/%012d", board, index)}
}
}
if err := validateCanvasModel(result.Model); err != nil {
return result, err
}
return result, nil
}
func modelContentScale(data map[string]any) float64 {
presentation, _ := data["presentation"].(map[string]any)
if scale, ok := presentation["contentScale"].(float64); ok && scale > 0 {
return scale
}
return 1
}
func legacyModelGeometry(node map[string]any, kind string) map[string]any {
width, height := float64(260), float64(200)
switch kind {
case "plain-text":
width, height = 240, 72
case "plain-image":
width, height = 320, 240
case "icon":
width, height = 80, 80
case "frame":
width, height = 640, 400
}
if value, exists := node["width"].(float64); exists {
width = value
}
if value, exists := node["height"].(float64); exists {
height = value
}
position := node["position"].(map[string]any)
data := node["data"].(map[string]any)
rotation, _ := data["rotation"].(float64)
return map[string]any{"x": position["x"], "y": position["y"], "width": width, "height": height, "rotation": rotation}
}
func modelSpaces(model canvasModel) (map[string]modelSpace, error) {
result := map[string]modelSpace{model.Root: {Scale: 1}}
remaining := len(model.Canvases) - 1
for depth := 0; remaining > 0 && depth < 9; depth++ {
progress := false
for _, id := range sortedKeys(model.Canvases) {
canvas := model.Canvases[id]
if _, exists := result[id]; exists || canvas.Parent == nil {
continue
}
parent, exists := result[canvas.Parent.CanvasID]
if !exists {
continue
}
entry := model.Elements[canvas.Parent.EntryElementID]
if entry == nil {
return nil, errCanvasModel
}
geometry := entry["geometry"].(map[string]any)
data := entry["data"].(map[string]any)
result[id] = modelSpace{X: parent.X + (geometry["x"].(float64)+24)*parent.Scale, Y: parent.Y + (geometry["y"].(float64)+36)*parent.Scale, Scale: parent.Scale * modelContentScale(data)}
remaining--
progress = true
}
if !progress {
return nil, errCanvasModel
}
}
if remaining != 0 {
return nil, errCanvasModel
}
return result, nil
}