feat(cache): 实现 invalidateFor 三步编排函数
This commit is contained in:
70
src/shared/lib/cache/invalidate.test.ts
vendored
Normal file
70
src/shared/lib/cache/invalidate.test.ts
vendored
Normal file
@@ -0,0 +1,70 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest"
|
||||
|
||||
// vi.mock 工厂被提升到文件顶部,引用的变量必须用 vi.hoisted 提升,否则进入 TDZ
|
||||
const { mockStore } = vi.hoisted(() => ({
|
||||
mockStore: {
|
||||
getOrSet: vi.fn(),
|
||||
invalidateTags: vi.fn(),
|
||||
},
|
||||
}))
|
||||
vi.mock("./store-factory", () => ({
|
||||
getCacheStore: vi.fn().mockResolvedValue(mockStore),
|
||||
}))
|
||||
|
||||
const mockRevalidateTag = vi.fn()
|
||||
const mockRevalidatePath = vi.fn()
|
||||
vi.mock("next/cache", () => ({
|
||||
revalidateTag: (...args: unknown[]) => mockRevalidateTag(...args),
|
||||
revalidatePath: (...args: unknown[]) => mockRevalidatePath(...args),
|
||||
}))
|
||||
|
||||
import { invalidateFor } from "./invalidate"
|
||||
import { getCacheStore } from "./store-factory"
|
||||
|
||||
describe("invalidateFor", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
// vitest 配置 mockReset:true 会在每个测试前重置 mockResolvedValue,
|
||||
// 此处需重新绑定 getCacheStore 的返回值
|
||||
vi.mocked(getCacheStore).mockResolvedValue(mockStore)
|
||||
})
|
||||
|
||||
it("三步编排:store.invalidateTags → revalidateTag → revalidatePath", async () => {
|
||||
await invalidateFor("classes.update", { id: "cls_123" })
|
||||
|
||||
// 1. store.invalidateTags
|
||||
expect(mockStore.invalidateTags).toHaveBeenCalledWith([
|
||||
"classes",
|
||||
"classes:detail",
|
||||
"classes:detail:cls_123",
|
||||
])
|
||||
|
||||
// 2. revalidateTag × 3(Next.js 16 起强制要求传入 cache life profile)
|
||||
expect(mockRevalidateTag).toHaveBeenCalledTimes(3)
|
||||
expect(mockRevalidateTag).toHaveBeenCalledWith("classes", "default")
|
||||
expect(mockRevalidateTag).toHaveBeenCalledWith("classes:detail", "default")
|
||||
expect(mockRevalidateTag).toHaveBeenCalledWith("classes:detail:cls_123", "default")
|
||||
|
||||
// 3. revalidatePath × 2
|
||||
expect(mockRevalidatePath).toHaveBeenCalledTimes(2)
|
||||
expect(mockRevalidatePath).toHaveBeenCalledWith("/teacher/classes/my")
|
||||
expect(mockRevalidatePath).toHaveBeenCalledWith("/admin/classes")
|
||||
})
|
||||
|
||||
it("未知 actionId 抛错", async () => {
|
||||
await expect(invalidateFor("unknown.action")).rejects.toThrow(
|
||||
"[cache] Unknown actionId: unknown.action",
|
||||
)
|
||||
})
|
||||
|
||||
it("params 缺失时保留占位符(不抛错)", async () => {
|
||||
await invalidateFor("classes.update", {})
|
||||
expect(mockRevalidateTag).toHaveBeenCalledWith("classes:detail:{id}", "default")
|
||||
expect(mockRevalidatePath).toHaveBeenCalledWith("/teacher/classes/my")
|
||||
})
|
||||
|
||||
it("paths 含占位符时填充", async () => {
|
||||
await invalidateFor("classes.invitation.create", { classId: "cls_1" })
|
||||
expect(mockRevalidatePath).toHaveBeenCalledWith("/teacher/classes/my/cls_1")
|
||||
})
|
||||
})
|
||||
45
src/shared/lib/cache/invalidate.ts
vendored
Normal file
45
src/shared/lib/cache/invalidate.ts
vendored
Normal file
@@ -0,0 +1,45 @@
|
||||
import "server-only"
|
||||
|
||||
import { revalidatePath, revalidateTag } from "next/cache"
|
||||
|
||||
import { INVALIDATION_MAP, fillTemplate } from "./invalidation-map"
|
||||
import { getCacheStore } from "./store-factory"
|
||||
|
||||
/**
|
||||
* 由 Server Action 在写操作成功后调用,集中编排缓存失效。
|
||||
*
|
||||
* 三件事:
|
||||
* 1. cacheStore.invalidateTags(服务端数据缓存失效)
|
||||
* 2. revalidateTag × N(Next.js fetch 缓存 + unstable_cache 失效)
|
||||
* 3. revalidatePath × N(RSC 静态缓存失效)
|
||||
*
|
||||
* @param actionId 形如 "classes.update",对应 INVALIDATION_MAP 中的 key
|
||||
* @param params 模板参数,如 { id: "cls_123" },用于填充 {id} 占位符
|
||||
*/
|
||||
export async function invalidateFor(
|
||||
actionId: string,
|
||||
params: Record<string, string> = {},
|
||||
): Promise<void> {
|
||||
const rule = (INVALIDATION_MAP as Record<string, { tags: readonly string[]; queryKeys: readonly (readonly (string | number)[])[]; paths: readonly string[] }>)[actionId]
|
||||
if (!rule) {
|
||||
throw new Error(
|
||||
`[cache] Unknown actionId: ${actionId}. Update INVALIDATION_MAP.`,
|
||||
)
|
||||
}
|
||||
|
||||
// 1. 服务端数据缓存失效(Redis / 内存)
|
||||
const resolvedTags = rule.tags.map((t) => fillTemplate(t, params))
|
||||
const store = await getCacheStore()
|
||||
await store.invalidateTags(resolvedTags)
|
||||
|
||||
// 2. Next.js fetch 缓存 + unstable_cache 失效
|
||||
// Next.js 16 起 revalidateTag 强制要求传入 cache life profile
|
||||
for (const tag of resolvedTags) {
|
||||
revalidateTag(tag, "default")
|
||||
}
|
||||
|
||||
// 3. 路径级 RSC 缓存失效
|
||||
for (const path of rule.paths) {
|
||||
revalidatePath(fillTemplate(path, params))
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user