Files
codex-bot 19884b2a90
Foundry Go checks / check (push) Successful in 2m40s
Foundry Go checks / check (pull_request) Successful in 2m39s
Stage atomic canvas operation batches and durable receipts
2026-11-07 22:05:06 +08:00

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
}