← 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
M internal/generator/generator_test.goM internal/generator/templates/sync.go.tmplM internal/openapi/parser.goM internal/openapi/parser_test.goM internal/profiler/profiler.goM internal/spec/spec.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-tier-routing-api/tier-routing-golden/internal/cli/sync.go
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 →