← back to Cli Printing Press
fix(cli): handle PascalCase .NET-shape API responses in sync (#1135) (#1174)
84138f91da785001da9b105ce647cbfff0bdb386 · 2026-05-12 01:30:34 -0700 · Trevin Chow
Four compounding bugs caused generated CLIs to produce silently empty
local stores when the target API used .NET REST conventions (PascalCase
JSON keys like Id, Name; Items envelopes). Each was small in isolation;
together they collapsed N rows onto two on PK conflict ("true"/"false"
once a boolean was incorrectly chosen as the static IDField).
- sync.go.tmpl: extend pageItemKeys with PascalCase variants so {"Items":[...]}
envelopes extract through the fast path instead of falling through to
the ambiguity scan.
- store.go.tmpl: add "Id" to extractObjectID's lookup list, ordered
after lowercase "id" so the existing precedence holds.
- store.go.tmpl: append a PascalCase pass to LookupFieldValue after the
camelCase pass. Constructs the key explicitly without mutating shared
state from the prior loop.
- parser.go: new isPlausibleIDFieldSchema predicate rejects boolean,
enum, and date/date-time fields from tier-5 IDField selection — these
are structurally non-identifier-shaped and collapse rows on upsert.
Tests cover the runtime envelope/field-key paths via the inline
generated-CLI pattern (extractPageItems, extractObjectID, and
LookupFieldValue across .NET shapes plus mixed-casing precedence) and
the static parser tier-5 path for boolean, enum, date, and date-only
rejection.
Files touched
M internal/generator/generator_test.goM internal/generator/templates/store.go.tmplM internal/generator/templates/sync.go.tmplM internal/openapi/parser.goM internal/openapi/parser_test.goM internal/profiler/profiler.goM testdata/golden/expected/generate-golden-api/printing-press-golden/internal/cli/sync.goM testdata/golden/expected/generate-sync-walker-api/sync-walker-golden/internal/cli/sync.goM testdata/golden/expected/generate-sync-walker-api/sync-walker-golden/internal/store/store.goM testdata/golden/expected/generate-tier-routing-api/tier-routing-golden/internal/cli/sync.go
Diff
commit 84138f91da785001da9b105ce647cbfff0bdb386
Author: Trevin Chow <trevin@trevinchow.com>
Date: Tue May 12 01:30:34 2026 -0700
fix(cli): handle PascalCase .NET-shape API responses in sync (#1135) (#1174)
Four compounding bugs caused generated CLIs to produce silently empty
local stores when the target API used .NET REST conventions (PascalCase
JSON keys like Id, Name; Items envelopes). Each was small in isolation;
together they collapsed N rows onto two on PK conflict ("true"/"false"
once a boolean was incorrectly chosen as the static IDField).
- sync.go.tmpl: extend pageItemKeys with PascalCase variants so {"Items":[...]}
envelopes extract through the fast path instead of falling through to
the ambiguity scan.
- store.go.tmpl: add "Id" to extractObjectID's lookup list, ordered
after lowercase "id" so the existing precedence holds.
- store.go.tmpl: append a PascalCase pass to LookupFieldValue after the
camelCase pass. Constructs the key explicitly without mutating shared
state from the prior loop.
- parser.go: new isPlausibleIDFieldSchema predicate rejects boolean,
enum, and date/date-time fields from tier-5 IDField selection — these
are structurally non-identifier-shaped and collapse rows on upsert.
Tests cover the runtime envelope/field-key paths via the inline
generated-CLI pattern (extractPageItems, extractObjectID, and
LookupFieldValue across .NET shapes plus mixed-casing precedence) and
the static parser tier-5 path for boolean, enum, date, and date-only
rejection.
---
internal/generator/generator_test.go | 180 +++++++++++++++++++++
internal/generator/templates/store.go.tmpl | 21 ++-
internal/generator/templates/sync.go.tmpl | 9 +-
internal/openapi/parser.go | 43 ++++-
internal/openapi/parser_test.go | 74 +++++++++
internal/profiler/profiler.go | 2 +
.../printing-press-golden/internal/cli/sync.go | 9 +-
.../sync-walker-golden/internal/cli/sync.go | 9 +-
.../sync-walker-golden/internal/store/store.go | 21 ++-
.../tier-routing-golden/internal/cli/sync.go | 9 +-
10 files changed, 353 insertions(+), 24 deletions(-)
diff --git a/internal/generator/generator_test.go b/internal/generator/generator_test.go
index c7147496..381eeefa 100644
--- a/internal/generator/generator_test.go
+++ b/internal/generator/generator_test.go
@@ -2655,6 +2655,186 @@ func TestExtractPageItemsNoCursor(t *testing.T) {
runGoCommandRequired(t, outputDir, "test", "-run", "TestExtractPageItems", "./internal/cli")
}
+// TestGeneratedSyncHandlesPascalCaseDotNetShape verifies the generated sync +
+// store paths recognize .NET-shape PascalCase envelopes ("Items"), PKs ("Id"),
+// and field keys (LookupFieldValue PascalCase pass). Parser-side tier-5 PK
+// selection is covered separately in internal/openapi/parser_test.go.
+func TestGeneratedSyncHandlesPascalCaseDotNetShape(t *testing.T) {
+ t.Parallel()
+
+ apiSpec := &spec.APISpec{
+ Name: "dotnet",
+ Version: "0.1.0",
+ BaseURL: "https://dotnet.example.com",
+ Auth: spec.AuthConfig{Type: "none"},
+ Config: spec.ConfigSpec{
+ Format: "toml",
+ Path: "~/.config/dotnet-pp-cli/config.toml",
+ },
+ Resources: map[string]spec.Resource{
+ "orders": {
+ Description: "Orders",
+ Endpoints: map[string]spec.Endpoint{
+ "list": {
+ Method: "GET",
+ Path: "/orders",
+ Description: "List orders",
+ Response: spec.ResponseDef{Type: "array"},
+ },
+ },
+ },
+ },
+ }
+
+ 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())
+
+ inlineTest := `package cli
+
+import (
+ "encoding/json"
+ "testing"
+
+ "` + naming.CLI(apiSpec.Name) + `/internal/store"
+)
+
+func TestExtractPageItemsPascalCaseItemsEnvelope(t *testing.T) {
+ body := []byte(` + "`" + `{
+ "Items": [{"Id": "ord-1"}, {"Id": "ord-2"}],
+ "TotalItems": 2
+ }` + "`" + `)
+ items, _, _ := extractPageItems(json.RawMessage(body), "cursor")
+ if len(items) != 2 {
+ t.Fatalf("Items envelope: want 2 items, got %d", len(items))
+ }
+ var first map[string]any
+ if err := json.Unmarshal(items[0], &first); err != nil {
+ t.Fatalf("unmarshal item[0]: %v", err)
+ }
+ if first["Id"] != "ord-1" {
+ t.Fatalf("want item[0].Id=ord-1, got %v", first["Id"])
+ }
+}
+
+func TestExtractPageItemsPascalCaseDataEnvelope(t *testing.T) {
+ body := []byte(` + "`" + `{
+ "Data": [{"Id": "a"}, {"Id": "b"}, {"Id": "c"}]
+ }` + "`" + `)
+ items, _, _ := extractPageItems(json.RawMessage(body), "cursor")
+ if len(items) != 3 {
+ t.Fatalf("Data envelope: want 3 items, got %d", len(items))
+ }
+ var third map[string]any
+ if err := json.Unmarshal(items[2], &third); err != nil {
+ t.Fatalf("unmarshal item[2]: %v", err)
+ }
+ if third["Id"] != "c" {
+ t.Fatalf("want item[2].Id=c, got %v", third["Id"])
+ }
+}
+
+func TestExtractPageItemsPascalCaseResultsEnvelope(t *testing.T) {
+ body := []byte(` + "`" + `{
+ "Results": [{"Id": "x"}]
+ }` + "`" + `)
+ items, _, _ := extractPageItems(json.RawMessage(body), "cursor")
+ if len(items) != 1 {
+ t.Fatalf("Results envelope: want 1 item, got %d", len(items))
+ }
+ var only map[string]any
+ if err := json.Unmarshal(items[0], &only); err != nil {
+ t.Fatalf("unmarshal item[0]: %v", err)
+ }
+ if only["Id"] != "x" {
+ t.Fatalf("want item[0].Id=x, got %v", only["Id"])
+ }
+}
+
+// TestExtractPageItemsLowercaseTakesPriorityOverPascal pins the documented
+// scan ordering: when both lowercase and PascalCase wrapper keys appear in
+// the same response, the lowercase entry wins.
+func TestExtractPageItemsLowercaseTakesPriorityOverPascal(t *testing.T) {
+ body := []byte(` + "`" + `{
+ "items": [{"id": "lower"}],
+ "Items": [{"Id": "pascal"}]
+ }` + "`" + `)
+ items, _, _ := extractPageItems(json.RawMessage(body), "cursor")
+ if len(items) != 1 {
+ t.Fatalf("want 1 item from lowercase envelope, got %d", len(items))
+ }
+ var first map[string]any
+ if err := json.Unmarshal(items[0], &first); err != nil {
+ t.Fatalf("unmarshal item[0]: %v", err)
+ }
+ if first["id"] != "lower" {
+ t.Fatalf("lowercase must win: want lower, got %v", first["id"])
+ }
+}
+
+func TestLookupFieldValuePascalCase(t *testing.T) {
+ obj := map[string]any{"Id": "abc", "OrderTotal": float64(42)}
+
+ if v := store.LookupFieldValue(obj, "id"); v != "abc" {
+ t.Fatalf("LookupFieldValue id: want abc, got %v", v)
+ }
+ if v := store.LookupFieldValue(obj, "order_total"); v != float64(42) {
+ t.Fatalf("LookupFieldValue order_total: want 42, got %v", v)
+ }
+}
+
+func TestLookupFieldValueCamelCaseStillResolves(t *testing.T) {
+ obj := map[string]any{"orderTotal": float64(7)}
+ if v := store.LookupFieldValue(obj, "order_total"); v != float64(7) {
+ t.Fatalf("camelCase: want 7, got %v", v)
+ }
+}
+
+func TestLookupFieldValueSnakeCasePrecedence(t *testing.T) {
+ obj := map[string]any{
+ "order_total": float64(1),
+ "orderTotal": float64(2),
+ "OrderTotal": float64(3),
+ }
+ if v := store.LookupFieldValue(obj, "order_total"); v != float64(1) {
+ t.Fatalf("snake_case must win: got %v", v)
+ }
+}
+`
+ testPath := filepath.Join(outputDir, "internal", "cli", "sync_pascalcase_test.go")
+ require.NoError(t, os.WriteFile(testPath, []byte(inlineTest), 0o644))
+
+ const storeInlineTest = `package store
+
+import "testing"
+
+func TestExtractObjectIDPascalCaseId(t *testing.T) {
+ if got := extractObjectID(map[string]any{"Id": "ord-1"}); got != "ord-1" {
+ t.Fatalf("PascalCase Id: want ord-1, got %q", got)
+ }
+}
+
+func TestExtractObjectIDLowercaseIdWinsOverPascal(t *testing.T) {
+ if got := extractObjectID(map[string]any{"id": "lower", "Id": "pascal"}); got != "lower" {
+ t.Fatalf("lowercase id must win over PascalCase Id: got %q", got)
+ }
+}
+
+func TestExtractObjectIDPascalCaseIdWinsOverUppercaseID(t *testing.T) {
+ if got := extractObjectID(map[string]any{"ID": "upper", "Id": "pascal"}); got != "pascal" {
+ t.Fatalf("PascalCase Id must precede uppercase ID: got %q", got)
+ }
+}
+`
+ storeTestPath := filepath.Join(outputDir, "internal", "store", "store_pascalcase_test.go")
+ require.NoError(t, os.WriteFile(storeTestPath, []byte(storeInlineTest), 0o644))
+
+ runGoCommandRequired(t, outputDir, "mod", "tidy")
+ runGoCommandRequired(t, outputDir, "test", "-run", "TestExtractPageItemsPascalCase|TestLookupFieldValue", "./internal/cli")
+ runGoCommandRequired(t, outputDir, "test", "-run", "TestExtractObjectID", "./internal/store")
+}
+
func adsCampaignSpec() *spec.APISpec {
return &spec.APISpec{
Name: "ads",
diff --git a/internal/generator/templates/store.go.tmpl b/internal/generator/templates/store.go.tmpl
index ed754e3e..fe54b419 100644
--- a/internal/generator/templates/store.go.tmpl
+++ b/internal/generator/templates/store.go.tmpl
@@ -716,7 +716,7 @@ func (s *Store) Search(query string, limit int) ([]json.RawMessage, error) {
}
func extractObjectID(obj map[string]any) string {
- for _, key := range []string{"id", "ID", "uuid", "slug", "name"} {
+ for _, key := range []string{"id", "Id", "ID", "uuid", "slug", "name"} {
if v, ok := obj[key]; ok {
return fmt.Sprintf("%v", v)
}
@@ -739,10 +739,12 @@ func ftsRowID(scope, id string) int64 {
return int64(h & 0x7FFFFFFFFFFFFFFF) // ensure positive
}
-// LookupFieldValue resolves a field value from a JSON object map, trying
-// the snake_case key first and the camelCase rendering second. Exported so
-// the sync command's extractID and the upsert path resolve fields the same
-// way — a divergence here produces silent drops on heterogeneous payloads.
+// LookupFieldValue resolves a field value from a JSON object map, trying the
+// snake_case key first, then the camelCase rendering, then the PascalCase
+// rendering. Exported so the sync command's extractID and the upsert path
+// resolve fields the same way — a divergence here produces silent drops on
+// heterogeneous payloads. The PascalCase pass handles .NET-shaped responses
+// (`Id`, `Name`, `OrderId`) without forcing each spec to declare casing.
func LookupFieldValue(obj map[string]any, snakeKey string) any {
if v, ok := obj[snakeKey]; ok {
return sqliteFieldValue(v)
@@ -754,9 +756,16 @@ func LookupFieldValue(obj map[string]any, snakeKey string) any {
}
parts[i] = strings.ToUpper(parts[i][:1]) + parts[i][1:]
}
- if v, ok := obj[strings.Join(parts, "")]; ok {
+ camel := strings.Join(parts, "")
+ if v, ok := obj[camel]; ok {
return sqliteFieldValue(v)
}
+ if parts[0] != "" {
+ pascal := strings.ToUpper(parts[0][:1]) + parts[0][1:] + strings.Join(parts[1:], "")
+ if v, ok := obj[pascal]; ok {
+ return sqliteFieldValue(v)
+ }
+ }
return nil
}
diff --git a/internal/generator/templates/sync.go.tmpl b/internal/generator/templates/sync.go.tmpl
index 68de92fe..ce917d5b 100644
--- a/internal/generator/templates/sync.go.tmpl
+++ b/internal/generator/templates/sync.go.tmpl
@@ -1384,7 +1384,14 @@ var resourceIDFieldOverrides = map[string]string{
// annotations (x-resource-id), not this list.
var genericIDFieldFallbacks = []string{"id", "ID", "name", "uuid", "slug", "key", "code", "uid"}
-var pageItemKeys = []string{"data", "results", "items", "records", "nodes", "entries"}
+// pageItemKeys is scanned in priority order; lowercase REST-convention keys
+// come first, PascalCase .NET variants second. Without the PascalCase row,
+// {"Items": [...]} envelopes fall through to the ambiguity scan and a
+// single-array sibling miscount silently truncates sync.
+var pageItemKeys = []string{
+ "data", "results", "items", "records", "nodes", "entries",
+ "Data", "Results", "Items", "Records", "Nodes", "Entries",
+}
// criticalResources is the template-time projection of per-resource Critical
// (set by the profiler from the spec's path-item x-critical extension). It
diff --git a/internal/openapi/parser.go b/internal/openapi/parser.go
index 48848b5a..9d755a59 100644
--- a/internal/openapi/parser.go
+++ b/internal/openapi/parser.go
@@ -3024,18 +3024,23 @@ func resolveIDFieldFromResponseSchema(op *openapi3.Operation, resourceName strin
return "name"
}
- // Tier 5: first scalar field appearing in the schema's required[] array,
- // matched against properties in their schema-declared order. kin-openapi
- // preserves YAML/JSON property order in MapKeys/Extensions but not via
- // range over Properties (it's a Go map). Fall back to iterating the
- // required[] slice itself: that order is stable and is what spec authors
+ // Tier 5: first plausible-PK scalar field appearing in the schema's
+ // required[] array, matched against properties in their schema-declared
+ // order. kin-openapi preserves YAML/JSON property order in MapKeys/Extensions
+ // but not via range over Properties (it's a Go map). Fall back to iterating
+ // the required[] slice itself: that order is stable and is what spec authors
// intend when they care about which field "wins."
+ //
+ // "Plausible-PK" excludes boolean, enum, and date/date-time fields even
+ // though they are scalar — they are structurally low-cardinality or
+ // non-identifier-shaped, so committing them as a runtime override
+ // collapses unrelated rows onto the same PK during upsert.
for _, fieldName := range itemSchema.Required {
propRef, ok := itemSchema.Properties[fieldName]
if !ok || propRef == nil || propRef.Value == nil {
continue
}
- if isScalarSchema(propRef.Value) {
+ if isPlausibleIDFieldSchema(propRef.Value) {
return fieldName
}
}
@@ -3181,8 +3186,7 @@ func singleArrayProperty(schema *openapi3.Schema) *openapi3.Schema {
// isScalarSchema reports whether the schema's type is a scalar — string,
// integer, number, or boolean. Excludes objects, arrays, and refs that resolve
-// to either. Used by tier 4 of the IDField fallback chain to skip non-scalar
-// fields when picking a primary key.
+// to either.
func isScalarSchema(schema *openapi3.Schema) bool {
if schema == nil || schema.Type == nil {
return false
@@ -3197,6 +3201,29 @@ func isScalarSchema(schema *openapi3.Schema) bool {
return false
}
+// isPlausibleIDFieldSchema is the tier-5 PK predicate: a scalar that is also
+// not boolean, not enum-restricted, and not date/date-time formatted. Booleans
+// have cardinality 2 (true/false), enums have hand-picked low cardinality, and
+// date/date-time fields are timestamps — none can serve as a primary key
+// without collapsing distinct rows during upsert. See profiler.resourceIDFieldOverrides
+// and store.UpsertBatch.
+func isPlausibleIDFieldSchema(schema *openapi3.Schema) bool {
+ if !isScalarSchema(schema) {
+ return false
+ }
+ if schema.Type.Includes(openapi3.TypeBoolean) {
+ return false
+ }
+ if len(schema.Enum) > 0 {
+ return false
+ }
+ format := strings.ToLower(schema.Format)
+ if format == "date" || format == "date-time" {
+ return false
+ }
+ return true
+}
+
func mapTypes(doc *openapi3.T, out *spec.APISpec) {
if doc == nil || doc.Components == nil {
return
diff --git a/internal/openapi/parser_test.go b/internal/openapi/parser_test.go
index 750755b4..211b53b9 100644
--- a/internal/openapi/parser_test.go
+++ b/internal/openapi/parser_test.go
@@ -3897,6 +3897,80 @@ func TestParseIDFieldFallbackChain(t *testing.T) {
type: object
properties:
x: {type: string}
+`,
+ wantID: "",
+ },
+ {
+ // A required boolean must not be picked as the PK — booleans
+ // collapse N rows onto "true"/"false" during upsert.
+ name: "tier 5: boolean required field is skipped",
+ schemaYAML: ` type: object
+ required: [is_active, sku]
+ properties:
+ is_active: {type: boolean}
+ sku: {type: string}
+`,
+ wantID: "sku",
+ },
+ {
+ // A required enum-restricted string must not be picked — enums
+ // have hand-picked low cardinality and collapse distinct rows onto
+ // the same PK during upsert.
+ name: "tier 5: enum-restricted string is skipped",
+ schemaYAML: ` type: object
+ required: [status, ticker]
+ properties:
+ status:
+ type: string
+ enum: [active, paused, closed]
+ ticker: {type: string}
+`,
+ wantID: "ticker",
+ },
+ {
+ // A required date-time field must not be picked — timestamps are
+ // structurally non-identifier-shaped and often shared across
+ // batches of records.
+ name: "tier 5: date-time formatted field is skipped",
+ schemaYAML: ` type: object
+ required: [created_at, order_number]
+ properties:
+ created_at:
+ type: string
+ format: date-time
+ order_number: {type: string}
+`,
+ wantID: "order_number",
+ },
+ {
+ // Date-only format must also be skipped — same uniqueness concern
+ // as date-time.
+ name: "tier 5: date-only formatted field is skipped",
+ schemaYAML: ` type: object
+ required: [delivery_date, tracking_code]
+ properties:
+ delivery_date:
+ type: string
+ format: date
+ tracking_code: {type: string}
+`,
+ wantID: "tracking_code",
+ },
+ {
+ // All required fields are non-plausible-PK — empty result so
+ // templates fall through to runtime fallbacks instead of locking
+ // in a poison override.
+ name: "tier 5: empty when only boolean/enum/date-time required fields exist",
+ schemaYAML: ` type: object
+ required: [is_active, status, created_at]
+ properties:
+ is_active: {type: boolean}
+ status:
+ type: string
+ enum: [active, paused]
+ created_at:
+ type: string
+ format: date-time
`,
wantID: "",
},
diff --git a/internal/profiler/profiler.go b/internal/profiler/profiler.go
index ec9130c6..a4aee16c 100644
--- a/internal/profiler/profiler.go
+++ b/internal/profiler/profiler.go
@@ -750,6 +750,8 @@ func isListEndpoint(name string, endpoint spec.Endpoint, types map[string]spec.T
// wrapperArrayKeys are response object field names that indicate the object
// wraps a list of items. Kept in sync with extractPageItems in sync.go.tmpl.
+// hasWrapperArrayField lowercases each field name before lookup, so
+// PascalCase variants ("Items", "Data") match the lowercase entries here.
var wrapperArrayKeys = map[string]bool{
"data": true,
"results": true,
diff --git a/testdata/golden/expected/generate-golden-api/printing-press-golden/internal/cli/sync.go b/testdata/golden/expected/generate-golden-api/printing-press-golden/internal/cli/sync.go
index 041df0f6..78de9fa7 100644
--- a/testdata/golden/expected/generate-golden-api/printing-press-golden/internal/cli/sync.go
+++ b/testdata/golden/expected/generate-golden-api/printing-press-golden/internal/cli/sync.go
@@ -1269,7 +1269,14 @@ var resourceIDFieldOverrides = map[string]string{
// annotations (x-resource-id), not this list.
var genericIDFieldFallbacks = []string{"id", "ID", "name", "uuid", "slug", "key", "code", "uid"}
-var pageItemKeys = []string{"data", "results", "items", "records", "nodes", "entries"}
+// pageItemKeys is scanned in priority order; lowercase REST-convention keys
+// come first, PascalCase .NET variants second. Without the PascalCase row,
+// {"Items": [...]} envelopes fall through to the ambiguity scan and a
+// single-array sibling miscount silently truncates sync.
+var pageItemKeys = []string{
+ "data", "results", "items", "records", "nodes", "entries",
+ "Data", "Results", "Items", "Records", "Nodes", "Entries",
+}
// criticalResources is the template-time projection of per-resource Critical
// (set by the profiler from the spec's path-item x-critical extension). It
diff --git a/testdata/golden/expected/generate-sync-walker-api/sync-walker-golden/internal/cli/sync.go b/testdata/golden/expected/generate-sync-walker-api/sync-walker-golden/internal/cli/sync.go
index f0475f0c..19f0b86c 100644
--- a/testdata/golden/expected/generate-sync-walker-api/sync-walker-golden/internal/cli/sync.go
+++ b/testdata/golden/expected/generate-sync-walker-api/sync-walker-golden/internal/cli/sync.go
@@ -1262,7 +1262,14 @@ var resourceIDFieldOverrides = map[string]string{
// annotations (x-resource-id), not this list.
var genericIDFieldFallbacks = []string{"id", "ID", "name", "uuid", "slug", "key", "code", "uid"}
-var pageItemKeys = []string{"data", "results", "items", "records", "nodes", "entries"}
+// pageItemKeys is scanned in priority order; lowercase REST-convention keys
+// come first, PascalCase .NET variants second. Without the PascalCase row,
+// {"Items": [...]} envelopes fall through to the ambiguity scan and a
+// single-array sibling miscount silently truncates sync.
+var pageItemKeys = []string{
+ "data", "results", "items", "records", "nodes", "entries",
+ "Data", "Results", "Items", "Records", "Nodes", "Entries",
+}
// criticalResources is the template-time projection of per-resource Critical
// (set by the profiler from the spec's path-item x-critical extension). It
diff --git a/testdata/golden/expected/generate-sync-walker-api/sync-walker-golden/internal/store/store.go b/testdata/golden/expected/generate-sync-walker-api/sync-walker-golden/internal/store/store.go
index dbfaf727..374b95c3 100644
--- a/testdata/golden/expected/generate-sync-walker-api/sync-walker-golden/internal/store/store.go
+++ b/testdata/golden/expected/generate-sync-walker-api/sync-walker-golden/internal/store/store.go
@@ -670,7 +670,7 @@ func (s *Store) Search(query string, limit int) ([]json.RawMessage, error) {
}
func extractObjectID(obj map[string]any) string {
- for _, key := range []string{"id", "ID", "uuid", "slug", "name"} {
+ for _, key := range []string{"id", "Id", "ID", "uuid", "slug", "name"} {
if v, ok := obj[key]; ok {
return fmt.Sprintf("%v", v)
}
@@ -693,10 +693,12 @@ func ftsRowID(scope, id string) int64 {
return int64(h & 0x7FFFFFFFFFFFFFFF) // ensure positive
}
-// LookupFieldValue resolves a field value from a JSON object map, trying
-// the snake_case key first and the camelCase rendering second. Exported so
-// the sync command's extractID and the upsert path resolve fields the same
-// way — a divergence here produces silent drops on heterogeneous payloads.
+// LookupFieldValue resolves a field value from a JSON object map, trying the
+// snake_case key first, then the camelCase rendering, then the PascalCase
+// rendering. Exported so the sync command's extractID and the upsert path
+// resolve fields the same way — a divergence here produces silent drops on
+// heterogeneous payloads. The PascalCase pass handles .NET-shaped responses
+// (`Id`, `Name`, `OrderId`) without forcing each spec to declare casing.
func LookupFieldValue(obj map[string]any, snakeKey string) any {
if v, ok := obj[snakeKey]; ok {
return sqliteFieldValue(v)
@@ -708,9 +710,16 @@ func LookupFieldValue(obj map[string]any, snakeKey string) any {
}
parts[i] = strings.ToUpper(parts[i][:1]) + parts[i][1:]
}
- if v, ok := obj[strings.Join(parts, "")]; ok {
+ camel := strings.Join(parts, "")
+ if v, ok := obj[camel]; ok {
return sqliteFieldValue(v)
}
+ if parts[0] != "" {
+ pascal := strings.ToUpper(parts[0][:1]) + parts[0][1:] + strings.Join(parts[1:], "")
+ if v, ok := obj[pascal]; ok {
+ return sqliteFieldValue(v)
+ }
+ }
return nil
}
diff --git a/testdata/golden/expected/generate-tier-routing-api/tier-routing-golden/internal/cli/sync.go b/testdata/golden/expected/generate-tier-routing-api/tier-routing-golden/internal/cli/sync.go
index 6d4386f0..d1ba5f4b 100644
--- a/testdata/golden/expected/generate-tier-routing-api/tier-routing-golden/internal/cli/sync.go
+++ b/testdata/golden/expected/generate-tier-routing-api/tier-routing-golden/internal/cli/sync.go
@@ -980,7 +980,14 @@ var resourceIDFieldOverrides = map[string]string{}
// annotations (x-resource-id), not this list.
var genericIDFieldFallbacks = []string{"id", "ID", "name", "uuid", "slug", "key", "code", "uid"}
-var pageItemKeys = []string{"data", "results", "items", "records", "nodes", "entries"}
+// pageItemKeys is scanned in priority order; lowercase REST-convention keys
+// come first, PascalCase .NET variants second. Without the PascalCase row,
+// {"Items": [...]} envelopes fall through to the ambiguity scan and a
+// single-array sibling miscount silently truncates sync.
+var pageItemKeys = []string{
+ "data", "results", "items", "records", "nodes", "entries",
+ "Data", "Results", "Items", "Records", "Nodes", "Entries",
+}
// criticalResources is the template-time projection of per-resource Critical
// (set by the profiler from the spec's path-item x-critical extension). It
← 7ba9c20a fix(cli): accept backtick raw-string Use: in dogfood walkers
·
back to Cli Printing Press
·
fix(cli): credit auth_protocol on structural OAuth surface, e8c03cff →