Add canonical canvas rows, stable identity migration and model operations
Foundry Go checks / check (push) Failing after 2m29s
Foundry Go checks / check (pull_request) Failing after 2m31s

This commit is contained in:
2026-11-07 22:26:06 +08:00
parent 19884b2a90
commit b85e8bd2ca
10 changed files with 1996 additions and 3 deletions
+103
View File
@@ -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;
+2 -1
View File
@@ -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},
+407
View File
@@ -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, &copy)
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
}
+41
View File
@@ -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
}
+479
View File
@@ -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")
}
}
+282
View File
@@ -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
}
+299
View File
@@ -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
}
+151
View File
@@ -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")
}
}
+15 -2
View File
@@ -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))