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:
@@ -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
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user