[object Object]

← back to Cli Printing Press

feat(cli): auto-refresh stale caches before read commands (#233)

4c05e1eee2f5e53c6cd4067ae5c4e8f75b48bc16 · 2026-04-21 22:11:51 -0700 · Matt Van Horn

Phase 2 of the discrawl-inspired rollout. When a spec sets cache.enabled,
generated read commands silently refresh a stale local cache before
serving, bounded by a timeout; on failure they emit a stderr warning and
proceed with the stale cache rather than hard-failing.

Unit 2 (cliutil freshness): emits internal/cliutil/freshness.go with
EnsureFresh(ctx, db, resources, policy) returning a Decision
(fresh / stale-api / stale-share / no-store). Pure decision logic with
no side effects — callers compose it with their own refresh path.
13 generated table tests cover worst-case-wins, NULL last_synced_at,
per-resource overrides, env opt-out, and missing sync_state table.

Unit 3 (auto-refresh wrapper): emits internal/cli/auto_refresh.go with
autoRefreshIfStale and a readCommandResources map derived from the
profiler's SyncableResources. Wired into root.go's PersistentPreRunE.
Respects the existing --data-source auto|live|local contract: only auto
triggers the hook. Env opt-out defaults to <CLI>_NO_AUTO_REFRESH.

Unit 7 (sync --latest-only): new flag on sync and graphql_sync. Caps
maxPages at 1 and clears the resume cursor so the next run fetches from
the head rather than resuming historical backfill. --since takes
precedence when both are set.

Emission is gated on VisionSet.Store plus cache.enabled (freshness helper
and auto-refresh wrapper) so CLIs without a cache story carry no dead
code. Full test suite and golangci-lint clean.

Ref: docs/plans/2026-04-21-001-feat-auto-refresh-and-share-plan.md

Co-authored-by: Matt Van Horn <455140+mvanhorn@users.noreply.github.com>

Files touched

Diff

commit 4c05e1eee2f5e53c6cd4067ae5c4e8f75b48bc16
Author: Matt Van Horn <mvanhorn@users.noreply.github.com>
Date:   Tue Apr 21 22:11:51 2026 -0700

    feat(cli): auto-refresh stale caches before read commands (#233)
    
    Phase 2 of the discrawl-inspired rollout. When a spec sets cache.enabled,
    generated read commands silently refresh a stale local cache before
    serving, bounded by a timeout; on failure they emit a stderr warning and
    proceed with the stale cache rather than hard-failing.
    
    Unit 2 (cliutil freshness): emits internal/cliutil/freshness.go with
    EnsureFresh(ctx, db, resources, policy) returning a Decision
    (fresh / stale-api / stale-share / no-store). Pure decision logic with
    no side effects — callers compose it with their own refresh path.
    13 generated table tests cover worst-case-wins, NULL last_synced_at,
    per-resource overrides, env opt-out, and missing sync_state table.
    
    Unit 3 (auto-refresh wrapper): emits internal/cli/auto_refresh.go with
    autoRefreshIfStale and a readCommandResources map derived from the
    profiler's SyncableResources. Wired into root.go's PersistentPreRunE.
    Respects the existing --data-source auto|live|local contract: only auto
    triggers the hook. Env opt-out defaults to <CLI>_NO_AUTO_REFRESH.
    
    Unit 7 (sync --latest-only): new flag on sync and graphql_sync. Caps
    maxPages at 1 and clears the resume cursor so the next run fetches from
    the head rather than resuming historical backfill. --since takes
    precedence when both are set.
    
    Emission is gated on VisionSet.Store plus cache.enabled (freshness helper
    and auto-refresh wrapper) so CLIs without a cache story carry no dead
    code. Full test suite and golangci-lint clean.
    
    Ref: docs/plans/2026-04-21-001-feat-auto-refresh-and-share-plan.md
    
    Co-authored-by: Matt Van Horn <455140+mvanhorn@users.noreply.github.com>
---
 internal/generator/generator.go                    |  32 +++
 internal/generator/generator_test.go               |  72 +++++++
 internal/generator/templates/auto_refresh.go.tmpl  | 142 +++++++++++++
 .../generator/templates/cliutil_freshness.go.tmpl  | 164 +++++++++++++++
 .../templates/cliutil_freshness_test.go.tmpl       | 221 +++++++++++++++++++++
 internal/generator/templates/graphql_sync.go.tmpl  |  24 ++-
 internal/generator/templates/root.go.tmpl          |  10 +
 internal/generator/templates/sync.go.tmpl          |  28 +++
 8 files changed, 692 insertions(+), 1 deletion(-)

diff --git a/internal/generator/generator.go b/internal/generator/generator.go
index d69d06d4..e2070757 100644
--- a/internal/generator/generator.go
+++ b/internal/generator/generator.go
@@ -719,6 +719,38 @@ func (g *Generator) Generate() error {
 		}
 	}
 
+	// Emit the cliutil freshness helper only when the spec opts into cache
+	// or share and the CLI has a local store. Without a store there is
+	// nothing to check freshness against; without cache or share opt-in
+	// there is no caller that consumes the Decision.
+	if g.VisionSet.Store && (g.Spec.Cache.Enabled || g.Spec.Share.Enabled) {
+		if err := g.renderTemplate("cliutil_freshness.go.tmpl", filepath.Join("internal", "cliutil", "freshness.go"), g.Spec); err != nil {
+			return fmt.Errorf("rendering cliutil freshness: %w", err)
+		}
+		if err := g.renderTemplate("cliutil_freshness_test.go.tmpl", filepath.Join("internal", "cliutil", "freshness_test.go"), g.Spec); err != nil {
+			return fmt.Errorf("rendering cliutil freshness test: %w", err)
+		}
+	}
+
+	// Emit the auto-refresh wrapper only when cache is explicitly enabled
+	// and the CLI has both a store and a sync path to call. Without sync
+	// there is nothing to refresh with; without cache.enabled there is no
+	// read-path hook that would call autoRefreshIfStale.
+	if g.VisionSet.Store && g.VisionSet.Sync && g.Spec.Cache.Enabled {
+		autoRefreshData := struct {
+			*spec.APISpec
+			SyncableResources []profiler.SyncableResource
+			Pagination        profiler.PaginationProfile
+		}{
+			APISpec:           g.Spec,
+			SyncableResources: g.profile.SyncableResources,
+			Pagination:        g.profile.Pagination,
+		}
+		if err := g.renderTemplate("auto_refresh.go.tmpl", filepath.Join("internal", "cli", "auto_refresh.go"), autoRefreshData); err != nil {
+			return fmt.Errorf("rendering auto_refresh: %w", err)
+		}
+	}
+
 	if g.FixtureSet != nil {
 		if err := g.renderTemplate("captured_test.go.tmpl", filepath.Join("internal", "client", "client_captured_test.go"), g.FixtureSet); err != nil {
 			return fmt.Errorf("rendering captured fixture tests: %w", err)
diff --git a/internal/generator/generator_test.go b/internal/generator/generator_test.go
index f7d0adfc..9fc9edff 100644
--- a/internal/generator/generator_test.go
+++ b/internal/generator/generator_test.go
@@ -106,6 +106,78 @@ func TestGenerateCliutilPackage(t *testing.T) {
 	runGoCommand(t, outputDir, "test", "./internal/cliutil/...")
 }
 
+// TestGenerateFreshnessHelperEmitted verifies that the cliutil freshness
+// helper and auto-refresh wrapper are emitted when the spec opts into
+// cache, and that the resulting CLI compiles end-to-end and its cliutil
+// tests pass.
+func TestGenerateFreshnessHelperEmitted(t *testing.T) {
+	t.Parallel()
+
+	// Start from stytch (has resources -> has store) and flip cache on.
+	apiSpec, err := spec.Parse(filepath.Join("..", "..", "testdata", "stytch.yaml"))
+	require.NoError(t, err)
+	apiSpec.Cache = spec.CacheConfig{Enabled: true, StaleAfter: "6h"}
+
+	outputDir := filepath.Join(t.TempDir(), naming.CLI(apiSpec.Name))
+	gen := New(apiSpec, outputDir)
+	require.NoError(t, gen.Generate())
+
+	cliutilDir := filepath.Join(outputDir, "internal", "cliutil")
+	for _, name := range []string{"freshness.go", "freshness_test.go"} {
+		_, err := os.Stat(filepath.Join(cliutilDir, name))
+		require.NoError(t, err, "expected %s to be emitted when cache is enabled", name)
+	}
+
+	// auto_refresh.go wires EnsureFresh into the root command.
+	autoRefreshPath := filepath.Join(outputDir, "internal", "cli", "auto_refresh.go")
+	data, err := os.ReadFile(autoRefreshPath)
+	require.NoError(t, err, "auto_refresh.go must be emitted when cache is enabled")
+	src := string(data)
+	for _, snippet := range []string{
+		"var readCommandResources = map[string][]string{",
+		"func cachePolicy() cliutil.Policy",
+		"func autoRefreshIfStale(",
+		"func runAutoRefresh(",
+		// Env opt-out is derived at runtime from the CLI name; probe the
+		// expression that yields e.g. "STYTCH_NO_AUTO_REFRESH".
+		`strings.ReplaceAll(strings.ToUpper("stytch"), "-", "_") + "_NO_AUTO_REFRESH"`,
+	} {
+		assert.Contains(t, src, snippet, "auto_refresh.go missing %q", snippet)
+	}
+
+	// Root command must wire the hook.
+	rootSrc, err := os.ReadFile(filepath.Join(outputDir, "internal", "cli", "root.go"))
+	require.NoError(t, err)
+	assert.Contains(t, string(rootSrc), "autoRefreshIfStale(cmd.Context(), &flags, resources)",
+		"root.go must invoke autoRefreshIfStale from PersistentPreRunE")
+
+	// Generated helper must compile and its tests must pass end-to-end,
+	// exercising the sync_state contract against a real SQLite DB.
+	runGoCommand(t, outputDir, "mod", "tidy")
+	runGoCommand(t, outputDir, "build", "./...")
+	runGoCommand(t, outputDir, "test", "./internal/cliutil/...")
+}
+
+// TestGenerateFreshnessHelperSkippedWhenCacheOff verifies that a spec
+// without cache or share does not receive the freshness helper.
+// CLIs without a cache story should not carry dead code.
+func TestGenerateFreshnessHelperSkippedWhenCacheOff(t *testing.T) {
+	t.Parallel()
+
+	apiSpec, err := spec.Parse(filepath.Join("..", "..", "testdata", "stytch.yaml"))
+	require.NoError(t, err)
+	require.False(t, apiSpec.Cache.Enabled, "baseline stytch spec should not enable cache")
+	require.False(t, apiSpec.Share.Enabled, "baseline stytch spec should not enable share")
+
+	outputDir := filepath.Join(t.TempDir(), naming.CLI(apiSpec.Name))
+	gen := New(apiSpec, outputDir)
+	require.NoError(t, gen.Generate())
+
+	freshnessPath := filepath.Join(outputDir, "internal", "cliutil", "freshness.go")
+	_, err = os.Stat(freshnessPath)
+	assert.True(t, os.IsNotExist(err), "freshness.go must not be emitted when cache and share are both off")
+}
+
 // TestGenerateAgentContextCommand verifies that every generated CLI ships
 // with the agent-context subcommand and that it emits valid JSON matching
 // the documented schema. Inspired by Cloudflare's /cdn-cgi/explorer/api
diff --git a/internal/generator/templates/auto_refresh.go.tmpl b/internal/generator/templates/auto_refresh.go.tmpl
new file mode 100644
index 00000000..39875f52
--- /dev/null
+++ b/internal/generator/templates/auto_refresh.go.tmpl
@@ -0,0 +1,142 @@
+// Copyright {{currentYear}} {{.Owner}}. Licensed under Apache-2.0. See LICENSE.
+// Generated by CLI Printing Press (https://github.com/mvanhorn/cli-printing-press). DO NOT EDIT.
+
+package cli
+
+import (
+	"context"
+	"fmt"
+	"os"
+	"strings"
+	"time"
+
+	"{{modulePath}}/internal/cliutil"
+	"{{modulePath}}/internal/store"
+)
+
+// readCommandResources maps command paths ({{backtick}}cmd.CommandPath(){{backtick}}) to the
+// resource types those commands read. The auto-refresh hook consults this
+// map to decide whether to refresh the local cache before serving.
+// Populated from SyncableResources at generation time.
+var readCommandResources = map[string][]string{
+{{- range .SyncableResources}}
+	"{{$.Name}}-pp-cli {{.Name}}":        {"{{.Name}}"},
+	"{{$.Name}}-pp-cli {{.Name}} list":   {"{{.Name}}"},
+	"{{$.Name}}-pp-cli {{.Name}} get":    {"{{.Name}}"},
+	"{{$.Name}}-pp-cli {{.Name}} search": {"{{.Name}}"},
+{{- end}}
+}
+
+// cachePolicy returns the cache freshness policy assembled from spec
+// configuration. Defaults: 6h global stale-after, env opt-out named after
+// the CLI. Per-resource overrides from spec.Cache.Resources take priority.
+func cachePolicy() cliutil.Policy {
+	staleAfter := 6 * time.Hour
+{{- if .Cache.StaleAfter}}
+	if d, err := time.ParseDuration("{{.Cache.StaleAfter}}"); err == nil {
+		staleAfter = d
+	}
+{{- end}}
+	perResource := map[string]time.Duration{}
+{{- range $resource, $dur := .Cache.Resources}}
+	if d, err := time.ParseDuration("{{$dur}}"); err == nil {
+		perResource["{{$resource}}"] = d
+	}
+{{- end}}
+{{- if .Cache.EnvOptOut}}
+	envOptOut := "{{.Cache.EnvOptOut}}"
+{{- else}}
+	// Default env opt-out name is the CLI name, upper-cased with dashes
+	// normalised to underscores, plus the _NO_AUTO_REFRESH suffix. For
+	// "cal-com" that yields "CAL_COM_NO_AUTO_REFRESH".
+	envOptOut := strings.ReplaceAll(strings.ToUpper("{{.Name}}"), "-", "_") + "_NO_AUTO_REFRESH"
+{{- end}}
+	return cliutil.Policy{
+		StaleAfter:   staleAfter,
+		PerResource:  perResource,
+		EnvOptOut:    envOptOut,
+		ShareEnabled: {{if .Share.Enabled}}true{{else}}false{{end}},
+	}
+}
+
+// refreshTimeout returns the wall-clock budget for a single auto-refresh
+// call. Beyond this, the command serves stale data with a stderr warning
+// instead of blocking the agent or user indefinitely.
+func refreshTimeout() time.Duration {
+{{- if .Cache.RefreshTimeout}}
+	if d, err := time.ParseDuration("{{.Cache.RefreshTimeout}}"); err == nil {
+		return d
+	}
+{{- end}}
+	return 30 * time.Second
+}
+
+// autoRefreshIfStale decides whether to refresh and runs the refresh in
+// one call. It returns nothing: refresh failures become stderr warnings
+// and the command proceeds with the stale cache. The data-source contract
+// is respected: only "auto" triggers the hook; "live" and "local" short-
+// circuit at the first check.
+func autoRefreshIfStale(ctx context.Context, flags *rootFlags, resources []string) {
+	if flags.dataSource != "auto" {
+		return
+	}
+	if len(resources) == 0 {
+		return
+	}
+	dbPath := defaultDBPath("{{.Name}}-pp-cli")
+	if _, err := os.Stat(dbPath); err != nil {
+		// No cache on disk yet; nothing to refresh against. The first
+		// `sync` hydrates and subsequent reads benefit from auto-refresh.
+		return
+	}
+	db, err := store.Open(dbPath)
+	if err != nil {
+		fmt.Fprintf(os.Stderr, "warning: auto-refresh skipped (open: %v)\n", err)
+		return
+	}
+	defer db.Close()
+
+	policy := cachePolicy()
+	decision, err := cliutil.EnsureFresh(ctx, db.DB(), resources, policy)
+	if err != nil {
+		fmt.Fprintf(os.Stderr, "warning: auto-refresh decision failed: %v\n", err)
+		return
+	}
+	if decision == cliutil.DecisionFresh || decision == cliutil.DecisionNoStore {
+		return
+	}
+
+	refreshCtx, cancel := context.WithTimeout(ctx, refreshTimeout())
+	defer cancel()
+	if err := runAutoRefresh(refreshCtx, flags, db, resources); err != nil {
+		fmt.Fprintf(os.Stderr, "warning: using stale {{.Name}}-pp-cli cache (refresh failed: %v)\n", err)
+	}
+}
+
+// runAutoRefresh invokes the API-backed refresh path for the stale resources.
+// Phase 3 will split this into share-vs-API based on cliutil.Decision; today
+// we always go to the API with a tight page budget (maxPages=1), matching
+// sync --latest-only semantics.
+func runAutoRefresh(ctx context.Context, flags *rootFlags, db *store.Store, resources []string) error {
+	c, err := flags.newClient()
+	if err != nil {
+		return fmt.Errorf("build client: %w", err)
+	}
+	c.NoCache = true
+	var failures []string
+	for _, resource := range resources {
+		select {
+		case <-ctx.Done():
+			return ctx.Err()
+		default:
+		}
+		result := syncResource(c, db, resource, "", false, 1{{if .Pagination.DateRangeParam}}, ""{{end}})
+		if result.Err != nil {
+			failures = append(failures, fmt.Sprintf("%s: %v", resource, result.Err))
+		}
+	}
+	if len(failures) > 0 {
+		return fmt.Errorf("%s", strings.Join(failures, "; "))
+	}
+	return nil
+}
diff --git a/internal/generator/templates/cliutil_freshness.go.tmpl b/internal/generator/templates/cliutil_freshness.go.tmpl
new file mode 100644
index 00000000..ca3a1c60
--- /dev/null
+++ b/internal/generator/templates/cliutil_freshness.go.tmpl
@@ -0,0 +1,164 @@
+// Copyright {{currentYear}} {{.Owner}}. Licensed under Apache-2.0. See LICENSE.
+// Generated by CLI Printing Press (https://github.com/mvanhorn/cli-printing-press). DO NOT EDIT.
+
+package cliutil
+
+import (
+	"context"
+	"database/sql"
+	"fmt"
+	"os"
+	"strings"
+	"time"
+)
+
+// Decision is the outcome of a freshness check. It tells the caller what
+// to do next without doing the refresh itself — EnsureFresh is pure
+// decision logic so callers can compose it with their own refresh path
+// (API sync, git-backed share import, or both).
+type Decision int
+
+const (
+	// DecisionFresh means every requested resource is within the staleness
+	// budget and the caller can serve from cache directly.
+	DecisionFresh Decision = iota
+	// DecisionStaleAPI means at least one requested resource is stale and
+	// the caller should refresh from the upstream API.
+	DecisionStaleAPI
+	// DecisionStaleShare means at least one requested resource is stale
+	// AND the caller configured a share remote, so the caller should
+	// prefer `git pull + import` over an API hit.
+	DecisionStaleShare
+	// DecisionNoStore means the caller asked about freshness but the store
+	// has no sync_state table yet (first run). Treated the same as stale
+	// by most callers, but distinct for logging.
+	DecisionNoStore
+)
+
+// String returns a stable tag suitable for logging and JSON.
+func (d Decision) String() string {
+	switch d {
+	case DecisionFresh:
+		return "fresh"
+	case DecisionStaleAPI:
+		return "stale-api"
+	case DecisionStaleShare:
+		return "stale-share"
+	case DecisionNoStore:
+		return "no-store"
+	default:
+		return "unknown"
+	}
+}
+
+// Policy configures a freshness check. StaleAfter is the default threshold
+// applied to any resource not listed in PerResource. PerResource is an
+// override map (resource_type -> per-type threshold). EnvOptOut, when set
+// and that env var equals "1", short-circuits to DecisionFresh without
+// touching the database — useful for CI batch jobs and for agents that
+// want to serve whatever is on disk without a refresh round trip.
+// ShareEnabled controls which stale decision is returned: when true and
+// the data is stale, the decision is DecisionStaleShare so the caller
+// prefers a git pull over an API refresh.
+type Policy struct {
+	StaleAfter     time.Duration
+	PerResource    map[string]time.Duration
+	EnvOptOut      string
+	ShareEnabled   bool
+}
+
+// EnsureFresh inspects sync_state for the requested resources and returns
+// a Decision. It does not refresh — callers are expected to branch on the
+// decision and invoke the appropriate refresh path themselves. The ctx
+// bounds the DB query only; it does not bound the caller's subsequent
+// refresh work.
+//
+// A resource with no sync_state row or a NULL last_synced_at is treated
+// as stale. The worst-case decision across resources wins — one stale
+// resource makes the whole read stale.
+func EnsureFresh(ctx context.Context, db *sql.DB, resources []string, policy Policy) (Decision, error) {
+	if policy.EnvOptOut != "" && os.Getenv(policy.EnvOptOut) == "1" {
+		return DecisionFresh, nil
+	}
+	if db == nil {
+		return DecisionNoStore, nil
+	}
+	if len(resources) == 0 {
+		return DecisionFresh, nil
+	}
+
+	// Build the IN clause with positional parameters — resource names come
+	// from the generated command map, not user input, but sanitising with
+	// placeholders is still the right instinct.
+	placeholders := make([]string, len(resources))
+	args := make([]any, len(resources))
+	for i, r := range resources {
+		placeholders[i] = "?"
+		args[i] = r
+	}
+	query := fmt.Sprintf(
+		`SELECT resource_type, last_synced_at FROM sync_state WHERE resource_type IN (%s)`,
+		strings.Join(placeholders, ","),
+	)
+
+	rows, err := db.QueryContext(ctx, query, args...)
+	if err != nil {
+		// sync_state may not exist on a brand-new DB that was just migrated
+		// but never synced. Treat as no-store so the caller knows to
+		// hydrate, not as an outright error.
+		if strings.Contains(err.Error(), "no such table") {
+			return DecisionNoStore, nil
+		}
+		return DecisionFresh, fmt.Errorf("query sync_state: %w", err)
+	}
+	defer rows.Close()
+
+	seen := make(map[string]bool, len(resources))
+	anyStale := false
+	for rows.Next() {
+		var rtype string
+		var lastSynced sql.NullTime
+		if err := rows.Scan(&rtype, &lastSynced); err != nil {
+			return DecisionFresh, fmt.Errorf("scan sync_state row: %w", err)
+		}
+		seen[rtype] = true
+		if !lastSynced.Valid {
+			anyStale = true
+			continue
+		}
+		threshold := policy.StaleAfter
+		if policy.PerResource != nil {
+			if override, ok := policy.PerResource[rtype]; ok {
+				threshold = override
+			}
+		}
+		if threshold <= 0 {
+			// A zero-valued threshold means "never stale" — used by callers
+			// that want to opt a specific resource out of auto-refresh
+			// without disabling the whole policy.
+			continue
+		}
+		if time.Since(lastSynced.Time) > threshold {
+			anyStale = true
+		}
+	}
+	if err := rows.Err(); err != nil {
+		return DecisionFresh, fmt.Errorf("iterate sync_state rows: %w", err)
+	}
+
+	// A requested resource with no row is stale by definition.
+	for _, r := range resources {
+		if !seen[r] {
+			anyStale = true
+			break
+		}
+	}
+
+	if !anyStale {
+		return DecisionFresh, nil
+	}
+	if policy.ShareEnabled {
+		return DecisionStaleShare, nil
+	}
+	return DecisionStaleAPI, nil
+}
diff --git a/internal/generator/templates/cliutil_freshness_test.go.tmpl b/internal/generator/templates/cliutil_freshness_test.go.tmpl
new file mode 100644
index 00000000..13d65a22
--- /dev/null
+++ b/internal/generator/templates/cliutil_freshness_test.go.tmpl
@@ -0,0 +1,221 @@
+// Copyright {{currentYear}} {{.Owner}}. Licensed under Apache-2.0. See LICENSE.
+// Generated by CLI Printing Press (https://github.com/mvanhorn/cli-printing-press). DO NOT EDIT.
+
+package cliutil
+
+import (
+	"context"
+	"database/sql"
+	"path/filepath"
+	"testing"
+	"time"
+
+	_ "modernc.org/sqlite"
+)
+
+// newTestDB creates a tiny sqlite DB matching the sync_state shape that
+// generated stores emit. Keeping this independent of the real store
+// package lets cliutil stay self-contained.
+func newTestDB(t *testing.T) *sql.DB {
+	t.Helper()
+	dbPath := filepath.Join(t.TempDir(), "cliutil.db")
+	db, err := sql.Open("sqlite", dbPath)
+	if err != nil {
+		t.Fatalf("open: %v", err)
+	}
+	t.Cleanup(func() { db.Close() })
+	if _, err := db.Exec(`CREATE TABLE sync_state (
+		resource_type TEXT PRIMARY KEY,
+		last_cursor TEXT,
+		last_synced_at DATETIME,
+		total_count INTEGER DEFAULT 0
+	)`); err != nil {
+		t.Fatalf("create sync_state: %v", err)
+	}
+	return db
+}
+
+func stamp(t *testing.T, db *sql.DB, rtype string, last time.Time) {
+	t.Helper()
+	if _, err := db.Exec(
+		`INSERT INTO sync_state(resource_type, last_synced_at, total_count) VALUES (?, ?, 0)
+		 ON CONFLICT(resource_type) DO UPDATE SET last_synced_at = excluded.last_synced_at`,
+		rtype, last,
+	); err != nil {
+		t.Fatalf("stamp sync_state: %v", err)
+	}
+}
+
+func TestEnsureFresh_AllFresh(t *testing.T) {
+	db := newTestDB(t)
+	stamp(t, db, "issues", time.Now().Add(-1*time.Minute))
+
+	decision, err := EnsureFresh(context.Background(), db, []string{"issues"}, Policy{StaleAfter: 6 * time.Hour})
+	if err != nil {
+		t.Fatalf("err: %v", err)
+	}
+	if decision != DecisionFresh {
+		t.Fatalf("got %v, want fresh", decision)
+	}
+}
+
+func TestEnsureFresh_StaleAPI(t *testing.T) {
+	db := newTestDB(t)
+	stamp(t, db, "issues", time.Now().Add(-10*time.Hour))
+
+	decision, err := EnsureFresh(context.Background(), db, []string{"issues"}, Policy{StaleAfter: 6 * time.Hour})
+	if err != nil {
+		t.Fatalf("err: %v", err)
+	}
+	if decision != DecisionStaleAPI {
+		t.Fatalf("got %v, want stale-api", decision)
+	}
+}
+
+func TestEnsureFresh_StaleShare(t *testing.T) {
+	db := newTestDB(t)
+	stamp(t, db, "issues", time.Now().Add(-10*time.Hour))
+
+	decision, err := EnsureFresh(context.Background(), db, []string{"issues"}, Policy{StaleAfter: 6 * time.Hour, ShareEnabled: true})
+	if err != nil {
+		t.Fatalf("err: %v", err)
+	}
+	if decision != DecisionStaleShare {
+		t.Fatalf("got %v, want stale-share", decision)
+	}
+}
+
+func TestEnsureFresh_WorstCaseWins(t *testing.T) {
+	db := newTestDB(t)
+	stamp(t, db, "issues", time.Now().Add(-1*time.Minute)) // fresh
+	stamp(t, db, "teams", time.Now().Add(-10*time.Hour))   // stale
+
+	decision, _ := EnsureFresh(context.Background(), db, []string{"issues", "teams"}, Policy{StaleAfter: 6 * time.Hour})
+	if decision != DecisionStaleAPI {
+		t.Fatalf("got %v, want stale-api (worst case wins)", decision)
+	}
+}
+
+func TestEnsureFresh_MissingResourceIsStale(t *testing.T) {
+	db := newTestDB(t)
+	stamp(t, db, "issues", time.Now().Add(-1*time.Minute))
+
+	decision, _ := EnsureFresh(context.Background(), db, []string{"issues", "never_synced"}, Policy{StaleAfter: 6 * time.Hour})
+	if decision != DecisionStaleAPI {
+		t.Fatalf("got %v, want stale-api (missing row)", decision)
+	}
+}
+
+func TestEnsureFresh_NullLastSyncedIsStale(t *testing.T) {
+	db := newTestDB(t)
+	if _, err := db.Exec(`INSERT INTO sync_state(resource_type, last_synced_at) VALUES (?, NULL)`, "issues"); err != nil {
+		t.Fatalf("stamp null: %v", err)
+	}
+
+	decision, err := EnsureFresh(context.Background(), db, []string{"issues"}, Policy{StaleAfter: 6 * time.Hour})
+	if err != nil {
+		t.Fatalf("err: %v", err)
+	}
+	if decision != DecisionStaleAPI {
+		t.Fatalf("got %v, want stale-api (NULL last_synced_at)", decision)
+	}
+}
+
+func TestEnsureFresh_PerResourceOverrideShorterWins(t *testing.T) {
+	db := newTestDB(t)
+	stamp(t, db, "quotes", time.Now().Add(-30*time.Minute))
+
+	// global 6h threshold would pass, but quotes:5m override flips to stale.
+	decision, _ := EnsureFresh(context.Background(), db, []string{"quotes"}, Policy{
+		StaleAfter:  6 * time.Hour,
+		PerResource: map[string]time.Duration{"quotes": 5 * time.Minute},
+	})
+	if decision != DecisionStaleAPI {
+		t.Fatalf("got %v, want stale-api (shorter per-resource threshold wins)", decision)
+	}
+}
+
+func TestEnsureFresh_ZeroThresholdNeverStale(t *testing.T) {
+	db := newTestDB(t)
+	stamp(t, db, "teams", time.Now().Add(-1000*time.Hour))
+
+	// Zero per-resource threshold means "never stale" — an escape hatch for
+	// slow-changing resources that should not auto-refresh.
+	decision, _ := EnsureFresh(context.Background(), db, []string{"teams"}, Policy{
+		StaleAfter:  6 * time.Hour,
+		PerResource: map[string]time.Duration{"teams": 0},
+	})
+	if decision != DecisionFresh {
+		t.Fatalf("got %v, want fresh (zero threshold)", decision)
+	}
+}
+
+func TestEnsureFresh_EnvOptOutShortCircuits(t *testing.T) {
+	db := newTestDB(t)
+	stamp(t, db, "issues", time.Now().Add(-1000*time.Hour)) // very stale
+
+	t.Setenv("MY_CLI_NO_AUTO_REFRESH", "1")
+
+	decision, err := EnsureFresh(context.Background(), db, []string{"issues"}, Policy{
+		StaleAfter: 6 * time.Hour,
+		EnvOptOut:  "MY_CLI_NO_AUTO_REFRESH",
+	})
+	if err != nil {
+		t.Fatalf("err: %v", err)
+	}
+	if decision != DecisionFresh {
+		t.Fatalf("got %v, want fresh (env opt-out)", decision)
+	}
+}
+
+func TestEnsureFresh_NilDBReturnsNoStore(t *testing.T) {
+	decision, err := EnsureFresh(context.Background(), nil, []string{"issues"}, Policy{StaleAfter: 6 * time.Hour})
+	if err != nil {
+		t.Fatalf("err: %v", err)
+	}
+	if decision != DecisionNoStore {
+		t.Fatalf("got %v, want no-store", decision)
+	}
+}
+
+func TestEnsureFresh_NoResourcesReturnsFresh(t *testing.T) {
+	db := newTestDB(t)
+	decision, err := EnsureFresh(context.Background(), db, nil, Policy{StaleAfter: 6 * time.Hour})
+	if err != nil {
+		t.Fatalf("err: %v", err)
+	}
+	if decision != DecisionFresh {
+		t.Fatalf("got %v, want fresh (empty resources)", decision)
+	}
+}
+
+func TestEnsureFresh_MissingSyncStateTableIsNoStore(t *testing.T) {
+	dbPath := filepath.Join(t.TempDir(), "empty.db")
+	db, err := sql.Open("sqlite", dbPath)
+	if err != nil {
+		t.Fatalf("open: %v", err)
+	}
+	t.Cleanup(func() { db.Close() })
+
+	decision, err := EnsureFresh(context.Background(), db, []string{"issues"}, Policy{StaleAfter: 6 * time.Hour})
+	if err != nil {
+		t.Fatalf("err: %v", err)
+	}
+	if decision != DecisionNoStore {
+		t.Fatalf("got %v, want no-store (missing sync_state table)", decision)
+	}
+}
+
+func TestDecision_StringStableTags(t *testing.T) {
+	cases := map[Decision]string{
+		DecisionFresh:       "fresh",
+		DecisionStaleAPI:    "stale-api",
+		DecisionStaleShare:  "stale-share",
+		DecisionNoStore:     "no-store",
+	}
+	for d, want := range cases {
+		if d.String() != want {
+			t.Errorf("%d.String() = %q, want %q", d, d.String(), want)
+		}
+	}
+}
diff --git a/internal/generator/templates/graphql_sync.go.tmpl b/internal/generator/templates/graphql_sync.go.tmpl
index 2bff885c..af260814 100644
--- a/internal/generator/templates/graphql_sync.go.tmpl
+++ b/internal/generator/templates/graphql_sync.go.tmpl
@@ -33,6 +33,7 @@ func newSyncCmd(flags *rootFlags) *cobra.Command {
 	var concurrency int
 	var dbPath string
 	var maxPages int
+	var latestOnly bool
 
 	cmd := &cobra.Command{
 		Use:   "sync",
@@ -50,7 +51,10 @@ Once synced, use the 'search' command for instant full-text search.`,
   {{.Name}}-pp-cli sync --full
 
   # Parallel sync with 8 workers
-  {{.Name}}-pp-cli sync --concurrency 8`,
+  {{.Name}}-pp-cli sync --concurrency 8
+
+  # Latest-only: refresh head of each resource, no historical backfill
+  {{.Name}}-pp-cli sync --latest-only`,
 		RunE: func(cmd *cobra.Command, args []string) error {
 			c, err := flags.newClient()
 			if err != nil {
@@ -80,6 +84,23 @@ Once synced, use the 'search' command for instant full-text search.`,
 				}
 			}
 
+			// --latest-only: cap at page 1 and clear the resume cursor so
+			// we fetch from the head. --since takes precedence when both
+			// are set (historical context trumps latest-head).
+			if latestOnly {
+				if since == "" {
+					maxPages = 1
+					for _, resource := range resources {
+						existing, _, _, _ := db.GetSyncState(resource)
+						if existing != "" {
+							_ = db.SaveSyncState(resource, "", 0)
+						}
+					}
+				} else if humanFriendly {
+					fmt.Fprintln(os.Stderr, "warning: --latest-only ignored because --since is set; --since takes precedence")
+				}
+			}
+
 			// Resolve --since into an RFC3339 timestamp
 			sinceTS := ""
 			if since != "" {
@@ -156,6 +177,7 @@ Once synced, use the 'search' command for instant full-text search.`,
 	cmd.Flags().IntVar(&concurrency, "concurrency", 4, "Number of parallel sync workers")
 	cmd.Flags().StringVar(&dbPath, "db", "", "Database path (default: ~/.local/share/{{.Name}}-pp-cli/data.db)")
 	cmd.Flags().IntVar(&maxPages, "max-pages", 10, "Maximum pages to fetch per resource (0 = unlimited)")
+	cmd.Flags().BoolVar(&latestOnly, "latest-only", false, "Refresh head of each resource only; clears resume cursor and caps pages at 1. Mutually exclusive with --since (--since wins).")
 
 	return cmd
 }
diff --git a/internal/generator/templates/root.go.tmpl b/internal/generator/templates/root.go.tmpl
index 21bef38a..73848c4f 100644
--- a/internal/generator/templates/root.go.tmpl
+++ b/internal/generator/templates/root.go.tmpl
@@ -170,6 +170,16 @@ Run '{{.Name}}-pp-cli doctor' to verify auth and connectivity.{{backtick}},
 		default:
 			return fmt.Errorf("invalid --data-source value %q: must be auto, live, or local", flags.dataSource)
 		}
+{{- if .Cache.Enabled}}
+		// Auto-refresh stale local caches before serving read commands.
+		// Looks up the current command path in readCommandResources and
+		// consults cliutil.EnsureFresh against sync_state. When stale,
+		// runs a bounded API refresh. Failures become stderr warnings;
+		// the command proceeds with the stale cache either way.
+		if resources, isRead := readCommandResources[cmd.CommandPath()]; isRead {
+			autoRefreshIfStale(cmd.Context(), &flags, resources)
+		}
+{{- end}}
 		return nil
 	}
 
diff --git a/internal/generator/templates/sync.go.tmpl b/internal/generator/templates/sync.go.tmpl
index 990c8b8a..87a074ec 100644
--- a/internal/generator/templates/sync.go.tmpl
+++ b/internal/generator/templates/sync.go.tmpl
@@ -33,6 +33,7 @@ func newSyncCmd(flags *rootFlags) *cobra.Command {
 	var concurrency int
 	var dbPath string
 	var maxPages int
+	var latestOnly bool
 {{- if .Pagination.DateRangeParam}}
 	var dates string
 {{- end}}
@@ -57,6 +58,9 @@ Once synced, use the 'search' command for instant full-text search.`,
 
   # Parallel sync with 8 workers
   {{.Name}}-pp-cli sync --concurrency 8
+
+  # Latest-only: refresh head of each resource, no historical backfill
+  {{.Name}}-pp-cli sync --latest-only
 {{- if .Pagination.DateRangeParam}}
 
   # Sync historical data for a date range
@@ -90,6 +94,29 @@ Once synced, use the 'search' command for instant full-text search.`,
 				}
 			}
 
+			// --latest-only narrows to the first page of each resource
+			// ignoring the historical resume cursor. We cap maxPages at 1
+			// here rather than re-interpreting it downstream so the rest
+			// of the sync loop stays oblivious. Mutually-useful with
+			// --since: if the user set --since, that threshold still wins
+			// and we don't short-circuit historical context they asked for.
+			if latestOnly {
+				if since == "" {
+					maxPages = 1
+					// Clear the cursor so we start from the head each time;
+					// the goal of --latest-only is "refresh the top" not
+					// "resume from wherever I left off".
+					for _, resource := range resources {
+						existing, _, _, _ := db.GetSyncState(resource)
+						if existing != "" {
+							_ = db.SaveSyncState(resource, "", 0)
+						}
+					}
+				} else if humanFriendly {
+					fmt.Fprintln(os.Stderr, "warning: --latest-only ignored because --since is set; --since takes precedence")
+				}
+			}
+
 			// Resolve --since into an RFC3339 timestamp
 			sinceTS := ""
 			if since != "" {
@@ -189,6 +216,7 @@ Once synced, use the 'search' command for instant full-text search.`,
 	cmd.Flags().IntVar(&concurrency, "concurrency", 4, "Number of parallel sync workers")
 	cmd.Flags().StringVar(&dbPath, "db", "", "Database path (default: ~/.local/share/{{.Name}}-pp-cli/data.db)")
 	cmd.Flags().IntVar(&maxPages, "max-pages", 10, "Maximum pages to fetch per resource (0 = unlimited)")
+	cmd.Flags().BoolVar(&latestOnly, "latest-only", false, "Refresh head of each resource only; clears resume cursor and caps pages at 1. Mutually exclusive with --since (--since wins).")
 {{- if .Pagination.DateRangeParam}}
 	cmd.Flags().StringVar(&dates, "dates", "", "Date or date range to sync (passed as {{.Pagination.DateRangeParam}} query parameter)")
 {{- end}}

← e116a27c feat(cli): schema-version gate, doctor cache section, cache/  ·  back to Cli Printing Press  ·  feat(cli): git-backed snapshot share + cache_freshness score 5e2ed6a8 →