1// Package store persists JSON documents under the user's cache and config2// directories.3package store45import (6 "context"7 "encoding/json"8 "errors"9 "fmt"10 "os"11 "path/filepath"12 "strings"13 "time"14)1516// Cache is a directory of JSON files keyed by name, each stamped with the17// time it was written.18type Cache struct {19 dir string20}2122type envelope struct {23 Saved time.Time `json:"saved"`24 Data json.RawMessage `json:"data"`25}2627// NewCache opens (creating if needed) the cache directory for app.28func NewCache(app string) (*Cache, error) {29 base, err := os.UserCacheDir()30 if err != nil {31 return nil, fmt.Errorf("locate cache dir: %w", err)32 }33 dir := filepath.Join(base, app)34 if err := os.MkdirAll(dir, 0o755); err != nil {35 return nil, fmt.Errorf("create cache dir: %w", err)36 }37 return &Cache{dir: dir}, nil38}3940// OpenCache opens (creating if needed) a cache in dir.41func OpenCache(dir string) (*Cache, error) {42 if err := os.MkdirAll(dir, 0o755); err != nil {43 return nil, fmt.Errorf("create cache dir: %w", err)44 }45 return &Cache{dir: dir}, nil46}4748// Dir returns the directory backing the cache.49func (c *Cache) Dir() string { return c.dir }5051func (c *Cache) path(key string) string {52 clean := strings.Map(func(r rune) rune {53 switch {54 case r >= 'a' && r <= 'z', r >= 'A' && r <= 'Z', r >= '0' && r <= '9', r == '-', r == '.':55 return r56 }57 return '_'58 }, key)59 return filepath.Join(c.dir, clean+".json")60}6162// Get decodes the entry for key into v. It returns the time the entry was63// saved, or an error if the entry is missing or unreadable.64func (c *Cache) Get(key string, v any) (time.Time, error) {65 raw, err := os.ReadFile(c.path(key))66 if err != nil {67 return time.Time{}, err68 }69 var env envelope70 if err := json.Unmarshal(raw, &env); err != nil {71 return time.Time{}, err72 }73 if err := json.Unmarshal(env.Data, v); err != nil {74 return time.Time{}, err75 }76 return env.Saved, nil77}7879// Put stores v under key.80func (c *Cache) Put(key string, v any) error {81 data, err := json.Marshal(v)82 if err != nil {83 return err84 }85 raw, err := json.Marshal(envelope{Saved: time.Now(), Data: data})86 if err != nil {87 return err88 }89 tmp := c.path(key) + ".tmp"90 if err := os.WriteFile(tmp, raw, 0o644); err != nil {91 return err92 }93 return os.Rename(tmp, c.path(key))94}9596// Load returns the cached value for key when it is younger than maxAge,97// otherwise it calls fetch and caches the result. If fetch fails and a stale98// entry exists, the stale entry is returned along with no error so callers99// keep working offline. A nil cache always fetches.100func Load[T any](ctx context.Context, c *Cache, key string, maxAge time.Duration, refresh bool, fetch func(context.Context) (T, error)) (T, error) {101 var cached T102 var saved time.Time103 var cacheErr error = errors.New("no cache")104 if c != nil {105 saved, cacheErr = c.Get(key, &cached)106 if cacheErr == nil && !refresh && time.Since(saved) < maxAge {107 return cached, nil108 }109 }110 v, err := fetch(ctx)111 if err != nil {112 if cacheErr == nil {113 return cached, nil114 }115 return v, err116 }117 if c != nil {118 // A failed write only costs a refetch next time.119 _ = c.Put(key, v)120 }121 return v, nil122}