Preserve staged resource identities and expose upload receipts
Foundry Go checks / check (push) Successful in 2m56s
Foundry Go checks / check (pull_request) Successful in 2m49s

This commit is contained in:
2026-11-07 23:18:20 +08:00
parent bf8763e0de
commit 0d7fc4dbe5
4 changed files with 93 additions and 7 deletions
+59 -4
View File
@@ -65,6 +65,18 @@ func (s *service) uploadAsset(w http.ResponseWriter, r *http.Request, project, a
reject(w, 400, "invalid_idempotency_key")
return nil
}
resourceID := strings.ToLower(r.Header.Get("X-Resource-Id"))
if resourceID != "" && !uuidSlugPattern.MatchString(resourceID) {
reject(w, 400, "invalid_resource_id")
return nil
}
respond := func(info assetInfo) {
if resourceID == "" {
reply(w, 201, info)
} else {
reply(w, 201, response{"protocol_version": 2, "request_id": key, "status": "applied", "asset": info})
}
}
tx, err := s.db.BeginTx(r.Context(), nil)
if err != nil {
return err
@@ -146,18 +158,28 @@ func (s *service) uploadAsset(w http.ResponseWriter, r *http.Request, project, a
var previous string
err = tx.QueryRowContext(r.Context(), `SELECT id::text,digest FROM canvas_assets WHERE project=$1 AND workspace_id=$2 AND uploaded_by=$3 AND idempotency_key=$4`, project, workspace, account, key).Scan(&info.ID, &previous)
if err == nil {
if previous != hash {
if previous != hash || resourceID != "" && resourceID != info.ID {
reject(w, 409, "idempotency_conflict")
return nil
}
info.URL = s.assetURL(project, workspace, info.ID)
reply(w, 201, info)
respond(info)
return nil
}
if err != sql.ErrNoRows {
return err
}
err = tx.QueryRowContext(r.Context(), `INSERT INTO canvas_assets(project,workspace_id,uploaded_by,mime_type,bytes,width,height,digest,idempotency_key) VALUES($1,$2,$3,$4,$5,$6,$7,$8,$9) RETURNING id::text`, project, workspace, account, mime, encoded, width, height, hash, key).Scan(&info.ID)
if resourceID != "" {
var occupied bool
if err = tx.QueryRowContext(r.Context(), `SELECT EXISTS(SELECT 1 FROM canvas_assets WHERE project=$1 AND workspace_id=$2 AND id=$3)`, project, workspace, resourceID).Scan(&occupied); err != nil {
return err
}
if occupied {
reject(w, 409, "resource_identity_conflict")
return nil
}
}
err = tx.QueryRowContext(r.Context(), `INSERT INTO canvas_assets(project,workspace_id,uploaded_by,mime_type,bytes,width,height,digest,idempotency_key,id) VALUES($1,$2,$3,$4,$5,$6,$7,$8,$9,COALESCE(NULLIF($10,'')::uuid,gen_random_uuid())) RETURNING id::text`, project, workspace, account, mime, encoded, width, height, hash, key, resourceID).Scan(&info.ID)
if err != nil {
return err
}
@@ -165,7 +187,40 @@ func (s *service) uploadAsset(w http.ResponseWriter, r *http.Request, project, a
return err
}
info.URL = s.assetURL(project, workspace, info.ID)
reply(w, 201, info)
respond(info)
return nil
}
// A missing receipt is unknown: callers may only retry the same resource/key/bytes.
func (s *service) assetOperation(w http.ResponseWriter, r *http.Request, project, account string) error {
if r.Method != http.MethodGet {
reject(w, 405, "method_not_allowed")
return nil
}
key, workspace := r.PathValue("request"), r.PathValue("workspace")
if !uuidSlugPattern.MatchString(key) {
reject(w, 400, "invalid_idempotency_key")
return nil
}
tx, err := s.db.BeginTx(r.Context(), nil)
if err != nil {
return err
}
defer tx.Rollback()
if _, _, err = lockWorkspace(r.Context(), tx, project, workspace, account); err != nil {
return err
}
var info assetInfo
err = tx.QueryRowContext(r.Context(), `SELECT id::text,mime_type,octet_length(bytes),width,height FROM canvas_assets WHERE project=$1 AND workspace_id=$2 AND uploaded_by=$3 AND idempotency_key=$4`, project, workspace, account, key).Scan(&info.ID, &info.MIME, &info.Bytes, &info.Width, &info.Height)
if err == sql.ErrNoRows {
reply(w, 404, response{"protocol_version": 2, "request_id": key, "status": "unknown", "error": "asset_receipt_unknown"})
return nil
}
if err != nil {
return err
}
info.URL = s.assetURL(project, workspace, info.ID)
reply(w, 200, response{"protocol_version": 2, "request_id": key, "status": "applied", "asset": info})
return nil
}
func assetReadable(r *http.Request, tx *sql.Tx, project, workspace, id, account string) (bool, error) {
+1 -1
View File
@@ -50,7 +50,7 @@ func (s *service) canvasCapabilities(w http.ResponseWriter, r *http.Request, pro
"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},
"ai": ai,
"assets": response{"previews": true, "preview_max_edge": 1024, "originals_preserved": true},
"assets": response{"client_resource_ids": true, "receipt_protocol_version": 2, "previews": true, "preview_max_edge": 1024, "originals_preserved": true},
"limits": response{"max_depth": 8, "max_nodes": 500, "max_edges": 2000, "max_frame_ports": 4000, "max_curve_knots": 64, "max_snapshot_bytes": canvasLimit, "max_active_tasks": 128, "max_run_steps": 64, "max_execution_depth": 32, "max_input_images": 4, "max_input_image_bytes": 20 << 20, "max_coordinate": 1000000},
"retention": response{"idempotency_min_seconds": 86400, "undo_receipt_seconds": 604800},
})
+2 -1
View File
@@ -76,7 +76,7 @@ func (s *service) protected(callbacks map[string]string, handle endpoint) http.H
}
if r.Method == http.MethodOptions {
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PATCH, DELETE, OPTIONS")
w.Header().Set("Access-Control-Allow-Headers", "Authorization, Content-Type, If-Match, Idempotency-Key, X-Expected-Account")
w.Header().Set("Access-Control-Allow-Headers", "Authorization, Content-Type, If-Match, Idempotency-Key, X-Expected-Account, X-Resource-Id")
reply(w, 204, nil)
return
}
@@ -150,6 +150,7 @@ func Register(mux *http.ServeMux, db *sql.DB, callbacks map[string]string, origi
}
mux.HandleFunc("/projects/{project}/workspaces/{workspace}/materials", s.protected(callbacks, s.materials))
mux.HandleFunc("/projects/{project}/workspaces/{workspace}/assets", s.protected(callbacks, s.uploadAsset))
mux.HandleFunc("/projects/{project}/workspaces/{workspace}/assets/operations/{request}", s.protected(callbacks, s.assetOperation))
mux.HandleFunc("/projects/{project}/workspaces/{workspace}/assets/{asset}", s.protected(callbacks, s.getAsset))
mux.HandleFunc("/projects/{project}/workspaces/{workspace}/materials/{material}", s.protected(callbacks, s.material))
mux.HandleFunc("/projects/{project}/workspaces/{workspace}/materials/{material}/canvas", s.protected(callbacks, s.canvas))
+31 -1
View File
@@ -495,7 +495,7 @@ func TestOnboardingAndSharing(t *testing.T) {
if err := png.Encode(&assetPNG, image.NewRGBA(image.Rect(0, 0, 3, 2))); err != nil {
t.Fatal(err)
}
upload := func(encoded []byte, key string, status int) response {
upload := func(encoded []byte, key string, status int, resourceIDs ...string) response {
t.Helper()
var body bytes.Buffer
writer := multipart.NewWriter(&body)
@@ -509,6 +509,9 @@ func TestOnboardingAndSharing(t *testing.T) {
request.Header.Set("Authorization", "Bearer "+tokens[0])
request.Header.Set("Content-Type", writer.FormDataContentType())
request.Header.Set("Idempotency-Key", key)
if len(resourceIDs) > 0 {
request.Header.Set("X-Resource-Id", resourceIDs[0])
}
result := httptest.NewRecorder()
mux.ServeHTTP(result, request)
if result.Code != status {
@@ -525,6 +528,33 @@ func TestOnboardingAndSharing(t *testing.T) {
if uploaded["id"] != retriedAsset["id"] || uploaded["mime_type"] != "image/png" {
t.Fatal("asset idempotency or actual MIME detection")
}
// New uploads keep the resource identity allocated in the frontend durable cut.
resourceID, resourceKey := modelTestID(9100), modelTestID(9101)
managed := upload(assetPNG.Bytes(), resourceKey, 201, resourceID)
if managed["protocol_version"] != float64(2) || managed["request_id"] != resourceKey || managed["asset"].(map[string]any)["id"] != resourceID {
t.Fatal("upload did not retain client resource identity")
}
repeated := upload(assetPNG.Bytes(), resourceKey, 201, resourceID)
if !modelEqual(managed, repeated) {
t.Fatal("resource retry changed receipt")
}
lookup := call("GET", workspacePath+"/assets/operations/"+resourceKey, tokens[0], nil, 200)
if lookup["status"] != "applied" || !modelEqual(lookup["asset"], managed["asset"]) {
t.Fatal("lost upload acknowledgment cannot be recovered")
}
unknown := call("GET", workspacePath+"/assets/operations/"+modelTestID(9102), tokens[0], nil, 404)
if unknown["status"] != "unknown" || unknown["terminal"] == true {
t.Fatal("missing asset receipt misreported zero-write")
}
call("GET", workspacePath+"/assets/operations/"+resourceKey, tokens[2], nil, 404)
upload(assetPNG.Bytes(), resourceKey, 409, modelTestID(9103))
upload(assetPNG.Bytes(), modelTestID(9104), 409, resourceID)
var alternatePNG bytes.Buffer
if err := png.Encode(&alternatePNG, image.NewRGBA(image.Rect(0, 0, 4, 4))); err != nil {
t.Fatal(err)
}
upload(alternatePNG.Bytes(), resourceKey, 409, resourceID)
upload(assetPNG.Bytes(), modelTestID(9105), 400, "invalid-resource")
download := func(token string, status int) {
t.Helper()
request := httptest.NewRequest("GET", workspacePath+"/assets/"+uploaded["id"].(string), nil)