feat(web): shared Postgres read-through cache for MusicBrainz (ApiCache)

Reopening an artist (discography), an album (tracklist), or repeating a lookup
re-hit MusicBrainz every time with no cache and no throttle. Adds a generic
Postgres-backed read-through cache keyed on the LOGICAL operation (not the
request URL) so the worker and web app share entries.

- New ApiCache(key, json, fetchedAt) model + migration (generic name so the
  Last.fm browse cache can ride the same table).
- lib/apicache.ts cached(): read-through, TTL from fetchedAt (tunable, no
  migration), serve-stale-on-outage, never caches null.
- Wrap the high-level MB fns with logical keys + TTLs: discography &
  tracklists & artist-name 30d, searchReleaseGroup 7d, searchArtists 1d.
  Search keys normalized (trim+lowercase) to match the worker byte-for-byte.
- album-modal: a session-scoped Map<rgMbid,Track[]> so reopening is instant
  with zero network (complements the DB cache).
- Test setup clears ApiCache between tests. web 147 tests, 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:17:51 +02:00
parent d275a0ddd6
commit bdbf9d237e
7 changed files with 195 additions and 46 deletions
@@ -0,0 +1,11 @@
-- CreateTable
CREATE TABLE "ApiCache" (
"key" TEXT NOT NULL,
"json" JSONB NOT NULL,
"fetchedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "ApiCache_pkey" PRIMARY KEY ("key")
);
-- CreateIndex
CREATE INDEX "ApiCache_fetchedAt_idx" ON "ApiCache"("fetchedAt");
+12
View File
@@ -182,3 +182,15 @@ model ScanWorkItem {
@@unique([scanId, path])
@@index([scanId, done, artist, album])
}
// Generic read-through cache for external API responses shared by web + worker (keyed on
// the LOGICAL operation, not the request URL, so both processes share entries). TTL is
// applied at read time from fetchedAt in code (no expiresAt column → tunable, no migration).
// Named generically so the Last.fm browse cache can ride the same table.
model ApiCache {
key String @id
json Json
fetchedAt DateTime @default(now())
@@index([fetchedAt])
}
+17 -3
View File
@@ -6,6 +6,10 @@ import { CoverArt } from "./cover-art";
type Track = { position: number; title: string; lengthMs: number | null };
// Session-scoped, per-release-group tracklist cache: reopening the same album modal is
// instant with zero network. Complements the server-side ApiCache (which spans sessions).
const trackCache = new Map<string, Track[]>();
function fmt(ms: number | null): string {
if (ms == null) return "";
const s = Math.round(ms / 1000);
@@ -39,15 +43,25 @@ export function AlbumModal({
useEffect(() => {
if (!open) return;
if (!album.rgMbid) {
const rgMbid = album.rgMbid;
if (!rgMbid) {
setTracks("error");
return;
}
const hit = trackCache.get(rgMbid);
if (hit) {
setTracks(hit);
return;
}
setTracks("loading");
let cancelled = false;
fetch(`/api/mb/release-groups/${album.rgMbid}/tracks`)
fetch(`/api/mb/release-groups/${rgMbid}/tracks`)
.then((r) => (r.ok ? r.json() : Promise.reject()))
.then((d) => !cancelled && setTracks(d.tracks))
.then((d) => {
if (cancelled) return;
trackCache.set(rgMbid, d.tracks);
setTracks(d.tracks);
})
.catch(() => !cancelled && setTracks("error"));
return () => {
cancelled = true;
+54
View File
@@ -0,0 +1,54 @@
import { describe, it, expect, vi } from "vitest";
import { cached, normKey } from "./apicache";
import { prisma } from "@/lib/db";
describe("cached (read-through)", () => {
it("fetches once, then serves the cached value without re-fetching", async () => {
const fetchFresh = vi.fn(async () => ({ v: 1 }));
const first = await cached("k1", 3600, fetchFresh);
const second = await cached("k1", 3600, fetchFresh);
expect(first).toEqual({ v: 1 });
expect(second).toEqual({ v: 1 });
expect(fetchFresh).toHaveBeenCalledTimes(1); // second served from cache
});
it("re-fetches once the TTL has elapsed", async () => {
const fetchFresh = vi.fn(async () => ({ v: 2 }));
await cached("k2", 3600, fetchFresh);
// backdate the row beyond the ttl
await prisma.apiCache.update({ where: { key: "k2" }, data: { fetchedAt: new Date(Date.now() - 7200_000) } });
await cached("k2", 3600, fetchFresh);
expect(fetchFresh).toHaveBeenCalledTimes(2);
});
it("serves a stale value when the fresh fetch throws", async () => {
await cached("k3", 3600, async () => ({ v: "old" }));
await prisma.apiCache.update({ where: { key: "k3" }, data: { fetchedAt: new Date(Date.now() - 7200_000) } });
const result = await cached("k3", 3600, async () => {
throw new Error("upstream down");
});
expect(result).toEqual({ v: "old" }); // stale-on-outage
});
it("rethrows when the fetch fails and there is no cached row", async () => {
await expect(
cached("k4", 3600, async () => {
throw new Error("boom");
}),
).rejects.toThrow("boom");
// nothing was cached
expect(await prisma.apiCache.findUnique({ where: { key: "k4" } })).toBeNull();
});
it("does not cache null results", async () => {
const fetchFresh = vi.fn(async () => null);
await cached("k5", 3600, fetchFresh);
await cached("k5", 3600, fetchFresh);
expect(fetchFresh).toHaveBeenCalledTimes(2); // null never cached → always re-fetched
expect(await prisma.apiCache.findUnique({ where: { key: "k5" } })).toBeNull();
});
it("normKey trims and lowercases", () => {
expect(normKey(" Radiohead ")).toBe("radiohead");
});
});
+39
View File
@@ -0,0 +1,39 @@
import { prisma } from "@/lib/db";
// Read-through cache over the shared ApiCache table. Keyed on the LOGICAL operation (not the
// request URL) so the worker and web app share entries. TTL is applied here from fetchedAt,
// so TTLs are tunable in code with no migration. On a fetch failure a stale row is served.
// Null/undefined results are NOT cached (keeps negative caching out + avoids Prisma JsonNull).
export const CACHE_TTL = {
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> {
const hit = await prisma.apiCache.findUnique({ where: { key } });
if (hit && (Date.now() - hit.fetchedAt.getTime()) / 1000 < ttlSec) {
return hit.json as T;
}
try {
const fresh = await fetchFresh();
if (fresh !== null && fresh !== undefined) {
await prisma.apiCache.upsert({
where: { key },
create: { key, json: fresh as object },
update: { json: fresh as object, fetchedAt: new Date() },
});
}
return fresh;
} catch (e) {
if (hit) return hit.json as T; // serve stale on an upstream outage
throw e;
}
}
/** Normalize a free-text search term so casing/whitespace don't fragment the cache. MUST
* match the worker's normalization byte-for-byte (both: trim + lowercase). */
export function normKey(s: string): string {
return s.trim().toLowerCase();
}
+18
View File
@@ -1,3 +1,5 @@
import { cached, normKey, CACHE_TTL } from "@/lib/apicache";
const MB_BASE = "https://musicbrainz.org/ws/2";
const USER_AGENT = "Lyra/0.1 ( https://git.jger.nl/Jonathan/Lyra )";
@@ -30,15 +32,20 @@ function toReleaseGroup(g: any): ReleaseGroupInfo {
}
export async function searchArtists(query: string): Promise<ArtistHit[]> {
const q = normKey(query);
if (!q) return [];
return cached(`artist-search:${q}`, CACHE_TTL.DAY, async () => {
const data = await mbGet(`/artist?query=${encodeURIComponent(query)}&fmt=json&limit=8`);
return (data.artists ?? []).map((a: any) => ({
mbid: a.id,
name: a.name ?? "",
disambiguation: a.disambiguation ?? "",
}));
});
}
export async function browseReleaseGroups(artistMbid: string): Promise<ReleaseGroupInfo[]> {
return cached(`artist-rgs:${artistMbid}`, CACHE_TTL.MONTH, async () => {
const out: ReleaseGroupInfo[] = [];
let offset = 0;
for (;;) {
@@ -52,6 +59,7 @@ export async function browseReleaseGroups(artistMbid: string): Promise<ReleaseGr
if (groups.length === 0 || offset >= total) break;
}
return out;
});
}
function escapeLucenePhrase(s: string): string {
@@ -94,6 +102,12 @@ function albumPreferenceRank(g: any): number {
}
export async function searchReleaseGroup(artist: string, album: string): Promise<ReleaseGroupMatch | null> {
return cached(`rg-search:${normKey(artist)}|${normKey(album)}`, CACHE_TTL.WEEK, () =>
_searchReleaseGroup(artist, album),
);
}
async function _searchReleaseGroup(artist: string, album: string): Promise<ReleaseGroupMatch | null> {
const art = escapeLucenePhrase(artist);
const stripped = stripEditionQualifiers(album);
// Decreasing precision: exact phrase on the given title, then on the edition-stripped
@@ -121,11 +135,14 @@ export async function searchReleaseGroup(artist: string, album: string): Promise
export type Track = { position: number; title: string; lengthMs: number | null };
export async function getArtistName(mbid: string): Promise<string> {
return cached(`artist-name:${mbid}`, CACHE_TTL.MONTH, async () => {
const data = await mbGet(`/artist/${encodeURIComponent(mbid)}?fmt=json`);
return data.name ?? "";
});
}
export async function browseReleaseGroupTracks(rgMbid: string): Promise<Track[]> {
return cached(`rg-tracks:${rgMbid}`, CACHE_TTL.MONTH, async () => {
const data = await mbGet(
`/release?release-group=${encodeURIComponent(rgMbid)}&inc=recordings&fmt=json&limit=25`,
);
@@ -149,4 +166,5 @@ export async function browseReleaseGroupTracks(rgMbid: string): Promise<Track[]>
}
}
return tracks;
});
}
+1
View File
@@ -17,4 +17,5 @@ beforeEach(async () => {
await prisma.watchedArtist.deleteMany();
await prisma.discoverySuggestion.deleteMany();
await prisma.config.deleteMany();
await prisma.apiCache.deleteMany(); // read-through cache must not leak across tests
});