[object Object]

← back to Cli Printing Press

fix(cli): route discriminator sync to typed tables (#503)

97180c037ba976b528ebc9e1d9ff7e58ca8b630f · 2026-05-02 10:09:03 -0700 · Trevin Chow

Files touched

Diff

commit 97180c037ba976b528ebc9e1d9ff7e58ca8b630f
Author: Trevin Chow <trevin@trevinchow.com>
Date:   Sat May 2 10:09:03 2026 -0700

    fix(cli): route discriminator sync to typed tables (#503)
---
 internal/generator/generator_test.go      | 131 ++++++++++++++++++++-
 internal/generator/templates/sync.go.tmpl |  84 +++++++++++++-
 internal/openapi/parser.go                |  57 +++++++--
 internal/openapi/parser_test.go           |  70 +++++++++++
 internal/profiler/profiler.go             | 187 +++++++++++++++++++++++++-----
 internal/profiler/profiler_test.go        |  46 ++++++++
 internal/spec/spec.go                     |  15 ++-
 7 files changed, 542 insertions(+), 48 deletions(-)

diff --git a/internal/generator/generator_test.go b/internal/generator/generator_test.go
index 1f95a704..22528382 100644
--- a/internal/generator/generator_test.go
+++ b/internal/generator/generator_test.go
@@ -2,6 +2,7 @@ package generator
 
 import (
 	"encoding/json"
+	"fmt"
 	"go/parser"
 	"go/token"
 	"net/http"
@@ -1709,6 +1710,128 @@ func TestGenerateStoreUpsertBatchDispatchesToTypedTable(t *testing.T) {
 	runGoCommand(t, outputDir, "test", "./internal/store")
 }
 
+func TestSyncDiscriminatorDispatchRoutesMixedItemsToTypedTables(t *testing.T) {
+	t.Parallel()
+
+	typedListEndpoint := func(path string) spec.Endpoint {
+		return spec.Endpoint{
+			Method:      "GET",
+			Path:        path,
+			Description: "List typed entities",
+			Response:    spec.ResponseDef{Type: "array"},
+			Params: []spec.Param{
+				{Name: "id", Type: "string"},
+				{Name: "type", Type: "string"},
+				{Name: "name", Type: "string"},
+				{Name: "created_at", Type: "string", Format: "date-time"},
+			},
+		}
+	}
+
+	apiSpec := &spec.APISpec{
+		Name:    "mixednet",
+		Version: "0.1.0",
+		BaseURL: "https://api.example.com",
+		Auth:    spec.AuthConfig{Type: "none"},
+		Config:  spec.ConfigSpec{Format: "toml", Path: "~/.config/mixednet-pp-cli/config.toml"},
+		Resources: map[string]spec.Resource{
+			"network_entities": {
+				Description: "Mixed network entities",
+				Endpoints: map[string]spec.Endpoint{
+					"list": {
+						Method:      "GET",
+						Path:        "/network-entities",
+						Description: "List mixed network entities",
+						Response:    spec.ResponseDef{Type: "array", Item: "NetworkEntity"},
+						Params: []spec.Param{
+							{Name: "limit", Type: "integer"},
+							{Name: "cursor", Type: "string"},
+						},
+						Pagination: &spec.Pagination{CursorParam: "cursor", LimitParam: "limit"},
+					},
+				},
+			},
+			"workspaces": {
+				Description: "Workspaces",
+				Endpoints:   map[string]spec.Endpoint{"list": typedListEndpoint("/workspaces")},
+			},
+			"collections": {
+				Description: "Collections",
+				Endpoints:   map[string]spec.Endpoint{"list": typedListEndpoint("/collections")},
+			},
+			"teams": {
+				Description: "Teams",
+				Endpoints:   map[string]spec.Endpoint{"list": typedListEndpoint("/teams")},
+			},
+		},
+		Types: map[string]spec.TypeDef{
+			"NetworkEntity": {
+				Fields: []spec.TypeField{
+					{Name: "type", Type: "string", Enum: []string{"workspace", "collection", "team"}},
+					{Name: "id", Type: "string"},
+				},
+			},
+		},
+	}
+
+	outputDir := filepath.Join(t.TempDir(), naming.CLI(apiSpec.Name))
+	gen := New(apiSpec, outputDir)
+	gen.VisionSet = VisionTemplateSet{Store: true, Sync: true}
+	require.NoError(t, gen.Generate())
+
+	syncSrc, err := os.ReadFile(filepath.Join(outputDir, "internal", "cli", "sync.go"))
+	require.NoError(t, err)
+	src := string(syncSrc)
+	assert.Contains(t, src, `"workspace": "workspaces"`)
+	assert.Contains(t, src, `upsertResourceBatch(db, resource, items)`)
+
+	inlineTest := fmt.Sprintf(`package cli
+
+import (
+	"encoding/json"
+	"path/filepath"
+	"testing"
+
+	"%s/internal/store"
+)
+
+func TestUpsertResourceBatchRoutesDiscriminatorItems(t *testing.T) {
+	s, err := store.Open(filepath.Join(t.TempDir(), "data.db"))
+	if err != nil {
+		t.Fatalf("open: %%v", err)
+	}
+	defer s.Close()
+
+	items := []json.RawMessage{
+		json.RawMessage(`+"`"+`{"type":"workspace","id":"w1","name":"Workspace","created_at":"2026-01-01T00:00:00Z"}`+"`"+`),
+		json.RawMessage(`+"`"+`{"type":"collection","id":"c1","name":"Collection","created_at":"2026-01-01T00:00:00Z"}`+"`"+`),
+		json.RawMessage(`+"`"+`{"type":"team","id":"t1","name":"Team","created_at":"2026-01-01T00:00:00Z"}`+"`"+`),
+	}
+	stored, extractFailures, err := upsertResourceBatch(s, "network_entities", items)
+	if err != nil {
+		t.Fatalf("upsertResourceBatch: %%v", err)
+	}
+	if stored != len(items) || extractFailures != 0 {
+		t.Fatalf("stored/extractFailures = %%d/%%d, want %%d/0", stored, extractFailures, len(items))
+	}
+
+	for _, table := range []string{"workspaces", "collections", "teams"} {
+		var count int
+		if err := s.DB().QueryRow("SELECT COUNT(*) FROM " + table).Scan(&count); err != nil {
+			t.Fatalf("count %%s: %%v", table, err)
+		}
+		if count != 1 {
+			t.Fatalf("%%s count = %%d, want 1", table, count)
+		}
+	}
+}
+`, naming.CLI(apiSpec.Name))
+	require.NoError(t, os.WriteFile(filepath.Join(outputDir, "internal", "cli", "sync_discriminator_test.go"), []byte(inlineTest), 0o644))
+
+	runGoCommandRequired(t, outputDir, "mod", "tidy")
+	runGoCommandRequired(t, outputDir, "test", "./internal/cli", "./internal/store")
+}
+
 func TestGenerateStoreBackfillsIndexedColumnsOnUpgrade(t *testing.T) {
 	t.Parallel()
 
@@ -4475,11 +4598,11 @@ func TestGeneratedSyncIDFieldOverridesAndProbes(t *testing.T) {
 	// all three return values. Without this contract, extractFailures would
 	// not be observable from sync's per-item warning code.
 	assert.Contains(t, syncContent,
-		`stored, extractFailures, err := db.UpsertBatch(resource, items)`,
-		"sync.go syncResource must consume the three-tuple UpsertBatch return")
+		`stored, extractFailures, err := upsertResourceBatch(db, resource, items)`,
+		"sync.go syncResource must consume the three-tuple batch upsert return")
 	assert.Contains(t, syncContent,
-		`stored, extractFailures, err := db.UpsertBatch(dep.Name, items)`,
-		"sync.go syncDependentResource must consume the three-tuple UpsertBatch return")
+		`stored, extractFailures, err := upsertResourceBatch(db, dep.Name, items)`,
+		"sync.go syncDependentResource must consume the three-tuple batch upsert return")
 
 	// store.go's UpsertBatch declaration matches the new signature.
 	assert.Contains(t, storeContent,
diff --git a/internal/generator/templates/sync.go.tmpl b/internal/generator/templates/sync.go.tmpl
index baeac779..e629701e 100644
--- a/internal/generator/templates/sync.go.tmpl
+++ b/internal/generator/templates/sync.go.tmpl
@@ -411,7 +411,7 @@ func syncResource(c interface {
 		// "primary_key_unresolved" the first time any single item
 		// fails, and the F4b "stored_count_zero_after_extraction"
 		// probe when extraction succeeded but rows still didn't land.
-		stored, extractFailures, err := db.UpsertBatch(resource, items)
+		stored, extractFailures, err := upsertResourceBatch(db, resource, items)
 		if err != nil {
 			if !humanFriendly {
 				fmt.Fprintf(os.Stderr, `{"event":"sync_error","resource":"%s","error":"%s"}`+"\n", resource, strings.ReplaceAll(err.Error(), `"`, `\"`))
@@ -685,6 +685,84 @@ func findCursorInMap(m map[string]json.RawMessage, cursorKeys []string) string {
 	return ""
 }
 
+type discriminatorDispatch struct {
+	Field  string
+	Values map[string]string
+}
+
+var discriminatorDispatchers = map[string]discriminatorDispatch{
+{{- range .SyncableResources}}
+{{- if .Discriminator.Field}}
+	{{printf "%q" .Name}}: {
+		Field: {{printf "%q" .Discriminator.Field}},
+		Values: map[string]string{
+{{- range .Discriminator.Mappings}}
+			{{printf "%q" .Value}}: {{printf "%q" .Resource}},
+{{- end}}
+		},
+	},
+{{- end}}
+{{- end}}
+{{- range .DependentSyncResources}}
+{{- if .Discriminator.Field}}
+	{{printf "%q" .Name}}: {
+		Field: {{printf "%q" .Discriminator.Field}},
+		Values: map[string]string{
+{{- range .Discriminator.Mappings}}
+			{{printf "%q" .Value}}: {{printf "%q" .Resource}},
+{{- end}}
+		},
+	},
+{{- end}}
+{{- end}}
+}
+
+func upsertResourceBatch(db *store.Store, resource string, items []json.RawMessage) (int, int, error) {
+	if _, ok := discriminatorDispatchers[resource]; !ok {
+		return db.UpsertBatch(resource, items)
+	}
+
+	grouped := map[string][]json.RawMessage{}
+	order := []string{}
+	for _, item := range items {
+		target := resource
+		var obj map[string]any
+		if err := json.Unmarshal(item, &obj); err == nil {
+			target = resolveDiscriminatedResource(resource, obj)
+		}
+		if _, ok := grouped[target]; !ok {
+			order = append(order, target)
+		}
+		grouped[target] = append(grouped[target], item)
+	}
+
+	var stored, extractFailures int
+	for _, target := range order {
+		targetStored, targetExtractFailures, err := db.UpsertBatch(target, grouped[target])
+		if err != nil {
+			return stored, extractFailures + targetExtractFailures, err
+		}
+		stored += targetStored
+		extractFailures += targetExtractFailures
+	}
+	return stored, extractFailures, nil
+}
+
+func resolveDiscriminatedResource(resource string, obj map[string]any) string {
+	dispatcher, ok := discriminatorDispatchers[resource]
+	if !ok || dispatcher.Field == "" {
+		return resource
+	}
+	value := store.LookupFieldValue(obj, dispatcher.Field)
+	if value == nil {
+		return resource
+	}
+	if target, ok := dispatcher.Values[fmt.Sprintf("%v", value)]; ok && target != "" {
+		return target
+	}
+	return resource
+}
+
 // upsertSingleObject stores a non-array API response as a single record.
 func upsertSingleObject(db *store.Store, resource string, data json.RawMessage) error {
 	var obj map[string]any
@@ -693,6 +771,8 @@ func upsertSingleObject(db *store.Store, resource string, data json.RawMessage)
 		return db.Upsert(resource, resource, data)
 	}
 
+	resource = resolveDiscriminatedResource(resource, obj)
+
 	id := extractID(resource, obj)
 	if id == "" {
 		id = resource
@@ -893,7 +973,7 @@ func syncDependentResource(c interface {
 				}
 			}
 
-			stored, extractFailures, err := db.UpsertBatch(dep.Name, items)
+			stored, extractFailures, err := upsertResourceBatch(db, dep.Name, items)
 			if err != nil {
 				if humanFriendly {
 					fmt.Fprintf(os.Stderr, "\n  %s: upsert error for parent %s: %v\n", dep.Name, parentID, err)
diff --git a/internal/openapi/parser.go b/internal/openapi/parser.go
index 79b99196..e22a871e 100644
--- a/internal/openapi/parser.go
+++ b/internal/openapi/parser.go
@@ -1820,7 +1820,7 @@ func collectAllOfProperties(
 		if prop == nil {
 			continue
 		}
-		properties[name] = prop
+		properties[naming.ASCIIFold(name)] = prop
 	}
 	for _, sub := range schema.AllOf {
 		if collectAllOfProperties(sub, properties, required, visited) {
@@ -1849,24 +1849,28 @@ func mapResponse(op *openapi3.Operation, fallbackName string) (spec.ResponseDef,
 	schema := schemaRef.Value
 	if isObjectSchema(schema) {
 		if dataRef := schema.Properties["data"]; dataRef != nil && isArraySchema(schemaRefValue(dataRef)) {
+			itemRef := schemaRefValue(dataRef).Items
 			return spec.ResponseDef{
-				Type: "array",
-				Item: schemaTypeName(schemaRefValue(dataRef).Items, fallbackName+"Item"),
+				Type:          "array",
+				Item:          schemaTypeName(itemRef, fallbackName+"Item"),
+				Discriminator: mapResponseDiscriminator(itemRef),
 			}, "data"
 		}
 	}
 
 	if isArraySchema(schema) {
 		return spec.ResponseDef{
-			Type: "array",
-			Item: schemaTypeName(schema.Items, fallbackName+"Item"),
+			Type:          "array",
+			Item:          schemaTypeName(schema.Items, fallbackName+"Item"),
+			Discriminator: mapResponseDiscriminator(schema.Items),
 		}, ""
 	}
 
 	if isObjectSchema(schema) {
 		return spec.ResponseDef{
-			Type: "object",
-			Item: schemaTypeName(schemaRef, fallbackName+"Response"),
+			Type:          "object",
+			Item:          schemaTypeName(schemaRef, fallbackName+"Response"),
+			Discriminator: mapResponseDiscriminator(schemaRef),
 		}, ""
 	}
 
@@ -2130,9 +2134,11 @@ func mapTypes(doc *openapi3.T, out *spec.APISpec) {
 				continue
 			}
 			seenGoNames[goFieldName] = true
+			fieldSchema := schemaRefValue(properties[fieldName])
 			fields = append(fields, spec.TypeField{
 				Name: fieldName,
-				Type: mapSchemaType(schemaRefValue(properties[fieldName])),
+				Type: mapSchemaType(fieldSchema),
+				Enum: schemaEnum(fieldSchema),
 			})
 		}
 
@@ -2140,6 +2146,39 @@ func mapTypes(doc *openapi3.T, out *spec.APISpec) {
 	}
 }
 
+func mapResponseDiscriminator(schemaRef *openapi3.SchemaRef) *spec.ResponseDiscriminator {
+	schema := schemaRefValue(schemaRef)
+	if schema == nil || schema.Discriminator == nil || strings.TrimSpace(schema.Discriminator.PropertyName) == "" {
+		return nil
+	}
+
+	out := &spec.ResponseDiscriminator{
+		Field:   strings.TrimSpace(schema.Discriminator.PropertyName),
+		Mapping: map[string]string{},
+	}
+	if len(schema.Discriminator.Mapping) == 0 {
+		return out
+	}
+
+	values := make([]string, 0, len(schema.Discriminator.Mapping))
+	for value := range schema.Discriminator.Mapping {
+		values = append(values, value)
+	}
+	sort.Strings(values)
+	for _, value := range values {
+		ref := schema.Discriminator.Mapping[value].Ref
+		target := refComponentName(ref)
+		if target == "" {
+			target = ref
+		}
+		if target == "" {
+			target = value
+		}
+		out.Mapping[value] = toTypeName(target)
+	}
+	return out
+}
+
 func collectTypeProperties(schemaRef *openapi3.SchemaRef, properties map[string]*openapi3.SchemaRef, visited map[*openapi3.Schema]struct{}) {
 	if schemaRef == nil || schemaRef.Value == nil {
 		return
@@ -2158,7 +2197,7 @@ func collectTypeProperties(schemaRef *openapi3.SchemaRef, properties map[string]
 		if strings.HasPrefix(name, "_") {
 			continue
 		}
-		properties[sanitizeTypeName(name)] = prop
+		properties[naming.ASCIIFold(name)] = prop
 	}
 	for _, sub := range schema.AllOf {
 		collectTypeProperties(sub, properties, visited)
diff --git a/internal/openapi/parser_test.go b/internal/openapi/parser_test.go
index 1a48c376..038604c9 100644
--- a/internal/openapi/parser_test.go
+++ b/internal/openapi/parser_test.go
@@ -50,6 +50,76 @@ func TestParsePetstore(t *testing.T) {
 	assert.Contains(t, parsed.Types, "Pet")
 }
 
+func TestParsePreservesResponseDiscriminatorAndEnumFields(t *testing.T) {
+	t.Parallel()
+
+	parsed, err := Parse([]byte(`
+openapi: 3.0.3
+info:
+  title: Mixed Network
+  version: 1.0.0
+paths:
+  /network-entities:
+    get:
+      operationId: listNetworkEntities
+      responses:
+        "200":
+          description: ok
+          content:
+            application/json:
+              schema:
+                type: object
+                properties:
+                  data:
+                    type: array
+                    items:
+                      $ref: "#/components/schemas/NetworkEntity"
+components:
+  schemas:
+    NetworkEntity:
+      type: object
+      discriminator:
+        propertyName: type
+        mapping:
+          workspace: "#/components/schemas/Workspace"
+          collection: "#/components/schemas/Collection"
+      properties:
+        type:
+          type: string
+          enum: [workspace, collection]
+        id:
+          type: string
+    Workspace:
+      type: object
+      properties:
+        id:
+          type: string
+    Collection:
+      type: object
+      properties:
+        id:
+          type: string
+`))
+	require.NoError(t, err)
+
+	endpoint := parsed.Resources["network-entities"].Endpoints["list"]
+	require.NotNil(t, endpoint.Response.Discriminator)
+	assert.Equal(t, "type", endpoint.Response.Discriminator.Field)
+	assert.Equal(t, map[string]string{
+		"collection": "Collection",
+		"workspace":  "Workspace",
+	}, endpoint.Response.Discriminator.Mapping)
+
+	var typeField spec.TypeField
+	for _, field := range parsed.Types["NetworkEntity"].Fields {
+		if field.Name == "type" {
+			typeField = field
+			break
+		}
+	}
+	assert.Equal(t, []string{"workspace", "collection"}, typeField.Enum)
+}
+
 func TestParseStytchOpenAPI(t *testing.T) {
 	t.Parallel()
 
diff --git a/internal/profiler/profiler.go b/internal/profiler/profiler.go
index 96506bb4..47953626 100644
--- a/internal/profiler/profiler.go
+++ b/internal/profiler/profiler.go
@@ -54,6 +54,20 @@ type SearchBodyField struct {
 	Required bool   `json:"required"`
 }
 
+// DiscriminatorMapping routes one discriminator value to the concrete resource
+// whose typed table should receive the item.
+type DiscriminatorMapping struct {
+	Value    string
+	Resource string
+}
+
+// DiscriminatorDispatch describes a mixed response payload whose items carry a
+// discriminator field such as type/kind/__typename/objectType.
+type DiscriminatorDispatch struct {
+	Field    string
+	Mappings []DiscriminatorMapping
+}
+
 // SyncableResource describes a resource that supports list sync (paginated or single-page).
 type SyncableResource struct {
 	Name string
@@ -68,6 +82,11 @@ type SyncableResource struct {
 	// should fail the whole run even under the new (non-strict) exit-code
 	// policy. Defaults to false.
 	Critical bool
+
+	// Discriminator routes heterogeneous response items to concrete typed
+	// resources before storage. Empty when the endpoint returns a homogeneous
+	// resource.
+	Discriminator DiscriminatorDispatch
 }
 
 // DependentResource describes a child resource that requires iterating a parent
@@ -91,6 +110,10 @@ type DependentResource struct {
 	// SyncableResource.Critical so spec authors can mark child paths as
 	// load-bearing.
 	Critical bool
+
+	// Discriminator routes heterogeneous dependent-resource response items to
+	// concrete typed resources before storage.
+	Discriminator DiscriminatorDispatch
 }
 
 // APIProfile describes the shape of an API and what power-user features it warrants.
@@ -141,7 +164,7 @@ func Profile(s *spec.APISpec) *APIProfile {
 		SearchableFields: make(map[string][]string),
 	}
 
-	resourceNames := collectResourceNames(s.Resources)
+	resourceNames, resourceNameIndex := collectResourceNameMetadata(s.Resources)
 	syncable := make(map[string]syncableMeta)      // resource name -> chosen list endpoint metadata
 	parameterized := make(map[string]syncableMeta) // resource name -> parameterized list endpoint metadata (excluded from flat sync; carries IDField/Critical for dependent-resource emission)
 	searchable := make(map[string]map[string]struct{})
@@ -277,7 +300,7 @@ func Profile(s *spec.APISpec) *APIProfile {
 				//     like GetFriendList?steamid=REQUIRED that need a parent ID)
 				if !strings.Contains(endpoint.Path, "{") && !hasRequiredScopeParams(endpoint) {
 					if existing, ok := syncable[resourceName]; !ok || len(endpoint.Path) < len(existing.Path) {
-						syncable[resourceName] = metaFromEndpoint(endpoint)
+						syncable[resourceName] = metaFromEndpoint(endpoint, s.Types, resourceNameIndex)
 					}
 				}
 
@@ -296,7 +319,7 @@ func Profile(s *spec.APISpec) *APIProfile {
 							// Enum-expanded paths are more specific than generic resource
 							// paths, so they always win on name collision. This ensures
 							// deterministic output regardless of Go map iteration order.
-							meta := metaFromEndpoint(endpoint)
+							meta := metaFromEndpoint(endpoint, s.Types, resourceNameIndex)
 							meta.Path = expandedPath
 							syncable[expandedName] = meta
 						}
@@ -307,13 +330,13 @@ func Profile(s *spec.APISpec) *APIProfile {
 						// annotations on a child path-item flow into the override
 						// and critical-resource maps.
 						if _, ok := parameterized[resourceName]; !ok {
-							parameterized[resourceName] = metaFromEndpoint(endpoint)
+							parameterized[resourceName] = metaFromEndpoint(endpoint, s.Types, resourceNameIndex)
 						}
 					} else {
 						// Paginated endpoints override the path set above — they have
 						// richer pagination support for full data retrieval.
 						if existing, ok := syncable[resourceName]; !ok || len(endpoint.Path) < len(existing.Path) {
-							syncable[resourceName] = metaFromEndpoint(endpoint)
+							syncable[resourceName] = metaFromEndpoint(endpoint, s.Types, resourceNameIndex)
 						}
 					}
 				}
@@ -324,7 +347,7 @@ func Profile(s *spec.APISpec) *APIProfile {
 				// Only include endpoints whose name suggests a collection (list, all,
 				// index, etc.) — exclude singular getters like "get" or "show".
 				if existing, ok := syncable[resourceName]; !ok || len(endpoint.Path) < len(existing.Path) {
-					syncable[resourceName] = metaFromEndpoint(endpoint)
+					syncable[resourceName] = metaFromEndpoint(endpoint, s.Types, resourceNameIndex)
 				}
 			}
 
@@ -382,7 +405,9 @@ func Profile(s *spec.APISpec) *APIProfile {
 			p.CRUDResources++
 		}
 
-		for subName, sub := range r.SubResources {
+		subNames := sortedKeys(r.SubResources)
+		for _, subName := range subNames {
+			sub := r.SubResources[subName]
 			walk(subName, sub)
 		}
 	}
@@ -772,24 +797,29 @@ func matchesResource(name string, resourceNames map[string]struct{}) bool {
 	return false
 }
 
-func collectResourceNames(resources map[string]spec.Resource) map[string]struct{} {
+func collectResourceNameMetadata(resources map[string]spec.Resource) (map[string]struct{}, map[string]string) {
 	names := make(map[string]struct{})
+	index := make(map[string]string)
 
-	var walk func(name string, r spec.Resource)
-	walk = func(name string, r spec.Resource) {
+	walkResources(resources, func(name string, _ spec.Resource) {
+		resourceName := strings.ToLower(name)
 		for _, variant := range nameVariants(name) {
 			names[variant] = struct{}{}
+			if _, ok := index[variant]; !ok {
+				index[variant] = resourceName
+			}
 		}
-		for subName, sub := range r.SubResources {
-			walk(subName, sub)
-		}
-	}
+	})
 
-	for name, resource := range resources {
-		walk(name, resource)
-	}
+	return names, index
+}
 
-	return names
+func walkResources(resources map[string]spec.Resource, visit func(name string, resource spec.Resource)) {
+	for _, name := range sortedKeys(resources) {
+		resource := resources[name]
+		visit(name, resource)
+		walkResources(resource.SubResources, visit)
+	}
 }
 
 func nameVariants(name string) []string {
@@ -931,6 +961,7 @@ func detectDependentResources(parameterized map[string]syncableMeta, syncable ma
 			Path:           path,
 			IDField:        meta.IDField,
 			Critical:       meta.Critical,
+			Discriminator:  meta.Discriminator,
 		})
 	}
 	// Sort for deterministic output
@@ -944,21 +975,118 @@ func detectDependentResources(parameterized map[string]syncableMeta, syncable ma
 // is still selecting between candidates (e.g., flat vs. paginated). It is
 // converted into a SyncableResource at the end of Profile().
 type syncableMeta struct {
-	Path     string
-	IDField  string
-	Critical bool
+	Path          string
+	IDField       string
+	Critical      bool
+	Discriminator DiscriminatorDispatch
 }
 
 // metaFromEndpoint extracts the IDField and Critical fields a parser populated
 // from path-item-level extensions (or, for IDField, from response-schema
 // inference). Keeps the per-endpoint plumbing in one place so future profiler
 // fields propagate uniformly.
-func metaFromEndpoint(e spec.Endpoint) syncableMeta {
+func metaFromEndpoint(e spec.Endpoint, types map[string]spec.TypeDef, resourceNameIndex map[string]string) syncableMeta {
 	return syncableMeta{
-		Path:     e.Path,
-		IDField:  e.IDField,
-		Critical: e.Critical,
+		Path:          e.Path,
+		IDField:       e.IDField,
+		Critical:      e.Critical,
+		Discriminator: discriminatorDispatchForEndpoint(e, types, resourceNameIndex),
+	}
+}
+
+func discriminatorDispatchForEndpoint(endpoint spec.Endpoint, types map[string]spec.TypeDef, resourceNameIndex map[string]string) DiscriminatorDispatch {
+	if endpoint.Response.Discriminator != nil {
+		dispatch := buildDiscriminatorDispatch(endpoint.Response.Discriminator.Field, endpoint.Response.Discriminator.Mapping, resourceNameIndex)
+		if len(dispatch.Mappings) >= 2 {
+			return dispatch
+		}
+	}
+
+	typeDef, ok := lookupTypeDef(endpoint.Response.Item, types)
+	if !ok {
+		return DiscriminatorDispatch{}
+	}
+	for _, field := range typeDef.Fields {
+		if !isDiscriminatorField(field.Name) || len(field.Enum) < 2 {
+			continue
+		}
+		mapping := make(map[string]string, len(field.Enum))
+		for _, value := range field.Enum {
+			mapping[value] = value
+		}
+		dispatch := buildDiscriminatorDispatch(field.Name, mapping, resourceNameIndex)
+		if len(dispatch.Mappings) >= 2 {
+			return dispatch
+		}
+	}
+	return DiscriminatorDispatch{}
+}
+
+func lookupTypeDef(name string, types map[string]spec.TypeDef) (spec.TypeDef, bool) {
+	if name == "" || len(types) == 0 {
+		return spec.TypeDef{}, false
+	}
+	if typeDef, ok := types[name]; ok {
+		return typeDef, true
+	}
+	normalized := normalizeName(name)
+	for typeName, typeDef := range types {
+		if normalizeName(typeName) == normalized {
+			return typeDef, true
+		}
+	}
+	return spec.TypeDef{}, false
+}
+
+func isDiscriminatorField(name string) bool {
+	switch strings.ToLower(strings.ReplaceAll(name, "_", "")) {
+	case "type", "kind", "typename", "objecttype":
+		return true
+	default:
+		return false
+	}
+}
+
+func buildDiscriminatorDispatch(field string, rawMapping map[string]string, resourceNameIndex map[string]string) DiscriminatorDispatch {
+	if strings.TrimSpace(field) == "" || len(rawMapping) == 0 || len(resourceNameIndex) == 0 {
+		return DiscriminatorDispatch{}
+	}
+	values := make([]string, 0, len(rawMapping))
+	for value := range rawMapping {
+		values = append(values, value)
+	}
+	sort.Strings(values)
+
+	seenResources := make(map[string]struct{})
+	dispatch := DiscriminatorDispatch{Field: field}
+	for _, value := range values {
+		target := rawMapping[value]
+		resource, ok := resourceNameForDiscriminatorTarget(target, resourceNameIndex)
+		if !ok {
+			resource, ok = resourceNameForDiscriminatorTarget(value, resourceNameIndex)
+		}
+		if !ok {
+			continue
+		}
+		dispatch.Mappings = append(dispatch.Mappings, DiscriminatorMapping{
+			Value:    value,
+			Resource: resource,
+		})
+		seenResources[resource] = struct{}{}
+	}
+	if len(seenResources) < 2 {
+		return DiscriminatorDispatch{}
+	}
+	return dispatch
+}
+
+func resourceNameForDiscriminatorTarget(target string, resourceNameIndex map[string]string) (string, bool) {
+	for _, variant := range nameVariants(target) {
+		if resource, ok := resourceNameIndex[variant]; ok {
+			return resource, true
+		}
 	}
+	return "", false
 }
 
 // sortedSyncableResources converts the per-resource metadata map into a sorted
@@ -973,10 +1101,11 @@ func sortedSyncableResources(m map[string]syncableMeta) []SyncableResource {
 	for i, name := range names {
 		meta := m[name]
 		resources[i] = SyncableResource{
-			Name:     name,
-			Path:     meta.Path,
-			IDField:  meta.IDField,
-			Critical: meta.Critical,
+			Name:          name,
+			Path:          meta.Path,
+			IDField:       meta.IDField,
+			Critical:      meta.Critical,
+			Discriminator: meta.Discriminator,
 		}
 	}
 	return resources
diff --git a/internal/profiler/profiler_test.go b/internal/profiler/profiler_test.go
index ac002f9e..b10cf0dc 100644
--- a/internal/profiler/profiler_test.go
+++ b/internal/profiler/profiler_test.go
@@ -134,6 +134,52 @@ func TestProfileEnumExpansion(t *testing.T) {
 	assert.Equal(t, "/v1/api/team", syncPaths["team"])
 }
 
+func TestProfileDiscriminatorDispatchFromResponseTypeEnum(t *testing.T) {
+	s := &spec.APISpec{
+		Name: "mixed-network",
+		Resources: map[string]spec.Resource{
+			"network_entities": {
+				Endpoints: map[string]spec.Endpoint{
+					"list": {
+						Method:     "GET",
+						Path:       "/network-entities",
+						Response:   spec.ResponseDef{Type: "array", Item: "NetworkEntity"},
+						Pagination: &spec.Pagination{CursorParam: "cursor", LimitParam: "limit"},
+					},
+				},
+			},
+			"workspaces":  {Endpoints: map[string]spec.Endpoint{"list": {Method: "GET", Path: "/workspaces"}}},
+			"collections": {Endpoints: map[string]spec.Endpoint{"list": {Method: "GET", Path: "/collections"}}},
+			"teams":       {Endpoints: map[string]spec.Endpoint{"list": {Method: "GET", Path: "/teams"}}},
+		},
+		Types: map[string]spec.TypeDef{
+			"NetworkEntity": {
+				Fields: []spec.TypeField{
+					{Name: "type", Type: "string", Enum: []string{"workspace", "collection", "team", "unknown"}},
+					{Name: "id", Type: "string"},
+				},
+			},
+		},
+	}
+
+	profile := Profile(s)
+
+	var mixed SyncableResource
+	for _, resource := range profile.SyncableResources {
+		if resource.Name == "network_entities" {
+			mixed = resource
+			break
+		}
+	}
+	require.Equal(t, "network_entities", mixed.Name)
+	require.Equal(t, "type", mixed.Discriminator.Field)
+	assert.Equal(t, []DiscriminatorMapping{
+		{Value: "collection", Resource: "collections"},
+		{Value: "team", Resource: "teams"},
+		{Value: "workspace", Resource: "workspaces"},
+	}, mixed.Discriminator.Mappings)
+}
+
 func TestProfileEnumExpansion_NoExpansionForNonEnum(t *testing.T) {
 	// Standard API without enum params should not be affected
 	profile := Profile(petstoreSpec())
diff --git a/internal/spec/spec.go b/internal/spec/spec.go
index 7705323c..62db7e05 100644
--- a/internal/spec/spec.go
+++ b/internal/spec/spec.go
@@ -637,8 +637,14 @@ type Param struct {
 }
 
 type ResponseDef struct {
-	Type string `yaml:"type" json:"type"` // object, array
-	Item string `yaml:"item" json:"item"` // type name
+	Type          string                 `yaml:"type" json:"type"` // object, array
+	Item          string                 `yaml:"item" json:"item"` // type name
+	Discriminator *ResponseDiscriminator `yaml:"discriminator,omitempty" json:"discriminator,omitempty"`
+}
+
+type ResponseDiscriminator struct {
+	Field   string            `yaml:"field" json:"field"`
+	Mapping map[string]string `yaml:"mapping,omitempty" json:"mapping,omitempty"` // discriminator value -> schema/resource name
 }
 
 type Pagination struct {
@@ -654,8 +660,9 @@ type TypeDef struct {
 }
 
 type TypeField struct {
-	Name string `yaml:"name" json:"name"`
-	Type string `yaml:"type" json:"type"`
+	Name string   `yaml:"name" json:"name"`
+	Type string   `yaml:"type" json:"type"`
+	Enum []string `yaml:"enum,omitempty" json:"enum,omitempty"`
 	// Selection is an optional GraphQL sub-selection rendered when this field
 	// is used in a generated GraphQL query. It lets wrapper specs keep the Go
 	// field simple (for example, totalPriceSet as json.RawMessage) while still

← 263be3b3 fix(cli): strip build/ from publish staging (#502)  ·  back to Cli Printing Press  ·  fix(cli): honor documented typed exit codes (#504) 31e63be4 →