Add canonical canvas rows, stable identity migration and model operations
This commit is contained in:
@@ -0,0 +1,103 @@
|
||||
BEGIN;
|
||||
-- Canonical rows are separate from the legacy snapshot. Adoption is an explicit,
|
||||
-- version-checked transaction; installing this schema does not migrate user data.
|
||||
CREATE TABLE canvas_model_projects (
|
||||
project text NOT NULL,
|
||||
workspace_id uuid NOT NULL,
|
||||
root_id uuid NOT NULL,
|
||||
document_format_version integer NOT NULL DEFAULT 1 CHECK(document_format_version=1),
|
||||
migration_id uuid NOT NULL,
|
||||
source_versions jsonb NOT NULL,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
PRIMARY KEY(project,workspace_id,root_id),
|
||||
FOREIGN KEY(project,workspace_id,root_id) REFERENCES materials ON DELETE CASCADE
|
||||
);
|
||||
CREATE TABLE canvas_model_canvases (
|
||||
project text NOT NULL,
|
||||
workspace_id uuid NOT NULL,
|
||||
root_id uuid NOT NULL,
|
||||
canvas_id uuid NOT NULL,
|
||||
parent_canvas_id uuid,
|
||||
entry_element_id text,
|
||||
version bigint NOT NULL CHECK(version>0),
|
||||
archived boolean NOT NULL DEFAULT false,
|
||||
PRIMARY KEY(project,workspace_id,canvas_id),
|
||||
UNIQUE(project,canvas_id),
|
||||
UNIQUE(project,workspace_id,root_id,canvas_id),
|
||||
CHECK((parent_canvas_id IS NULL)=(entry_element_id IS NULL)),
|
||||
CHECK(parent_canvas_id IS NOT NULL OR canvas_id=root_id),
|
||||
FOREIGN KEY(project,workspace_id,root_id) REFERENCES canvas_model_projects ON DELETE CASCADE,
|
||||
FOREIGN KEY(project,workspace_id,canvas_id) REFERENCES materials ON DELETE CASCADE,
|
||||
FOREIGN KEY(project,workspace_id,root_id,parent_canvas_id) REFERENCES canvas_model_canvases(project,workspace_id,root_id,canvas_id) DEFERRABLE INITIALLY DEFERRED
|
||||
);
|
||||
CREATE UNIQUE INDEX canvas_model_active_entry ON canvas_model_canvases(project,workspace_id,entry_element_id) WHERE NOT archived;
|
||||
CREATE TABLE canvas_model_elements (
|
||||
project text NOT NULL,
|
||||
workspace_id uuid NOT NULL,
|
||||
root_id uuid NOT NULL,
|
||||
element_id text NOT NULL CHECK(element_id ~ '^[A-Za-z0-9_-]{1,128}$'),
|
||||
owner_canvas_id uuid NOT NULL,
|
||||
incarnation bigint NOT NULL CHECK(incarnation>0 AND incarnation<=9007199254740991),
|
||||
deleted_incarnation bigint NOT NULL DEFAULT 0 CHECK(deleted_incarnation>=0 AND deleted_incarnation<=incarnation),
|
||||
record jsonb NOT NULL,
|
||||
deleted boolean NOT NULL DEFAULT false,
|
||||
PRIMARY KEY(project,element_id),
|
||||
UNIQUE(project,workspace_id,element_id),
|
||||
CHECK(jsonb_typeof(record)='object' AND record->>'id'=element_id AND record->>'ownerCanvasId'=owner_canvas_id::text AND (record->>'incarnation')::bigint=incarnation),
|
||||
CHECK(NOT deleted OR deleted_incarnation=incarnation),
|
||||
FOREIGN KEY(project,workspace_id,root_id) REFERENCES canvas_model_projects ON DELETE CASCADE,
|
||||
FOREIGN KEY(project,workspace_id,root_id,owner_canvas_id) REFERENCES canvas_model_canvases(project,workspace_id,root_id,canvas_id) DEFERRABLE INITIALLY DEFERRED
|
||||
);
|
||||
CREATE INDEX canvas_model_owner ON canvas_model_elements(project,workspace_id,root_id,owner_canvas_id) WHERE NOT deleted;
|
||||
ALTER TABLE canvas_model_canvases ADD CONSTRAINT canvas_model_entry_fk FOREIGN KEY(project,workspace_id,entry_element_id) REFERENCES canvas_model_elements(project,workspace_id,element_id) DEFERRABLE INITIALLY DEFERRED;
|
||||
CREATE TABLE canvas_model_identity_map (
|
||||
project text NOT NULL,
|
||||
workspace_id uuid NOT NULL,
|
||||
root_id uuid NOT NULL,
|
||||
source_canvas_id uuid NOT NULL,
|
||||
source_structure text NOT NULL CHECK(source_structure IN('node','connection')),
|
||||
source_element_id text NOT NULL,
|
||||
element_id text NOT NULL,
|
||||
PRIMARY KEY(project,workspace_id,root_id,source_canvas_id,source_structure,source_element_id),
|
||||
UNIQUE(project,element_id),
|
||||
FOREIGN KEY(project,workspace_id,root_id) REFERENCES canvas_model_projects ON DELETE CASCADE,
|
||||
FOREIGN KEY(project,workspace_id,element_id) REFERENCES canvas_model_elements(project,workspace_id,element_id) DEFERRABLE INITIALLY DEFERRED
|
||||
);
|
||||
-- Logical operations cannot acquire a second successful execution under a new request key.
|
||||
CREATE TABLE canvas_model_requests (
|
||||
project text NOT NULL,
|
||||
workspace_id uuid NOT NULL,
|
||||
account_id uuid NOT NULL,
|
||||
request_id uuid NOT NULL,
|
||||
root_id uuid NOT NULL,
|
||||
action text NOT NULL,
|
||||
protocol_version integer NOT NULL,
|
||||
payload_hash text NOT NULL,
|
||||
response_status integer NOT NULL,
|
||||
response_body jsonb NOT NULL,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
PRIMARY KEY(project,workspace_id,account_id,request_id),
|
||||
FOREIGN KEY(project,workspace_id,root_id) REFERENCES materials ON DELETE RESTRICT,
|
||||
FOREIGN KEY(project,account_id) REFERENCES project_accounts
|
||||
);
|
||||
CREATE TABLE canvas_model_operations (
|
||||
project text NOT NULL,
|
||||
workspace_id uuid NOT NULL,
|
||||
operation_id uuid NOT NULL,
|
||||
request_id uuid NOT NULL,
|
||||
account_id uuid NOT NULL,
|
||||
PRIMARY KEY(project,workspace_id,operation_id),
|
||||
FOREIGN KEY(project,workspace_id,account_id,request_id) REFERENCES canvas_model_requests DEFERRABLE INITIALLY DEFERRED,
|
||||
FOREIGN KEY(project,account_id) REFERENCES project_accounts
|
||||
);
|
||||
CREATE FUNCTION foundry_guard_canonical_canvas() RETURNS trigger LANGUAGE plpgsql AS $$
|
||||
BEGIN
|
||||
IF (NEW.canvas_document IS DISTINCT FROM OLD.canvas_document OR NEW.canvas_version IS DISTINCT FROM OLD.canvas_version)
|
||||
AND EXISTS(SELECT 1 FROM canvas_model_canvases c WHERE c.project=NEW.project AND c.workspace_id=NEW.workspace_id AND c.canvas_id=NEW.id)
|
||||
AND current_setting('foundry.canonical_writer',true) IS DISTINCT FROM 'on' THEN
|
||||
RAISE EXCEPTION 'canonical_write_required' USING ERRCODE='55000';
|
||||
END IF;
|
||||
RETURN NEW;
|
||||
END $$;
|
||||
CREATE TRIGGER canonical_canvas_write_barrier BEFORE UPDATE OF canvas_document,canvas_version ON materials FOR EACH ROW EXECUTE FUNCTION foundry_guard_canonical_canvas();
|
||||
COMMIT;
|
||||
@@ -44,7 +44,8 @@ func (s *service) canvasCapabilities(w http.ResponseWriter, r *http.Request, pro
|
||||
ai["unavailable_reason"] = "codex_unavailable"
|
||||
}
|
||||
reply(w, 200, response{
|
||||
"contract_version": "canvas-v2-2026-11-06",
|
||||
"contract_version": "canvas-headless-2026-11-07",
|
||||
"canonical_model": response{"document_format_version": canvasModelFormat, "protocol_version": canvasModelProtocol, "explicit_migration": true, "stable_element_ids": true, "atomic_multi_canvas": true, "logical_operation_dedup": true, "legacy_snapshot_writes": false},
|
||||
"supported_schema_versions": []int{1, 2},
|
||||
"edge_features": response{"appearance": true, "markers": true, "bezier_chain": true, "animation_configuration": true, "execution_events": true},
|
||||
"subgraphs": response{"create": true, "migrate": true, "undo": true, "members": true, "dissolve": true, "connections": true, "member_positions": true},
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
package platform
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Old frozen receipts remain readable. Old whole-snapshot/ownership writers may
|
||||
// not edit a migrated tree, even when their old client has not seen the barrier.
|
||||
func (s *service) guardLegacyCanonicalRequest(w http.ResponseWriter, r *http.Request, project, account string) (bool, error) {
|
||||
if r.PathValue("material") == "" {
|
||||
return false, nil
|
||||
}
|
||||
path := r.URL.Path
|
||||
legacy := strings.HasSuffix(path, "/canvas")
|
||||
if r.Method != http.MethodGet {
|
||||
for _, part := range []string{"/canvas/subgraphs", "/canvas/connections", "/canvas/transactions", "/canvas/operations"} {
|
||||
legacy = legacy || strings.Contains(path, part)
|
||||
}
|
||||
}
|
||||
if !legacy {
|
||||
return false, nil
|
||||
}
|
||||
tx, err := s.db.BeginTx(r.Context(), nil)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
if _, _, err = lockMaterial(r.Context(), tx, project, r.PathValue("workspace"), r.PathValue("material"), account); err != nil {
|
||||
return false, err
|
||||
}
|
||||
root, err := canonicalRoot(r.Context(), tx, project, r.PathValue("workspace"), r.PathValue("material"))
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
if root == "" {
|
||||
return false, nil
|
||||
}
|
||||
reply(w, 409, response{"error": "canonical_write_required", "root_id": root, "model_path": "canvas/model"})
|
||||
return true, nil
|
||||
}
|
||||
@@ -0,0 +1,479 @@
|
||||
package platform
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"database/sql"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
const modelMigrateAction = "model.migrate.v1"
|
||||
const modelEditAction = "model.operations.v2"
|
||||
|
||||
func newModelID() string {
|
||||
var value [16]byte
|
||||
if _, err := rand.Read(value[:]); err != nil {
|
||||
return ""
|
||||
}
|
||||
value[6], value[8] = value[6]&0x0f|0x40, value[8]&0x3f|0x80
|
||||
text := hex.EncodeToString(value[:])
|
||||
return text[:8] + "-" + text[8:12] + "-" + text[12:16] + "-" + text[16:20] + "-" + text[20:]
|
||||
}
|
||||
func legacyModelRoot(r *http.Request, tx *sql.Tx, project, workspace, board string) (string, error) {
|
||||
seen := map[string]bool{}
|
||||
for len(seen) <= 8 {
|
||||
if seen[board] {
|
||||
return "", errCanvasModel
|
||||
}
|
||||
seen[board] = true
|
||||
relation, err := readSubgraphRelation(r, tx, project, workspace, board)
|
||||
if err == sql.ErrNoRows {
|
||||
return board, nil
|
||||
}
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if relation.Archived {
|
||||
return "", sql.ErrNoRows
|
||||
}
|
||||
board = relation.Parent
|
||||
}
|
||||
return "", errCanvasModel
|
||||
}
|
||||
func collectLegacyModel(r *http.Request, tx *sql.Tx, project, workspace, root string) (map[string]canvasSnapshot, []subgraphRelation, error) {
|
||||
rows, err := tx.QueryContext(r.Context(), `WITH RECURSIVE tree(id,path) AS (SELECT $3::uuid,ARRAY[$3::uuid] UNION ALL SELECT s.child_board_id,t.path||s.child_board_id FROM tree t JOIN canvas_subgraphs s ON s.project=$1 AND s.workspace_id=$2 AND s.parent_board_id=t.id WHERE s.archived_at IS NULL AND NOT s.child_board_id=ANY(t.path) AND cardinality(t.path)<=8) SELECT m.id::text,m.canvas_version,COALESCE(m.canvas_document,'{"schema_version":2,"nodes":[],"edges":[],"viewport":{"x":0,"y":0,"zoom":1}}'::jsonb) FROM tree t JOIN materials m ON m.project=$1 AND m.workspace_id=$2 AND m.id=t.id WHERE m.kind='board' ORDER BY m.id LIMIT 257`, project, workspace, root)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
snapshots := map[string]canvasSnapshot{}
|
||||
for rows.Next() {
|
||||
var snapshot canvasSnapshot
|
||||
if err = rows.Scan(&snapshot.BoardID, &snapshot.Version, &snapshot.Document); err != nil {
|
||||
rows.Close()
|
||||
return nil, nil, err
|
||||
}
|
||||
snapshots[snapshot.BoardID] = snapshot
|
||||
}
|
||||
err = rows.Err()
|
||||
rows.Close()
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
if len(snapshots) == 0 || len(snapshots) > 256 {
|
||||
return nil, nil, errGraphCapacity
|
||||
}
|
||||
rows, err = tx.QueryContext(r.Context(), `SELECT parent_board_id::text,frame_id,child_board_id::text FROM canvas_subgraphs WHERE project=$1 AND workspace_id=$2 AND archived_at IS NULL ORDER BY child_board_id`, project, workspace)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
relations := []subgraphRelation{}
|
||||
for rows.Next() {
|
||||
var relation subgraphRelation
|
||||
if err = rows.Scan(&relation.Parent, &relation.Frame, &relation.Child); err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
if _, exists := snapshots[relation.Parent]; exists {
|
||||
relations = append(relations, relation)
|
||||
}
|
||||
}
|
||||
return snapshots, relations, rows.Err()
|
||||
}
|
||||
func modelRequestRetry(w http.ResponseWriter, r *http.Request, tx *sql.Tx, project, workspace, account, root, action, key, hash string) (bool, error) {
|
||||
var previousRoot, previousAction, previousHash string
|
||||
var status int
|
||||
var body json.RawMessage
|
||||
err := tx.QueryRowContext(r.Context(), `SELECT root_id::text,action,payload_hash,response_status,response_body FROM canvas_model_requests WHERE project=$1 AND workspace_id=$2 AND account_id=$3 AND request_id=$4`, project, workspace, account, key).Scan(&previousRoot, &previousAction, &previousHash, &status, &body)
|
||||
if err == sql.ErrNoRows {
|
||||
return false, nil
|
||||
}
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
if previousRoot != root || previousAction != action || previousHash != hash {
|
||||
reject(w, 409, "idempotency_conflict")
|
||||
return true, nil
|
||||
}
|
||||
reply(w, status, body)
|
||||
return true, nil
|
||||
}
|
||||
func commitModelReceipt(w http.ResponseWriter, r *http.Request, tx *sql.Tx, project, workspace, account, root, action, key, hash string, status int, body response) error {
|
||||
body["request_id"], body["protocol_version"] = key, canvasModelProtocol
|
||||
raw, err := json.Marshal(body)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = tx.ExecContext(r.Context(), `INSERT INTO canvas_model_requests(project,workspace_id,account_id,request_id,root_id,action,protocol_version,payload_hash,response_status,response_body) VALUES($1,$2,$3,$4,$5,$6,$7,$8,$9,$10)`, project, workspace, account, key, root, action, canvasModelProtocol, hash, status, raw)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err = tx.Commit(); err != nil {
|
||||
return err
|
||||
}
|
||||
reply(w, status, body)
|
||||
return nil
|
||||
}
|
||||
func decodeModelRequest(w http.ResponseWriter, r *http.Request, input any) bool {
|
||||
decoder := json.NewDecoder(http.MaxBytesReader(w, r.Body, canvasLimit))
|
||||
decoder.DisallowUnknownFields()
|
||||
if decoder.Decode(input) != nil || decoder.Decode(new(any)) != io.EOF {
|
||||
reject(w, 400, "invalid_model_request")
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func (s *service) getCanvasModel(w http.ResponseWriter, r *http.Request, project, account string) error {
|
||||
if r.Method != http.MethodGet {
|
||||
reject(w, 405, "method_not_allowed")
|
||||
return nil
|
||||
}
|
||||
tx, err := s.db.BeginTx(r.Context(), nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
workspace, board := r.PathValue("workspace"), r.PathValue("material")
|
||||
kind, _, err := lockMaterial(r.Context(), tx, project, workspace, board, account)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if kind != "board" {
|
||||
reject(w, 400, "invalid_material")
|
||||
return nil
|
||||
}
|
||||
root, err := canonicalRoot(r.Context(), tx, project, workspace, board)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if root == "" {
|
||||
root, err = legacyModelRoot(r, tx, project, workspace, board)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
snapshots, _, err := collectLegacyModel(r, tx, project, workspace, root)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
versions := map[string]int64{}
|
||||
for id, snapshot := range snapshots {
|
||||
versions[id] = snapshot.Version
|
||||
}
|
||||
reply(w, 409, response{"error": "canonical_migration_required", "root_id": root, "source_versions": versions, "document_format_version": canvasModelFormat})
|
||||
return nil
|
||||
}
|
||||
model, err := loadCanvasModel(r.Context(), tx, project, workspace, root)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err = tx.Rollback(); err != nil {
|
||||
return err
|
||||
}
|
||||
reply(w, 200, response{"model": model.view(nil)})
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *service) migrateCanonicalCanvas(w http.ResponseWriter, r *http.Request, project, account string) error {
|
||||
if r.Method != http.MethodPost {
|
||||
reject(w, 405, "method_not_allowed")
|
||||
return nil
|
||||
}
|
||||
var input struct {
|
||||
ProtocolVersion int `json:"protocol_version"`
|
||||
RequestID string `json:"request_id"`
|
||||
Expected map[string]int64 `json:"expected_versions"`
|
||||
}
|
||||
if !decodeModelRequest(w, r, &input) {
|
||||
return nil
|
||||
}
|
||||
key := canvasIdempotencyKey(r)
|
||||
if input.ProtocolVersion != canvasModelProtocol || !uuidSlugPattern.MatchString(key) || input.RequestID != key || len(input.Expected) == 0 || len(input.Expected) > 256 {
|
||||
reject(w, 400, "invalid_model_migration")
|
||||
return nil
|
||||
}
|
||||
tx, err := s.db.BeginTx(r.Context(), nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
workspace, board := r.PathValue("workspace"), r.PathValue("material")
|
||||
kind, role, err := lockMaterial(r.Context(), tx, project, workspace, board, account)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if kind != "board" {
|
||||
reject(w, 400, "invalid_material")
|
||||
return nil
|
||||
}
|
||||
if role == "viewer" {
|
||||
reject(w, 403, "forbidden")
|
||||
return nil
|
||||
}
|
||||
root, err := canonicalRoot(r.Context(), tx, project, workspace, board)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
already := root != ""
|
||||
if !already {
|
||||
root, err = legacyModelRoot(r, tx, project, workspace, board)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
done, err := modelRequestRetry(w, r, tx, project, workspace, account, root, modelMigrateAction, key, payloadDigest(input))
|
||||
if err != nil || done {
|
||||
return err
|
||||
}
|
||||
rejectMigration := func(status int, code string) error {
|
||||
return commitModelReceipt(w, r, tx, project, workspace, account, root, modelMigrateAction, key, payloadDigest(input), status, response{"status": "not-applied", "terminal": true, "error": code})
|
||||
}
|
||||
if already {
|
||||
return rejectMigration(409, "canonical_migration_already_applied")
|
||||
}
|
||||
snapshots, relations, err := collectLegacyModel(r, tx, project, workspace, root)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(snapshots) != len(input.Expected) {
|
||||
return rejectMigration(412, "migration_version_conflict")
|
||||
}
|
||||
for id, snapshot := range snapshots {
|
||||
if input.Expected[id] != snapshot.Version {
|
||||
return rejectMigration(412, "migration_version_conflict")
|
||||
}
|
||||
}
|
||||
// Active or outcome-unknown paid work must settle before the identity barrier.
|
||||
for id := range snapshots {
|
||||
var busy bool
|
||||
if err = tx.QueryRowContext(r.Context(), `SELECT EXISTS(SELECT 1 FROM canvas_ai_steps WHERE project=$1 AND workspace_id=$2 AND board_id=$3 AND status IN('queued','running'))`, project, workspace, id).Scan(&busy); err != nil {
|
||||
return err
|
||||
}
|
||||
if busy {
|
||||
return rejectMigration(409, "migration_waiting_for_runs")
|
||||
}
|
||||
}
|
||||
migration, err := migrateCanvasModel(root, snapshots, relations, newModelID)
|
||||
if err != nil {
|
||||
return rejectMigration(422, "model_migration_failed")
|
||||
}
|
||||
versionsRaw, _ := json.Marshal(input.Expected)
|
||||
_, err = tx.ExecContext(r.Context(), `INSERT INTO canvas_model_projects(project,workspace_id,root_id,migration_id,source_versions) VALUES($1,$2,$3,$4,$5)`, project, workspace, root, key, versionsRaw)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
before := emptyModel(root)
|
||||
for id, snapshot := range snapshots {
|
||||
before.Versions[id] = snapshot.Version
|
||||
}
|
||||
participants := map[string]bool{}
|
||||
for id := range migration.Model.Canvases {
|
||||
participants[id] = true
|
||||
}
|
||||
if err = s.saveCanvasModel(r, tx, project, workspace, account, before, migration.Model, participants); err != nil {
|
||||
return err
|
||||
}
|
||||
for _, identity := range migration.Identities {
|
||||
_, err = tx.ExecContext(r.Context(), `INSERT INTO canvas_model_identity_map(project,workspace_id,root_id,source_canvas_id,source_structure,source_element_id,element_id) VALUES($1,$2,$3,$4,$5,$6,$7)`, project, workspace, root, identity.SourceCanvasID, identity.Structure, identity.SourceID, identity.ElementID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return commitModelReceipt(w, r, tx, project, workspace, account, root, modelMigrateAction, key, payloadDigest(input), 200, response{"status": "applied", "model": migration.Model.view(nil), "identity_map": migration.Identities, "source_versions": input.Expected})
|
||||
}
|
||||
|
||||
func (s *service) submitCanvasModelOperations(w http.ResponseWriter, r *http.Request, project, account string) error {
|
||||
if r.Method != http.MethodPost {
|
||||
reject(w, 405, "method_not_allowed")
|
||||
return nil
|
||||
}
|
||||
var input modelBatch
|
||||
if !decodeModelRequest(w, r, &input) {
|
||||
return nil
|
||||
}
|
||||
key := canvasIdempotencyKey(r)
|
||||
if input.ProtocolVersion != canvasModelProtocol || !uuidSlugPattern.MatchString(key) || input.RequestID != key || len(input.Expected) == 0 || len(input.Expected) > 256 || len(input.Operations) == 0 || len(input.Operations) > 2500 {
|
||||
reject(w, 400, "invalid_model_request")
|
||||
return nil
|
||||
}
|
||||
for id, version := range input.Expected {
|
||||
if !uuidSlugPattern.MatchString(id) || version < 0 || version > 9007199254740990 {
|
||||
reject(w, 400, "invalid_model_version")
|
||||
return nil
|
||||
}
|
||||
}
|
||||
tx, err := s.db.BeginTx(r.Context(), nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
workspace, board := r.PathValue("workspace"), r.PathValue("material")
|
||||
kind, role, err := lockMaterial(r.Context(), tx, project, workspace, board, account)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if kind != "board" {
|
||||
reject(w, 400, "invalid_material")
|
||||
return nil
|
||||
}
|
||||
if role == "viewer" {
|
||||
reject(w, 403, "forbidden")
|
||||
return nil
|
||||
}
|
||||
root, err := canonicalRoot(r.Context(), tx, project, workspace, board)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if root == "" {
|
||||
reject(w, 409, "canonical_migration_required")
|
||||
return nil
|
||||
}
|
||||
done, err := modelRequestRetry(w, r, tx, project, workspace, account, root, modelEditAction, key, payloadDigest(input))
|
||||
if err != nil || done {
|
||||
return err
|
||||
}
|
||||
rejectBatch := func(status int, code string) error {
|
||||
return commitModelReceipt(w, r, tx, project, workspace, account, root, modelEditAction, key, payloadDigest(input), status, response{"status": "not-applied", "terminal": true, "error": code})
|
||||
}
|
||||
before, err := loadCanvasModel(r.Context(), tx, project, workspace, root)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for id, expected := range input.Expected {
|
||||
if before.Versions[id] != expected {
|
||||
return rejectBatch(412, "version_conflict")
|
||||
}
|
||||
}
|
||||
for _, operation := range input.Operations {
|
||||
if !uuidSlugPattern.MatchString(operation.ID) {
|
||||
return rejectBatch(400, "invalid_model_operation")
|
||||
}
|
||||
var used bool
|
||||
if err = tx.QueryRowContext(r.Context(), `SELECT EXISTS(SELECT 1 FROM canvas_model_operations WHERE project=$1 AND workspace_id=$2 AND operation_id=$3)`, project, workspace, operation.ID).Scan(&used); err != nil {
|
||||
return err
|
||||
}
|
||||
if used {
|
||||
return rejectBatch(409, "operation_already_applied")
|
||||
}
|
||||
}
|
||||
after, touched, err := applyModelOperations(before, input.Operations)
|
||||
if err != nil {
|
||||
code := "invalid_model_operation"
|
||||
if err == errModelConflict {
|
||||
code = "canvas_model_field_conflict"
|
||||
}
|
||||
return rejectBatch(409, code)
|
||||
}
|
||||
for id := range touched {
|
||||
if _, exists := input.Expected[id]; !exists {
|
||||
return rejectBatch(428, "version_required")
|
||||
}
|
||||
}
|
||||
for id := range input.Expected {
|
||||
if _, existed := before.Versions[id]; !existed {
|
||||
if _, created := after.Canvases[id]; !created {
|
||||
return rejectBatch(409, "invalid_model_participant")
|
||||
}
|
||||
var exists bool
|
||||
if err = tx.QueryRowContext(r.Context(), `SELECT EXISTS(SELECT 1 FROM materials WHERE project=$1 AND workspace_id=$2 AND id=$3)`, project, workspace, id).Scan(&exists); err != nil {
|
||||
return err
|
||||
}
|
||||
if exists {
|
||||
return rejectBatch(409, "canvas_identity_conflict")
|
||||
}
|
||||
}
|
||||
if !touched[id] {
|
||||
touched[id] = true
|
||||
after.Versions[id] = before.Versions[id] + 1
|
||||
}
|
||||
}
|
||||
for id, element := range after.Elements {
|
||||
if before.Elements[id] == nil {
|
||||
var foreign bool
|
||||
if err = tx.QueryRowContext(r.Context(), `SELECT EXISTS(SELECT 1 FROM canvas_model_elements WHERE project=$1 AND element_id=$2 AND (workspace_id<>$3 OR root_id<>$4))`, project, id, workspace, root).Scan(&foreign); err != nil {
|
||||
return err
|
||||
}
|
||||
if foreign {
|
||||
return rejectBatch(409, "element_identity_conflict")
|
||||
}
|
||||
}
|
||||
if element["structure"] == "node" {
|
||||
data := element["data"].(map[string]any)
|
||||
if asset := modelString(data["assetId"]); asset != "" {
|
||||
prior := before.Elements[id]
|
||||
if prior == nil || !modelEqual(prior["data"].(map[string]any)["assetId"], data["assetId"]) {
|
||||
readable, e := assetReadable(r, tx, project, workspace, asset, account)
|
||||
if e != nil {
|
||||
return e
|
||||
}
|
||||
if !readable {
|
||||
return rejectBatch(400, "invalid_asset_reference")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if err = s.saveCanvasModel(r, tx, project, workspace, account, before, after, touched); err != nil {
|
||||
return err
|
||||
}
|
||||
ids := []string{}
|
||||
for _, operation := range input.Operations {
|
||||
ids = append(ids, operation.ID)
|
||||
_, err = tx.ExecContext(r.Context(), `INSERT INTO canvas_model_operations(project,workspace_id,operation_id,request_id,account_id) VALUES($1,$2,$3,$4,$5)`, project, workspace, operation.ID, key, account)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return commitModelReceipt(w, r, tx, project, workspace, account, root, modelEditAction, key, payloadDigest(input), 200, response{"status": "applied", "operation_ids": ids, "model": after.view(input.Expected)})
|
||||
}
|
||||
func (s *service) getCanvasModelOperation(w http.ResponseWriter, r *http.Request, project, account string) error {
|
||||
if r.Method != http.MethodGet {
|
||||
reject(w, 405, "method_not_allowed")
|
||||
return nil
|
||||
}
|
||||
key := r.PathValue("request")
|
||||
if !uuidSlugPattern.MatchString(key) {
|
||||
reject(w, 400, "invalid_request_id")
|
||||
return nil
|
||||
}
|
||||
tx, err := s.db.BeginTx(r.Context(), nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
workspace, board := r.PathValue("workspace"), r.PathValue("material")
|
||||
if _, _, err = lockMaterial(r.Context(), tx, project, workspace, board, account); err != nil {
|
||||
return err
|
||||
}
|
||||
root, err := canonicalRoot(r.Context(), tx, project, workspace, board)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if root == "" {
|
||||
root, err = legacyModelRoot(r, tx, project, workspace, board)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
var body json.RawMessage
|
||||
err = tx.QueryRowContext(r.Context(), `SELECT response_body FROM canvas_model_requests WHERE project=$1 AND workspace_id=$2 AND account_id=$3 AND request_id=$4 AND root_id=$5`, project, workspace, account, key, root).Scan(&body)
|
||||
if err == sql.ErrNoRows {
|
||||
reply(w, 404, response{"status": "unknown", "error": "operation_receipt_unknown", "request_id": key})
|
||||
return nil
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err = tx.Rollback(); err != nil {
|
||||
return err
|
||||
}
|
||||
reply(w, 200, body)
|
||||
return nil
|
||||
}
|
||||
|
||||
func modelExists(ctx context.Context, tx *sql.Tx, project, workspace, board string) (bool, error) {
|
||||
root, err := canonicalRoot(ctx, tx, project, workspace, board)
|
||||
return root != "", err
|
||||
}
|
||||
@@ -0,0 +1,217 @@
|
||||
package platform
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"database/sql"
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestCanonicalModelMigrationAndIncrementalLifecycle(t *testing.T) {
|
||||
dsn := os.Getenv("PLATFORM_TEST_DATABASE_URL")
|
||||
if dsn == "" {
|
||||
t.Skip("requires an isolated integration database")
|
||||
}
|
||||
db, err := sql.Open("pgx", dsn)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer db.Close()
|
||||
ctx := context.Background()
|
||||
var entropy [12]byte
|
||||
_, _ = rand.Read(entropy[:])
|
||||
project := "canonical-" + hex.EncodeToString(entropy[:])
|
||||
defer func() {
|
||||
for _, table := range []string{"canvas_model_operations", "canvas_model_identity_map", "canvas_model_requests", "canvas_model_projects", "canvas_asset_bindings", "canvas_subgraphs", "workspaces", "project_accounts"} {
|
||||
_, _ = db.ExecContext(ctx, "DELETE FROM "+table+" WHERE project=$1", project)
|
||||
}
|
||||
}()
|
||||
var account, workspace, root string
|
||||
if err = db.QueryRowContext(ctx, `INSERT INTO project_accounts(project,id) VALUES($1,gen_random_uuid()) RETURNING id::text`, project).Scan(&account); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var tokenBytes [32]byte
|
||||
_, _ = rand.Read(tokenBytes[:])
|
||||
token := base64.RawURLEncoding.EncodeToString(tokenBytes[:])
|
||||
hash := sha256.Sum256([]byte(token))
|
||||
if _, err = db.ExecContext(ctx, `INSERT INTO project_sessions(token_hash,project,account_id,expires_at) VALUES($1,$2,$3,now()+interval '1 hour')`, base64.RawURLEncoding.EncodeToString(hash[:]), project, account); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err = db.QueryRowContext(ctx, `INSERT INTO workspaces(project,name,owner_id) VALUES($1,'Canonical',$2) RETURNING id::text`, project, account).Scan(&workspace); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err = db.ExecContext(ctx, `INSERT INTO workspace_members(project,workspace_id,account_id,role) VALUES($1,$2,$3,'admin')`, project, workspace, account); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
legacy := json.RawMessage(`{"schema_version":1,"nodes":[` + modelLegacyFrame + `,{"id":"a","type":"plainText","position":{"x":150,"y":260},"width":240,"height":72,"data":{"kind":"plain-text","title":"a","text":"preserved","frameId":"f"}}],"edges":[],"viewport":{"x":0,"y":0,"zoom":1}}`)
|
||||
if err = db.QueryRowContext(ctx, `INSERT INTO materials(project,workspace_id,kind,title,created_by,canvas_document) VALUES($1,$2,'board','Root',$3,$4) RETURNING id::text`, project, workspace, account, legacy).Scan(&root); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
mux := http.NewServeMux()
|
||||
Register(mux, db, map[string]string{project: "https://app.example.com/auth/callback"}, "https://api.example.com", nil)
|
||||
base := "/projects/" + project + "/workspaces/" + workspace + "/materials/" + root + "/canvas"
|
||||
call := func(method, path string, input any, key string, status int) response {
|
||||
t.Helper()
|
||||
raw, _ := json.Marshal(input)
|
||||
request := httptest.NewRequest(method, path, bytes.NewReader(raw))
|
||||
request.Header.Set("Authorization", "Bearer "+token)
|
||||
request.Header.Set("Accept", "application/vnd.foundry.canvas+json;version=2")
|
||||
if key != "" {
|
||||
request.Header.Set("Idempotency-Key", key)
|
||||
}
|
||||
writer := httptest.NewRecorder()
|
||||
mux.ServeHTTP(writer, request)
|
||||
if writer.Code != status {
|
||||
t.Fatalf("%s %s got %d %s want %d", method, path, writer.Code, writer.Body.String(), status)
|
||||
}
|
||||
out := response{}
|
||||
if writer.Body.Len() > 0 {
|
||||
if err := json.Unmarshal(writer.Body.Bytes(), &out); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
read := func() canvasModel {
|
||||
t.Helper()
|
||||
tx, err := db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer tx.Rollback()
|
||||
model, err := loadCanvasModel(ctx, tx, project, workspace, root)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return model
|
||||
}
|
||||
if call("GET", base+"/model", nil, "", 409)["error"] != "canonical_migration_required" {
|
||||
t.Fatal("legacy read silently migrated")
|
||||
}
|
||||
var count int
|
||||
if err = db.QueryRowContext(ctx, `SELECT count(*) FROM canvas_model_projects WHERE project=$1`, project).Scan(&count); err != nil || count != 0 {
|
||||
t.Fatal("GET created a canonical project")
|
||||
}
|
||||
migrationKey := modelTestID(1000)
|
||||
migrationInput := response{"protocol_version": 2, "request_id": migrationKey, "expected_versions": map[string]int64{root: 1}}
|
||||
migration := call("POST", base+"/model/migrate", migrationInput, migrationKey, 200)
|
||||
if migration["status"] != "applied" {
|
||||
t.Fatal("migration missing applied receipt")
|
||||
}
|
||||
retry := call("POST", base+"/model/migrate", migrationInput, migrationKey, 200)
|
||||
if !modelEqual(migration["identity_map"], retry["identity_map"]) {
|
||||
t.Fatal("migration retry allocated a second identity map")
|
||||
}
|
||||
identities := migration["identity_map"].([]any)
|
||||
nodeID, frameID := "", ""
|
||||
for _, item := range identities {
|
||||
mapping := item.(map[string]any)
|
||||
switch mapping["sourceId"] {
|
||||
case "a":
|
||||
nodeID = mapping["elementId"].(string)
|
||||
case "f":
|
||||
frameID = mapping["elementId"].(string)
|
||||
}
|
||||
}
|
||||
model := read()
|
||||
if nodeID == "" || frameID == "" || len(model.Canvases) != 2 {
|
||||
t.Fatal("migration lost identities or an implicit partition")
|
||||
}
|
||||
child := modelString(model.Elements[nodeID]["ownerCanvasId"])
|
||||
if child == root || model.Canvases[child].Parent.EntryElementID != frameID || model.Versions[root] != 2 || model.Versions[child] != 1 {
|
||||
t.Fatal("canonical owning tree or version barrier is incorrect")
|
||||
}
|
||||
if call("PATCH", base, response{"document": legacy}, "", 409)["error"] != "canonical_write_required" {
|
||||
t.Fatal("old snapshot writer can overwrite canonical records")
|
||||
}
|
||||
call("GET", base, nil, "", 409)
|
||||
if _, err = db.ExecContext(ctx, `UPDATE materials SET canvas_document=$4,canvas_version=canvas_version+1 WHERE project=$1 AND workspace_id=$2 AND id=$3`, project, workspace, root, legacy); err == nil {
|
||||
t.Fatal("database identity barrier allowed an old writer")
|
||||
}
|
||||
edit := func(n int, versions map[string]int64, changes ...modelChange) response {
|
||||
t.Helper()
|
||||
key := modelTestID(n)
|
||||
input := modelBatch{ProtocolVersion: 2, RequestID: key, Expected: versions, Operations: []modelOperation{{ID: modelTestID(n + 10000), Changes: changes}}}
|
||||
return call("POST", base+"/model/operations", input, key, 200)
|
||||
}
|
||||
titleChange := modelChange{Kind: "field", Target: modelTarget{ID: nodeID, Incarnation: 1}, Field: "data.title", Before: modelJSON("a"), After: modelJSON("renamed")}
|
||||
first := edit(1001, map[string]int64{child: 1}, titleChange)
|
||||
if first["model"].(map[string]any)["complete"] != false {
|
||||
t.Fatal("operation receipt pretends to be a full project snapshot")
|
||||
}
|
||||
model = read()
|
||||
if model.Versions[root] != 2 || model.Versions[child] != 2 || model.Elements[nodeID]["data"].(map[string]any)["text"] != "preserved" {
|
||||
t.Fatal("child edit changed unrelated canvas/version/content")
|
||||
}
|
||||
requestKey := modelTestID(1001)
|
||||
repeat := modelBatch{ProtocolVersion: 2, RequestID: requestKey, Expected: map[string]int64{child: 1}, Operations: []modelOperation{{ID: modelTestID(11001), Changes: []modelChange{titleChange}}}}
|
||||
call("POST", base+"/model/operations", repeat, requestKey, 200)
|
||||
if read().Versions[child] != 2 {
|
||||
t.Fatal("exact retry executed twice")
|
||||
}
|
||||
if call("GET", base+"/model/operations/"+requestKey, nil, "", 200)["status"] != "applied" {
|
||||
t.Fatal("lost ACK cannot be queried")
|
||||
}
|
||||
if call("GET", base+"/model/operations/"+modelTestID(9999), nil, "", 404)["status"] != "unknown" {
|
||||
t.Fatal("missing receipt claimed zero writes")
|
||||
}
|
||||
// Move is a row owner update, retaining ElementId and incarnation.
|
||||
geometry := model.Elements[nodeID]["geometry"]
|
||||
edit(1002, map[string]int64{root: 2, child: 2}, modelChange{Kind: "field", Target: modelTarget{ID: nodeID, Incarnation: 1}, Field: "ownerCanvasId", Before: modelJSON(child), After: modelJSON(root)}, modelChange{Kind: "field", Target: modelTarget{ID: nodeID, Incarnation: 1}, Field: "geometry", Before: modelJSON(geometry), After: modelJSON(map[string]any{"x": 150, "y": 260, "width": 240, "height": 72, "rotation": 0})})
|
||||
model = read()
|
||||
if model.Elements[nodeID]["ownerCanvasId"] != root || modelInt(model.Elements[nodeID]["incarnation"]) != 1 {
|
||||
t.Fatal("move recreated the element")
|
||||
}
|
||||
// Future component/envelope bytes survive editing another object and legacy projections.
|
||||
opaqueID := modelTestID(3000)
|
||||
opaque := modelTestNode(opaqueID, root)
|
||||
opaque["componentId"], opaque["dataVersion"], opaque["data"], opaque["futureEnvelope"] = "future.plugin", float64(9), map[string]any{"raw": []any{map[string]any{"keep": true}}}, map[string]any{"extension": "retained"}
|
||||
edit(1003, map[string]int64{root: 3}, modelChange{Kind: "element", ID: opaqueID, Before: modelJSON(nil), After: modelJSON(opaque)})
|
||||
model = read()
|
||||
if !modelEqual(model.Elements[opaqueID], opaque) {
|
||||
t.Fatal("future component data changed")
|
||||
}
|
||||
edit(1004, map[string]int64{root: 4}, modelChange{Kind: "element", ID: nodeID, Before: modelJSON(model.Elements[nodeID]), After: modelJSON(nil)})
|
||||
model = read()
|
||||
if model.Tombstones[nodeID] != 1 {
|
||||
t.Fatal("delete did not persist its lifecycle tombstone")
|
||||
}
|
||||
restored := modelTestNode(nodeID, root)
|
||||
restored["componentId"] = "plain-text"
|
||||
restored["incarnation"] = float64(2)
|
||||
restored["data"].(map[string]any)["title"] = "restored"
|
||||
edit(1005, map[string]int64{root: 5}, modelChange{Kind: "element", ID: nodeID, Before: modelJSON(nil), After: modelJSON(restored)})
|
||||
staleKey := modelTestID(1006)
|
||||
stale := modelBatch{ProtocolVersion: 2, RequestID: staleKey, Expected: map[string]int64{root: 6}, Operations: []modelOperation{{ID: modelTestID(11006), Changes: []modelChange{{Kind: "field", Target: modelTarget{ID: nodeID, Incarnation: 1}, Field: "data.title", Before: modelJSON("restored"), After: modelJSON("stale callback")}}}}}
|
||||
if call("POST", base+"/model/operations", stale, staleKey, 409)["status"] != "not-applied" {
|
||||
t.Fatal("stale callback did not receive a zero-write receipt")
|
||||
}
|
||||
if call("GET", base+"/model/operations/"+staleKey, nil, "", 200)["terminal"] != true {
|
||||
t.Fatal("terminal rejection was not queryable")
|
||||
}
|
||||
model = read()
|
||||
if model.Versions[root] != 6 || !modelEqual(model.Elements[opaqueID], opaque) || modelInt(model.Elements[nodeID]["incarnation"]) != 2 {
|
||||
t.Fatal("stale callback or unrelated update damaged state")
|
||||
}
|
||||
duplicateKey := modelTestID(1007)
|
||||
duplicate := modelBatch{ProtocolVersion: 2, RequestID: duplicateKey, Expected: map[string]int64{root: 6}, Operations: []modelOperation{{ID: modelTestID(11002), Changes: []modelChange{{Kind: "field", Target: modelTarget{ID: nodeID, Incarnation: 2}, Field: "data.title", Before: modelJSON("restored"), After: modelJSON("duplicate effect")}}}}}
|
||||
if call("POST", base+"/model/operations", duplicate, duplicateKey, 409)["error"] != "operation_already_applied" {
|
||||
t.Fatal("logical operation was executed under a new request ID")
|
||||
}
|
||||
var owner string
|
||||
var incarnation int64
|
||||
if err = db.QueryRowContext(ctx, `SELECT owner_canvas_id::text,incarnation FROM canvas_model_elements WHERE project=$1 AND element_id=$2 AND NOT deleted`, project, nodeID).Scan(&owner, &incarnation); err != nil || owner != root || incarnation != 2 {
|
||||
t.Fatal("canonical row authority disagrees with the API")
|
||||
}
|
||||
if call("GET", base+"/model", nil, "", 200)["model"].(map[string]any)["complete"] != true {
|
||||
t.Fatal("initial read has no explicit coverage")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,282 @@
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,299 @@
|
||||
package platform
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"sort"
|
||||
)
|
||||
|
||||
func canonicalRoot(ctx context.Context, tx *sql.Tx, project, workspace, board string) (string, error) {
|
||||
var root string
|
||||
err := tx.QueryRowContext(ctx, `SELECT root_id::text FROM canvas_model_canvases WHERE project=$1 AND workspace_id=$2 AND canvas_id=$3`, project, workspace, board).Scan(&root)
|
||||
if err == sql.ErrNoRows {
|
||||
return "", nil
|
||||
}
|
||||
return root, err
|
||||
}
|
||||
func loadCanvasModel(ctx context.Context, tx *sql.Tx, project, workspace, root string) (canvasModel, error) {
|
||||
model := emptyModel(root)
|
||||
rows, err := tx.QueryContext(ctx, `SELECT canvas_id::text,parent_canvas_id::text,entry_element_id,version,archived FROM canvas_model_canvases WHERE project=$1 AND workspace_id=$2 AND root_id=$3 ORDER BY canvas_id`, project, workspace, root)
|
||||
if err != nil {
|
||||
return model, err
|
||||
}
|
||||
for rows.Next() {
|
||||
var canvas modelCanvas
|
||||
var parent, entry sql.NullString
|
||||
var version int64
|
||||
var archived bool
|
||||
if err = rows.Scan(&canvas.ID, &parent, &entry, &version, &archived); err != nil {
|
||||
rows.Close()
|
||||
return model, err
|
||||
}
|
||||
if parent.Valid {
|
||||
canvas.Parent = &modelParent{CanvasID: parent.String, EntryElementID: entry.String}
|
||||
}
|
||||
model.Versions[canvas.ID] = version
|
||||
if !archived {
|
||||
model.Canvases[canvas.ID] = canvas
|
||||
}
|
||||
}
|
||||
err = rows.Err()
|
||||
rows.Close()
|
||||
if err != nil {
|
||||
return model, err
|
||||
}
|
||||
rows, err = tx.QueryContext(ctx, `SELECT element_id,record,deleted,deleted_incarnation FROM canvas_model_elements WHERE project=$1 AND workspace_id=$2 AND root_id=$3 ORDER BY element_id`, project, workspace, root)
|
||||
if err != nil {
|
||||
return model, err
|
||||
}
|
||||
defer rows.Close()
|
||||
for rows.Next() {
|
||||
var id string
|
||||
var raw json.RawMessage
|
||||
var deleted bool
|
||||
var dead int64
|
||||
if err = rows.Scan(&id, &raw, &deleted, &dead); err != nil {
|
||||
return model, err
|
||||
}
|
||||
if dead > 0 {
|
||||
model.Tombstones[id] = dead
|
||||
}
|
||||
if !deleted {
|
||||
var element map[string]any
|
||||
if err = json.Unmarshal(raw, &element); err != nil {
|
||||
return model, err
|
||||
}
|
||||
model.Elements[id] = element
|
||||
}
|
||||
}
|
||||
if err = rows.Err(); err != nil {
|
||||
return model, err
|
||||
}
|
||||
return model, validateCanvasModel(model)
|
||||
}
|
||||
|
||||
// Only changed element rows are written. canvas_document is a compatibility
|
||||
// projection for internal AI readers, never the source of a canonical GET/replay.
|
||||
func (s *service) saveCanvasModel(r *http.Request, tx *sql.Tx, project, workspace, account string, before, after canvasModel, participants map[string]bool) error {
|
||||
ctx := r.Context()
|
||||
if _, err := tx.ExecContext(ctx, `SELECT set_config('foundry.canonical_writer','on',true)`); err != nil {
|
||||
return err
|
||||
}
|
||||
projection, err := projectCanvasModel(after)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, id := range sortedKeys(participants) {
|
||||
canvas, active := after.Canvases[id]
|
||||
if active {
|
||||
if _, existed := before.Versions[id]; !existed {
|
||||
title := "Canvas"
|
||||
if canvas.Parent != nil {
|
||||
data := after.Elements[canvas.Parent.EntryElementID]["data"].(map[string]any)
|
||||
if value := modelString(data["title"]); value != "" {
|
||||
title = value
|
||||
}
|
||||
}
|
||||
if _, err = tx.ExecContext(ctx, `INSERT INTO materials(project,workspace_id,id,kind,title,created_by) VALUES($1,$2,$3,'board',$4,$5)`, project, workspace, id, title, account); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
var parent, entry any
|
||||
if canvas.Parent != nil {
|
||||
parent, entry = canvas.Parent.CanvasID, canvas.Parent.EntryElementID
|
||||
}
|
||||
_, err = tx.ExecContext(ctx, `INSERT INTO canvas_model_canvases(project,workspace_id,root_id,canvas_id,parent_canvas_id,entry_element_id,version) VALUES($1,$2,$3,$4,$5,$6,$7) ON CONFLICT(project,workspace_id,canvas_id) DO UPDATE SET parent_canvas_id=EXCLUDED.parent_canvas_id,entry_element_id=EXCLUDED.entry_element_id,version=EXCLUDED.version,archived=false`, project, workspace, after.Root, id, parent, entry, after.Versions[id])
|
||||
} else {
|
||||
_, err = tx.ExecContext(ctx, `UPDATE canvas_model_canvases SET archived=true,version=$4 WHERE project=$1 AND workspace_id=$2 AND canvas_id=$3`, project, workspace, id, after.Versions[id])
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
for _, id := range sortedKeys(after.Elements) {
|
||||
element := after.Elements[id]
|
||||
if modelEqual(before.Elements[id], element) && before.Tombstones[id] == after.Tombstones[id] {
|
||||
continue
|
||||
}
|
||||
raw, err := json.Marshal(element)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
written, err := tx.ExecContext(ctx, `INSERT INTO canvas_model_elements(project,workspace_id,root_id,element_id,owner_canvas_id,incarnation,deleted_incarnation,record) VALUES($1,$2,$3,$4,$5,$6,$7,$8) ON CONFLICT(project,element_id) DO UPDATE SET owner_canvas_id=EXCLUDED.owner_canvas_id,incarnation=EXCLUDED.incarnation,deleted_incarnation=EXCLUDED.deleted_incarnation,record=EXCLUDED.record,deleted=false WHERE canvas_model_elements.workspace_id=EXCLUDED.workspace_id AND canvas_model_elements.root_id=EXCLUDED.root_id`, project, workspace, after.Root, id, element["ownerCanvasId"], modelInt(element["incarnation"]), after.Tombstones[id], raw)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if count, err := written.RowsAffected(); err != nil {
|
||||
return err
|
||||
} else if count != 1 {
|
||||
return errModelConflict
|
||||
}
|
||||
}
|
||||
for _, id := range sortedKeys(before.Elements) {
|
||||
if after.Elements[id] == nil {
|
||||
if _, err = tx.ExecContext(ctx, `UPDATE canvas_model_elements SET deleted=true,deleted_incarnation=incarnation WHERE project=$1 AND workspace_id=$2 AND root_id=$3 AND element_id=$4`, project, workspace, after.Root, id); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
// Archive relations first; parent/entry changes then install the final owning tree.
|
||||
for _, id := range sortedKeys(participants) {
|
||||
if id != after.Root {
|
||||
if _, err = tx.ExecContext(ctx, `UPDATE canvas_subgraphs SET archived_at=now() WHERE project=$1 AND workspace_id=$2 AND child_board_id=$3`, project, workspace, id); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
canvases := sortedKeys(after.Canvases)
|
||||
sort.SliceStable(canvases, func(i, j int) bool {
|
||||
a, _ := modelAncestors(after, canvases[i])
|
||||
b, _ := modelAncestors(after, canvases[j])
|
||||
return len(a) < len(b)
|
||||
})
|
||||
for _, id := range canvases {
|
||||
canvas := after.Canvases[id]
|
||||
if !participants[id] || canvas.Parent == nil {
|
||||
continue
|
||||
}
|
||||
_, err = tx.ExecContext(ctx, `INSERT INTO canvas_subgraphs(project,workspace_id,parent_board_id,frame_id,child_board_id) VALUES($1,$2,$3,$4,$5) ON CONFLICT(project,workspace_id,child_board_id) DO UPDATE SET parent_board_id=EXCLUDED.parent_board_id,frame_id=EXCLUDED.frame_id,archived_at=NULL`, project, workspace, canvas.Parent.CanvasID, canvas.Parent.EntryElementID, id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
for _, id := range sortedKeys(participants) {
|
||||
if _, active := after.Canvases[id]; !active {
|
||||
_, err = tx.ExecContext(ctx, `UPDATE materials SET archived_at=COALESCE(archived_at,now()),canvas_version=$4 WHERE project=$1 AND workspace_id=$2 AND id=$3`, project, workspace, id, after.Versions[id])
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
continue
|
||||
}
|
||||
// AI invalidation/asset bindings still run through the existing service checks.
|
||||
if err = s.saveCanvas(r, tx, project, workspace, canvasSnapshot{BoardID: id, Version: after.Versions[id], Document: projection[id]}); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err = tx.ExecContext(ctx, `UPDATE materials SET archived_at=NULL WHERE project=$1 AND workspace_id=$2 AND id=$3`, project, workspace, id); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func projectCanvasModel(model canvasModel) (map[string]json.RawMessage, error) {
|
||||
documents := map[string]map[string]any{}
|
||||
for id := range model.Canvases {
|
||||
documents[id] = map[string]any{"schema_version": float64(2), "nodes": []any{}, "edges": []any{}, "viewport": map[string]any{"x": float64(0), "y": float64(0), "zoom": float64(1)}}
|
||||
}
|
||||
elements := sortedKeys(model.Elements)
|
||||
sort.SliceStable(elements, func(i, j int) bool {
|
||||
return modelString(model.Elements[elements[i]]["orderKey"]) < modelString(model.Elements[elements[j]]["orderKey"])
|
||||
})
|
||||
childByEntry := map[string]string{}
|
||||
for id, canvas := range model.Canvases {
|
||||
if canvas.Parent != nil {
|
||||
childByEntry[canvas.Parent.EntryElementID] = id
|
||||
}
|
||||
}
|
||||
for _, id := range elements {
|
||||
element := model.Elements[id]
|
||||
if element["structure"] != "node" {
|
||||
continue
|
||||
}
|
||||
kind := modelString(element["componentId"])
|
||||
if modelInt(element["dataVersion"]) != 1 || !canvasChoice(kind, "text", "script", "image", "plain-text", "plain-image", "icon", "frame") {
|
||||
continue
|
||||
}
|
||||
data := map[string]any{}
|
||||
for key, value := range element["data"].(map[string]any) {
|
||||
data[key] = value
|
||||
}
|
||||
data["kind"] = kind
|
||||
geometry := element["geometry"].(map[string]any)
|
||||
data["rotation"] = geometry["rotation"]
|
||||
typ := "material"
|
||||
switch kind {
|
||||
case "plain-text":
|
||||
typ = "plainText"
|
||||
case "plain-image":
|
||||
typ = "plainImage"
|
||||
case "icon":
|
||||
typ = "icon"
|
||||
case "frame":
|
||||
typ = "frame"
|
||||
}
|
||||
if child := childByEntry[id]; child != "" {
|
||||
data["subgraph"], data["ports"] = map[string]any{"boardId": child}, []any{}
|
||||
}
|
||||
node := map[string]any{"id": id, "type": typ, "position": map[string]any{"x": geometry["x"], "y": geometry["y"]}, "width": geometry["width"], "height": geometry["height"], "data": data}
|
||||
owner := modelString(element["ownerCanvasId"])
|
||||
documents[owner]["nodes"] = append(documents[owner]["nodes"].([]any), node)
|
||||
}
|
||||
for _, id := range elements {
|
||||
element := model.Elements[id]
|
||||
if element["structure"] != "connection" {
|
||||
continue
|
||||
}
|
||||
owner := modelString(element["ownerCanvasId"])
|
||||
endpoints := []canvasConnectionEndpoint{}
|
||||
skip := false
|
||||
for index, field := range []string{"source", "target"} {
|
||||
endpoint := element[field].(map[string]any)
|
||||
node := model.Elements[modelString(endpoint["elementId"])]
|
||||
board := modelString(node["ownerCanvasId"])
|
||||
if connectionNode(documents[board], modelString(node["id"])) == nil {
|
||||
skip = true
|
||||
break
|
||||
}
|
||||
handle := modelString(endpoint["portId"])
|
||||
if handle == "" {
|
||||
handle = "left"
|
||||
if index == 0 {
|
||||
handle = "right"
|
||||
}
|
||||
}
|
||||
path := []subgraphRelation{}
|
||||
for next := board; next != owner; {
|
||||
canvas := model.Canvases[next]
|
||||
if canvas.Parent == nil {
|
||||
return nil, errCanvasModel
|
||||
}
|
||||
path = append(path, subgraphRelation{Parent: canvas.Parent.CanvasID, Frame: canvas.Parent.EntryElementID, Child: next})
|
||||
next = canvas.Parent.CanvasID
|
||||
}
|
||||
direction := "output"
|
||||
if index == 1 {
|
||||
direction = "input"
|
||||
}
|
||||
resolved, _, err := exposeConnectionEndpoint(canvasConnectionEndpoint{Board: board, Node: modelString(node["id"]), Handle: handle}, direction, path, documents)
|
||||
// Direct frame/icon endpoints are canonical concepts; old AI readers cannot execute them.
|
||||
if err != nil {
|
||||
skip = true
|
||||
break
|
||||
}
|
||||
endpoints = append(endpoints, resolved)
|
||||
}
|
||||
if skip {
|
||||
continue
|
||||
}
|
||||
data := map[string]any{}
|
||||
for key, value := range element["data"].(map[string]any) {
|
||||
data[key] = value
|
||||
}
|
||||
if element["path"] != nil {
|
||||
data["path"] = element["path"]
|
||||
}
|
||||
edge := map[string]any{"id": id, "source": endpoints[0].Node, "target": endpoints[1].Node, "sourceHandle": endpoints[0].Handle, "targetHandle": endpoints[1].Handle, "data": data}
|
||||
documents[owner]["edges"] = append(documents[owner]["edges"].([]any), edge)
|
||||
}
|
||||
result := map[string]json.RawMessage{}
|
||||
for id, document := range documents {
|
||||
raw, err := json.Marshal(document)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result[id] = raw
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
package platform
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"testing"
|
||||
)
|
||||
|
||||
const modelRootID = "10000000-0000-4000-8000-000000000001"
|
||||
const modelChildID = "10000000-0000-4000-8000-000000000002"
|
||||
const modelLegacyFrame = `{"id":"f","type":"frame","position":{"x":100,"y":200},"width":640,"height":400,"data":{"kind":"frame","title":"Partition","text":"","labelMode":"fixed"}}`
|
||||
|
||||
func modelJSON(value any) json.RawMessage { raw, _ := json.Marshal(value); return raw }
|
||||
func modelTestID(n int) string { return fmt.Sprintf("90000000-0000-4000-8000-%012d", n) }
|
||||
func modelTestAllocator() func() string {
|
||||
next := 0
|
||||
return func() string { next++; return modelTestID(next) }
|
||||
}
|
||||
func modelTestNode(id, owner string) map[string]any {
|
||||
return map[string]any{"id": id, "incarnation": float64(1), "ownerCanvasId": owner, "componentId": "text", "dataVersion": float64(1), "structure": "node", "data": map[string]any{"title": id, "text": "preserved"}, "geometry": map[string]any{"x": float64(0), "y": float64(0), "width": float64(260), "height": float64(200), "rotation": float64(0)}}
|
||||
}
|
||||
func modelTestDocument() canvasModel {
|
||||
model := emptyModel(modelRootID)
|
||||
model.Canvases[modelRootID] = modelCanvas{ID: modelRootID}
|
||||
model.Versions[modelRootID] = 1
|
||||
model.Elements["a"] = modelTestNode("a", modelRootID)
|
||||
return model
|
||||
}
|
||||
|
||||
func TestCanonicalMigrationResolvesCanvasLocalIdentitiesAndPorts(t *testing.T) {
|
||||
root := `{"schema_version":2,"nodes":[{"id":"a","type":"plainText","position":{"x":0,"y":0},"width":240,"height":72,"data":{"kind":"plain-text","title":"parent","text":""}},{"id":"f","type":"frame","position":{"x":100,"y":200},"width":640,"height":400,"data":{"kind":"frame","title":"F","text":"","subgraph":{"boardId":"` + modelChildID + `"},"ports":[{"id":"p","nodeId":"a","handle":"right","direction":"output"}]}}],"edges":[{"id":"e","source":"f","sourceHandle":"port:p","target":"a","targetHandle":"left"}],"viewport":{"x":0,"y":0,"zoom":1}}`
|
||||
child := `{"schema_version":2,"nodes":[{"id":"a","type":"plainText","position":{"x":10,"y":20},"width":240,"height":72,"data":{"kind":"plain-text","title":"child","text":""}}],"edges":[],"viewport":{"x":0,"y":0,"zoom":1}}`
|
||||
snapshots := map[string]canvasSnapshot{modelRootID: {BoardID: modelRootID, Version: 3, Document: json.RawMessage(root)}, modelChildID: {BoardID: modelChildID, Version: 7, Document: json.RawMessage(child)}}
|
||||
result, err := migrateCanvasModel(modelRootID, snapshots, []subgraphRelation{{Parent: modelRootID, Frame: "f", Child: modelChildID}}, modelTestAllocator())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ids := map[string]string{}
|
||||
for _, mapping := range result.Identities {
|
||||
ids[mapping.SourceCanvasID+"/"+mapping.Structure+"/"+mapping.SourceID] = mapping.ElementID
|
||||
}
|
||||
parentA, childA := ids[modelRootID+"/node/a"], ids[modelChildID+"/node/a"]
|
||||
if parentA == childA || parentA == "" || childA == "" {
|
||||
t.Fatal("canvas-local node IDs were merged")
|
||||
}
|
||||
edge := result.Model.Elements[ids[modelRootID+"/connection/e"]]
|
||||
if edge["source"].(map[string]any)["elementId"] != childA || edge["target"].(map[string]any)["elementId"] != parentA || edge["ownerCanvasId"] != modelRootID {
|
||||
t.Fatal("logical endpoint or common ancestor owner was lost")
|
||||
}
|
||||
if result.Model.Canvases[modelChildID].Parent.EntryElementID != ids[modelRootID+"/node/f"] || result.Model.Versions[modelRootID] != 4 || result.Model.Versions[modelChildID] != 8 {
|
||||
t.Fatal("migration did not publish an identity/version barrier")
|
||||
}
|
||||
if string(snapshots[modelRootID].Document) != root || string(snapshots[modelChildID].Document) != child {
|
||||
t.Fatal("migration mutated source snapshots")
|
||||
}
|
||||
projection, err := projectCanvasModel(result.Model)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, raw := range projection {
|
||||
if _, valid := normalizeCanvas(raw); !valid {
|
||||
t.Fatalf("invalid compatibility projection: %s", raw)
|
||||
}
|
||||
}
|
||||
}
|
||||
func TestCanonicalMigrationTurnsImplicitFramesIntoOwningCanvases(t *testing.T) {
|
||||
root := `{"schema_version":1,"nodes":[` + modelLegacyFrame + `,{"id":"a","type":"plainText","position":{"x":150,"y":260},"width":240,"height":72,"data":{"kind":"plain-text","title":"a","text":"","frameId":"f"}}],"edges":[],"viewport":{"x":0,"y":0,"zoom":1}}`
|
||||
result, err := migrateCanvasModel(modelRootID, map[string]canvasSnapshot{modelRootID: {BoardID: modelRootID, Version: 1, Document: json.RawMessage(root)}}, nil, modelTestAllocator())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(result.NewCanvases) != 1 || len(result.Model.Canvases) != 2 {
|
||||
t.Fatal("legacy partition was not given a canvas")
|
||||
}
|
||||
var node map[string]any
|
||||
for _, mapping := range result.Identities {
|
||||
if mapping.SourceID == "a" {
|
||||
node = result.Model.Elements[mapping.ElementID]
|
||||
}
|
||||
}
|
||||
geometry := node["geometry"].(map[string]any)
|
||||
if node["ownerCanvasId"] != result.NewCanvases[0] || geometry["x"] != float64(26) || geometry["y"] != float64(24) {
|
||||
t.Fatal("membership or owner-local geometry changed")
|
||||
}
|
||||
if _, exists := node["data"].(map[string]any)["frameId"]; exists {
|
||||
t.Fatal("second ownership fact leaked into canonical data")
|
||||
}
|
||||
}
|
||||
func TestCanonicalOperationsPreserveOpaquePayloadAndStableMoveIdentity(t *testing.T) {
|
||||
model := modelTestDocument()
|
||||
frame := modelTestNode("f", modelRootID)
|
||||
frame["componentId"] = "frame"
|
||||
model.Elements["f"] = frame
|
||||
model.Canvases[modelChildID] = modelCanvas{ID: modelChildID, Parent: &modelParent{CanvasID: modelRootID, EntryElementID: "f"}}
|
||||
model.Versions[modelChildID] = 1
|
||||
future := modelTestNode("future", modelRootID)
|
||||
future["componentId"], future["dataVersion"], future["data"], future["futureEnvelope"] = "plugin.future", float64(42), map[string]any{"nested": map[string]any{"list": []any{"x", float64(2)}}}, map[string]any{"retain": true}
|
||||
model.Elements["future"] = future
|
||||
op := modelOperation{ID: modelTestID(1), Changes: []modelChange{{Kind: "field", Target: modelTarget{ID: "a", Incarnation: 1}, Field: "ownerCanvasId", Before: modelJSON(modelRootID), After: modelJSON(modelChildID)}}}
|
||||
after, touched, err := applyModelOperations(model, []modelOperation{op})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if after.Elements["a"]["id"] != "a" || after.Elements["a"]["ownerCanvasId"] != modelChildID || !touched[modelRootID] || !touched[modelChildID] {
|
||||
t.Fatal("move changed identity or omitted a participant")
|
||||
}
|
||||
if !modelEqual(after.Elements["future"], future) || model.Elements["a"]["ownerCanvasId"] != modelRootID {
|
||||
t.Fatal("opaque payload or old snapshot was mutated")
|
||||
}
|
||||
}
|
||||
func TestCanonicalDeleteRestoreRejectsOldIncarnation(t *testing.T) {
|
||||
model := modelTestDocument()
|
||||
deleted, _, err := applyModelOperations(model, []modelOperation{{ID: modelTestID(1), Changes: []modelChange{{Kind: "element", ID: "a", Before: modelJSON(model.Elements["a"]), After: modelJSON(nil)}}}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if deleted.Tombstones["a"] != 1 {
|
||||
t.Fatal("missing tombstone")
|
||||
}
|
||||
restored := modelTestNode("a", modelRootID)
|
||||
restored["incarnation"] = float64(2)
|
||||
live, _, err := applyModelOperations(deleted, []modelOperation{{ID: modelTestID(2), Changes: []modelChange{{Kind: "element", ID: "a", Before: modelJSON(nil), After: modelJSON(restored)}}}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, _, err = applyModelOperations(live, []modelOperation{{ID: modelTestID(3), Changes: []modelChange{{Kind: "field", Target: modelTarget{ID: "a", Incarnation: 1}, Field: "data.title", Before: modelJSON("a"), After: modelJSON("stale")}}}})
|
||||
if err != errModelConflict || live.Elements["a"]["data"].(map[string]any)["title"] != "a" {
|
||||
t.Fatal("old callback claimed the restored incarnation")
|
||||
}
|
||||
}
|
||||
func TestCanonicalValidationRejectsPartialChangesAndWrongEdgeOwner(t *testing.T) {
|
||||
model := modelTestDocument()
|
||||
before := modelJSON(model)
|
||||
operations := []modelOperation{{ID: modelTestID(1), Changes: []modelChange{{Kind: "field", Target: modelTarget{ID: "a", Incarnation: 1}, Field: "data.title", Before: modelJSON("a"), After: modelJSON("changed")}, {Kind: "field", Target: modelTarget{ID: "missing", Incarnation: 1}, Field: "geometry", Before: modelJSON(nil), After: modelJSON(nil)}}}}
|
||||
if _, _, err := applyModelOperations(model, operations); err == nil {
|
||||
t.Fatal("invalid second change was accepted")
|
||||
}
|
||||
if string(modelJSON(model)) != string(before) {
|
||||
t.Fatal("failed batch changed the source")
|
||||
}
|
||||
frame := modelTestNode("f", modelRootID)
|
||||
frame["componentId"] = "frame"
|
||||
model.Elements["f"] = frame
|
||||
model.Canvases[modelChildID] = modelCanvas{ID: modelChildID, Parent: &modelParent{CanvasID: modelRootID, EntryElementID: "f"}}
|
||||
model.Versions[modelChildID] = 1
|
||||
model.Elements["child"] = modelTestNode("child", modelChildID)
|
||||
model.Elements["e"] = map[string]any{"id": "e", "ownerCanvasId": modelChildID, "incarnation": float64(1), "componentId": "connection", "dataVersion": float64(1), "structure": "connection", "data": map[string]any{}, "source": map[string]any{"elementId": "a"}, "target": map[string]any{"elementId": "child"}, "path": nil}
|
||||
if validateCanvasModel(model) == nil {
|
||||
t.Fatal("cross-child connection acquired a child owner")
|
||||
}
|
||||
}
|
||||
@@ -103,12 +103,21 @@ func (s *service) protected(callbacks map[string]string, handle endpoint) http.H
|
||||
r.SetPathValue("material", id)
|
||||
}
|
||||
}
|
||||
if err == nil {
|
||||
var stopped bool
|
||||
stopped, err = s.guardLegacyCanonicalRequest(w, r, project, account)
|
||||
if stopped {
|
||||
return
|
||||
}
|
||||
}
|
||||
if err == nil {
|
||||
err = handle(w, r, project, account)
|
||||
}
|
||||
if err != nil {
|
||||
var pg *pgconn.PgError
|
||||
switch {
|
||||
case errors.As(err, &pg) && pg.Code == "55000" && pg.Message == "canonical_write_required":
|
||||
reject(w, 409, "canonical_write_required")
|
||||
case errors.Is(err, errSubgraphCycle):
|
||||
reject(w, 409, "subgraph_cycle")
|
||||
case errors.Is(err, errSubgraphDepth):
|
||||
@@ -147,8 +156,12 @@ func Register(mux *http.ServeMux, db *sql.DB, callbacks map[string]string, origi
|
||||
mux.HandleFunc("/projects/{project}/workspaces/{workspace}/materials/{material}/canvas/ai/runs/{run}/cancel", s.protected(callbacks, s.cancelCanvasAIRun))
|
||||
mux.HandleFunc("/projects/{project}/workspaces/{workspace}/materials/{material}/canvas/ai/runs/{run}/apply", s.protected(callbacks, s.applyCanvasAIOutput))
|
||||
mux.HandleFunc("/projects/{project}/workspaces/{workspace}/materials/{material}/canvas/capabilities", s.protected(callbacks, s.canvasCapabilities))
|
||||
mux.HandleFunc("POST /projects/{project}/workspaces/{workspace}/materials/{material}/canvas/operations", s.protected(callbacks, s.submitCanvasOperations))
|
||||
mux.HandleFunc("GET /projects/{project}/workspaces/{workspace}/materials/{material}/canvas/operations/{request}", s.protected(callbacks, s.getCanvasOperation))
|
||||
mux.HandleFunc("/projects/{project}/workspaces/{workspace}/materials/{material}/canvas/operations", s.protected(callbacks, s.submitCanvasOperations))
|
||||
mux.HandleFunc("/projects/{project}/workspaces/{workspace}/materials/{material}/canvas/operations/{request}", s.protected(callbacks, s.getCanvasOperation))
|
||||
mux.HandleFunc("/projects/{project}/workspaces/{workspace}/materials/{material}/canvas/model", s.protected(callbacks, s.getCanvasModel))
|
||||
mux.HandleFunc("/projects/{project}/workspaces/{workspace}/materials/{material}/canvas/model/migrate", s.protected(callbacks, s.migrateCanonicalCanvas))
|
||||
mux.HandleFunc("/projects/{project}/workspaces/{workspace}/materials/{material}/canvas/model/operations", s.protected(callbacks, s.submitCanvasModelOperations))
|
||||
mux.HandleFunc("/projects/{project}/workspaces/{workspace}/materials/{material}/canvas/model/operations/{request}", s.protected(callbacks, s.getCanvasModelOperation))
|
||||
mux.HandleFunc("/projects/{project}/workspaces/{workspace}/materials/{material}/canvas/connections", s.protected(callbacks, s.createCanvasConnection))
|
||||
mux.HandleFunc("/projects/{project}/workspaces/{workspace}/materials/{material}/canvas/subgraphs", s.protected(callbacks, s.createSubgraph))
|
||||
mux.HandleFunc("/projects/{project}/workspaces/{workspace}/materials/{material}/canvas/subgraphs/migrate", s.protected(callbacks, s.migrateSubgraphs))
|
||||
|
||||
Reference in New Issue
Block a user