feat(web): cache Last.fm browse data + Refresh button (rides ApiCache)

/lastfm hit the Last.fm API live on every load / period switch / page. Cache the
raw Last.fm payload in the shared ApiCache table (12h TTL) while keeping the
own-state annotation (followed/owned/wanted) computed LIVE on top — so a follow
made after the payload was cached shows immediately.

- cached() gains a { force } option to bypass a fresh row (still serves stale if
  the forced fetch fails); CACHE_TTL.HOUR added.
- All three routes wrap their Last.fm call: lfm-top-artists / lfm-top-albums
  :{user}:{period}:{page}, lfm-artist-plays:{user}:{artist} (keys normalized).
- A "Refresh" button on /lastfm force-bypasses the cache (?refresh=1).
- Factored the copy-pasted getCreds/PERIODS/LIMIT into a route-only _creds.ts
  (closes the Last.fm-routes DRY todo #19) — prisma stays out of any client
  bundle since lib/lastfm.ts is imported by a client component.

web 150 tests (cache-hit + refresh + own-state-live asserted), tsc + build clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Jonathan
2026-07-14 11:31:27 +02:00
parent 034ae40084
commit c63f765c1f
8 changed files with 127 additions and 61 deletions
+17
View File
@@ -48,6 +48,23 @@ describe("cached (read-through)", () => {
expect(await prisma.apiCache.findUnique({ where: { key: "k5" } })).toBeNull();
});
it("force bypasses a fresh cached row and re-fetches + overwrites", async () => {
let n = 0;
const fetchFresh = () => Promise.resolve({ n: ++n });
expect(await cached("k6", 3600, fetchFresh)).toEqual({ n: 1 });
expect(await cached("k6", 3600, fetchFresh)).toEqual({ n: 1 }); // cache hit
expect(await cached("k6", 3600, fetchFresh, { force: true })).toEqual({ n: 2 }); // bypassed
expect(await cached("k6", 3600, fetchFresh)).toEqual({ n: 2 }); // overwrote the row
});
it("force still serves the stale row if the forced fetch fails", async () => {
await cached("k7", 3600, async () => ({ v: "cached" }));
const result = await cached("k7", 3600, async () => {
throw new Error("down");
}, { force: true });
expect(result).toEqual({ v: "cached" }); // stale-on-outage even on a forced refresh
});
it("normKey trims and lowercases", () => {
expect(normKey(" Radiohead ")).toBe("radiohead");
});
+10 -2
View File
@@ -6,14 +6,22 @@ import { prisma } from "@/lib/db";
// Null/undefined results are NOT cached (keeps negative caching out + avoids Prisma JsonNull).
export const CACHE_TTL = {
HOUR: 3_600,
DAY: 86_400,
WEEK: 7 * 86_400,
MONTH: 30 * 86_400,
};
export async function cached<T>(key: string, ttlSec: number, fetchFresh: () => Promise<T>): Promise<T> {
/** Read-through cache. Pass `{ force: true }` to bypass a fresh cached row and re-fetch
* (a manual Refresh) — a stale row is still served if the forced fetch then fails. */
export async function cached<T>(
key: string,
ttlSec: number,
fetchFresh: () => Promise<T>,
opts?: { force?: boolean },
): Promise<T> {
const hit = await prisma.apiCache.findUnique({ where: { key } });
if (hit && (Date.now() - hit.fetchedAt.getTime()) / 1000 < ttlSec) {
if (!opts?.force && hit && (Date.now() - hit.fetchedAt.getTime()) / 1000 < ttlSec) {
return hit.json as T;
}
try {