148 lines
4.9 KiB
Go
148 lines
4.9 KiB
Go
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
|
|
}
|