Compare commits
3 commits
ax/review-
...
dev
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5eb611f5e2 | ||
|
|
19232c5575 | ||
|
|
248f542b08 |
5 changed files with 91 additions and 220 deletions
89
cache.go
89
cache.go
|
|
@ -4,6 +4,7 @@
|
|||
package cache
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"io/fs"
|
||||
"time"
|
||||
|
||||
|
|
@ -27,9 +28,9 @@ type Cache struct {
|
|||
|
||||
// Entry is the serialized cache record written to the backing Medium.
|
||||
type Entry struct {
|
||||
Data string `json:"data"`
|
||||
CachedAt time.Time `json:"cached_at"`
|
||||
ExpiresAt time.Time `json:"expires_at"`
|
||||
Data json.RawMessage `json:"data"`
|
||||
CachedAt time.Time `json:"cached_at"`
|
||||
ExpiresAt time.Time `json:"expires_at"`
|
||||
}
|
||||
|
||||
// New creates a cache and applies default Medium, base directory, and TTL values
|
||||
|
|
@ -42,12 +43,12 @@ func New(medium coreio.Medium, baseDir string, ttl time.Duration) (*Cache, error
|
|||
}
|
||||
|
||||
if baseDir == "" {
|
||||
workingDirectory := currentDir()
|
||||
if workingDirectory == "" || workingDirectory == "." {
|
||||
cwd := currentDir()
|
||||
if cwd == "" || cwd == "." {
|
||||
return nil, core.E("cache.New", "failed to resolve current working directory", nil)
|
||||
}
|
||||
|
||||
baseDir = normalizePath(core.JoinPath(workingDirectory, ".core", "cache"))
|
||||
baseDir = normalizePath(core.JoinPath(cwd, ".core", "cache"))
|
||||
} else {
|
||||
baseDir = absolutePath(baseDir)
|
||||
}
|
||||
|
|
@ -122,8 +123,8 @@ func (c *Cache) Get(key string, dest any) (bool, error) {
|
|||
return false, nil
|
||||
}
|
||||
|
||||
if result := core.JSONUnmarshalString(entry.Data, dest); !result.OK {
|
||||
return false, core.E("cache.Get", "failed to unmarshal cached data", result.Value.(error))
|
||||
if err := core.JSONUnmarshal(entry.Data, dest); !err.OK {
|
||||
return false, core.E("cache.Get", "failed to unmarshal cached data", err.Value.(error))
|
||||
}
|
||||
|
||||
return true, nil
|
||||
|
|
@ -151,26 +152,26 @@ func (c *Cache) Set(key string, data any) error {
|
|||
return core.E("cache.Set", "failed to marshal cache data", dataResult.Value.(error))
|
||||
}
|
||||
|
||||
cacheTTL := c.ttl
|
||||
if cacheTTL < 0 {
|
||||
ttl := c.ttl
|
||||
if ttl < 0 {
|
||||
return core.E("cache.Set", "cache ttl must be >= 0", nil)
|
||||
}
|
||||
if cacheTTL == 0 {
|
||||
cacheTTL = DefaultTTL
|
||||
if ttl == 0 {
|
||||
ttl = DefaultTTL
|
||||
}
|
||||
|
||||
entry := Entry{
|
||||
Data: string(dataResult.Value.([]byte)),
|
||||
Data: dataResult.Value.([]byte),
|
||||
CachedAt: time.Now(),
|
||||
ExpiresAt: time.Now().Add(cacheTTL),
|
||||
ExpiresAt: time.Now().Add(ttl),
|
||||
}
|
||||
|
||||
entryResult := core.JSONMarshal(entry)
|
||||
if !entryResult.OK {
|
||||
return core.E("cache.Set", "failed to marshal cache entry", entryResult.Value.(error))
|
||||
entryBytes, err := json.MarshalIndent(entry, "", " ")
|
||||
if err != nil {
|
||||
return core.E("cache.Set", "failed to marshal cache entry", err)
|
||||
}
|
||||
|
||||
if err := c.medium.Write(path, string(entryResult.Value.([]byte))); err != nil {
|
||||
if err := c.medium.Write(path, string(entryBytes)); err != nil {
|
||||
return core.E("cache.Set", "failed to write cache file", err)
|
||||
}
|
||||
return nil
|
||||
|
|
@ -199,6 +200,32 @@ func (c *Cache) Delete(key string) error {
|
|||
return nil
|
||||
}
|
||||
|
||||
// DeleteMany removes several cached items in one call.
|
||||
//
|
||||
// err := c.DeleteMany("github/acme/repos", "github/acme/meta")
|
||||
func (c *Cache) DeleteMany(keys ...string) error {
|
||||
if err := c.ensureReady("cache.DeleteMany"); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for _, key := range keys {
|
||||
path, err := c.Path(key)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
err = c.medium.Delete(path)
|
||||
if core.Is(err, fs.ErrNotExist) {
|
||||
continue
|
||||
}
|
||||
if err != nil {
|
||||
return core.E("cache.DeleteMany", "failed to delete cache file", err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Clear removes all cached items under the cache base directory.
|
||||
//
|
||||
// err := c.Clear()
|
||||
|
|
@ -257,22 +284,22 @@ func GitHubRepoKey(org, repo string) string {
|
|||
}
|
||||
|
||||
func pathSeparator() string {
|
||||
if separator := core.Env("DS"); separator != "" {
|
||||
return separator
|
||||
if ds := core.Env("DS"); ds != "" {
|
||||
return ds
|
||||
}
|
||||
|
||||
return "/"
|
||||
}
|
||||
|
||||
func normalizePath(path string) string {
|
||||
separator := pathSeparator()
|
||||
normalized := core.Replace(path, "\\", separator)
|
||||
ds := pathSeparator()
|
||||
normalized := core.Replace(path, "\\", ds)
|
||||
|
||||
if separator != "/" {
|
||||
normalized = core.Replace(normalized, "/", separator)
|
||||
if ds != "/" {
|
||||
normalized = core.Replace(normalized, "/", ds)
|
||||
}
|
||||
|
||||
return core.CleanPath(normalized, separator)
|
||||
return core.CleanPath(normalized, ds)
|
||||
}
|
||||
|
||||
func absolutePath(path string) string {
|
||||
|
|
@ -281,18 +308,18 @@ func absolutePath(path string) string {
|
|||
return normalized
|
||||
}
|
||||
|
||||
workingDirectory := currentDir()
|
||||
if workingDirectory == "" || workingDirectory == "." {
|
||||
cwd := currentDir()
|
||||
if cwd == "" || cwd == "." {
|
||||
return normalized
|
||||
}
|
||||
|
||||
return normalizePath(core.JoinPath(workingDirectory, normalized))
|
||||
return normalizePath(core.JoinPath(cwd, normalized))
|
||||
}
|
||||
|
||||
func currentDir() string {
|
||||
workingDirectory := normalizePath(core.Env("PWD"))
|
||||
if workingDirectory != "" && workingDirectory != "." {
|
||||
return workingDirectory
|
||||
cwd := normalizePath(core.Env("PWD"))
|
||||
if cwd != "" && cwd != "." {
|
||||
return cwd
|
||||
}
|
||||
|
||||
return normalizePath(core.Env("DIR_CWD"))
|
||||
|
|
|
|||
217
cache_test.go
217
cache_test.go
|
|
@ -3,6 +3,7 @@
|
|||
package cache_test
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
|
|
@ -60,6 +61,9 @@ func TestCache_New_Good(t *testing.T) {
|
|||
if err != nil {
|
||||
t.Fatalf("Read failed: %v", err)
|
||||
}
|
||||
if !strings.Contains(raw, "\n \"data\":") {
|
||||
t.Fatalf("expected pretty-printed cache entry, got %q", raw)
|
||||
}
|
||||
|
||||
entry := readEntry(t, raw)
|
||||
ttl := entry.ExpiresAt.Sub(entry.CachedAt)
|
||||
|
|
@ -75,32 +79,6 @@ func TestCache_New_Bad(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestCache_New_Ugly(t *testing.T) {
|
||||
// New with zero ttl falls back to DefaultTTL; verify a set entry uses it.
|
||||
c, m := newTestCache(t, "/tmp/cache-default-ttl", 0)
|
||||
|
||||
const key = "ugly-key"
|
||||
if err := c.Set(key, map[string]string{"x": "y"}); err != nil {
|
||||
t.Fatalf("Set failed: %v", err)
|
||||
}
|
||||
|
||||
path, err := c.Path(key)
|
||||
if err != nil {
|
||||
t.Fatalf("Path failed: %v", err)
|
||||
}
|
||||
|
||||
raw, err := m.Read(path)
|
||||
if err != nil {
|
||||
t.Fatalf("Read failed: %v", err)
|
||||
}
|
||||
|
||||
entry := readEntry(t, raw)
|
||||
appliedTTL := entry.ExpiresAt.Sub(entry.CachedAt)
|
||||
if appliedTTL < cache.DefaultTTL || appliedTTL > cache.DefaultTTL+time.Second {
|
||||
t.Fatalf("expected ttl near %v, got %v", cache.DefaultTTL, appliedTTL)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCache_Path_Good(t *testing.T) {
|
||||
c, _ := newTestCache(t, "/tmp/cache-path", time.Minute)
|
||||
|
||||
|
|
@ -124,15 +102,6 @@ func TestCache_Path_Bad(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestCache_Path_Ugly(t *testing.T) {
|
||||
// Path on a nil receiver returns an error rather than panicking.
|
||||
var c *cache.Cache
|
||||
_, err := c.Path("any-key")
|
||||
if err == nil {
|
||||
t.Fatal("expected Path to fail on nil receiver")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCache_Get_Good(t *testing.T) {
|
||||
c, _ := newTestCache(t, "/tmp/cache", time.Minute)
|
||||
|
||||
|
|
@ -156,20 +125,6 @@ func TestCache_Get_Good(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestCache_Get_Bad(t *testing.T) {
|
||||
// Get on a missing key returns (false, nil) — not an error.
|
||||
c, _ := newTestCache(t, "/tmp/cache-get-bad", time.Minute)
|
||||
|
||||
var retrieved map[string]string
|
||||
found, err := c.Get("nonexistent-key", &retrieved)
|
||||
if err != nil {
|
||||
t.Fatalf("expected no error for missing key, got: %v", err)
|
||||
}
|
||||
if found {
|
||||
t.Fatal("expected found=false for missing key")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCache_Get_Ugly(t *testing.T) {
|
||||
c, _ := newTestCache(t, "/tmp/cache-expiry", 10*time.Millisecond)
|
||||
|
||||
|
|
@ -201,84 +156,6 @@ func TestCache_Age_Good(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestCache_Age_Bad(t *testing.T) {
|
||||
// Age returns -1 for a key that was never written.
|
||||
c, _ := newTestCache(t, "/tmp/cache-age-bad", time.Minute)
|
||||
|
||||
if age := c.Age("missing-key"); age != -1 {
|
||||
t.Errorf("expected -1 for missing key, got %v", age)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCache_Age_Ugly(t *testing.T) {
|
||||
// Age returns -1 when the stored file contains corrupt JSON.
|
||||
c, medium := newTestCache(t, "/tmp/cache-age-ugly", time.Minute)
|
||||
|
||||
path, err := c.Path("corrupt-key")
|
||||
if err != nil {
|
||||
t.Fatalf("Path failed: %v", err)
|
||||
}
|
||||
|
||||
medium.Files[path] = "not-valid-json"
|
||||
|
||||
if age := c.Age("corrupt-key"); age != -1 {
|
||||
t.Errorf("expected -1 for corrupt entry, got %v", age)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCache_Set_Good(t *testing.T) {
|
||||
// Set writes a valid entry that Get can later retrieve.
|
||||
c, _ := newTestCache(t, "/tmp/cache-set-good", time.Minute)
|
||||
|
||||
if err := c.Set("set-key", map[string]int{"count": 42}); err != nil {
|
||||
t.Fatalf("Set failed: %v", err)
|
||||
}
|
||||
|
||||
var retrieved map[string]int
|
||||
found, err := c.Get("set-key", &retrieved)
|
||||
if err != nil {
|
||||
t.Fatalf("Get after Set failed: %v", err)
|
||||
}
|
||||
if !found {
|
||||
t.Fatal("expected to find item after Set")
|
||||
}
|
||||
if retrieved["count"] != 42 {
|
||||
t.Errorf("expected count=42, got %v", retrieved["count"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestCache_Set_Bad(t *testing.T) {
|
||||
// Set on a nil receiver returns an error rather than panicking.
|
||||
var c *cache.Cache
|
||||
if err := c.Set("key", map[string]string{"a": "b"}); err == nil {
|
||||
t.Fatal("expected Set to fail on nil receiver")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCache_Set_Ugly(t *testing.T) {
|
||||
// Set overwrites an existing entry; subsequent Get returns the new value.
|
||||
c, _ := newTestCache(t, "/tmp/cache-set-ugly", time.Minute)
|
||||
|
||||
if err := c.Set("overwrite-key", map[string]string{"v": "first"}); err != nil {
|
||||
t.Fatalf("first Set failed: %v", err)
|
||||
}
|
||||
if err := c.Set("overwrite-key", map[string]string{"v": "second"}); err != nil {
|
||||
t.Fatalf("second Set failed: %v", err)
|
||||
}
|
||||
|
||||
var retrieved map[string]string
|
||||
found, err := c.Get("overwrite-key", &retrieved)
|
||||
if err != nil {
|
||||
t.Fatalf("Get failed: %v", err)
|
||||
}
|
||||
if !found {
|
||||
t.Fatal("expected to find item")
|
||||
}
|
||||
if retrieved["v"] != "second" {
|
||||
t.Errorf("expected v=second after overwrite, got %q", retrieved["v"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestCache_NilReceiver_Good(t *testing.T) {
|
||||
var c *cache.Cache
|
||||
var target map[string]string
|
||||
|
|
@ -358,21 +235,35 @@ func TestCache_Delete_Good(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestCache_Delete_Bad(t *testing.T) {
|
||||
// Delete of a key that does not exist returns nil — idempotent.
|
||||
c, _ := newTestCache(t, "/tmp/cache-delete-bad", time.Minute)
|
||||
func TestCache_DeleteMany_Good(t *testing.T) {
|
||||
c, _ := newTestCache(t, "/tmp/cache-delete-many", time.Minute)
|
||||
data := map[string]string{"foo": "bar"}
|
||||
|
||||
if err := c.Delete("nonexistent-key"); err != nil {
|
||||
t.Fatalf("expected Delete of missing key to return nil, got: %v", err)
|
||||
if err := c.Set("key1", data); err != nil {
|
||||
t.Fatalf("Set failed for key1: %v", err)
|
||||
}
|
||||
if err := c.Set("key2", data); err != nil {
|
||||
t.Fatalf("Set failed for key2: %v", err)
|
||||
}
|
||||
if err := c.DeleteMany("key1", "missing", "key2"); err != nil {
|
||||
t.Fatalf("DeleteMany failed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCache_Delete_Ugly(t *testing.T) {
|
||||
// Delete rejects a path traversal key with an error.
|
||||
c, _ := newTestCache(t, "/tmp/cache-delete-ugly", time.Minute)
|
||||
var retrieved map[string]string
|
||||
found, err := c.Get("key1", &retrieved)
|
||||
if err != nil {
|
||||
t.Fatalf("Get after DeleteMany returned an unexpected error: %v", err)
|
||||
}
|
||||
if found {
|
||||
t.Error("expected key1 to be deleted")
|
||||
}
|
||||
|
||||
if err := c.Delete("../../etc/shadow"); err == nil {
|
||||
t.Fatal("expected Delete to reject path traversal key")
|
||||
found, err = c.Get("key2", &retrieved)
|
||||
if err != nil {
|
||||
t.Fatalf("Get after DeleteMany returned an unexpected error: %v", err)
|
||||
}
|
||||
if found {
|
||||
t.Error("expected key2 to be deleted")
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -400,23 +291,6 @@ func TestCache_Clear_Good(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestCache_Clear_Bad(t *testing.T) {
|
||||
// Clear on a nil receiver returns an error rather than panicking.
|
||||
var c *cache.Cache
|
||||
if err := c.Clear(); err == nil {
|
||||
t.Fatal("expected Clear to fail on nil receiver")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCache_Clear_Ugly(t *testing.T) {
|
||||
// Clear on an empty cache (no entries written) returns nil — idempotent.
|
||||
c, _ := newTestCache(t, "/tmp/cache-clear-ugly", time.Minute)
|
||||
|
||||
if err := c.Clear(); err != nil {
|
||||
t.Fatalf("expected Clear on empty cache to return nil, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCache_GitHubReposKey_Good(t *testing.T) {
|
||||
key := cache.GitHubReposKey("myorg")
|
||||
if key != "github/myorg/repos" {
|
||||
|
|
@ -424,42 +298,9 @@ func TestCache_GitHubReposKey_Good(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestCache_GitHubReposKey_Bad(t *testing.T) {
|
||||
// GitHubReposKey with an empty org still produces a structurally valid path.
|
||||
key := cache.GitHubReposKey("")
|
||||
if key == "" {
|
||||
t.Error("expected a non-empty path even for empty org")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCache_GitHubReposKey_Ugly(t *testing.T) {
|
||||
// GitHubReposKey with an org containing slashes produces a deterministic path.
|
||||
key := cache.GitHubReposKey("org/sub")
|
||||
if key == "" {
|
||||
t.Error("expected a non-empty path for org with slash")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCache_GitHubRepoKey_Good(t *testing.T) {
|
||||
key := cache.GitHubRepoKey("myorg", "myrepo")
|
||||
if key != "github/myorg/myrepo/meta" {
|
||||
t.Errorf("unexpected GitHubRepoKey: %q", key)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCache_GitHubRepoKey_Bad(t *testing.T) {
|
||||
// GitHubRepoKey with empty args still returns a non-empty path.
|
||||
key := cache.GitHubRepoKey("", "")
|
||||
if key == "" {
|
||||
t.Error("expected a non-empty path even for empty org and repo")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCache_GitHubRepoKey_Ugly(t *testing.T) {
|
||||
// GitHubRepoKey differentiates between two repositories in the same org.
|
||||
keyA := cache.GitHubRepoKey("org", "repo-a")
|
||||
keyB := cache.GitHubRepoKey("org", "repo-b")
|
||||
if keyA == keyB {
|
||||
t.Errorf("expected different keys for different repos, both got %q", keyA)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -22,6 +22,7 @@ own usage example in a doc comment or Go example test.
|
|||
| `(*Cache).Get` | `func (c *Cache) Get(key string, dest any) (bool, error)` | `dappco.re/go/core/cache` | Retrieves a cached item if it exists and has not expired. | yes | no |
|
||||
| `(*Cache).Set` | `func (c *Cache) Set(key string, data any) error` | `dappco.re/go/core/cache` | Stores an item in the cache. | yes | no |
|
||||
| `(*Cache).Delete` | `func (c *Cache) Delete(key string) error` | `dappco.re/go/core/cache` | Removes an item from the cache. | yes | no |
|
||||
| `(*Cache).DeleteMany` | `func (c *Cache) DeleteMany(keys ...string) error` | `dappco.re/go/core/cache` | Removes several items from the cache in one call. | yes | no |
|
||||
| `(*Cache).Clear` | `func (c *Cache) Clear() error` | `dappco.re/go/core/cache` | Removes all cached items. | yes | no |
|
||||
| `(*Cache).Age` | `func (c *Cache) Age(key string) time.Duration` | `dappco.re/go/core/cache` | Returns how old a cached item is, or `-1` if it is not cached. | yes | no |
|
||||
| `GitHubReposKey` | `func GitHubReposKey(org string) string` | `dappco.re/go/core/cache` | Returns the cache key for an organization's repo list. | yes | no |
|
||||
|
|
|
|||
|
|
@ -136,6 +136,8 @@ Key behaviours:
|
|||
|
||||
- **`Delete(key)`** removes a single entry. If the file does not exist, the
|
||||
operation succeeds silently.
|
||||
- **`DeleteMany(keys...)`** removes several entries in one call and ignores
|
||||
missing files, using the same per-key path validation as `Delete()`.
|
||||
- **`Clear()`** calls `medium.DeleteAll(baseDir)`, removing the entire cache
|
||||
directory and all its contents.
|
||||
|
||||
|
|
|
|||
2
go.mod
2
go.mod
|
|
@ -7,4 +7,4 @@ require (
|
|||
dappco.re/go/core/io v0.2.0
|
||||
)
|
||||
|
||||
require forge.lthn.ai/core/go-log v0.0.4 // indirect
|
||||
require dappco.re/go/core/log v0.0.4 // indirect
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue