Add canonical canvas rows, stable identity migration and model operations
This commit is contained in:
@@ -0,0 +1,407 @@
|
||||
package platform
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"math"
|
||||
"reflect"
|
||||
"sort"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const canvasModelFormat = 1
|
||||
const canvasModelProtocol = 2
|
||||
|
||||
var errCanvasModel = errors.New("invalid_canvas_model")
|
||||
var errModelConflict = errors.New("canvas_model_field_conflict")
|
||||
|
||||
type modelParent struct {
|
||||
CanvasID string `json:"canvasId"`
|
||||
EntryElementID string `json:"entryElementId"`
|
||||
}
|
||||
type modelCanvas struct {
|
||||
ID string `json:"id"`
|
||||
Parent *modelParent `json:"parent"`
|
||||
}
|
||||
type canvasModel struct {
|
||||
Root string
|
||||
Canvases map[string]modelCanvas
|
||||
Elements map[string]map[string]any
|
||||
Tombstones map[string]int64
|
||||
Versions map[string]int64
|
||||
}
|
||||
type modelView struct {
|
||||
Format int `json:"documentFormatVersion"`
|
||||
ProjectID string `json:"projectId"`
|
||||
Canvases []modelCanvas `json:"canvases"`
|
||||
Elements []map[string]any `json:"elements"`
|
||||
Tombstones map[string]int64 `json:"tombstones"`
|
||||
Versions map[string]int64 `json:"versions"`
|
||||
Complete bool `json:"complete"`
|
||||
Removed []string `json:"removedCanvasIds"`
|
||||
}
|
||||
type modelTarget struct {
|
||||
ID string `json:"id"`
|
||||
Incarnation int64 `json:"incarnation"`
|
||||
}
|
||||
type modelChange struct {
|
||||
Kind string `json:"kind"`
|
||||
ID string `json:"id,omitempty"`
|
||||
Target modelTarget `json:"target,omitempty"`
|
||||
Field string `json:"field,omitempty"`
|
||||
Before json.RawMessage `json:"before"`
|
||||
After json.RawMessage `json:"after"`
|
||||
BeforePresent *bool `json:"beforePresent,omitempty"`
|
||||
AfterPresent *bool `json:"afterPresent,omitempty"`
|
||||
}
|
||||
type modelOperation struct {
|
||||
ID string `json:"id"`
|
||||
Changes []modelChange `json:"changes"`
|
||||
}
|
||||
type modelBatch struct {
|
||||
ProtocolVersion int `json:"protocol_version"`
|
||||
RequestID string `json:"request_id"`
|
||||
Expected map[string]int64 `json:"expected_versions"`
|
||||
Operations []modelOperation `json:"operations"`
|
||||
}
|
||||
|
||||
func emptyModel(root string) canvasModel {
|
||||
return canvasModel{Root: root, Canvases: map[string]modelCanvas{}, Elements: map[string]map[string]any{}, Tombstones: map[string]int64{}, Versions: map[string]int64{}}
|
||||
}
|
||||
func modelString(value any) string { text, _ := value.(string); return text }
|
||||
func modelInt(value any) int64 {
|
||||
number, ok := value.(float64)
|
||||
if !ok || number < 1 || number > 9007199254740991 || math.Trunc(number) != number {
|
||||
return 0
|
||||
}
|
||||
return int64(number)
|
||||
}
|
||||
func modelEqual(left, right any) bool {
|
||||
// Round-trip numeric Go structs and JSON records to the same JSON value domain.
|
||||
a, ea := json.Marshal(left)
|
||||
b, eb := json.Marshal(right)
|
||||
if ea != nil || eb != nil {
|
||||
return false
|
||||
}
|
||||
var av, bv any
|
||||
return json.Unmarshal(a, &av) == nil && json.Unmarshal(b, &bv) == nil && reflect.DeepEqual(av, bv)
|
||||
}
|
||||
func (model canvasModel) view(participants map[string]int64) modelView {
|
||||
view := modelView{Format: canvasModelFormat, ProjectID: model.Root, Canvases: []modelCanvas{}, Elements: []map[string]any{}, Tombstones: map[string]int64{}, Versions: map[string]int64{}, Complete: participants == nil, Removed: []string{}}
|
||||
for _, id := range sortedKeys(model.Canvases) {
|
||||
if participants != nil {
|
||||
if _, exists := participants[id]; !exists {
|
||||
continue
|
||||
}
|
||||
}
|
||||
view.Canvases = append(view.Canvases, model.Canvases[id])
|
||||
view.Versions[id] = model.Versions[id]
|
||||
}
|
||||
for _, id := range sortedKeys(model.Elements) {
|
||||
element := model.Elements[id]
|
||||
if participants != nil {
|
||||
if _, exists := participants[modelString(element["ownerCanvasId"])]; !exists {
|
||||
continue
|
||||
}
|
||||
}
|
||||
view.Elements = append(view.Elements, element)
|
||||
}
|
||||
// Tombstones are small identity facts, not payloads; include them for safe incarnation checks.
|
||||
for id, incarnation := range model.Tombstones {
|
||||
view.Tombstones[id] = incarnation
|
||||
}
|
||||
for _, id := range sortedKeys(model.Versions) {
|
||||
if participants != nil {
|
||||
if _, exists := participants[id]; !exists {
|
||||
continue
|
||||
}
|
||||
}
|
||||
view.Versions[id] = model.Versions[id]
|
||||
if _, exists := model.Canvases[id]; !exists {
|
||||
view.Removed = append(view.Removed, id)
|
||||
}
|
||||
}
|
||||
return view
|
||||
}
|
||||
func sortedKeys[T any](values map[string]T) []string {
|
||||
keys := make([]string, 0, len(values))
|
||||
for key := range values {
|
||||
keys = append(keys, key)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
return keys
|
||||
}
|
||||
func cloneModel(model canvasModel) canvasModel {
|
||||
raw, _ := json.Marshal(model)
|
||||
var copy canvasModel
|
||||
_ = json.Unmarshal(raw, ©)
|
||||
return copy
|
||||
}
|
||||
func modelAncestors(model canvasModel, id string) ([]string, error) {
|
||||
path, seen := []string{}, map[string]bool{}
|
||||
for id != "" {
|
||||
canvas, exists := model.Canvases[id]
|
||||
if !exists || seen[id] || len(path) > 8 {
|
||||
return nil, errCanvasModel
|
||||
}
|
||||
seen[id] = true
|
||||
path = append(path, id)
|
||||
id = ""
|
||||
if canvas.Parent != nil {
|
||||
id = canvas.Parent.CanvasID
|
||||
}
|
||||
}
|
||||
return path, nil
|
||||
}
|
||||
func modelCommonCanvas(model canvasModel, left, right string) (string, error) {
|
||||
a, err := modelAncestors(model, left)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
b, err := modelAncestors(model, right)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
seen := map[string]bool{}
|
||||
for _, id := range a {
|
||||
seen[id] = true
|
||||
}
|
||||
for _, id := range b {
|
||||
if seen[id] {
|
||||
return id, nil
|
||||
}
|
||||
}
|
||||
return "", errCanvasModel
|
||||
}
|
||||
func validateCanvasModel(model canvasModel) error {
|
||||
if !uuidSlugPattern.MatchString(model.Root) || len(model.Canvases) == 0 || len(model.Canvases) > 256 || len(model.Elements) > 128000 {
|
||||
return errCanvasModel
|
||||
}
|
||||
roots, entries, counts := 0, map[string]bool{}, map[string][2]int{}
|
||||
for id, canvas := range model.Canvases {
|
||||
if id != canvas.ID || !uuidSlugPattern.MatchString(id) {
|
||||
return errCanvasModel
|
||||
}
|
||||
if _, err := modelAncestors(model, id); err != nil {
|
||||
return err
|
||||
}
|
||||
if canvas.Parent == nil {
|
||||
roots++
|
||||
if id != model.Root {
|
||||
return errCanvasModel
|
||||
}
|
||||
continue
|
||||
}
|
||||
entry := model.Elements[canvas.Parent.EntryElementID]
|
||||
if entry == nil || entry["structure"] != "node" || entry["ownerCanvasId"] != canvas.Parent.CanvasID || entries[canvas.Parent.EntryElementID] {
|
||||
return errCanvasModel
|
||||
}
|
||||
entries[canvas.Parent.EntryElementID] = true
|
||||
}
|
||||
if roots != 1 {
|
||||
return errCanvasModel
|
||||
}
|
||||
for id, incarnation := range model.Tombstones {
|
||||
if !canvasID.MatchString(id) || incarnation < 1 || incarnation > 9007199254740991 {
|
||||
return errCanvasModel
|
||||
}
|
||||
}
|
||||
for id, element := range model.Elements {
|
||||
owner, component := modelString(element["ownerCanvasId"]), modelString(element["componentId"])
|
||||
_, canvasExists := model.Canvases[owner]
|
||||
incarnation, version := modelInt(element["incarnation"]), modelInt(element["dataVersion"])
|
||||
data, dataOK := element["data"].(map[string]any)
|
||||
if !canvasID.MatchString(id) || element["id"] != id || !canvasExists || incarnation == 0 || incarnation <= model.Tombstones[id] || version == 0 || component == "" || len(component) > 128 || !dataOK || data == nil {
|
||||
return errCanvasModel
|
||||
}
|
||||
encoded, err := json.Marshal(element)
|
||||
if err != nil || len(encoded) > 1<<20 {
|
||||
return errCanvasModel
|
||||
}
|
||||
count := counts[owner]
|
||||
switch element["structure"] {
|
||||
case "node":
|
||||
count[0]++
|
||||
geometry, ok := element["geometry"].(map[string]any)
|
||||
if !ok || !canvasNumber(geometry["x"], -1e6, 1e6) || !canvasNumber(geometry["y"], -1e6, 1e6) || !canvasNumber(geometry["width"], 0.000001, 1e6) || !canvasNumber(geometry["height"], 0.000001, 1e6) || !canvasNumber(geometry["rotation"], 0, math.Nextafter(360, 0)) {
|
||||
return errCanvasModel
|
||||
}
|
||||
if version == 1 && canvasChoice(component, "text", "image", "script", "plain-text", "plain-image", "icon", "frame") {
|
||||
if !canvasString(data["title"], 300) || !canvasString(data["text"], 100000) || component == "frame" && geometry["rotation"] != float64(0) {
|
||||
return errCanvasModel
|
||||
}
|
||||
if ai, exists := data["ai"]; exists && !validCanvasAI(ai, component) {
|
||||
return errCanvasModel
|
||||
}
|
||||
if asset, exists := data["assetId"]; exists && (!canvasChoice(component, "image", "plain-image") || !uuidSlugPattern.MatchString(modelString(asset))) {
|
||||
return errCanvasModel
|
||||
}
|
||||
}
|
||||
case "connection":
|
||||
count[1]++
|
||||
source, sourceOK := element["source"].(map[string]any)
|
||||
target, targetOK := element["target"].(map[string]any)
|
||||
if !sourceOK || !targetOK {
|
||||
return errCanvasModel
|
||||
}
|
||||
a, b := model.Elements[modelString(source["elementId"])], model.Elements[modelString(target["elementId"])]
|
||||
if a == nil || b == nil || a["structure"] != "node" || b["structure"] != "node" {
|
||||
return errCanvasModel
|
||||
}
|
||||
for _, endpoint := range []map[string]any{source, target} {
|
||||
if port, exists := endpoint["portId"]; exists && (!canvasString(port, 256) || modelString(port) == "") {
|
||||
return errCanvasModel
|
||||
}
|
||||
}
|
||||
common, err := modelCommonCanvas(model, modelString(a["ownerCanvasId"]), modelString(b["ownerCanvasId"]))
|
||||
if err != nil || common != owner {
|
||||
return errCanvasModel
|
||||
}
|
||||
default:
|
||||
return errCanvasModel
|
||||
}
|
||||
if count[0] > 500 || count[1] > 2000 {
|
||||
return errGraphCapacity
|
||||
}
|
||||
counts[owner] = count
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Pure transaction reducer. Unknown component data/envelope fields are retained.
|
||||
func applyModelOperations(initial canvasModel, operations []modelOperation) (canvasModel, map[string]bool, error) {
|
||||
model, touched, seen := cloneModel(initial), map[string]bool{}, map[string]bool{}
|
||||
if len(operations) == 0 || len(operations) > 2500 {
|
||||
return model, nil, errCanvasModel
|
||||
}
|
||||
changeCount := 0
|
||||
for _, operation := range operations {
|
||||
if !uuidSlugPattern.MatchString(operation.ID) || seen[operation.ID] || len(operation.Changes) == 0 {
|
||||
return model, nil, errCanvasModel
|
||||
}
|
||||
seen[operation.ID] = true
|
||||
for _, change := range operation.Changes {
|
||||
changeCount++
|
||||
if changeCount > 10000 {
|
||||
return model, nil, errGraphCapacity
|
||||
}
|
||||
var before, after any
|
||||
if json.Unmarshal(change.Before, &before) != nil || json.Unmarshal(change.After, &after) != nil {
|
||||
return model, nil, errCanvasModel
|
||||
}
|
||||
switch change.Kind {
|
||||
case "field":
|
||||
element := model.Elements[change.Target.ID]
|
||||
if element == nil || modelInt(element["incarnation"]) != change.Target.Incarnation {
|
||||
return model, nil, errModelConflict
|
||||
}
|
||||
touched[modelString(element["ownerCanvasId"])] = true
|
||||
if strings.HasPrefix(change.Field, "data.") {
|
||||
key := strings.TrimPrefix(change.Field, "data.")
|
||||
if key == "" || canvasChoice(key, "__proto__", "constructor", "prototype") {
|
||||
return model, nil, errCanvasModel
|
||||
}
|
||||
data := element["data"].(map[string]any)
|
||||
prior, present := data[key]
|
||||
if present != (change.BeforePresent == nil || *change.BeforePresent) || !modelEqual(prior, before) {
|
||||
return model, nil, errModelConflict
|
||||
}
|
||||
if change.AfterPresent != nil && !*change.AfterPresent {
|
||||
delete(data, key)
|
||||
} else {
|
||||
data[key] = after
|
||||
}
|
||||
} else {
|
||||
if !canvasChoice(change.Field, "geometry", "ownerCanvasId", "source", "target", "path") {
|
||||
return model, nil, errCanvasModel
|
||||
}
|
||||
if !modelEqual(element[change.Field], before) {
|
||||
return model, nil, errModelConflict
|
||||
}
|
||||
element[change.Field] = after
|
||||
if change.Field == "ownerCanvasId" {
|
||||
touched[modelString(after)] = true
|
||||
}
|
||||
}
|
||||
case "element":
|
||||
if !canvasID.MatchString(change.ID) || !modelEqual(model.Elements[change.ID], before) {
|
||||
return model, nil, errModelConflict
|
||||
}
|
||||
if prior := model.Elements[change.ID]; prior != nil {
|
||||
touched[modelString(prior["ownerCanvasId"])] = true
|
||||
}
|
||||
if after == nil {
|
||||
if prior := model.Elements[change.ID]; prior != nil {
|
||||
model.Tombstones[change.ID] = modelInt(prior["incarnation"])
|
||||
}
|
||||
delete(model.Elements, change.ID)
|
||||
} else {
|
||||
record, ok := after.(map[string]any)
|
||||
if !ok || record["id"] != change.ID {
|
||||
return model, nil, errCanvasModel
|
||||
}
|
||||
if before == nil && modelInt(record["incarnation"]) != model.Tombstones[change.ID]+1 {
|
||||
return model, nil, errModelConflict
|
||||
}
|
||||
if prior := model.Elements[change.ID]; prior != nil && modelInt(record["incarnation"]) != modelInt(prior["incarnation"]) {
|
||||
return model, nil, errModelConflict
|
||||
}
|
||||
model.Elements[change.ID] = record
|
||||
touched[modelString(record["ownerCanvasId"])] = true
|
||||
}
|
||||
case "canvas":
|
||||
prior, exists := model.Canvases[change.ID]
|
||||
var priorValue any
|
||||
if exists {
|
||||
priorValue = prior
|
||||
}
|
||||
if !uuidSlugPattern.MatchString(change.ID) || !modelEqual(priorValue, before) {
|
||||
return model, nil, errModelConflict
|
||||
}
|
||||
touched[change.ID] = true
|
||||
if prior.Parent != nil {
|
||||
touched[prior.Parent.CanvasID] = true
|
||||
}
|
||||
if after == nil {
|
||||
delete(model.Canvases, change.ID)
|
||||
} else {
|
||||
var canvas modelCanvas
|
||||
if json.Unmarshal(change.After, &canvas) != nil || canvas.ID != change.ID {
|
||||
return model, nil, errCanvasModel
|
||||
}
|
||||
model.Canvases[change.ID] = canvas
|
||||
if canvas.Parent != nil {
|
||||
touched[canvas.Parent.CanvasID] = true
|
||||
}
|
||||
}
|
||||
default:
|
||||
return model, nil, errCanvasModel
|
||||
}
|
||||
}
|
||||
}
|
||||
if err := validateCanvasModel(model); err != nil {
|
||||
return model, nil, err
|
||||
}
|
||||
// Endpoint owners are read participants, guarding against delete/restore races in child canvases.
|
||||
for _, operation := range operations {
|
||||
for _, change := range operation.Changes {
|
||||
if change.Kind == "element" && string(change.Before) == "null" || change.Kind == "field" && (change.Field == "source" || change.Field == "target") {
|
||||
id := change.ID
|
||||
if change.Kind == "field" {
|
||||
id = change.Target.ID
|
||||
}
|
||||
element := model.Elements[id]
|
||||
if element != nil && element["structure"] == "connection" {
|
||||
for _, field := range []string{"source", "target"} {
|
||||
endpoint := element[field].(map[string]any)
|
||||
touched[modelString(model.Elements[modelString(endpoint["elementId"])]["ownerCanvasId"])] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
for id := range touched {
|
||||
model.Versions[id] = initial.Versions[id] + 1
|
||||
}
|
||||
return model, touched, nil
|
||||
}
|
||||
Reference in New Issue
Block a user