The Appview for the kipclip.com atproto bookmarking service

Remove Deno KV caching layer

The KV caching was causing 'Deno.openKv is not a function' errors
in production. Since the app is already fast without caching,
removing it entirely simplifies the code and eliminates the errors.

- Remove kv-cache.ts utility module
- Simplify plc-resolver.ts to call PLC directory directly
- Simplify enrichment.ts to fetch metadata directly
- Remove plc-resolver.test.ts (only tested caching behavior)

+5 -368
+2 -21
lib/enrichment.ts
··· 1 1 import type { UrlMetadata } from "../shared/types.ts"; 2 2 import { decode } from "html-entities"; 3 - import { getCached } from "./kv-cache.ts"; 4 - 5 - const METADATA_CACHE_TTL_MS = 24 * 60 * 60 * 1000; // 24 hours 6 - 7 - /** 8 - * Generate a cache key for URL metadata. 9 - * Uses a hash of the URL to keep keys manageable. 10 - */ 11 - function getMetadataCacheKey(url: string): Deno.KvKey { 12 - // Use URL directly as part of key (KV handles long keys) 13 - return ["metadata", url]; 14 - } 15 3 16 4 /** 17 5 * Extracts metadata from a URL by fetching and parsing the HTML. 18 - * Results are cached for 24 hours. 19 6 */ 20 - export function extractUrlMetadata( 21 - url: string, 22 - ): Promise<UrlMetadata> { 23 - return getCached<UrlMetadata>( 24 - getMetadataCacheKey(url), 25 - METADATA_CACHE_TTL_MS, 26 - () => fetchUrlMetadata(url), 27 - ); 7 + export function extractUrlMetadata(url: string): Promise<UrlMetadata> { 8 + return fetchUrlMetadata(url); 28 9 } 29 10 30 11 /**
-79
lib/kv-cache.ts
··· 1 - /** 2 - * Deno KV caching utilities. 3 - * Provides a simple cache-aside pattern for expensive operations. 4 - */ 5 - 6 - let kv: Deno.Kv | null = null; 7 - 8 - /** 9 - * Get or initialize the Deno KV instance. 10 - * Uses local file storage in development, Deno Deploy KV in production. 11 - */ 12 - async function getKv(): Promise<Deno.Kv> { 13 - if (!kv) { 14 - kv = await Deno.openKv(); 15 - } 16 - return kv; 17 - } 18 - 19 - /** 20 - * Cache entry with expiration metadata. 21 - */ 22 - interface CacheEntry<T> { 23 - value: T; 24 - expiresAt: number; 25 - } 26 - 27 - /** 28 - * Get a cached value, or fetch and cache it if not present or expired. 29 - * 30 - * @param key - KV key tuple (e.g., ["metadata", urlHash]) 31 - * @param ttlMs - Time-to-live in milliseconds 32 - * @param fetcher - Async function to fetch the value if not cached 33 - * @returns The cached or freshly fetched value 34 - */ 35 - export async function getCached<T>( 36 - key: Deno.KvKey, 37 - ttlMs: number, 38 - fetcher: () => Promise<T>, 39 - ): Promise<T> { 40 - const db = await getKv(); 41 - 42 - // Try to get from cache 43 - const cached = await db.get<CacheEntry<T>>(key); 44 - 45 - if (cached.value && cached.value.expiresAt > Date.now()) { 46 - return cached.value.value; 47 - } 48 - 49 - // Fetch fresh value 50 - const value = await fetcher(); 51 - 52 - // Store in cache with expiration 53 - const entry: CacheEntry<T> = { 54 - value, 55 - expiresAt: Date.now() + ttlMs, 56 - }; 57 - 58 - await db.set(key, entry); 59 - 60 - return value; 61 - } 62 - 63 - /** 64 - * Invalidate a cached value. 65 - */ 66 - export async function invalidateCache(key: Deno.KvKey): Promise<void> { 67 - const db = await getKv(); 68 - await db.delete(key); 69 - } 70 - 71 - /** 72 - * Close the KV connection (useful for tests). 73 - */ 74 - export function closeKv(): void { 75 - if (kv) { 76 - kv.close(); 77 - kv = null; 78 - } 79 - }
+3 -36
lib/plc-resolver.ts
··· 1 1 /** 2 - * PLC Directory resolver with Deno KV caching. 3 - * Caches DID document lookups to reduce PLC directory requests. 2 + * PLC Directory resolver. 3 + * Resolves DID documents from the PLC directory. 4 4 */ 5 5 6 - import { getCached, invalidateCache } from "./kv-cache.ts"; 7 - 8 6 const PLC_DIRECTORY = "https://plc.directory"; 9 - const CACHE_TTL_MS = 60 * 60 * 1000; // 1 hour 10 7 11 8 /** 12 9 * Resolved DID document data. ··· 61 58 62 59 /** 63 60 * Resolve a DID to its PDS URL and handle. 64 - * Results are cached for 1 hour. Cached nulls are automatically invalidated and re-fetched. 65 61 */ 66 62 export async function resolveDid(did: string): Promise<ResolvedDid | null> { 67 63 if (!did.startsWith("did:")) { 68 64 return null; 69 65 } 70 66 71 - const cacheKey: Deno.KvKey = ["plc", did]; 72 - 73 67 try { 74 - let result: ResolvedDid | null = null; 75 - let cacheHit = false; 76 - 77 - try { 78 - result = await getCached<ResolvedDid | null>( 79 - cacheKey, 80 - CACHE_TTL_MS, 81 - () => fetchDidDoc(did), 82 - ); 83 - cacheHit = true; 84 - } catch (cacheError) { 85 - console.error(`[PLC] Cache error for ${did}:`, cacheError); 86 - // If cache fails, fall back to direct fetch 87 - result = await fetchDidDoc(did); 88 - } 89 - 90 - // If we got a cached null, invalidate it and fetch fresh 91 - if (result === null && cacheHit) { 92 - await invalidateCache(cacheKey); 93 - const fresh = await fetchDidDoc(did); 94 - // Only cache successful results 95 - if (fresh !== null) { 96 - await getCached(cacheKey, CACHE_TTL_MS, () => Promise.resolve(fresh)); 97 - } 98 - return fresh; 99 - } 100 - 101 - return result; 68 + return await fetchDidDoc(did); 102 69 } catch (error) { 103 70 console.error(`[PLC] Failed to resolve DID ${did}:`, error); 104 71 return null;
-232
tests/plc-resolver.test.ts
··· 1 - /** 2 - * Tests for PLC resolver caching behavior. 3 - * Ensures cached null values are properly invalidated and re-fetched. 4 - */ 5 - 6 - import "./test-setup.ts"; 7 - 8 - import { assertEquals, assertNotEquals } from "@std/assert"; 9 - import { getCached, invalidateCache } from "../lib/kv-cache.ts"; 10 - 11 - // Disable resource sanitizer since KV is shared across tests 12 - const testOpts = { sanitizeResources: false, sanitizeOps: false }; 13 - 14 - Deno.test( 15 - "getCached - returns cached value when not expired", 16 - testOpts, 17 - async () => { 18 - const key: Deno.KvKey = ["test", "cached-value"]; 19 - const ttl = 60000; // 1 minute 20 - 21 - // Clean up first 22 - await invalidateCache(key); 23 - 24 - let fetchCount = 0; 25 - const fetcher = () => { 26 - fetchCount++; 27 - return Promise.resolve({ value: "test" }); 28 - }; 29 - 30 - // First call should fetch 31 - const result1 = await getCached(key, ttl, fetcher); 32 - assertEquals(result1, { value: "test" }); 33 - assertEquals(fetchCount, 1); 34 - 35 - // Second call should return cached value 36 - const result2 = await getCached(key, ttl, fetcher); 37 - assertEquals(result2, { value: "test" }); 38 - assertEquals(fetchCount, 1); // Should NOT have fetched again 39 - 40 - // Clean up 41 - await invalidateCache(key); 42 - }, 43 - ); 44 - 45 - Deno.test( 46 - "getCached - caches null values (the problem scenario)", 47 - testOpts, 48 - async () => { 49 - const key: Deno.KvKey = ["test", "null-value"]; 50 - const ttl = 60000; 51 - 52 - // Clean up first 53 - await invalidateCache(key); 54 - 55 - let fetchCount = 0; 56 - const fetcher = () => { 57 - fetchCount++; 58 - return Promise.resolve(null); 59 - }; 60 - 61 - // First call - fetches and caches null 62 - const result1 = await getCached(key, ttl, fetcher); 63 - assertEquals(result1, null); 64 - assertEquals(fetchCount, 1); 65 - 66 - // Second call - returns cached null (this is the problematic behavior we need to handle) 67 - const result2 = await getCached(key, ttl, fetcher); 68 - assertEquals(result2, null); 69 - assertEquals(fetchCount, 1); // Didn't fetch again because null was cached 70 - 71 - // Clean up 72 - await invalidateCache(key); 73 - }, 74 - ); 75 - 76 - Deno.test("invalidateCache - removes cached value", testOpts, async () => { 77 - const key: Deno.KvKey = ["test", "invalidate"]; 78 - const ttl = 60000; 79 - 80 - // Clean up first 81 - await invalidateCache(key); 82 - 83 - let fetchCount = 0; 84 - const fetcher = () => { 85 - fetchCount++; 86 - return Promise.resolve({ count: fetchCount }); 87 - }; 88 - 89 - // First call 90 - const result1 = await getCached(key, ttl, fetcher); 91 - assertEquals(result1, { count: 1 }); 92 - 93 - // Invalidate 94 - await invalidateCache(key); 95 - 96 - // Next call should fetch fresh 97 - const result2 = await getCached(key, ttl, fetcher); 98 - assertEquals(result2, { count: 2 }); 99 - 100 - // Clean up 101 - await invalidateCache(key); 102 - }); 103 - 104 - Deno.test( 105 - "PLC resolver - recovers from cached null by re-fetching", 106 - testOpts, 107 - async () => { 108 - // This test simulates the exact bug we had: 109 - // 1. First request fails (returns null) and gets cached 110 - // 2. Second request should detect cached null, invalidate, and fetch fresh 111 - 112 - const key: Deno.KvKey = ["test", "plc-recovery"]; 113 - const ttl = 60000; 114 - 115 - // Clean up first 116 - await invalidateCache(key); 117 - 118 - // Simulate the resolver logic 119 - type ResolvedDid = { did: string; pdsUrl: string; handle: string } | null; 120 - 121 - let callCount = 0; 122 - const fetchDidDoc = (): Promise<ResolvedDid> => { 123 - callCount++; 124 - // First call fails, subsequent calls succeed 125 - if (callCount === 1) { 126 - return Promise.resolve(null); // Simulates failed PLC lookup 127 - } 128 - return Promise.resolve({ 129 - did: "did:plc:test123", 130 - pdsUrl: "https://test.pds.example", 131 - handle: "test.handle", 132 - }); 133 - }; 134 - 135 - // Implementation of the fix we applied 136 - const resolveDid = async (): Promise<ResolvedDid> => { 137 - let result: ResolvedDid = null; 138 - let cacheHit = false; 139 - 140 - try { 141 - result = await getCached<ResolvedDid>(key, ttl, fetchDidDoc); 142 - cacheHit = true; 143 - } catch { 144 - result = await fetchDidDoc(); 145 - } 146 - 147 - // If we got a cached null, invalidate and re-fetch 148 - if (result === null && cacheHit) { 149 - await invalidateCache(key); 150 - const fresh = await fetchDidDoc(); 151 - if (fresh !== null) { 152 - // Re-cache the good result 153 - await getCached(key, ttl, () => Promise.resolve(fresh)); 154 - } 155 - return fresh; 156 - } 157 - 158 - return result; 159 - }; 160 - 161 - // First call: getCached calls fetchDidDoc which returns null, caches it 162 - const result1 = await resolveDid(); 163 - // But our fix detects cached null and re-fetches, getting good result 164 - assertNotEquals(result1, null); 165 - assertEquals(result1?.handle, "test.handle"); 166 - 167 - // Verify fetch was called twice (once for initial cache miss, once for recovery) 168 - assertEquals(callCount, 2); 169 - 170 - // Third call should use cached good value 171 - const result2 = await resolveDid(); 172 - assertEquals(result2?.handle, "test.handle"); 173 - assertEquals(callCount, 2); // No additional fetch 174 - 175 - // Clean up 176 - await invalidateCache(key); 177 - }, 178 - ); 179 - 180 - Deno.test( 181 - "PLC resolver - does not infinitely loop on persistent null", 182 - testOpts, 183 - async () => { 184 - // If the DID truly doesn't exist, we should return null without looping 185 - const key: Deno.KvKey = ["test", "plc-truly-null"]; 186 - const ttl = 60000; 187 - 188 - // Clean up first 189 - await invalidateCache(key); 190 - 191 - type ResolvedDid = { did: string; pdsUrl: string; handle: string } | null; 192 - 193 - let callCount = 0; 194 - const fetchDidDoc = (): Promise<ResolvedDid> => { 195 - callCount++; 196 - return Promise.resolve(null); // Always returns null (DID doesn't exist) 197 - }; 198 - 199 - const resolveDid = async (): Promise<ResolvedDid> => { 200 - let result: ResolvedDid = null; 201 - let cacheHit = false; 202 - 203 - try { 204 - result = await getCached<ResolvedDid>(key, ttl, fetchDidDoc); 205 - cacheHit = true; 206 - } catch { 207 - result = await fetchDidDoc(); 208 - } 209 - 210 - if (result === null && cacheHit) { 211 - await invalidateCache(key); 212 - const fresh = await fetchDidDoc(); 213 - if (fresh !== null) { 214 - await getCached(key, ttl, () => Promise.resolve(fresh)); 215 - } 216 - return fresh; 217 - } 218 - 219 - return result; 220 - }; 221 - 222 - // Call should return null after trying twice 223 - const result = await resolveDid(); 224 - assertEquals(result, null); 225 - 226 - // Should have called fetch exactly twice (initial + one retry) 227 - assertEquals(callCount, 2); 228 - 229 - // Clean up 230 - await invalidateCache(key); 231 - }, 232 - );