← back to Cli Printing Press
fix(cli): handle empty sync pages (#903)
d94d89dc6d8aec8c55212e9907a3543b4e61a94d · 2026-05-10 10:50:08 -0700 · Trevin Chow
* fix(cli): handle empty sync pages
* fix(cli): reject null empty-page matches
* fix(cli): reject null sync page bodies
Files touched
M internal/generator/generator_test.goM internal/generator/templates/sync.go.tmplM testdata/golden/expected/generate-golden-api/printing-press-golden/internal/cli/sync.goM testdata/golden/expected/generate-tier-routing-api/tier-routing-golden/internal/cli/sync.go
Diff
commit d94d89dc6d8aec8c55212e9907a3543b4e61a94d
Author: Trevin Chow <trevin@trevinchow.com>
Date: Sun May 10 10:50:08 2026 -0700
fix(cli): handle empty sync pages (#903)
* fix(cli): handle empty sync pages
* fix(cli): reject null empty-page matches
* fix(cli): reject null sync page bodies
---
internal/generator/generator_test.go | 120 +++++++++++++++++++++
internal/generator/templates/sync.go.tmpl | 42 +++++++-
.../printing-press-golden/internal/cli/sync.go | 42 +++++++-
.../tier-routing-golden/internal/cli/sync.go | 42 +++++++-
4 files changed, 240 insertions(+), 6 deletions(-)
diff --git a/internal/generator/generator_test.go b/internal/generator/generator_test.go
index c381a384..bd1aec9c 100644
--- a/internal/generator/generator_test.go
+++ b/internal/generator/generator_test.go
@@ -5848,6 +5848,126 @@ func TestGeneratedSyncMaxPagesAndStickyCursor(t *testing.T) {
runGoCommand(t, outputDir, "build", "./...")
}
+func TestGeneratedSyncTreatsEmptyWrappedPageAsSuccessfulZeroRecords(t *testing.T) {
+ t.Parallel()
+
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("Content-Type", "application/json")
+ switch r.URL.Path {
+ case "/empty-records":
+ _, _ = w.Write([]byte(`{"results":[]}`))
+ case "/records":
+ _, _ = w.Write([]byte(`{"results":[{"id":"rec_1","name":"One"}]}`))
+ default:
+ http.NotFound(w, r)
+ }
+ }))
+ t.Cleanup(server.Close)
+
+ apiSpec := &spec.APISpec{
+ Name: "emptywrapsync",
+ Version: "0.1.0",
+ BaseURL: server.URL,
+ Auth: spec.AuthConfig{Type: "none"},
+ Config: spec.ConfigSpec{
+ Format: "toml",
+ Path: "~/.config/emptywrapsync-pp-cli/config.toml",
+ },
+ Resources: map[string]spec.Resource{
+ "empty_records": {
+ Description: "Manage empty records",
+ Endpoints: map[string]spec.Endpoint{
+ "list": {
+ Method: "GET",
+ Path: "/empty-records",
+ Description: "List empty records",
+ Response: spec.ResponseDef{Type: "array", Item: "Record"},
+ Pagination: &spec.Pagination{CursorParam: "cursor", LimitParam: "limit"},
+ },
+ },
+ },
+ "records": {
+ Description: "Manage records",
+ Endpoints: map[string]spec.Endpoint{
+ "list": {
+ Method: "GET",
+ Path: "/records",
+ Description: "List records",
+ Response: spec.ResponseDef{Type: "array", Item: "Record"},
+ Pagination: &spec.Pagination{CursorParam: "cursor", LimitParam: "limit"},
+ },
+ },
+ },
+ },
+ Types: map[string]spec.TypeDef{
+ "Record": {
+ Fields: []spec.TypeField{
+ {Name: "id", Type: "string"},
+ {Name: "name", Type: "string"},
+ },
+ },
+ },
+ }
+
+ outputDir := filepath.Join(t.TempDir(), naming.CLI(apiSpec.Name))
+ gen := New(apiSpec, outputDir)
+ require.NoError(t, gen.Generate())
+
+ behaviorTest := `package cli
+
+import (
+ "encoding/json"
+ "testing"
+)
+
+func TestIsEmptyPageResponseRejectsNullSingletonFields(t *testing.T) {
+ cases := []struct {
+ name string
+ body string
+ want bool
+ }{
+ {"known wrapper empty array", ` + "`" + `{"results":[]}` + "`" + `, true},
+ {"unknown wrapper empty array", ` + "`" + `{"empty":[]}` + "`" + `, true},
+ {"top-level null is not an empty page", ` + "`" + `null` + "`" + `, false},
+ {"known wrapper null is not an empty page", ` + "`" + `{"results":null}` + "`" + `, false},
+ {"known data wrapper null is not an empty page", ` + "`" + `{"data":null}` + "`" + `, false},
+ {"single null field is not an empty page", ` + "`" + `{"user":null}` + "`" + `, false},
+ {"singleton object with null field is not an empty page", ` + "`" + `{"id":"rec_1","user":null}` + "`" + `, false},
+ }
+ for _, tc := range cases {
+ t.Run(tc.name, func(t *testing.T) {
+ if got := isEmptyPageResponse(json.RawMessage(tc.body)); got != tc.want {
+ t.Fatalf("isEmptyPageResponse(%s) = %v, want %v", tc.body, got, tc.want)
+ }
+ })
+ }
+}
+`
+ require.NoError(t, os.WriteFile(filepath.Join(outputDir, "internal", "cli", "sync_empty_page_test.go"), []byte(behaviorTest), 0o644))
+ runGoCommand(t, outputDir, "test", "./internal/cli", "-run", "TestIsEmptyPageResponseRejectsNullSingletonFields")
+
+ binaryPath := filepath.Join(outputDir, "emptywrapsync-pp-cli")
+ runGoCommand(t, outputDir, "build", "-o", binaryPath, "./cmd/emptywrapsync-pp-cli")
+
+ emptyDB := filepath.Join(t.TempDir(), "empty.db")
+ cmd := exec.Command(binaryPath, "--json", "sync", "--resources", "empty_records", "--db", emptyDB, "--max-pages", "1")
+ out, err := cmd.CombinedOutput()
+ require.NoError(t, err, string(out))
+ output := string(out)
+ assert.NotContains(t, output, `"event":"sync_error"`)
+ assert.Contains(t, output, `{"event":"sync_complete","resource":"empty_records","total":0`)
+ assert.Contains(t, output, `{"event":"sync_summary","total_records":0,"resources":1,"success":1,"warned":0,"errored":0`)
+
+ recordsDB := filepath.Join(t.TempDir(), "records.db")
+ cmd = exec.Command(binaryPath, "--json", "sync", "--resources", "records", "--db", recordsDB, "--max-pages", "1")
+ out, err = cmd.CombinedOutput()
+ require.NoError(t, err, string(out))
+ output = string(out)
+ assert.NotContains(t, output, `"event":"sync_error"`)
+ assert.Contains(t, output, `{"event":"sync_complete","resource":"records","total":1`)
+ assert.Contains(t, output, `{"event":"sync_summary","total_records":1,"resources":1,"success":1,"warned":0,"errored":0`)
+}
+
// TestGeneratedSyncExitPolicy pins the generated sync command's exit-code
// contract: (a) a --strict flag for callers that want any per-resource
// failure to exit non-zero, (b) a `criticalResources` map literal at the
diff --git a/internal/generator/templates/sync.go.tmpl b/internal/generator/templates/sync.go.tmpl
index 97e4cbea..935bbbea 100644
--- a/internal/generator/templates/sync.go.tmpl
+++ b/internal/generator/templates/sync.go.tmpl
@@ -397,6 +397,9 @@ func syncResource(c interface {
items, nextCursor, hasMore := extractPageItems(data, pageSize.cursorParam)
if len(items) == 0 {
+ if isEmptyPageResponse(data) {
+ break
+ }
// Single object response - try to store as-is
if err := upsertSingleObject(db, resource, data); err != nil {
if !humanFriendly {
@@ -589,8 +592,7 @@ func extractPageItems(data json.RawMessage, cursorParam string) ([]json.RawMessa
}
// Try common item keys first (fast path)
- itemKeys := []string{"data", "results", "items", "records", "nodes", "entries"}
- for _, key := range itemKeys {
+ for _, key := range pageItemKeys {
if raw, ok := envelope[key]; ok {
if err := json.Unmarshal(raw, &items); err == nil && len(items) > 0 {
nextCursor, hasMore := extractPaginationFromEnvelope(envelope, cursorParam)
@@ -622,6 +624,40 @@ func extractPageItems(data json.RawMessage, cursorParam string) ([]json.RawMessa
return nil, "", false
}
+func isEmptyPageResponse(data json.RawMessage) bool {
+ var direct []json.RawMessage
+ if err := json.Unmarshal(data, &direct); err == nil && !isJSONNull(data) {
+ return len(direct) == 0
+ }
+
+ var envelope map[string]json.RawMessage
+ if err := json.Unmarshal(data, &envelope); err != nil {
+ return false
+ }
+
+ for _, key := range pageItemKeys {
+ if raw, ok := envelope[key]; ok {
+ var items []json.RawMessage
+ if err := json.Unmarshal(raw, &items); err == nil && !isJSONNull(raw) {
+ return len(items) == 0
+ }
+ }
+ }
+
+ arrayCount := 0
+ for _, raw := range envelope {
+ var candidate []json.RawMessage
+ if err := json.Unmarshal(raw, &candidate); err == nil && len(candidate) == 0 && !isJSONNull(raw) {
+ arrayCount++
+ }
+ }
+ return arrayCount == 1
+}
+
+func isJSONNull(raw json.RawMessage) bool {
+ return strings.TrimSpace(string(raw)) == "null"
+}
+
// extractPaginationFromEnvelope extracts cursor and has_more from a response envelope.
func extractPaginationFromEnvelope(envelope map[string]json.RawMessage, cursorParam string) (string, bool) {
var hasMore bool
@@ -1169,6 +1205,8 @@ 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"}
+
// criticalResources is the template-time projection of per-resource Critical
// (set by the profiler from the spec's path-item x-critical extension). It
// is consulted at error-aggregation time so a non-critical failure can be
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 6394ea13..c53c0291 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
@@ -368,6 +368,9 @@ func syncResource(c interface {
items, nextCursor, hasMore := extractPageItems(data, pageSize.cursorParam)
if len(items) == 0 {
+ if isEmptyPageResponse(data) {
+ break
+ }
// Single object response - try to store as-is
if err := upsertSingleObject(db, resource, data); err != nil {
if !humanFriendly {
@@ -553,8 +556,7 @@ func extractPageItems(data json.RawMessage, cursorParam string) ([]json.RawMessa
}
// Try common item keys first (fast path)
- itemKeys := []string{"data", "results", "items", "records", "nodes", "entries"}
- for _, key := range itemKeys {
+ for _, key := range pageItemKeys {
if raw, ok := envelope[key]; ok {
if err := json.Unmarshal(raw, &items); err == nil && len(items) > 0 {
nextCursor, hasMore := extractPaginationFromEnvelope(envelope, cursorParam)
@@ -586,6 +588,40 @@ func extractPageItems(data json.RawMessage, cursorParam string) ([]json.RawMessa
return nil, "", false
}
+func isEmptyPageResponse(data json.RawMessage) bool {
+ var direct []json.RawMessage
+ if err := json.Unmarshal(data, &direct); err == nil && !isJSONNull(data) {
+ return len(direct) == 0
+ }
+
+ var envelope map[string]json.RawMessage
+ if err := json.Unmarshal(data, &envelope); err != nil {
+ return false
+ }
+
+ for _, key := range pageItemKeys {
+ if raw, ok := envelope[key]; ok {
+ var items []json.RawMessage
+ if err := json.Unmarshal(raw, &items); err == nil && !isJSONNull(raw) {
+ return len(items) == 0
+ }
+ }
+ }
+
+ arrayCount := 0
+ for _, raw := range envelope {
+ var candidate []json.RawMessage
+ if err := json.Unmarshal(raw, &candidate); err == nil && len(candidate) == 0 && !isJSONNull(raw) {
+ arrayCount++
+ }
+ }
+ return arrayCount == 1
+}
+
+func isJSONNull(raw json.RawMessage) bool {
+ return strings.TrimSpace(string(raw)) == "null"
+}
+
// extractPaginationFromEnvelope extracts cursor and has_more from a response envelope.
func extractPaginationFromEnvelope(envelope map[string]json.RawMessage, cursorParam string) (string, bool) {
var hasMore bool
@@ -1072,6 +1108,8 @@ 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"}
+
// criticalResources is the template-time projection of per-resource Critical
// (set by the profiler from the spec's path-item x-critical extension). It
// is consulted at error-aggregation time so a non-critical failure can be
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 935905a8..2cdd2396 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
@@ -345,6 +345,9 @@ func syncResource(c interface {
items, nextCursor, hasMore := extractPageItems(data, pageSize.cursorParam)
if len(items) == 0 {
+ if isEmptyPageResponse(data) {
+ break
+ }
// Single object response - try to store as-is
if err := upsertSingleObject(db, resource, data); err != nil {
if !humanFriendly {
@@ -530,8 +533,7 @@ func extractPageItems(data json.RawMessage, cursorParam string) ([]json.RawMessa
}
// Try common item keys first (fast path)
- itemKeys := []string{"data", "results", "items", "records", "nodes", "entries"}
- for _, key := range itemKeys {
+ for _, key := range pageItemKeys {
if raw, ok := envelope[key]; ok {
if err := json.Unmarshal(raw, &items); err == nil && len(items) > 0 {
nextCursor, hasMore := extractPaginationFromEnvelope(envelope, cursorParam)
@@ -563,6 +565,40 @@ func extractPageItems(data json.RawMessage, cursorParam string) ([]json.RawMessa
return nil, "", false
}
+func isEmptyPageResponse(data json.RawMessage) bool {
+ var direct []json.RawMessage
+ if err := json.Unmarshal(data, &direct); err == nil && !isJSONNull(data) {
+ return len(direct) == 0
+ }
+
+ var envelope map[string]json.RawMessage
+ if err := json.Unmarshal(data, &envelope); err != nil {
+ return false
+ }
+
+ for _, key := range pageItemKeys {
+ if raw, ok := envelope[key]; ok {
+ var items []json.RawMessage
+ if err := json.Unmarshal(raw, &items); err == nil && !isJSONNull(raw) {
+ return len(items) == 0
+ }
+ }
+ }
+
+ arrayCount := 0
+ for _, raw := range envelope {
+ var candidate []json.RawMessage
+ if err := json.Unmarshal(raw, &candidate); err == nil && len(candidate) == 0 && !isJSONNull(raw) {
+ arrayCount++
+ }
+ }
+ return arrayCount == 1
+}
+
+func isJSONNull(raw json.RawMessage) bool {
+ return strings.TrimSpace(string(raw)) == "null"
+}
+
// extractPaginationFromEnvelope extracts cursor and has_more from a response envelope.
func extractPaginationFromEnvelope(envelope map[string]json.RawMessage, cursorParam string) (string, bool) {
var hasMore bool
@@ -836,6 +872,8 @@ 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"}
+
// criticalResources is the template-time projection of per-resource Critical
// (set by the profiler from the spec's path-item x-critical extension). It
// is consulted at error-aggregation time so a non-critical failure can be
← fc291cae fix(cli): emit multipart requests for upload endpoints (#904
·
back to Cli Printing Press
·
fix(cli): preserve internal sibling packages on force regen dceb6e58 →