[object Object]

← back to Cli Printing Press

fix(cli): detect integer page paginator and advance numerically in sync (#1519)

0c3c8bccc55fdb8a63693927eaa3dada519195b0 · 2026-05-16 12:04:00 -0700 · Trevin Chow

* fix(cli): detect integer page paginator and advance numerically in sync

OpenAPI specs that paginate by integer ?page=N (Freshworks family,
Atlassian Cloud, HubSpot, ~30-40% of REST APIs) used to fall through
detectPagination's "after" cursor default, so the generated sync loop
tried to extract a body cursor that never existed and terminated after
page 1.

The OpenAPI parser now recognizes page / page_number / pageNumber /
page[number] as a Type="page" paginator after the existing
cursor/token/offset branches (cursor wins on mixed-form specs). The
sync template adds a runtime fallback: when cursorParam == "page" and
no body cursor is extracted but the page is full, advance the integer
counter so subsequent pages are fetched. Mirrored in the
dependent-resource sync loop for parity.

Link-header pagination (the other case called out in #1296) is a
separate code path (HTTP header parsing rather than body extraction)
and stays a follow-up.

Closes #1296

* fix(cli): guard page-int sync fallback on cursor type, not param name

Greptile flagged that the original guard `pageSize.cursorParam == "page"`
only fires for APIs that literally name the param `page`. The parser
also recognises `page_number`, `pageNumber`, and `page[number]` and
preserves their original case in CursorParam, so those three variants
would silently skip the fallback and still truncate after page 1.

Plumb the paginator class through the runtime: PaginationProfile gains
CursorType (aggregated from endpoint.Pagination.Type), determine-
PaginationDefaults emits both cursorParam (request-key name) and
cursorType ("page"/"cursor"/…) into paginationDefaults, and the two
fallback blocks compare on cursorType so every canonical spelling
shares the same guard.

Generator test is now parametrised over all four spellings.

---------

Co-authored-by: mergify[bot] <37929162+mergify[bot]@users.noreply.github.com>

Files touched

Diff

commit 0c3c8bccc55fdb8a63693927eaa3dada519195b0
Author: Trevin Chow <trevin@trevinchow.com>
Date:   Sat May 16 12:04:00 2026 -0700

    fix(cli): detect integer page paginator and advance numerically in sync (#1519)
    
    * fix(cli): detect integer page paginator and advance numerically in sync
    
    OpenAPI specs that paginate by integer ?page=N (Freshworks family,
    Atlassian Cloud, HubSpot, ~30-40% of REST APIs) used to fall through
    detectPagination's "after" cursor default, so the generated sync loop
    tried to extract a body cursor that never existed and terminated after
    page 1.
    
    The OpenAPI parser now recognizes page / page_number / pageNumber /
    page[number] as a Type="page" paginator after the existing
    cursor/token/offset branches (cursor wins on mixed-form specs). The
    sync template adds a runtime fallback: when cursorParam == "page" and
    no body cursor is extracted but the page is full, advance the integer
    counter so subsequent pages are fetched. Mirrored in the
    dependent-resource sync loop for parity.
    
    Link-header pagination (the other case called out in #1296) is a
    separate code path (HTTP header parsing rather than body extraction)
    and stays a follow-up.
    
    Closes #1296
    
    * fix(cli): guard page-int sync fallback on cursor type, not param name
    
    Greptile flagged that the original guard `pageSize.cursorParam == "page"`
    only fires for APIs that literally name the param `page`. The parser
    also recognises `page_number`, `pageNumber`, and `page[number]` and
    preserves their original case in CursorParam, so those three variants
    would silently skip the fallback and still truncate after page 1.
    
    Plumb the paginator class through the runtime: PaginationProfile gains
    CursorType (aggregated from endpoint.Pagination.Type), determine-
    PaginationDefaults emits both cursorParam (request-key name) and
    cursorType ("page"/"cursor"/…) into paginationDefaults, and the two
    fallback blocks compare on cursorType so every canonical spelling
    shares the same guard.
    
    Generator test is now parametrised over all four spellings.
    
    ---------
    
    Co-authored-by: mergify[bot] <37929162+mergify[bot]@users.noreply.github.com>
---
 internal/generator/generator_test.go               | 83 ++++++++++++++++++++++
 internal/generator/templates/sync.go.tmpl          | 30 ++++++++
 internal/openapi/parser.go                         | 13 ++++
 internal/openapi/parser_test.go                    | 60 ++++++++++++++++
 internal/profiler/profiler.go                      |  6 ++
 internal/spec/spec.go                              |  4 +-
 .../printing-press-golden/internal/cli/sync.go     | 30 ++++++++
 .../sync-walker-golden/internal/cli/sync.go        | 30 ++++++++
 .../tier-routing-golden/internal/cli/sync.go       | 17 +++++
 9 files changed, 271 insertions(+), 2 deletions(-)

diff --git a/internal/generator/generator_test.go b/internal/generator/generator_test.go
index 7750aff1..2d14e6dc 100644
--- a/internal/generator/generator_test.go
+++ b/internal/generator/generator_test.go
@@ -2851,6 +2851,89 @@ func TestExtractPageItemsNoCursor(t *testing.T) {
 	runGoCommandRequired(t, outputDir, "test", "-run", "TestExtractPageItems", "./internal/cli")
 }
 
+// TestSyncPageIntPaginationAdvancesAfterFullPage guards #1296: APIs that
+// paginate by integer ?page=N (Freshworks, HubSpot, Atlassian, …) emit
+// no body cursor, so extractPageItems returns ("", false). The runtime
+// must detect the paginator type (not the raw param name) and advance
+// the integer counter when the page is full, otherwise sync stops after
+// page 1. Parametrized over every canonical page-int spelling so all
+// four variants share the same guard regardless of casing.
+func TestSyncPageIntPaginationAdvancesAfterFullPage(t *testing.T) {
+	t.Parallel()
+
+	cases := []struct {
+		name      string
+		paramName string
+	}{
+		{"plain page", "page"},
+		{"snake_case page_number", "page_number"},
+		{"camelCase pageNumber", "pageNumber"},
+		{"json:api page[number]", "page[number]"},
+	}
+
+	for _, tc := range cases {
+		t.Run(tc.name, func(t *testing.T) {
+			t.Parallel()
+
+			apiSpec := &spec.APISpec{
+				Name:    "freshy",
+				Version: "0.1.0",
+				BaseURL: "https://freshy.example.com",
+				Auth:    spec.AuthConfig{Type: "none"},
+				Config: spec.ConfigSpec{
+					Format: "toml",
+					Path:   "~/.config/freshy-pp-cli/config.toml",
+				},
+				Resources: map[string]spec.Resource{
+					"tickets": {
+						Description: "Tickets",
+						Endpoints: map[string]spec.Endpoint{
+							"list": {
+								Method:      "GET",
+								Path:        "/tickets",
+								Description: "List tickets",
+								Response:    spec.ResponseDef{Type: "array"},
+								Params: []spec.Param{
+									{Name: tc.paramName, Type: "integer"},
+									{Name: "per_page", Type: "integer"},
+								},
+								Pagination: &spec.Pagination{
+									Type:        "page",
+									CursorParam: tc.paramName,
+									LimitParam:  "per_page",
+								},
+							},
+						},
+					},
+				},
+			}
+
+			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)
+
+			// The guard must compare on cursorType so every canonical
+			// page-int spelling (page, page_number, pageNumber,
+			// page[number]) fires the fallback.
+			assert.Contains(t, src, `pageSize.cursorType == "page"`,
+				"sync.go must guard the page-int fallback on cursorType, not the raw param name")
+			assert.Contains(t, src, `strconv.Itoa(currentPage + 1)`,
+				"page-int fallback must increment the integer cursor")
+			assert.Contains(t, src, `cursorType:  "page"`,
+				"determinePaginationDefaults must emit cursorType \"page\" when the profile selects it")
+			// CursorParam still carries the original-case param name so
+			// the HTTP request key matches the spec.
+			assert.Contains(t, src, `cursorParam: "`+tc.paramName+`"`,
+				"determinePaginationDefaults must preserve the original-case page-int param name")
+		})
+	}
+}
+
 // 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
diff --git a/internal/generator/templates/sync.go.tmpl b/internal/generator/templates/sync.go.tmpl
index 889bb7c9..1ab413e3 100644
--- a/internal/generator/templates/sync.go.tmpl
+++ b/internal/generator/templates/sync.go.tmpl
@@ -553,6 +553,21 @@ func syncResource(c interface {
 		// Strategy: try array first, then common wrapper keys.
 		items, nextCursor, hasMore := extractPageItems(data, pageSize.cursorParam)
 
+		// Page-int paginator fallback: when the API paginates by integer
+		// ?page=N and emits no body cursor, treat a full page as a signal
+		// to advance numerically. Without this the loop breaks after page
+		// 1 even though more pages exist (the original symptom in #1296).
+		// Guard on cursorType, not cursorParam name, so all canonical
+		// spellings (page / page_number / pageNumber / page[number]) work.
+		if pageSize.cursorType == "page" && nextCursor == "" && len(items) >= pageSize.limit {
+			currentPage, _ := strconv.Atoi(cursor)
+			if currentPage < 1 {
+				currentPage = 1
+			}
+			nextCursor = strconv.Itoa(currentPage + 1)
+			hasMore = true
+		}
+
 		if len(items) == 0 {
 			if isEmptyPageResponse(data) {
 				break
@@ -710,6 +725,7 @@ func syncResource(c interface {
 // paginationDefaults holds the resolved pagination parameter names and page size.
 type paginationDefaults struct {
 	cursorParam string
+	cursorType  string // paginator class: "", "cursor", "page_token", "offset", "page"
 	limitParam  string
 	limit       int
 }
@@ -719,6 +735,7 @@ type paginationDefaults struct {
 func determinePaginationDefaults() paginationDefaults {
 	return paginationDefaults{
 		cursorParam: "{{if .Pagination.CursorParam}}{{.Pagination.CursorParam}}{{else}}after{{end}}",
+		cursorType:  "{{.Pagination.CursorType}}",
 		limitParam:  "{{if .Pagination.PageSizeParam}}{{.Pagination.PageSizeParam}}{{else}}limit{{end}}",
 		limit:       {{if .Pagination.DefaultPageSize}}{{.Pagination.DefaultPageSize}}{{else}}100{{end}},
 	}
@@ -1337,6 +1354,19 @@ func syncDependentResource(c interface {
 			}
 
 			items, nextCursor, hasMore := extractPageItems(data, pageSize.cursorParam)
+
+			// Page-int paginator fallback: mirrors syncResource so dependent
+			// resources on integer ?page=N APIs also advance past page 1.
+			// Guard on cursorType to cover every canonical spelling.
+			if pageSize.cursorType == "page" && nextCursor == "" && len(items) >= pageSize.limit {
+				currentPage, _ := strconv.Atoi(cursor)
+				if currentPage < 1 {
+					currentPage = 1
+				}
+				nextCursor = strconv.Itoa(currentPage + 1)
+				hasMore = true
+			}
+
 			if len(items) == 0 {
 				break
 			}
diff --git a/internal/openapi/parser.go b/internal/openapi/parser.go
index 5bacd948..79ecb131 100644
--- a/internal/openapi/parser.go
+++ b/internal/openapi/parser.go
@@ -5257,6 +5257,19 @@ func detectPagination(params []spec.Param, op *openapi3.Operation) *spec.Paginat
 			}
 		}
 	}
+	// Integer page paginator: classify only after the cursor/token/offset
+	// branches above so APIs that mix forms (e.g. cursor + page) keep
+	// cursor semantics. Runtime sync handles type "page" by incrementing
+	// CursorParam as an integer when no body cursor is extracted.
+	if pag.Type == "" {
+		for _, name := range []string{"page", "pagenumber", "page_number", "page[number]"} {
+			if orig, ok := originalCase[name]; ok {
+				pag.CursorParam = orig
+				pag.Type = "page"
+				break
+			}
+		}
+	}
 
 	// Also check for has_more in response schemas
 	if op != nil && op.Responses != nil {
diff --git a/internal/openapi/parser_test.go b/internal/openapi/parser_test.go
index a6adacaf..64cbef43 100644
--- a/internal/openapi/parser_test.go
+++ b/internal/openapi/parser_test.go
@@ -6454,3 +6454,63 @@ func TestDetectPaginationPreservesCursorParamCase(t *testing.T) {
 		})
 	}
 }
+
+// TestDetectPaginationRecognizesPageIntCursor guards #1296: APIs that
+// paginate by integer ?page=N (Freshworks family, Atlassian, HubSpot,
+// etc.) used to fall through to the "after" cursor default. Detection
+// now classifies a `page`/`pageNumber`/`page[number]` request param as
+// a Type="page" paginator so the sync template's page-int fallback
+// can advance numerically.
+func TestDetectPaginationRecognizesPageIntCursor(t *testing.T) {
+	t.Parallel()
+
+	cases := []struct {
+		name      string
+		paramName string
+		wantParam string
+	}{
+		{"plain page", "page", "page"},
+		{"snake_case page_number", "page_number", "page_number"},
+		{"camelCase pageNumber", "pageNumber", "pageNumber"},
+		{"json:api page[number]", "page[number]", "page[number]"},
+	}
+	for _, tc := range cases {
+		t.Run(tc.name, func(t *testing.T) {
+			t.Parallel()
+			pag := detectPagination([]spec.Param{{Name: tc.paramName}, {Name: "per_page"}}, nil)
+			require.NotNil(t, pag, "detector should classify %q as a paginator", tc.paramName)
+			assert.Equal(t, tc.wantParam, pag.CursorParam)
+			assert.Equal(t, "page", pag.Type)
+			assert.Equal(t, "per_page", pag.LimitParam)
+		})
+	}
+}
+
+// TestDetectPaginationCursorBeatsPage guards mixed-form specs: when an
+// API declares both `cursor` and `page` (rare but real), cursor must
+// win so existing cursor-based sync loops stay on the body-cursor
+// extraction path. Regression guard for #1296.
+func TestDetectPaginationCursorBeatsPage(t *testing.T) {
+	t.Parallel()
+
+	pag := detectPagination([]spec.Param{
+		{Name: "cursor"}, {Name: "page"}, {Name: "limit"},
+	}, nil)
+	require.NotNil(t, pag)
+	assert.Equal(t, "cursor", pag.CursorParam, "cursor must win over page when both declared")
+	assert.Equal(t, "cursor", pag.Type)
+	assert.Equal(t, "limit", pag.LimitParam)
+}
+
+// TestDetectPaginationOffsetBeatsPage guards offset-based APIs (Atlassian
+// older endpoints, etc.) against the new page branch.
+func TestDetectPaginationOffsetBeatsPage(t *testing.T) {
+	t.Parallel()
+
+	pag := detectPagination([]spec.Param{
+		{Name: "offset"}, {Name: "page"}, {Name: "limit"},
+	}, nil)
+	require.NotNil(t, pag)
+	assert.Equal(t, "offset", pag.CursorParam)
+	assert.Equal(t, "offset", pag.Type)
+}
diff --git a/internal/profiler/profiler.go b/internal/profiler/profiler.go
index a2702d21..21ccbaf1 100644
--- a/internal/profiler/profiler.go
+++ b/internal/profiler/profiler.go
@@ -42,6 +42,7 @@ type DomainSignals struct {
 // PaginationProfile describes the detected pagination patterns across the API.
 type PaginationProfile struct {
 	CursorParam     string `json:"cursor_param"`      // most common cursor param name (after, cursor, page_token, offset)
+	CursorType      string `json:"cursor_type"`       // most common paginator class (cursor, page_token, offset, page); drives runtime iteration strategy
 	PageSizeParam   string `json:"page_size_param"`   // most common page size param (limit, per_page, page_size, first)
 	SinceParam      string `json:"since_param"`       // temporal filter param (since, updated_after, modified_since)
 	DateRangeParam  string `json:"date_range_param"`  // date-range filter param (dates, date_range, dateRange)
@@ -217,6 +218,7 @@ func Profile(s *spec.APISpec) *APIProfile {
 	var hasSearchEndpoint bool
 
 	cursorParams := make(map[string]int)
+	cursorTypes := make(map[string]int)
 	pageSizeParams := make(map[string]int)
 	sinceParams := make(map[string]int)
 	dateRangeParams := make(map[string]int)
@@ -404,6 +406,9 @@ func Profile(s *spec.APISpec) *APIProfile {
 				if endpoint.Pagination.CursorParam != "" {
 					cursorParams[endpoint.Pagination.CursorParam]++
 				}
+				if endpoint.Pagination.Type != "" {
+					cursorTypes[endpoint.Pagination.Type]++
+				}
 				if endpoint.Pagination.LimitParam != "" {
 					pageSizeParams[endpoint.Pagination.LimitParam]++
 				}
@@ -508,6 +513,7 @@ func Profile(s *spec.APISpec) *APIProfile {
 
 	p.Pagination = PaginationProfile{
 		CursorParam:     mostCommon(cursorParams, "after"),
+		CursorType:      mostCommon(cursorTypes, ""),
 		PageSizeParam:   mostCommon(pageSizeParams, "limit"),
 		SinceParam:      mostCommon(sinceParams, ""),
 		DateRangeParam:  mostCommon(dateRangeParams, ""),
diff --git a/internal/spec/spec.go b/internal/spec/spec.go
index 3b63bd00..360536e1 100644
--- a/internal/spec/spec.go
+++ b/internal/spec/spec.go
@@ -1458,9 +1458,9 @@ type ResponseDiscriminator struct {
 }
 
 type Pagination struct {
-	Type           string `yaml:"type" json:"type"`                         // cursor, offset, page_token
+	Type           string `yaml:"type" json:"type"`                         // cursor, offset, page_token, page
 	LimitParam     string `yaml:"limit_param" json:"limit_param"`           // query param name for page size (limit, maxResults, pageSize)
-	CursorParam    string `yaml:"cursor_param" json:"cursor_param"`         // query param name for cursor (after, pageToken, offset)
+	CursorParam    string `yaml:"cursor_param" json:"cursor_param"`         // query param name for cursor (after, pageToken, offset, page)
 	NextCursorPath string `yaml:"next_cursor_path" json:"next_cursor_path"` // response field with next cursor (nextPageToken, cursor)
 	HasMoreField   string `yaml:"has_more_field" json:"has_more_field"`     // response field indicating more pages (has_more)
 }
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 a830d25a..a7a8c097 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
@@ -492,6 +492,21 @@ func syncResource(c interface {
 		// Strategy: try array first, then common wrapper keys.
 		items, nextCursor, hasMore := extractPageItems(data, pageSize.cursorParam)
 
+		// Page-int paginator fallback: when the API paginates by integer
+		// ?page=N and emits no body cursor, treat a full page as a signal
+		// to advance numerically. Without this the loop breaks after page
+		// 1 even though more pages exist (the original symptom in #1296).
+		// Guard on cursorType, not cursorParam name, so all canonical
+		// spellings (page / page_number / pageNumber / page[number]) work.
+		if pageSize.cursorType == "page" && nextCursor == "" && len(items) >= pageSize.limit {
+			currentPage, _ := strconv.Atoi(cursor)
+			if currentPage < 1 {
+				currentPage = 1
+			}
+			nextCursor = strconv.Itoa(currentPage + 1)
+			hasMore = true
+		}
+
 		if len(items) == 0 {
 			if isEmptyPageResponse(data) {
 				break
@@ -649,6 +664,7 @@ func syncResource(c interface {
 // paginationDefaults holds the resolved pagination parameter names and page size.
 type paginationDefaults struct {
 	cursorParam string
+	cursorType  string // paginator class: "", "cursor", "page_token", "offset", "page"
 	limitParam  string
 	limit       int
 }
@@ -658,6 +674,7 @@ type paginationDefaults struct {
 func determinePaginationDefaults() paginationDefaults {
 	return paginationDefaults{
 		cursorParam: "cursor",
+		cursorType:  "cursor",
 		limitParam:  "limit",
 		limit:       100,
 	}
@@ -1197,6 +1214,19 @@ func syncDependentResource(c interface {
 			}
 
 			items, nextCursor, hasMore := extractPageItems(data, pageSize.cursorParam)
+
+			// Page-int paginator fallback: mirrors syncResource so dependent
+			// resources on integer ?page=N APIs also advance past page 1.
+			// Guard on cursorType to cover every canonical spelling.
+			if pageSize.cursorType == "page" && nextCursor == "" && len(items) >= pageSize.limit {
+				currentPage, _ := strconv.Atoi(cursor)
+				if currentPage < 1 {
+					currentPage = 1
+				}
+				nextCursor = strconv.Itoa(currentPage + 1)
+				hasMore = true
+			}
+
 			if len(items) == 0 {
 				break
 			}
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 49e2ae40..c349184b 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
@@ -492,6 +492,21 @@ func syncResource(c interface {
 		// Strategy: try array first, then common wrapper keys.
 		items, nextCursor, hasMore := extractPageItems(data, pageSize.cursorParam)
 
+		// Page-int paginator fallback: when the API paginates by integer
+		// ?page=N and emits no body cursor, treat a full page as a signal
+		// to advance numerically. Without this the loop breaks after page
+		// 1 even though more pages exist (the original symptom in #1296).
+		// Guard on cursorType, not cursorParam name, so all canonical
+		// spellings (page / page_number / pageNumber / page[number]) work.
+		if pageSize.cursorType == "page" && nextCursor == "" && len(items) >= pageSize.limit {
+			currentPage, _ := strconv.Atoi(cursor)
+			if currentPage < 1 {
+				currentPage = 1
+			}
+			nextCursor = strconv.Itoa(currentPage + 1)
+			hasMore = true
+		}
+
 		if len(items) == 0 {
 			if isEmptyPageResponse(data) {
 				break
@@ -649,6 +664,7 @@ func syncResource(c interface {
 // paginationDefaults holds the resolved pagination parameter names and page size.
 type paginationDefaults struct {
 	cursorParam string
+	cursorType  string // paginator class: "", "cursor", "page_token", "offset", "page"
 	limitParam  string
 	limit       int
 }
@@ -658,6 +674,7 @@ type paginationDefaults struct {
 func determinePaginationDefaults() paginationDefaults {
 	return paginationDefaults{
 		cursorParam: "after",
+		cursorType:  "",
 		limitParam:  "limit",
 		limit:       100,
 	}
@@ -1189,6 +1206,19 @@ func syncDependentResource(c interface {
 			}
 
 			items, nextCursor, hasMore := extractPageItems(data, pageSize.cursorParam)
+
+			// Page-int paginator fallback: mirrors syncResource so dependent
+			// resources on integer ?page=N APIs also advance past page 1.
+			// Guard on cursorType to cover every canonical spelling.
+			if pageSize.cursorType == "page" && nextCursor == "" && len(items) >= pageSize.limit {
+				currentPage, _ := strconv.Atoi(cursor)
+				if currentPage < 1 {
+					currentPage = 1
+				}
+				nextCursor = strconv.Itoa(currentPage + 1)
+				hasMore = true
+			}
+
 			if len(items) == 0 {
 				break
 			}
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 18fefc85..f4c9835a 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
@@ -466,6 +466,21 @@ func syncResource(c interface {
 		// Strategy: try array first, then common wrapper keys.
 		items, nextCursor, hasMore := extractPageItems(data, pageSize.cursorParam)
 
+		// Page-int paginator fallback: when the API paginates by integer
+		// ?page=N and emits no body cursor, treat a full page as a signal
+		// to advance numerically. Without this the loop breaks after page
+		// 1 even though more pages exist (the original symptom in #1296).
+		// Guard on cursorType, not cursorParam name, so all canonical
+		// spellings (page / page_number / pageNumber / page[number]) work.
+		if pageSize.cursorType == "page" && nextCursor == "" && len(items) >= pageSize.limit {
+			currentPage, _ := strconv.Atoi(cursor)
+			if currentPage < 1 {
+				currentPage = 1
+			}
+			nextCursor = strconv.Itoa(currentPage + 1)
+			hasMore = true
+		}
+
 		if len(items) == 0 {
 			if isEmptyPageResponse(data) {
 				break
@@ -623,6 +638,7 @@ func syncResource(c interface {
 // paginationDefaults holds the resolved pagination parameter names and page size.
 type paginationDefaults struct {
 	cursorParam string
+	cursorType  string // paginator class: "", "cursor", "page_token", "offset", "page"
 	limitParam  string
 	limit       int
 }
@@ -632,6 +648,7 @@ type paginationDefaults struct {
 func determinePaginationDefaults() paginationDefaults {
 	return paginationDefaults{
 		cursorParam: "cursor",
+		cursorType:  "cursor",
 		limitParam:  "limit",
 		limit:       100,
 	}

← ff562375 fix(cli): drop MCP code-orch handler pre-marshal that base64  ·  back to Cli Printing Press  ·  fix(cli): cap body-flag recursion depth to prevent compiler 82ad787b →