[object Object]

← 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

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 →