← back to Cli Printing Press
fix(cli): gate sync since-param emission per resource (#1036)
dc5e8c55f70e7d6e343b6bd5199afaff38d16b6a · 2026-05-11 00:39:34 -0700 · Trevin Chow
* fix(cli): gate sync since-param emission per resource
Pre-fix, the sync template appended a profile-wide ?since=<RFC3339> query
parameter to every list endpoint regardless of whether the endpoint
actually declared one. Strict APIs like Notion's /v1/users reject the
unknown key with HTTP 400, breaking the first-run experience for any
mixed-cursor API. Stripe (created[gte]) and HubSpot (archived+after) hit
the same blind-append failure shape.
The profiler now records each list endpoint's actual temporal-filter
query parameter on SyncableResource.SinceParam (and the dependent-resource
mirror). The sync template renders a per-resource syncResourceSinceParam
switch and only injects the cursor when the resource declares such a
parameter. Resources that don't fall back to plain cursor pagination and
emit one resource_not_incremental sync_warning per run when the user
expected incremental behavior.
Closes #900
* fix(cli): warn human-mode users on sync since-param fallback
Address review feedback on #1036:
- Human/TTY users now see a stderr line when --since is dropped for a
resource without a temporal-filter param. The JSON sync_warning was
previously gated on !humanFriendly, silently zeroing effectiveSince
in TTY mode and producing a full backfill with no indication.
- Pin the full sync_warning JSON shape (event/resource/reason/message
keys + exact message) so future template churn can't silently break
the agent-facing event contract.
- Assert the warning emission and effectiveSince/depSinceTS zeroing
appear on both syncResource and syncDependentResource code paths;
the count-based check guards against one site being deleted.
- Extend profiler test to cover the updated_at heuristic branch and
add a modified_since case so all four substring matches in
detectEndpointSinceParam are exercised.
Files touched
M internal/generator/generator_test.goM internal/generator/templates/sync.go.tmplM internal/profiler/profiler.goM internal/profiler/profiler_test.goM 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 dc5e8c55f70e7d6e343b6bd5199afaff38d16b6a
Author: Trevin Chow <trevin@trevinchow.com>
Date: Mon May 11 00:39:34 2026 -0700
fix(cli): gate sync since-param emission per resource (#1036)
* fix(cli): gate sync since-param emission per resource
Pre-fix, the sync template appended a profile-wide ?since=<RFC3339> query
parameter to every list endpoint regardless of whether the endpoint
actually declared one. Strict APIs like Notion's /v1/users reject the
unknown key with HTTP 400, breaking the first-run experience for any
mixed-cursor API. Stripe (created[gte]) and HubSpot (archived+after) hit
the same blind-append failure shape.
The profiler now records each list endpoint's actual temporal-filter
query parameter on SyncableResource.SinceParam (and the dependent-resource
mirror). The sync template renders a per-resource syncResourceSinceParam
switch and only injects the cursor when the resource declares such a
parameter. Resources that don't fall back to plain cursor pagination and
emit one resource_not_incremental sync_warning per run when the user
expected incremental behavior.
Closes #900
* fix(cli): warn human-mode users on sync since-param fallback
Address review feedback on #1036:
- Human/TTY users now see a stderr line when --since is dropped for a
resource without a temporal-filter param. The JSON sync_warning was
previously gated on !humanFriendly, silently zeroing effectiveSince
in TTY mode and producing a full backfill with no indication.
- Pin the full sync_warning JSON shape (event/resource/reason/message
keys + exact message) so future template churn can't silently break
the agent-facing event contract.
- Assert the warning emission and effectiveSince/depSinceTS zeroing
appear on both syncResource and syncDependentResource code paths;
the count-based check guards against one site being deleted.
- Extend profiler test to cover the updated_at heuristic branch and
add a modified_since case so all four substring matches in
detectEndpointSinceParam are exercised.
---
internal/generator/generator_test.go | 124 +++++++++++++++++++
internal/generator/templates/sync.go.tmpl | 52 +++++++-
internal/profiler/profiler.go | 32 +++++
internal/profiler/profiler_test.go | 131 +++++++++++++++++++++
.../printing-press-golden/internal/cli/sync.go | 40 ++++++-
.../tier-routing-golden/internal/cli/sync.go | 26 +++-
6 files changed, 389 insertions(+), 16 deletions(-)
diff --git a/internal/generator/generator_test.go b/internal/generator/generator_test.go
index 0f0c85a6..dd261603 100644
--- a/internal/generator/generator_test.go
+++ b/internal/generator/generator_test.go
@@ -6124,6 +6124,130 @@ func TestGeneratedSyncMaxPagesAndStickyCursor(t *testing.T) {
runGoCommand(t, outputDir, "build", "./...")
}
+// TestGeneratedSyncGatesSinceParamPerResource pins the fix for issue #900:
+// generators must only inject the incremental-cursor query parameter on
+// resources whose list endpoint actually declares it. Resources that don't
+// (e.g. Notion's /v1/users) get a one-shot resource_not_incremental warning
+// instead of a blind ?since=... that the API rejects with 400.
+func TestGeneratedSyncGatesSinceParamPerResource(t *testing.T) {
+ t.Parallel()
+
+ apiSpec := &spec.APISpec{
+ Name: "gatedsync",
+ Version: "0.1.0",
+ BaseURL: "https://api.example.com",
+ Auth: spec.AuthConfig{
+ Type: "api_key",
+ Header: "Authorization",
+ Format: "Bearer {token}",
+ EnvVars: []string{"GATEDSYNC_API_KEY"},
+ },
+ Config: spec.ConfigSpec{
+ Format: "toml",
+ Path: "~/.config/gatedsync-pp-cli/config.toml",
+ },
+ Resources: map[string]spec.Resource{
+ // Declares since — sync should pass it through.
+ "events": {
+ Description: "Event log",
+ Endpoints: map[string]spec.Endpoint{
+ "list": {
+ Method: "GET",
+ Path: "/events",
+ Description: "List events",
+ Response: spec.ResponseDef{Type: "array"},
+ Pagination: &spec.Pagination{CursorParam: "after", LimitParam: "limit"},
+ Params: []spec.Param{
+ {Name: "since", Type: "string"},
+ },
+ },
+ },
+ },
+ // No temporal-filter param — sync must skip the cursor and warn.
+ "users": {
+ Description: "User directory",
+ Endpoints: map[string]spec.Endpoint{
+ "list": {
+ Method: "GET",
+ Path: "/users",
+ Description: "List users",
+ Response: spec.ResponseDef{Type: "array"},
+ Pagination: &spec.Pagination{CursorParam: "after", LimitParam: "limit"},
+ },
+ },
+ },
+ // Dependent (parameterized child) path with no temporal-filter
+ // param — exercises the second warning emission site in
+ // syncDependentResource so parity assertions can verify both
+ // code paths emit the same shape.
+ "comments": {
+ Description: "Comments per event",
+ Endpoints: map[string]spec.Endpoint{
+ "list": {
+ Method: "GET",
+ Path: "/events/{event_id}/comments",
+ Description: "List comments for an event",
+ Response: spec.ResponseDef{Type: "array"},
+ Pagination: &spec.Pagination{CursorParam: "after", LimitParam: "limit"},
+ },
+ },
+ },
+ },
+ }
+
+ outputDir := filepath.Join(t.TempDir(), naming.CLI(apiSpec.Name))
+ gen := New(apiSpec, outputDir)
+ require.NoError(t, gen.Generate())
+
+ syncGo, err := os.ReadFile(filepath.Join(outputDir, "internal", "cli", "sync.go"))
+ require.NoError(t, err)
+ syncContent := string(syncGo)
+
+ // Per-resource lookup replaces the blind global determineSinceParam().
+ assert.NotContains(t, syncContent, "determineSinceParam()",
+ "pre-fix helper that returned a blind global param name must be gone")
+ assert.Contains(t, syncContent, "func syncResourceSinceParam(resource string) string",
+ "per-resource helper must be emitted")
+
+ // events declares since → switch case present with the literal param name.
+ assert.Contains(t, syncContent, `case "events":`,
+ "events resource must appear in the per-resource switch")
+ assert.Contains(t, syncContent, `return "since"`,
+ "events resource must map to its declared param name")
+
+ // users declares no since-like param → no case for it (falls through to "").
+ assert.NotContains(t, syncContent, `case "users":`,
+ "users resource has no since-like param and must not appear in the switch — empty result skips the cursor")
+
+ // Pin the full warning JSON shape so future template churn can't silently
+ // drop a key, rename the event, or break the agent-facing contract.
+ const fullWarning = `{"event":"sync_warning","resource":"%s","reason":"resource_not_incremental","message":"endpoint does not declare a temporal filter parameter; incremental sync has no effect for this resource"}`
+ assert.Contains(t, syncContent, fullWarning,
+ "warning event must preserve the full agent-facing JSON shape")
+ // Both syncResource (flat) and syncDependentResource (parameterized child
+ // paths) must emit the warning. Counting occurrences guards against one
+ // site being deleted while a single assert.Contains still passes.
+ assert.GreaterOrEqual(t, strings.Count(syncContent, fullWarning), 2,
+ "warning must appear on both the flat and dependent-resource code paths")
+
+ // Human-mode warning to stderr — silent-fallback guard for TTY users.
+ assert.Contains(t, syncContent,
+ `" %s: incremental sync ignored (endpoint declares no temporal filter; falling back to full pagination)\n"`,
+ "human-mode users must see a stderr warning when --since is dropped")
+
+ // Zeroing the temporal-filter timestamp is what actually prevents the
+ // blind ?since= from reaching the API. Pin both sites; without these
+ // the warning emission alone is cosmetic.
+ assert.Contains(t, syncContent, `effectiveSince = ""`,
+ "flat path must zero effectiveSince after warning so no cursor leaks through")
+ assert.Contains(t, syncContent, `depSinceTS = ""`,
+ "dependent path must zero depSinceTS after warning so no cursor leaks through")
+
+ // Build to catch template-syntax / import errors.
+ runGoCommand(t, outputDir, "mod", "tidy")
+ runGoCommand(t, outputDir, "build", "./...")
+}
+
func TestGeneratedSyncTreatsEmptyWrappedPageAsSuccessfulZeroRecords(t *testing.T) {
t.Parallel()
diff --git a/internal/generator/templates/sync.go.tmpl b/internal/generator/templates/sync.go.tmpl
index 5f657914..495aafa4 100644
--- a/internal/generator/templates/sync.go.tmpl
+++ b/internal/generator/templates/sync.go.tmpl
@@ -376,11 +376,24 @@ func syncResource(c interface {
// Determine the since param value:
// 1. Explicit --since flag takes priority
// 2. Otherwise use last_synced_at from sync_state for incremental sync
- sinceParam := determineSinceParam()
+ sinceParam := syncResourceSinceParam(resource)
effectiveSince := sinceTS
if effectiveSince == "" && !lastSynced.IsZero() && !full {
effectiveSince = lastSynced.Format(time.RFC3339)
}
+ // Resources whose list endpoint declares no temporal-filter parameter
+ // fall back to plain pagination — sending a synthetic since=... would
+ // reach the API as an unknown query param and (for strict APIs like
+ // Notion) fail the whole resource with a 400. Warn once per resource
+ // when the user expected incremental behavior.
+ if effectiveSince != "" && sinceParam == "" {
+ if humanFriendly {
+ fmt.Fprintf(os.Stderr, " %s: incremental sync ignored (endpoint declares no temporal filter; falling back to full pagination)\n", resource)
+ } else {
+ fmt.Fprintf(os.Stdout, `{"event":"sync_warning","resource":"%s","reason":"resource_not_incremental","message":"endpoint does not declare a temporal filter parameter; incremental sync has no effect for this resource"}`+"\n", resource)
+ }
+ effectiveSince = ""
+ }
cursor := existingCursor
pageSize := determinePaginationDefaults()
@@ -607,9 +620,26 @@ func determinePaginationDefaults() paginationDefaults {
}
}
-// determineSinceParam returns the query parameter name for incremental sync filtering.
-func determineSinceParam() string {
- return "{{if .Pagination.SinceParam}}{{.Pagination.SinceParam}}{{else}}since{{end}}"
+// syncResourceSinceParam returns the query parameter name this resource's
+// list endpoint declares for incremental temporal filtering, or "" when the
+// endpoint declares none. Skipping the param for "" resources avoids
+// validation-error 400s on APIs that reject unknown query keys.
+func syncResourceSinceParam(resource string) string {
+ switch resource {
+{{- range .SyncableResources}}
+{{- if .SinceParam}}
+ case "{{.Name}}":
+ return "{{.SinceParam}}"
+{{- end}}
+{{- end}}
+{{- range .DependentSyncResources}}
+{{- if .SinceParam}}
+ case "{{.Name}}":
+ return "{{.SinceParam}}"
+{{- end}}
+{{- end}}
+ }
+ return ""
}
{{- if .Pagination.DateRangeParam}}
@@ -1065,6 +1095,16 @@ func syncDependentResource(c interface {
var deniedParents int
var firstDenial *accessWarning
pageSize := determinePaginationDefaults()
+ depSinceParam := syncResourceSinceParam(dep.Name)
+ depSinceTS := sinceTS
+ if depSinceTS != "" && depSinceParam == "" {
+ if humanFriendly {
+ fmt.Fprintf(os.Stderr, " %s: incremental sync ignored (endpoint declares no temporal filter; falling back to full pagination)\n", dep.Name)
+ } else {
+ fmt.Fprintf(os.Stdout, `{"event":"sync_warning","resource":"%s","reason":"resource_not_incremental","message":"endpoint does not declare a temporal filter parameter; incremental sync has no effect for this resource"}`+"\n", dep.Name)
+ }
+ depSinceTS = ""
+ }
// Per-resource extract-failure tracking for the F4b symptom probe and
// per-item primary_key_unresolved warning. See syncResource for the
// concurrency rationale (one goroutine per resource → no race).
@@ -1090,8 +1130,8 @@ func syncDependentResource(c interface {
if cursor != "" {
params[pageSize.cursorParam] = cursor
}
- if sinceTS != "" {
- params[determineSinceParam()] = sinceTS
+ if depSinceTS != "" {
+ params[depSinceParam] = depSinceTS
}
{{- if .Pagination.DateRangeParam}}
if dates != "" {
diff --git a/internal/profiler/profiler.go b/internal/profiler/profiler.go
index 5c3843a7..b8c102cf 100644
--- a/internal/profiler/profiler.go
+++ b/internal/profiler/profiler.go
@@ -84,6 +84,14 @@ type SyncableResource struct {
// policy. Defaults to false.
Critical bool
+ // SinceParam is the actual query parameter name this resource's list
+ // endpoint declares for incremental temporal filtering (since,
+ // updated_after, modified_since, …). Empty when the endpoint declares
+ // no such parameter; the sync template skips temporal filtering for
+ // those resources and emits one resource_not_incremental warning per
+ // run when --since/incremental sync was requested.
+ SinceParam string
+
// Discriminator routes heterogeneous response items to concrete typed
// resources before storage. Empty when the endpoint returns a homogeneous
// resource.
@@ -113,6 +121,11 @@ type DependentResource struct {
// load-bearing.
Critical bool
+ // SinceParam mirrors SyncableResource.SinceParam for child paths so
+ // the same per-resource temporal-filter gating applies to dependent
+ // syncs.
+ SinceParam string
+
// Discriminator routes heterogeneous dependent-resource response items to
// concrete typed resources before storage.
Discriminator DiscriminatorDispatch
@@ -976,6 +989,7 @@ func detectDependentResources(parameterized map[string]parameterizedEntry, synca
Tier: entry.meta.Tier,
IDField: entry.meta.IDField,
Critical: entry.meta.Critical,
+ SinceParam: entry.meta.SinceParam,
Discriminator: entry.meta.Discriminator,
})
}
@@ -1028,6 +1042,7 @@ type syncableMeta struct {
Tier string
IDField string
Critical bool
+ SinceParam string
Discriminator DiscriminatorDispatch
}
@@ -1055,10 +1070,26 @@ func metaFromEndpoint(s *spec.APISpec, resource spec.Resource, e spec.Endpoint,
Tier: s.EffectiveTier(resource, e),
IDField: e.IDField,
Critical: e.Critical,
+ SinceParam: detectEndpointSinceParam(e.Params),
Discriminator: discriminatorDispatchForEndpoint(e, types, resourceNameIndex),
}
}
+// detectEndpointSinceParam returns the actual query parameter name this
+// endpoint declares for incremental temporal filtering, or "" when none is
+// declared. The match list mirrors the profile-level aggregation in
+// Profile() so per-endpoint detection stays consistent with the
+// PaginationProfile.SinceParam summary.
+func detectEndpointSinceParam(params []spec.Param) string {
+ for _, p := range params {
+ name := strings.ToLower(p.Name)
+ if strings.Contains(name, "since") || strings.Contains(name, "updated_after") || strings.Contains(name, "modified_since") || strings.Contains(name, "updated_at") {
+ return p.Name
+ }
+ }
+ return ""
+}
+
func applySyncCandidates(syncable map[string]syncableMeta, candidates map[string][]syncableCandidate) {
resourceNames := sortedKeys(candidates)
for _, resourceName := range resourceNames {
@@ -1253,6 +1284,7 @@ func sortedSyncableResources(m map[string]syncableMeta) []SyncableResource {
Tier: meta.Tier,
IDField: meta.IDField,
Critical: meta.Critical,
+ SinceParam: meta.SinceParam,
Discriminator: meta.Discriminator,
}
}
diff --git a/internal/profiler/profiler_test.go b/internal/profiler/profiler_test.go
index 2df2acd1..5d290e7b 100644
--- a/internal/profiler/profiler_test.go
+++ b/internal/profiler/profiler_test.go
@@ -1253,6 +1253,137 @@ func TestProfileDependentResourceUnsetMetadata(t *testing.T) {
assert.False(t, profile.DependentSyncResources[0].Critical)
}
+// TestProfileSyncableResourceSinceParamPropagation asserts that per-endpoint
+// since-like query parameter declarations flow into SyncableResource.SinceParam.
+// The sync template uses that field to skip incremental-cursor emission for
+// resources whose endpoint does not declare such a parameter, avoiding the
+// Notion-style 400 the blind-append behavior used to produce.
+func TestProfileSyncableResourceSinceParamPropagation(t *testing.T) {
+ s := &spec.APISpec{
+ Name: "mixed",
+ Resources: map[string]spec.Resource{
+ "events": {
+ Endpoints: map[string]spec.Endpoint{
+ "list": {
+ Method: "GET",
+ Path: "/v1/events",
+ Response: spec.ResponseDef{Type: "array"},
+ Params: []spec.Param{
+ {Name: "since", Type: "string"},
+ },
+ },
+ },
+ },
+ "audit": {
+ Endpoints: map[string]spec.Endpoint{
+ "list": {
+ Method: "GET",
+ Path: "/v1/audit",
+ Response: spec.ResponseDef{Type: "array"},
+ Params: []spec.Param{
+ {Name: "updated_after", Type: "string"},
+ },
+ },
+ },
+ },
+ "posts": {
+ Endpoints: map[string]spec.Endpoint{
+ "list": {
+ Method: "GET",
+ Path: "/v1/posts",
+ Response: spec.ResponseDef{Type: "array"},
+ Params: []spec.Param{
+ {Name: "modified_since", Type: "string"},
+ },
+ },
+ },
+ },
+ "changelog": {
+ Endpoints: map[string]spec.Endpoint{
+ "list": {
+ Method: "GET",
+ Path: "/v1/changelog",
+ Response: spec.ResponseDef{Type: "array"},
+ Params: []spec.Param{
+ {Name: "updated_at", Type: "string"},
+ },
+ },
+ },
+ },
+ "users": {
+ Endpoints: map[string]spec.Endpoint{
+ "list": {
+ Method: "GET",
+ Path: "/v1/users",
+ Response: spec.ResponseDef{Type: "array"},
+ Params: []spec.Param{},
+ },
+ },
+ },
+ },
+ }
+
+ profile := Profile(s)
+ byName := make(map[string]SyncableResource, len(profile.SyncableResources))
+ for _, r := range profile.SyncableResources {
+ byName[r.Name] = r
+ }
+
+ require.Contains(t, byName, "events")
+ assert.Equal(t, "since", byName["events"].SinceParam, "literal since param should propagate verbatim")
+
+ require.Contains(t, byName, "audit")
+ assert.Equal(t, "updated_after", byName["audit"].SinceParam, "spec-declared name (not the profile-wide guess) wins")
+
+ require.Contains(t, byName, "posts")
+ assert.Equal(t, "modified_since", byName["posts"].SinceParam, "modified_since heuristic branch")
+
+ require.Contains(t, byName, "changelog")
+ assert.Equal(t, "updated_at", byName["changelog"].SinceParam, "updated_at heuristic branch")
+
+ require.Contains(t, byName, "users")
+ assert.Empty(t, byName["users"].SinceParam, "endpoints without a since-like param yield empty SinceParam — the sync template treats this as 'do not send'")
+}
+
+// TestProfileDependentResourceSinceParamPropagation mirrors
+// TestProfileSyncableResourceSinceParamPropagation for parameterized child
+// paths so dependent-resource sync gets the same per-endpoint gating as flat
+// resources.
+func TestProfileDependentResourceSinceParamPropagation(t *testing.T) {
+ s := &spec.APISpec{
+ Name: "chat",
+ Resources: map[string]spec.Resource{
+ "channels": {
+ Endpoints: map[string]spec.Endpoint{
+ "list": {
+ Method: "GET",
+ Path: "/channels",
+ Response: spec.ResponseDef{Type: "array"},
+ },
+ },
+ },
+ "messages": {
+ Endpoints: map[string]spec.Endpoint{
+ "list": {
+ Method: "GET",
+ Path: "/channels/{channel_id}/messages",
+ Response: spec.ResponseDef{Type: "array"},
+ Pagination: &spec.Pagination{CursorParam: "after", LimitParam: "limit"},
+ Params: []spec.Param{
+ {Name: "channel_id", Type: "string", PathParam: true},
+ {Name: "modified_since", Type: "string"},
+ },
+ },
+ },
+ },
+ },
+ }
+
+ profile := Profile(s)
+ require.Len(t, profile.DependentSyncResources, 1)
+ assert.Equal(t, "modified_since", profile.DependentSyncResources[0].SinceParam)
+}
+
// TestProfileSyncableResourceShorterPathWinsMetadata asserts that when two
// candidate endpoints can populate the same syncable resource, the shorter-path
// rule that already governs the Path field also picks the IDField/Critical
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 ca5c71d6..939112fe 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
@@ -354,11 +354,24 @@ func syncResource(c interface {
// Determine the since param value:
// 1. Explicit --since flag takes priority
// 2. Otherwise use last_synced_at from sync_state for incremental sync
- sinceParam := determineSinceParam()
+ sinceParam := syncResourceSinceParam(resource)
effectiveSince := sinceTS
if effectiveSince == "" && !lastSynced.IsZero() && !full {
effectiveSince = lastSynced.Format(time.RFC3339)
}
+ // Resources whose list endpoint declares no temporal-filter parameter
+ // fall back to plain pagination — sending a synthetic since=... would
+ // reach the API as an unknown query param and (for strict APIs like
+ // Notion) fail the whole resource with a 400. Warn once per resource
+ // when the user expected incremental behavior.
+ if effectiveSince != "" && sinceParam == "" {
+ if humanFriendly {
+ fmt.Fprintf(os.Stderr, " %s: incremental sync ignored (endpoint declares no temporal filter; falling back to full pagination)\n", resource)
+ } else {
+ fmt.Fprintf(os.Stdout, `{"event":"sync_warning","resource":"%s","reason":"resource_not_incremental","message":"endpoint does not declare a temporal filter parameter; incremental sync has no effect for this resource"}`+"\n", resource)
+ }
+ effectiveSince = ""
+ }
cursor := existingCursor
pageSize := determinePaginationDefaults()
@@ -578,9 +591,14 @@ func determinePaginationDefaults() paginationDefaults {
}
}
-// determineSinceParam returns the query parameter name for incremental sync filtering.
-func determineSinceParam() string {
- return "since"
+// syncResourceSinceParam returns the query parameter name this resource's
+// list endpoint declares for incremental temporal filtering, or "" when the
+// endpoint declares none. Skipping the param for "" resources avoids
+// validation-error 400s on APIs that reject unknown query keys.
+func syncResourceSinceParam(resource string) string {
+ switch resource {
+ }
+ return ""
}
// extractPageItems attempts to extract an array of items and pagination cursor from a response.
@@ -975,6 +993,16 @@ func syncDependentResource(c interface {
var deniedParents int
var firstDenial *accessWarning
pageSize := determinePaginationDefaults()
+ depSinceParam := syncResourceSinceParam(dep.Name)
+ depSinceTS := sinceTS
+ if depSinceTS != "" && depSinceParam == "" {
+ if humanFriendly {
+ fmt.Fprintf(os.Stderr, " %s: incremental sync ignored (endpoint declares no temporal filter; falling back to full pagination)\n", dep.Name)
+ } else {
+ fmt.Fprintf(os.Stdout, `{"event":"sync_warning","resource":"%s","reason":"resource_not_incremental","message":"endpoint does not declare a temporal filter parameter; incremental sync has no effect for this resource"}`+"\n", dep.Name)
+ }
+ depSinceTS = ""
+ }
// Per-resource extract-failure tracking for the F4b symptom probe and
// per-item primary_key_unresolved warning. See syncResource for the
// concurrency rationale (one goroutine per resource → no race).
@@ -1000,8 +1028,8 @@ func syncDependentResource(c interface {
if cursor != "" {
params[pageSize.cursorParam] = cursor
}
- if sinceTS != "" {
- params[determineSinceParam()] = sinceTS
+ if depSinceTS != "" {
+ params[depSinceParam] = depSinceTS
}
data, err := c.Get(path, params)
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 541b3867..a24be51e 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
@@ -331,11 +331,24 @@ func syncResource(c interface {
// Determine the since param value:
// 1. Explicit --since flag takes priority
// 2. Otherwise use last_synced_at from sync_state for incremental sync
- sinceParam := determineSinceParam()
+ sinceParam := syncResourceSinceParam(resource)
effectiveSince := sinceTS
if effectiveSince == "" && !lastSynced.IsZero() && !full {
effectiveSince = lastSynced.Format(time.RFC3339)
}
+ // Resources whose list endpoint declares no temporal-filter parameter
+ // fall back to plain pagination — sending a synthetic since=... would
+ // reach the API as an unknown query param and (for strict APIs like
+ // Notion) fail the whole resource with a 400. Warn once per resource
+ // when the user expected incremental behavior.
+ if effectiveSince != "" && sinceParam == "" {
+ if humanFriendly {
+ fmt.Fprintf(os.Stderr, " %s: incremental sync ignored (endpoint declares no temporal filter; falling back to full pagination)\n", resource)
+ } else {
+ fmt.Fprintf(os.Stdout, `{"event":"sync_warning","resource":"%s","reason":"resource_not_incremental","message":"endpoint does not declare a temporal filter parameter; incremental sync has no effect for this resource"}`+"\n", resource)
+ }
+ effectiveSince = ""
+ }
cursor := existingCursor
pageSize := determinePaginationDefaults()
@@ -555,9 +568,14 @@ func determinePaginationDefaults() paginationDefaults {
}
}
-// determineSinceParam returns the query parameter name for incremental sync filtering.
-func determineSinceParam() string {
- return "since"
+// syncResourceSinceParam returns the query parameter name this resource's
+// list endpoint declares for incremental temporal filtering, or "" when the
+// endpoint declares none. Skipping the param for "" resources avoids
+// validation-error 400s on APIs that reject unknown query keys.
+func syncResourceSinceParam(resource string) string {
+ switch resource {
+ }
+ return ""
}
// extractPageItems attempts to extract an array of items and pagination cursor from a response.
← 66fd401b fix(cli): Store.Get propagates sql.ErrNoRows so callers can
·
back to Cli Printing Press
·
fix(cli): reconcile MCPB manifest against internal/client en c93ce0c2 →