← 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
M internal/generator/generator.goM internal/generator/generator_test.goA internal/generator/templates/auto_refresh.go.tmplA internal/generator/templates/cliutil_freshness.go.tmplA internal/generator/templates/cliutil_freshness_test.go.tmplM internal/generator/templates/graphql_sync.go.tmplM internal/generator/templates/root.go.tmplM internal/generator/templates/sync.go.tmpl
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 →