perf : cache
This commit is contained in:
Vendored
+20
@@ -0,0 +1,20 @@
|
||||
import "server-only";
|
||||
|
||||
import { cacheLife, cacheTag } from "next/cache";
|
||||
|
||||
import { getActiveCatalog, getCatalogOptions } from "@/lib/guides/queries";
|
||||
import { CATALOG_CACHE_TAG, GLOSSARY_CACHE_TAG } from "./tags";
|
||||
|
||||
export async function getCachedCatalogOptions() {
|
||||
"use cache: remote";
|
||||
cacheLife({ stale: 0, revalidate: 300, expire: 3600 });
|
||||
cacheTag(CATALOG_CACHE_TAG, GLOSSARY_CACHE_TAG);
|
||||
return getCatalogOptions();
|
||||
}
|
||||
|
||||
export async function getCachedActiveCatalog() {
|
||||
"use cache: remote";
|
||||
cacheLife({ stale: 0, revalidate: 300, expire: 3600 });
|
||||
cacheTag(CATALOG_CACHE_TAG, GLOSSARY_CACHE_TAG);
|
||||
return getActiveCatalog();
|
||||
}
|
||||
Vendored
+29
@@ -0,0 +1,29 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const mocks = vi.hoisted(() => ({ tag: vi.fn(), guide: vi.fn(), summary: vi.fn() }));
|
||||
vi.mock("server-only", () => ({}));
|
||||
vi.mock("next/cache", () => ({ cacheTag: mocks.tag, cacheLife: vi.fn() }));
|
||||
vi.mock("@/lib/guides/queries", () => ({
|
||||
getPublicGuide: mocks.guide, getPublicGuideSummary: mocks.summary, listPublicGuideCards: vi.fn(),
|
||||
}));
|
||||
import { getCachedPublicGuide, getCachedPublicGuideSummary } from "./public";
|
||||
|
||||
beforeEach(() => vi.resetAllMocks());
|
||||
|
||||
describe("public cache dependency boundaries", () => {
|
||||
it.each([getCachedPublicGuide, getCachedPublicGuideSummary])("keeps existing guides independent of directory invalidations", async (read) => {
|
||||
mocks.guide.mockResolvedValue({ id: "first" });
|
||||
mocks.summary.mockResolvedValue({ id: "first" });
|
||||
await read("first");
|
||||
expect(mocks.tag).toHaveBeenCalledWith("buzz:page:first");
|
||||
expect(mocks.tag).toHaveBeenCalledWith("buzz:catalog");
|
||||
expect(mocks.tag).not.toHaveBeenCalledWith("buzz:directory");
|
||||
});
|
||||
|
||||
it.each([getCachedPublicGuide, getCachedPublicGuideSummary])("refreshes missing guides when the directory changes", async (read) => {
|
||||
mocks.guide.mockResolvedValue(null);
|
||||
mocks.summary.mockResolvedValue(null);
|
||||
expect(await read("missing")).toBeNull();
|
||||
expect(mocks.tag).toHaveBeenCalledWith("buzz:directory");
|
||||
});
|
||||
});
|
||||
Vendored
+6
-2
@@ -4,6 +4,7 @@ import { cacheLife, cacheTag } from "next/cache";
|
||||
|
||||
import { getPublicGuide, getPublicGuideSummary, listPublicGuideCards } from "@/lib/guides/queries";
|
||||
import {
|
||||
CATALOG_CACHE_TAG,
|
||||
DIRECTORY_CACHE_TAG,
|
||||
GLOSSARY_CACHE_TAG,
|
||||
pageCacheTag,
|
||||
@@ -11,6 +12,7 @@ import {
|
||||
|
||||
export async function getCachedPublicGuideCards() {
|
||||
"use cache: remote";
|
||||
cacheTag(CATALOG_CACHE_TAG);
|
||||
cacheLife({ stale: 30, revalidate: 60, expire: 60 * 60 });
|
||||
cacheTag(DIRECTORY_CACHE_TAG);
|
||||
cacheTag(GLOSSARY_CACHE_TAG);
|
||||
@@ -19,19 +21,21 @@ export async function getCachedPublicGuideCards() {
|
||||
|
||||
export async function getCachedPublicGuide(slug: string, section = "overview") {
|
||||
"use cache: remote";
|
||||
cacheTag(CATALOG_CACHE_TAG);
|
||||
cacheLife({ stale: 15, revalidate: 60, expire: 60 * 60 });
|
||||
const result = await getPublicGuide(slug, section);
|
||||
cacheTag(GLOSSARY_CACHE_TAG);
|
||||
cacheTag(DIRECTORY_CACHE_TAG);
|
||||
if (result) cacheTag(pageCacheTag(result.id));
|
||||
else cacheTag(DIRECTORY_CACHE_TAG);
|
||||
return result;
|
||||
}
|
||||
|
||||
export async function getCachedPublicGuideSummary(slug: string) {
|
||||
"use cache: remote";
|
||||
cacheTag(CATALOG_CACHE_TAG);
|
||||
cacheLife({ stale: 15, revalidate: 60, expire: 60 * 60 });
|
||||
cacheTag(DIRECTORY_CACHE_TAG);
|
||||
const result = await getPublicGuideSummary(slug);
|
||||
if (result) cacheTag(pageCacheTag(result.id));
|
||||
else cacheTag(DIRECTORY_CACHE_TAG);
|
||||
return result;
|
||||
}
|
||||
|
||||
Vendored
+4
@@ -2,6 +2,8 @@ import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
cacheTagsForOutboxEvent,
|
||||
CATALOG_CACHE_TAG,
|
||||
GLOSSARY_CACHE_TAG,
|
||||
DIRECTORY_CACHE_TAG,
|
||||
STYGIAN_CACHE_TAG,
|
||||
} from "./tags";
|
||||
@@ -11,6 +13,8 @@ describe("public cache tags", () => {
|
||||
expect(cacheTagsForOutboxEvent({ eventType: "directory.updated", aggregateId: "id" })).toEqual([DIRECTORY_CACHE_TAG]);
|
||||
expect(cacheTagsForOutboxEvent({ eventType: "page.updated", aggregateId: "page" })).toEqual(["buzz:page:page"]);
|
||||
expect(cacheTagsForOutboxEvent({ eventType: "stygian.updated", aggregateId: "5269001" })).toEqual([STYGIAN_CACHE_TAG]);
|
||||
expect(cacheTagsForOutboxEvent({ eventType: "catalog.updated", aggregateId: "catalog" })).toEqual([CATALOG_CACHE_TAG, DIRECTORY_CACHE_TAG, STYGIAN_CACHE_TAG]);
|
||||
expect(cacheTagsForOutboxEvent({ eventType: "glossary.updated", aggregateId: "alias" })).toEqual([GLOSSARY_CACHE_TAG]);
|
||||
expect(cacheTagsForOutboxEvent({ eventType: "admin.updated", aggregateId: "id" })).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
Vendored
+3
@@ -1,3 +1,4 @@
|
||||
export const CATALOG_CACHE_TAG = "buzz:catalog";
|
||||
export const DIRECTORY_CACHE_TAG = "buzz:directory";
|
||||
export const GLOSSARY_CACHE_TAG = "buzz:glossary";
|
||||
export const STYGIAN_CACHE_TAG = "buzz:stygian";
|
||||
@@ -12,6 +13,8 @@ export type OutboxCacheEvent = {
|
||||
};
|
||||
|
||||
export function cacheTagsForOutboxEvent(event: OutboxCacheEvent): string[] {
|
||||
if (event.eventType === "catalog.updated") return [CATALOG_CACHE_TAG, DIRECTORY_CACHE_TAG, STYGIAN_CACHE_TAG];
|
||||
if (event.eventType === "glossary.updated") return [GLOSSARY_CACHE_TAG];
|
||||
if (event.eventType === "directory.updated") return [DIRECTORY_CACHE_TAG];
|
||||
if (event.eventType === "page.updated") return [pageCacheTag(event.aggregateId)];
|
||||
if (event.eventType === "stygian.updated") return [STYGIAN_CACHE_TAG];
|
||||
|
||||
@@ -3,8 +3,12 @@ import { join } from "node:path";
|
||||
import { sql } from "drizzle-orm";
|
||||
import * as z from "zod";
|
||||
|
||||
import { CATALOG_CACHE_TAG, DIRECTORY_CACHE_TAG, STYGIAN_CACHE_TAG } from "@/lib/cache/tags";
|
||||
import { createRedisOutboxTransport } from "@/lib/outbox/processor";
|
||||
|
||||
import { getDb } from "@/db/client";
|
||||
import {
|
||||
outboxEvents,
|
||||
catalogArtifacts,
|
||||
catalogCharacters,
|
||||
catalogMaterials,
|
||||
@@ -644,7 +648,15 @@ async function syncCatalogVersion(version: string, control?: SyncControl): Promi
|
||||
imageKey: item.imageKey,
|
||||
})),
|
||||
);
|
||||
const eventId = crypto.randomUUID();
|
||||
await tx.insert(outboxEvents).values({
|
||||
topic: "admin", aggregateId: eventId, eventType: "catalog.updated",
|
||||
payload: { id: eventId, version: Date.now() },
|
||||
});
|
||||
});
|
||||
await createRedisOutboxTransport(await getRedisClient()).invalidate(
|
||||
[CATALOG_CACHE_TAG, DIRECTORY_CACHE_TAG, STYGIAN_CACHE_TAG], Date.now(),
|
||||
);
|
||||
await control?.report("database", 1, 1, "บันทึก Catalog แล้ว");
|
||||
await storage.write("catalog/current.json", JSON.stringify({
|
||||
version,
|
||||
|
||||
@@ -25,6 +25,13 @@ describe("invalidation event envelopes", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it.each(["catalog.updated", "glossary.updated"])("routes %s through the protected admin topic", (type) => {
|
||||
const payload = { id: "test", version: 100 };
|
||||
expect(toInvalidationEvent({ topic: "admin", aggregateId: "test", eventType: type, payload }))
|
||||
.toEqual({ type, ...payload });
|
||||
expect(toInvalidationEvent({ topic: "directory", aggregateId: "test", eventType: type, payload })).toBeNull();
|
||||
});
|
||||
|
||||
it("rejects unknown, malformed, and topic-mismatched messages", () => {
|
||||
expect(parseInvalidationEvent("not json")).toBeNull();
|
||||
expect(
|
||||
|
||||
@@ -3,6 +3,8 @@ export const INVALIDATION_TYPES = [
|
||||
"page.updated",
|
||||
"stygian.updated",
|
||||
"admin.updated",
|
||||
"catalog.updated",
|
||||
"glossary.updated",
|
||||
] as const;
|
||||
|
||||
export type InvalidationType = (typeof INVALIDATION_TYPES)[number];
|
||||
@@ -63,6 +65,8 @@ export function expectedTopic(event: InvalidationEvent): string {
|
||||
case "stygian.updated":
|
||||
return "stygian";
|
||||
case "admin.updated":
|
||||
case "catalog.updated":
|
||||
case "glossary.updated":
|
||||
return "admin";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import { asc, eq } from "drizzle-orm";
|
||||
|
||||
import { getDb } from "@/db";
|
||||
import {
|
||||
outboxEvents,
|
||||
catalogArtifacts,
|
||||
catalogCharacters,
|
||||
catalogWeapons,
|
||||
@@ -53,6 +54,10 @@ export async function createGlossaryAlias(input: unknown, actor: AuditActor) {
|
||||
catalogKey: created.catalogKey,
|
||||
},
|
||||
});
|
||||
await tx.insert(outboxEvents).values({
|
||||
topic: "admin", aggregateId: created.id, eventType: "glossary.updated",
|
||||
payload: { id: created.id, version: Date.now() },
|
||||
});
|
||||
return created;
|
||||
});
|
||||
}
|
||||
@@ -74,6 +79,10 @@ export async function deleteGlossaryAlias(id: string, actor: AuditActor) {
|
||||
catalogKey: deleted.catalogKey,
|
||||
},
|
||||
});
|
||||
await tx.insert(outboxEvents).values({
|
||||
topic: "admin", aggregateId: deleted.id, eventType: "glossary.updated",
|
||||
payload: { id: deleted.id, version: Date.now() },
|
||||
});
|
||||
return deleted;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const mocks = vi.hoisted(() => ({ reads: [] as unknown[], rows: new Map<unknown, unknown[]>() }));
|
||||
const mocks = vi.hoisted(() => ({ reads: [] as unknown[], selections: [] as unknown[], rows: new Map<unknown, unknown[]>() }));
|
||||
vi.mock("server-only", () => ({}));
|
||||
vi.mock("@/lib/media/storage", () => ({ publicMediaUrl: (key: string) => `/media/${key}` }));
|
||||
vi.mock("@/db", () => ({
|
||||
getDb: () => ({
|
||||
select: () => {
|
||||
select: (columns?: unknown) => {
|
||||
mocks.selections.push(columns);
|
||||
let table: unknown;
|
||||
const query = {
|
||||
from(value: unknown) { table = value; return query; },
|
||||
@@ -29,10 +30,11 @@ import {
|
||||
guides, guideSections, teamCompositions, teamConditions, teamMembers, weaponConditions,
|
||||
catalogCharacters, catalogMaterials, guidePosterBleeding, media, weaponRecommendations, artifactRecommendations,
|
||||
} from "@/db/schema";
|
||||
import { getActiveCatalog, getAdminGuideContent, getPublicGuide, listAdminTeamImportsForCharacter } from "./queries";
|
||||
import { getActiveCatalog, getCatalogOptions, getAdminGuideContent, getPublicGuide, listAdminTeamImportsForCharacter } from "./queries";
|
||||
|
||||
beforeEach(() => {
|
||||
mocks.reads.length = 0;
|
||||
mocks.selections.length = 0;
|
||||
mocks.rows.clear();
|
||||
mocks.rows.set(guides, [{
|
||||
guide: { id: "guide", slug: "test", posterArtMediaId: null },
|
||||
@@ -202,3 +204,15 @@ describe("public overview build summary", () => {
|
||||
expect(mocks.reads).not.toContain(weaponRecommendations);
|
||||
});
|
||||
});
|
||||
|
||||
describe("catalog picker reads", () => {
|
||||
it("selects only character picker and export artwork fields", async () => {
|
||||
mocks.rows.set(catalogCharacters, [{ key: "test", name: "Test", imageKey: "avatar", coopImageKey: "splash" }]);
|
||||
const catalog = await getCatalogOptions();
|
||||
expect(catalog?.characters[0].splashImageUrl).toBe("/media/splash");
|
||||
expect(mocks.reads).not.toContain(catalogMaterials);
|
||||
expect(Object.keys(mocks.selections[0] as object).sort()).toEqual([
|
||||
"key", "name", "rarity", "weaponType", "element", "imageKey", "elementIconKey", "coopImageKey",
|
||||
].sort());
|
||||
});
|
||||
});
|
||||
|
||||
@@ -131,6 +131,7 @@ export async function getCatalogOptions() {
|
||||
element: catalogCharacters.element,
|
||||
imageKey: catalogCharacters.imageKey,
|
||||
elementIconKey: catalogCharacters.elementIconKey,
|
||||
coopImageKey: catalogCharacters.coopImageKey,
|
||||
}).from(catalogCharacters).orderBy(asc(catalogCharacters.name)),
|
||||
() => getDb().select().from(catalogWeapons).orderBy(asc(catalogWeapons.name)),
|
||||
() => getDb().select().from(catalogArtifacts).orderBy(asc(catalogArtifacts.name)),
|
||||
@@ -142,6 +143,7 @@ export async function getCatalogOptions() {
|
||||
characters: characters.map((item) => ({
|
||||
...item,
|
||||
imageUrl: publicMediaUrl(item.imageKey),
|
||||
splashImageUrl: item.coopImageKey ? publicMediaUrl(item.coopImageKey) : null,
|
||||
elementIconUrl: item.elementIconKey ? publicMediaUrl(item.elementIconKey) : null,
|
||||
aliases: groupedAliases.character.get(item.key) ?? [],
|
||||
})),
|
||||
|
||||
Reference in New Issue
Block a user