ai cooking
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

purge tool replaces move tool (#236)

authored by

Paul Miller and committed by
GitHub
43dfdae7 cb77c89d

+237 -399
-253
cmd/moveshoppinglists/main.go
··· 1 - package main 2 - 3 - import ( 4 - "bytes" 5 - "careme/internal/cache" 6 - "context" 7 - "encoding/base64" 8 - "errors" 9 - "flag" 10 - "fmt" 11 - "io" 12 - "io/fs" 13 - "log" 14 - "os" 15 - "path/filepath" 16 - "slices" 17 - "strings" 18 - 19 - "github.com/samber/lo" 20 - ) 21 - 22 - const shoppingListPrefix = "shoppinglist/" 23 - const paramsPrefix = "params/" 24 - const ingredientsPrefix = "ingredients/" 25 - const legacyRecipeHashSeed = "recipe" 26 - const legacyIngredientsHashSeed = "ingredients" 27 - 28 - type migrationStats struct { 29 - RootKeys int 30 - ShoppingListKeys int 31 - ParamsKeys int 32 - IngredientsKeys int 33 - Copied int 34 - SkippedExisting int 35 - SkippedUnsupported int 36 - } 37 - 38 - func main() { 39 - var apply bool 40 - flag.BoolVar(&apply, "apply", false, "Apply changes. Default is dry-run.") 41 - flag.Parse() 42 - 43 - ctx := context.Background() 44 - c, err := cache.MakeCache() 45 - if err != nil { 46 - log.Fatalf("failed to create cache: %v", err) 47 - } 48 - 49 - stats, err := migrateShoppingLists(ctx, c, apply, os.Stdout) 50 - if err != nil { 51 - log.Fatalf("migration failed: %v", err) 52 - } 53 - 54 - fmt.Printf( 55 - "done: root=%d shoppinglists=%d params=%d ingredients=%d copied=%d skipped_existing=%d skipped_unsupported=%d mode=%s\n", 56 - stats.RootKeys, 57 - stats.ShoppingListKeys, 58 - stats.ParamsKeys, 59 - stats.IngredientsKeys, 60 - stats.Copied, 61 - stats.SkippedExisting, 62 - stats.SkippedUnsupported, 63 - mode(apply), 64 - ) 65 - } 66 - 67 - func mode(apply bool) string { 68 - if apply { 69 - return "apply" 70 - } 71 - return "dry-run" 72 - } 73 - 74 - func migrateShoppingLists(ctx context.Context, c cache.ListCache, apply bool, out io.Writer) (migrationStats, error) { 75 - var stats migrationStats 76 - 77 - rootKeys, err := listRootKeys(ctx, c) 78 - if err != nil { 79 - return stats, err 80 - } 81 - stats.RootKeys = len(rootKeys) 82 - 83 - for _, key := range rootKeys { 84 - switch { 85 - case strings.HasSuffix(key, ".params"): 86 - stats.ParamsKeys++ 87 - recipeHash := strings.TrimSuffix(key, ".params") 88 - newKey := paramsPrefix + canonicalOrOriginalHash(recipeHash, legacyRecipeHashSeed) 89 - copied, skipped, err := copyKey(ctx, c, key, newKey, apply, out) 90 - if err != nil { 91 - return stats, err 92 - } 93 - stats.Copied += copied 94 - stats.SkippedExisting += skipped 95 - continue 96 - case hasLegacyHashSeed(key, legacyIngredientsHashSeed): 97 - stats.IngredientsKeys++ 98 - newKey := ingredientsPrefix + canonicalOrOriginalHash(key, legacyIngredientsHashSeed) 99 - copied, skipped, err := copyKey(ctx, c, key, newKey, apply, out) 100 - if err != nil { 101 - return stats, err 102 - } 103 - stats.Copied += copied 104 - stats.SkippedExisting += skipped 105 - continue 106 - case hasLegacyHashSeed(key, legacyRecipeHashSeed): 107 - stats.ShoppingListKeys++ 108 - newKey := shoppingListPrefix + canonicalOrOriginalHash(key, legacyRecipeHashSeed) 109 - copied, skipped, err := copyKey(ctx, c, key, newKey, apply, out) 110 - if err != nil { 111 - return stats, err 112 - } 113 - stats.Copied += copied 114 - stats.SkippedExisting += skipped 115 - default: 116 - stats.SkippedUnsupported++ 117 - continue 118 - } 119 - } 120 - 121 - return stats, nil 122 - } 123 - 124 - func copyKey(ctx context.Context, c cache.Cache, srcKey, dstKey string, apply bool, out io.Writer) (copied int, skippedExisting int, err error) { 125 - exists, err := c.Exists(ctx, dstKey) 126 - if err != nil { 127 - return 0, 0, fmt.Errorf("check destination %q: %w", dstKey, err) 128 - } 129 - if exists { 130 - _ = lo.Must(fmt.Fprintf(out, "skip existing %s -> %s\n", srcKey, dstKey)) 131 - return 0, 1, nil 132 - } 133 - 134 - if !apply { 135 - _ = lo.Must(fmt.Fprintf(out, "would copy %s -> %s\n", srcKey, dstKey)) 136 - return 1, 0, nil 137 - } 138 - 139 - payload, err := readKey(ctx, c, srcKey) 140 - if err != nil { 141 - return 0, 0, fmt.Errorf("read %q: %w", srcKey, err) 142 - } 143 - if err := c.Put(ctx, dstKey, string(payload), cache.IfNoneMatch()); err != nil { 144 - if errors.Is(err, cache.ErrAlreadyExists) { 145 - _ = lo.Must(fmt.Fprintf(out, "skip existing %s -> %s\n", srcKey, dstKey)) 146 - return 0, 1, nil 147 - } 148 - return 0, 0, fmt.Errorf("write %q: %w", dstKey, err) 149 - } 150 - 151 - _ = lo.Must(fmt.Fprintf(out, "copied %s -> %s\n", srcKey, dstKey)) 152 - return 1, 0, nil 153 - } 154 - 155 - func listRootKeys(ctx context.Context, c cache.ListCache) ([]string, error) { 156 - if fc, ok := c.(*cache.FileCache); ok { 157 - return listFileRootKeys(fc.Dir) 158 - } 159 - 160 - keys, err := c.List(ctx, "", "") 161 - if err != nil { 162 - return nil, err 163 - } 164 - return normalizeRootKeys(keys), nil 165 - } 166 - 167 - func listFileRootKeys(dir string) ([]string, error) { 168 - if _, err := os.Stat(dir); err != nil { 169 - if os.IsNotExist(err) { 170 - return nil, nil 171 - } 172 - return nil, err 173 - } 174 - 175 - keys := make([]string, 0, 128) 176 - err := filepath.WalkDir(dir, func(path string, d fs.DirEntry, err error) error { 177 - if err != nil { 178 - return err 179 - } 180 - if d.IsDir() { 181 - return nil 182 - } 183 - 184 - rel, err := filepath.Rel(dir, path) 185 - if err != nil { 186 - return err 187 - } 188 - key := filepath.ToSlash(rel) 189 - if key == "." || strings.Contains(key, "/") { 190 - return nil 191 - } 192 - keys = append(keys, key) 193 - return nil 194 - }) 195 - if err != nil { 196 - return nil, err 197 - } 198 - 199 - slices.Sort(keys) 200 - return keys, nil 201 - } 202 - 203 - func normalizeRootKeys(keys []string) []string { 204 - root := make([]string, 0, len(keys)) 205 - seen := make(map[string]struct{}, len(keys)) 206 - for _, key := range keys { 207 - k := filepath.ToSlash(strings.TrimSpace(key)) 208 - k = strings.TrimPrefix(k, "./") 209 - k = strings.TrimPrefix(k, "/") 210 - if k == "" || strings.Contains(k, "/") { 211 - continue 212 - } 213 - if _, ok := seen[k]; ok { 214 - continue 215 - } 216 - seen[k] = struct{}{} 217 - root = append(root, k) 218 - } 219 - slices.Sort(root) 220 - return root 221 - } 222 - 223 - func readKey(ctx context.Context, c cache.Cache, key string) ([]byte, error) { 224 - r, err := c.Get(ctx, key) 225 - if err != nil { 226 - return nil, err 227 - } 228 - defer func() { 229 - _ = r.Close() 230 - }() 231 - return io.ReadAll(r) 232 - } 233 - 234 - func hasLegacyHashSeed(hash string, seed string) bool { 235 - decoded, err := base64.URLEncoding.DecodeString(hash) 236 - if err != nil { 237 - return false 238 - } 239 - seedBytes := []byte(seed) 240 - return bytes.HasPrefix(decoded, seedBytes) && len(decoded) > len(seedBytes) 241 - } 242 - 243 - func canonicalOrOriginalHash(hash string, seed string) string { 244 - decoded, err := base64.URLEncoding.DecodeString(hash) 245 - if err != nil { 246 - return hash 247 - } 248 - seedBytes := []byte(seed) 249 - if !bytes.HasPrefix(decoded, seedBytes) || len(decoded) == len(seedBytes) { 250 - return hash 251 - } 252 - return base64.RawURLEncoding.EncodeToString(decoded[len(seedBytes):]) 253 - }
-146
cmd/moveshoppinglists/main_test.go
··· 1 - package main 2 - 3 - import ( 4 - "bytes" 5 - "careme/internal/ai" 6 - "careme/internal/cache" 7 - "context" 8 - "encoding/base64" 9 - "encoding/json" 10 - "errors" 11 - "testing" 12 - ) 13 - 14 - func TestMigrateShoppingListsApply(t *testing.T) { 15 - ctx := context.Background() 16 - fc := cache.NewFileCache(t.TempDir()) 17 - canonicalShopping := "5paGKJp_BFc" 18 - canonicalIngredients := "4MVQVRdNr8M" 19 - legacyShopping := toLegacyHash(t, canonicalShopping, legacyRecipeHashSeed) 20 - legacyIngredients := toLegacyHash(t, canonicalIngredients, legacyIngredientsHashSeed) 21 - 22 - shopping := ai.ShoppingList{ 23 - ConversationID: "conv_123", 24 - Recipes: []ai.Recipe{ 25 - {Title: "Tacos"}, 26 - }, 27 - } 28 - shoppingJSON, err := json.Marshal(shopping) 29 - if err != nil { 30 - t.Fatalf("marshal shopping list: %v", err) 31 - } 32 - if err := fc.Put(ctx, legacyShopping, string(shoppingJSON), cache.Unconditional()); err != nil { 33 - t.Fatalf("seed shopping list: %v", err) 34 - } 35 - if err := fc.Put(ctx, legacyShopping+".params", `{"location":{"id":"1"}}`, cache.Unconditional()); err != nil { 36 - t.Fatalf("seed params: %v", err) 37 - } 38 - if err := fc.Put(ctx, legacyIngredients, `[{"description":"kale"}]`, cache.Unconditional()); err != nil { 39 - t.Fatalf("seed ingredients: %v", err) 40 - } 41 - if err := fc.Put(ctx, "recipe/abc", `{"title":"abc"}`, cache.Unconditional()); err != nil { 42 - t.Fatalf("seed recipe key: %v", err) 43 - } 44 - 45 - stats, err := migrateShoppingLists(ctx, fc, true, &bytes.Buffer{}) 46 - if err != nil { 47 - t.Fatalf("migrate: %v", err) 48 - } 49 - if stats.Copied != 3 { 50 - t.Fatalf("expected 3 copied keys, got %d", stats.Copied) 51 - } 52 - if stats.ShoppingListKeys != 1 || stats.ParamsKeys != 1 || stats.IngredientsKeys != 1 { 53 - t.Fatalf("expected shopping=1 params=1 ingredients=1, got shopping=%d params=%d ingredients=%d", stats.ShoppingListKeys, stats.ParamsKeys, stats.IngredientsKeys) 54 - } 55 - 56 - if _, err := fc.Get(ctx, legacyShopping); err != nil { 57 - t.Fatalf("expected old shopping list key to remain after copy: %v", err) 58 - } 59 - 60 - newBlob, err := fc.Get(ctx, "shoppinglist/"+canonicalShopping) 61 - if err != nil { 62 - t.Fatalf("expected moved shopping list: %v", err) 63 - } 64 - _ = newBlob.Close() 65 - 66 - if _, err := fc.Get(ctx, "params/"+canonicalShopping); err != nil { 67 - t.Fatalf("expected params copied to prefixed key: %v", err) 68 - } 69 - if _, err := fc.Get(ctx, "ingredients/"+canonicalIngredients); err != nil { 70 - t.Fatalf("expected ingredients copied to prefixed key: %v", err) 71 - } 72 - } 73 - 74 - func TestMigrateShoppingListsDryRun(t *testing.T) { 75 - ctx := context.Background() 76 - fc := cache.NewFileCache(t.TempDir()) 77 - canonicalShopping := "5paGKJp_BFc" 78 - legacyShopping := toLegacyHash(t, canonicalShopping, legacyRecipeHashSeed) 79 - 80 - if err := fc.Put(ctx, legacyShopping, `{"recipes":[{"title":"Soup"}]}`, cache.Unconditional()); err != nil { 81 - t.Fatalf("seed shopping list: %v", err) 82 - } 83 - 84 - stats, err := migrateShoppingLists(ctx, fc, false, &bytes.Buffer{}) 85 - if err != nil { 86 - t.Fatalf("migrate: %v", err) 87 - } 88 - if stats.Copied != 1 { 89 - t.Fatalf("expected 1 planned copy, got %d", stats.Copied) 90 - } 91 - 92 - if _, err := fc.Get(ctx, legacyShopping); err != nil { 93 - t.Fatalf("expected original key to remain in dry-run: %v", err) 94 - } 95 - if _, err := fc.Get(ctx, "shoppinglist/"+canonicalShopping); !errors.Is(err, cache.ErrNotFound) { 96 - t.Fatalf("expected destination key not created in dry-run, got err=%v", err) 97 - } 98 - } 99 - 100 - func TestMigrateShoppingListsApply_TransformsLegacySeededHashes(t *testing.T) { 101 - ctx := context.Background() 102 - fc := cache.NewFileCache(t.TempDir()) 103 - 104 - canonicalShopping := "5paGKJp_BFc" 105 - canonicalIngredients := "4MVQVRdNr8M" 106 - legacyShopping := toLegacyHash(t, canonicalShopping, legacyRecipeHashSeed) 107 - legacyIngredients := toLegacyHash(t, canonicalIngredients, legacyIngredientsHashSeed) 108 - 109 - if err := fc.Put(ctx, legacyShopping, `{"recipes":[{"title":"Soup"}]}`, cache.Unconditional()); err != nil { 110 - t.Fatalf("seed legacy shopping list: %v", err) 111 - } 112 - if err := fc.Put(ctx, legacyShopping+".params", `{"location":{"id":"1"}}`, cache.Unconditional()); err != nil { 113 - t.Fatalf("seed legacy params: %v", err) 114 - } 115 - if err := fc.Put(ctx, legacyIngredients, `[{"description":"kale"}]`, cache.Unconditional()); err != nil { 116 - t.Fatalf("seed legacy ingredients: %v", err) 117 - } 118 - 119 - stats, err := migrateShoppingLists(ctx, fc, true, &bytes.Buffer{}) 120 - if err != nil { 121 - t.Fatalf("migrate: %v", err) 122 - } 123 - if stats.Copied != 3 { 124 - t.Fatalf("expected 3 copied keys, got %d", stats.Copied) 125 - } 126 - 127 - if _, err := fc.Get(ctx, shoppingListPrefix+canonicalShopping); err != nil { 128 - t.Fatalf("expected canonical shopping list key: %v", err) 129 - } 130 - if _, err := fc.Get(ctx, paramsPrefix+canonicalShopping); err != nil { 131 - t.Fatalf("expected canonical params key: %v", err) 132 - } 133 - if _, err := fc.Get(ctx, ingredientsPrefix+canonicalIngredients); err != nil { 134 - t.Fatalf("expected canonical ingredients key: %v", err) 135 - } 136 - } 137 - 138 - func toLegacyHash(t *testing.T, canonicalHash string, seed string) string { 139 - t.Helper() 140 - decoded, err := base64.RawURLEncoding.DecodeString(canonicalHash) 141 - if err != nil { 142 - t.Fatalf("decode canonical hash %q: %v", canonicalHash, err) 143 - } 144 - legacyBytes := append([]byte(seed), decoded...) 145 - return base64.URLEncoding.EncodeToString(legacyBytes) 146 - }
+132
cmd/purgeshoppinglist/main.go
··· 1 + package main 2 + 3 + import ( 4 + "careme/internal/cache" 5 + "careme/internal/recipes" 6 + "context" 7 + "flag" 8 + "fmt" 9 + "io" 10 + "log" 11 + "os" 12 + "path/filepath" 13 + "slices" 14 + "strings" 15 + ) 16 + 17 + type purgeCache interface { 18 + cache.ListCache 19 + Delete(ctx context.Context, key string) error 20 + } 21 + 22 + type purgeStats struct { 23 + Found int 24 + Valid int 25 + Invalid int 26 + WouldDelete int 27 + Deleted int 28 + DeleteErrors int 29 + } 30 + 31 + func main() { 32 + var apply bool 33 + flag.BoolVar(&apply, "apply", false, "Delete invalid shopping lists. Default is dry-run.") 34 + flag.Parse() 35 + 36 + ctx := context.Background() 37 + cacheStore, err := cache.MakeCache() 38 + if err != nil { 39 + log.Fatalf("failed to create cache: %v", err) 40 + } 41 + 42 + purgeable, ok := cacheStore.(purgeCache) 43 + if !ok { 44 + log.Fatalf("cache implementation %T does not support delete", cacheStore) 45 + } 46 + 47 + stats, err := purgeInvalidShoppingLists(ctx, purgeable, apply, os.Stdout) 48 + if err != nil { 49 + log.Fatalf("purge failed: %v", err) 50 + } 51 + 52 + fmt.Printf( 53 + "done: found=%d valid=%d invalid=%d would_delete=%d deleted=%d delete_errors=%d mode=%s\n", 54 + stats.Found, 55 + stats.Valid, 56 + stats.Invalid, 57 + stats.WouldDelete, 58 + stats.Deleted, 59 + stats.DeleteErrors, 60 + mode(apply), 61 + ) 62 + } 63 + 64 + func purgeInvalidShoppingLists(ctx context.Context, c purgeCache, apply bool, out io.Writer) (purgeStats, error) { 65 + var stats purgeStats 66 + 67 + keys, err := c.List(ctx, recipes.ShoppingListCachePrefix, "") 68 + if err != nil { 69 + return stats, fmt.Errorf("list shopping lists: %w", err) 70 + } 71 + 72 + hashes := normalizeShoppingListHashes(keys) 73 + stats.Found = len(hashes) 74 + 75 + rio := recipes.IO(c) 76 + for _, hash := range hashes { 77 + _, err := rio.FromCache(ctx, hash) 78 + if err == nil { 79 + stats.Valid++ 80 + continue 81 + } 82 + 83 + stats.Invalid++ 84 + key := recipes.ShoppingListCachePrefix + hash 85 + if !apply { 86 + stats.WouldDelete++ 87 + _, _ = fmt.Fprintf(out, "would delete %s (failed to load: %v)\n", key, err) 88 + continue 89 + } 90 + 91 + if err := c.Delete(ctx, key); err != nil { 92 + stats.DeleteErrors++ 93 + _, _ = fmt.Fprintf(out, "failed delete %s: %v\n", key, err) 94 + continue 95 + } 96 + 97 + stats.Deleted++ 98 + _, _ = fmt.Fprintf(out, "deleted %s (failed to load: %v)\n", key, err) 99 + } 100 + 101 + return stats, nil 102 + } 103 + 104 + func normalizeShoppingListHashes(keys []string) []string { 105 + hashes := make([]string, 0, len(keys)) 106 + seen := make(map[string]struct{}, len(keys)) 107 + 108 + for _, key := range keys { 109 + hash := filepath.ToSlash(strings.TrimSpace(key)) 110 + hash = strings.TrimPrefix(hash, "./") 111 + hash = strings.TrimPrefix(hash, "/") 112 + hash = strings.TrimPrefix(hash, recipes.ShoppingListCachePrefix) 113 + if hash == "" { 114 + continue 115 + } 116 + if _, ok := seen[hash]; ok { 117 + continue 118 + } 119 + seen[hash] = struct{}{} 120 + hashes = append(hashes, hash) 121 + } 122 + 123 + slices.Sort(hashes) 124 + return hashes 125 + } 126 + 127 + func mode(apply bool) string { 128 + if apply { 129 + return "apply" 130 + } 131 + return "dry-run" 132 + }
+105
cmd/purgeshoppinglist/main_test.go
··· 1 + package main 2 + 3 + import ( 4 + "bytes" 5 + "careme/internal/cache" 6 + "context" 7 + "errors" 8 + "testing" 9 + ) 10 + 11 + func TestPurgeInvalidShoppingListsApply(t *testing.T) { 12 + t.Chdir(t.TempDir()) 13 + ctx := context.Background() 14 + fc := cache.NewFileCache(".") 15 + 16 + if err := fc.Put(ctx, "shoppinglist/valid", `{"recipes":[{"title":"Soup"}]}`, cache.Unconditional()); err != nil { 17 + t.Fatalf("seed valid shopping list: %v", err) 18 + } 19 + if err := fc.Put(ctx, "shoppinglist/invalid", `{"recipes":`, cache.Unconditional()); err != nil { 20 + t.Fatalf("seed invalid shopping list: %v", err) 21 + } 22 + 23 + stats, err := purgeInvalidShoppingLists(ctx, fc, true, &bytes.Buffer{}) 24 + if err != nil { 25 + t.Fatalf("purge invalid shopping lists: %v", err) 26 + } 27 + if stats.Found != 2 || stats.Valid != 1 || stats.Invalid != 1 || stats.Deleted != 1 { 28 + t.Fatalf("unexpected stats: %+v", stats) 29 + } 30 + if stats.WouldDelete != 0 || stats.DeleteErrors != 0 { 31 + t.Fatalf("unexpected dry-run/delete errors stats: %+v", stats) 32 + } 33 + 34 + if _, err := fc.Get(ctx, "shoppinglist/valid"); err != nil { 35 + t.Fatalf("expected valid key to remain: %v", err) 36 + } 37 + if _, err := fc.Get(ctx, "shoppinglist/invalid"); !errors.Is(err, cache.ErrNotFound) { 38 + t.Fatalf("expected invalid key removed, got err=%v", err) 39 + } 40 + } 41 + 42 + func TestPurgeInvalidShoppingListsDryRun(t *testing.T) { 43 + t.Chdir(t.TempDir()) 44 + ctx := context.Background() 45 + fc := cache.NewFileCache(".") 46 + 47 + if err := fc.Put(ctx, "shoppinglist/invalid", `{"recipes":`, cache.Unconditional()); err != nil { 48 + t.Fatalf("seed invalid shopping list: %v", err) 49 + } 50 + 51 + stats, err := purgeInvalidShoppingLists(ctx, fc, false, &bytes.Buffer{}) 52 + if err != nil { 53 + t.Fatalf("purge invalid shopping lists: %v", err) 54 + } 55 + if stats.Found != 1 || stats.Valid != 0 || stats.Invalid != 1 || stats.WouldDelete != 1 { 56 + t.Fatalf("unexpected stats: %+v", stats) 57 + } 58 + if stats.Deleted != 0 || stats.DeleteErrors != 0 { 59 + t.Fatalf("unexpected apply/delete errors stats: %+v", stats) 60 + } 61 + 62 + if _, err := fc.Get(ctx, "shoppinglist/invalid"); err != nil { 63 + t.Fatalf("expected invalid key to remain in dry-run: %v", err) 64 + } 65 + } 66 + 67 + func TestPurgeInvalidShoppingListsHandlesPrefixedListResults(t *testing.T) { 68 + t.Chdir(t.TempDir()) 69 + ctx := context.Background() 70 + fc := cache.NewFileCache(".") 71 + prefixed := prefixedListCache{FileCache: fc} 72 + 73 + if err := fc.Put(ctx, "shoppinglist/invalid", `{"recipes":`, cache.Unconditional()); err != nil { 74 + t.Fatalf("seed invalid shopping list: %v", err) 75 + } 76 + 77 + stats, err := purgeInvalidShoppingLists(ctx, prefixed, true, &bytes.Buffer{}) 78 + if err != nil { 79 + t.Fatalf("purge invalid shopping lists: %v", err) 80 + } 81 + if stats.Found != 1 || stats.Invalid != 1 || stats.Deleted != 1 { 82 + t.Fatalf("unexpected stats: %+v", stats) 83 + } 84 + 85 + if _, err := fc.Get(ctx, "shoppinglist/invalid"); !errors.Is(err, cache.ErrNotFound) { 86 + t.Fatalf("expected invalid key removed, got err=%v", err) 87 + } 88 + } 89 + 90 + type prefixedListCache struct { 91 + *cache.FileCache 92 + } 93 + 94 + func (c prefixedListCache) List(ctx context.Context, prefix string, token string) ([]string, error) { 95 + keys, err := c.FileCache.List(ctx, prefix, token) 96 + if err != nil { 97 + return nil, err 98 + } 99 + 100 + out := make([]string, 0, len(keys)) 101 + for _, key := range keys { 102 + out = append(out, prefix+key) 103 + } 104 + return out, nil 105 + }