Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 30 additions & 0 deletions lib/cache.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import { Redis } from "@upstash/redis"

const redis = new Redis({
url: process.env.KV_REST_API_URL!,
token: process.env.KV_REST_API_TOKEN!,
})

/** 7 days in seconds */
const RESOLUTION_TTL = 60 * 60 * 24 * 7

export function resolutionCacheKey(query: string): string {
return `resolution:${query.toLowerCase()}`
}

export async function getCachedResolution<T>(query: string): Promise<T | null> {
try {
return await redis.get<T>(resolutionCacheKey(query))
} catch {
// Cache errors must never break resolution
return null
}
}

export async function setCachedResolution<T>(query: string, value: T): Promise<void> {
try {
await redis.set(resolutionCacheKey(query), value, { ex: RESOLUTION_TTL })
} catch {
// Cache errors must never break resolution
}
}
15 changes: 13 additions & 2 deletions lib/resolve.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import {
type Owner,
type Repo,
} from "@/lib/github"
import { getCachedResolution, setCachedResolution } from "@/lib/cache"

export type RepoHit = {
owner: string
Expand Down Expand Up @@ -298,18 +299,26 @@ export async function resolvePath(ownerFragment: string, repoFragment?: string):
return { status: "miss", query, reason: "Invalid repo fragment" }
}

// Only cache two-segment "hit" resolutions — single-owner and error responses are intentionally excluded.
if (repoFragment) {
const cached = await getCachedResolution<Resolution>(query)
if (cached) return cached
}

try {
// An exact owner/repository pair is unbeatable. Resolve it from the core
// REST quota and avoid the much tighter search quota entirely.
if (repoFragment) {
const exact = await getRepo(ownerFragment, repoFragment)
if (exact) {
return {
const resolution: Resolution = {
status: "hit",
query,
match: toHit(exact, "exact", "exact"),
alternates: [],
}
await setCachedResolution(query, resolution)
return resolution
}
}

Expand Down Expand Up @@ -353,7 +362,9 @@ export async function resolvePath(ownerFragment: string, repoFragment?: string):
if (!result) {
return { status: "miss", query, reason: `No repo matches "${query}"` }
}
return { status: "hit", query, match: result.match, alternates: result.alternates }
const resolution: Resolution = { status: "hit", query, match: result.match, alternates: result.alternates }
await setCachedResolution(query, resolution)
return resolution
} catch (err) {
if (err instanceof RateLimited) {
return {
Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
},
"dependencies": {
"@base-ui/react": "^1.5.0",
"@upstash/redis": "^1.38.1",
"@vercel/analytics": "1.6.1",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
Expand Down
15 changes: 15 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

116 changes: 116 additions & 0 deletions tests/cache.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
import { beforeEach, describe, expect, it, vi } from "vitest"

// `vi.mock` factories are hoisted to the top of the file before any variable
// declarations, so we cannot reference module-scope `const` from inside the
// factory. Instead, we keep state entirely inside the factory and expose it
// through a `__test__` export on the mock module so tests can inspect it.
vi.mock("@upstash/redis", () => {
const store = new Map<string, unknown>()

const getMock = vi.fn(async (key: string) => store.get(key) ?? null)
const setMock = vi.fn(async (key: string, value: unknown, _opts?: unknown) => {
store.set(key, value)
return "OK"
})

class RedisMock {
get = getMock
set = setMock
}

return {
Redis: RedisMock,
// Exported so tests can reach the mocks without coupling to internals.
__test__: { store, getMock, setMock },
}
})

import * as upstashModule from "@upstash/redis"
import { getCachedResolution, resolutionCacheKey, setCachedResolution } from "@/lib/cache"

// Typed handle to the in-factory state.
const { store, getMock, setMock } = (upstashModule as unknown as {
__test__: {
store: Map<string, unknown>
getMock: ReturnType<typeof vi.fn>
setMock: ReturnType<typeof vi.fn>
}
}).__test__

beforeEach(() => {
store.clear()
getMock.mockClear()
setMock.mockClear()
// Restore default implementations after any per-test overrides.
getMock.mockImplementation(async (key: string) => store.get(key) ?? null)
setMock.mockImplementation(async (key: string, value: unknown) => {
store.set(key, value)
return "OK"
})
})

describe("resolutionCacheKey", () => {
it("lowercases the query", () => {
expect(resolutionCacheKey("OpenAI/Codex")).toBe("resolution:openai/codex")
})

it("prefixes with 'resolution:'", () => {
expect(resolutionCacheKey("vercel/next.js")).toBe("resolution:vercel/next.js")
})
})

describe("getCachedResolution", () => {
it("returns null on a cache miss", async () => {
const result = await getCachedResolution("nobody/nothing")
expect(result).toBeNull()
})

it("returns the stored value on a cache hit", async () => {
const stored = { status: "hit", query: "openai/codex" }
store.set("resolution:openai/codex", stored)

const result = await getCachedResolution("openai/codex")
expect(result).toEqual(stored)
})

it("normalises the query key to lowercase", async () => {
const stored = { status: "hit", query: "OpenAI/Codex" }
store.set("resolution:openai/codex", stored)

const result = await getCachedResolution("OpenAI/Codex")
expect(result).toEqual(stored)
})

it("returns null and does not throw when Redis errors", async () => {
getMock.mockRejectedValueOnce(new Error("connection refused"))

await expect(getCachedResolution("openai/codex")).resolves.toBeNull()
})
})

describe("setCachedResolution", () => {
it("stores the value under the normalised key with a 7-day TTL", async () => {
const value = { status: "hit", query: "vercel/next.js" }

await setCachedResolution("vercel/next.js", value)

expect(setMock).toHaveBeenCalledWith(
"resolution:vercel/next.js",
value,
{ ex: 604800 },
)
})

it("passes the 7-day TTL (604800 seconds)", async () => {
await setCachedResolution("a/b", { status: "hit" })

const [, , opts] = setMock.mock.calls[0] as [string, unknown, { ex: number }]
expect(opts.ex).toBe(604800)
})

it("does not throw when Redis errors", async () => {
setMock.mockRejectedValueOnce(new Error("timeout"))

await expect(setCachedResolution("a/b", { status: "hit" })).resolves.toBeUndefined()
})
})
7 changes: 7 additions & 0 deletions tests/resolve.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,12 @@
import { beforeEach, describe, expect, it, vi } from "vitest"

// Prevent any real Redis calls during unit tests.
vi.mock("@/lib/cache", () => ({
getCachedResolution: vi.fn().mockResolvedValue(null),
setCachedResolution: vi.fn().mockResolvedValue(undefined),
resolutionCacheKey: (q: string) => `resolution:${q.toLowerCase()}`,
}))

import {
getOwner,
getRepo,
Expand Down
Loading