From b85e8bd2ca361e76e5bcf5b32c5e8069d97cdf8e Mon Sep 17 00:00:00 2001 From: Codex Bot Date: Sat, 7 Nov 2026 22:26:06 +0800 Subject: [PATCH] Add canonical canvas rows, stable identity migration and model operations --- db/008-canvas-model.sql | 103 ++++ internal/platform/canvas_capabilities.go | 3 +- internal/platform/canvas_model.go | 407 +++++++++++++++ internal/platform/canvas_model_guard.go | 41 ++ internal/platform/canvas_model_http.go | 479 ++++++++++++++++++ .../platform/canvas_model_integration_test.go | 217 ++++++++ internal/platform/canvas_model_migration.go | 282 +++++++++++ internal/platform/canvas_model_store.go | 299 +++++++++++ internal/platform/canvas_model_test.go | 151 ++++++ internal/platform/http.go | 17 +- 10 files changed, 1996 insertions(+), 3 deletions(-) create mode 100644 db/008-canvas-model.sql create mode 100644 internal/platform/canvas_model.go create mode 100644 internal/platform/canvas_model_guard.go create mode 100644 internal/platform/canvas_model_http.go create mode 100644 internal/platform/canvas_model_integration_test.go create mode 100644 internal/platform/canvas_model_migration.go create mode 100644 internal/platform/canvas_model_store.go create mode 100644 internal/platform/canvas_model_test.go diff --git a/db/008-canvas-model.sql b/db/008-canvas-model.sql new file mode 100644 index 0000000..e4a040d --- /dev/null +++ b/db/008-canvas-model.sql @@ -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; diff --git a/internal/platform/canvas_capabilities.go b/internal/platform/canvas_capabilities.go index 4b17366..875691e 100644 --- a/internal/platform/canvas_capabilities.go +++ b/internal/platform/canvas_capabilities.go @@ -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}, diff --git a/internal/platform/canvas_model.go b/internal/platform/canvas_model.go new file mode 100644 index 0000000..3baf1ec --- /dev/null +++ b/internal/platform/canvas_model.go @@ -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 +} diff --git a/internal/platform/canvas_model_guard.go b/internal/platform/canvas_model_guard.go new file mode 100644 index 0000000..37072db --- /dev/null +++ b/internal/platform/canvas_model_guard.go @@ -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 +} diff --git a/internal/platform/canvas_model_http.go b/internal/platform/canvas_model_http.go new file mode 100644 index 0000000..6193b2d --- /dev/null +++ b/internal/platform/canvas_model_http.go @@ -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 +} diff --git a/internal/platform/canvas_model_integration_test.go b/internal/platform/canvas_model_integration_test.go new file mode 100644 index 0000000..73ac5ab --- /dev/null +++ b/internal/platform/canvas_model_integration_test.go @@ -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") + } +} diff --git a/internal/platform/canvas_model_migration.go b/internal/platform/canvas_model_migration.go new file mode 100644 index 0000000..5938634 --- /dev/null +++ b/internal/platform/canvas_model_migration.go @@ -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 +} diff --git a/internal/platform/canvas_model_store.go b/internal/platform/canvas_model_store.go new file mode 100644 index 0000000..57021d0 --- /dev/null +++ b/internal/platform/canvas_model_store.go @@ -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 +} diff --git a/internal/platform/canvas_model_test.go b/internal/platform/canvas_model_test.go new file mode 100644 index 0000000..aeedd12 --- /dev/null +++ b/internal/platform/canvas_model_test.go @@ -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") + } +} diff --git a/internal/platform/http.go b/internal/platform/http.go index 191ed43..b8294c1 100644 --- a/internal/platform/http.go +++ b/internal/platform/http.go @@ -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))