Skip to content
Closed
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
75 changes: 75 additions & 0 deletions src/features/search/server/global-search-catalog-queries.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
import { buildCourseListWhere } from "@/features/catalog/server/course-query-filters";
import { buildSectionListQuery } from "@/features/catalog/server/section-query-filters";
import { SECTION_SUMMARY_DEFAULT_ORDER_BY } from "@/features/catalog/server/section-summary-read-model";
import { buildTeacherWhere } from "@/features/catalog/server/teacher-query";
import type { AppLocale } from "@/i18n/config";
import { getPrisma } from "@/lib/db/prisma";

export async function searchCoursesForGlobal(
query: string,
locale: AppLocale,
limit: number,
) {
return getPrisma(locale).course.findMany({
where: buildCourseListWhere({ search: query }),
orderBy: [{ code: "asc" }, { jwId: "asc" }],
select: {
code: true,
jwId: true,
nameCn: true,
namePrimary: true,
},
take: limit,
});
}

export async function searchSectionsForGlobal(
query: string,
locale: AppLocale,
limit: number,
) {
const { orderBy, where } = buildSectionListQuery({ search: query });
return getPrisma(locale).section.findMany({
where,
orderBy: orderBy ?? SECTION_SUMMARY_DEFAULT_ORDER_BY,
select: {
code: true,
jwId: true,
course: {
select: {
code: true,
nameCn: true,
namePrimary: true,
},
},
semester: {
select: {
nameCn: true,
},
},
},
take: limit,
});
}

export async function searchTeachersForGlobal(
query: string,
locale: AppLocale,
limit: number,
) {
return getPrisma(locale).teacher.findMany({
where: buildTeacherWhere({ search: query }),
orderBy: { nameCn: "asc" },
select: {
code: true,
id: true,
nameCn: true,
department: {
select: {
nameCn: true,
},
},
},
take: limit,
});
}
71 changes: 71 additions & 0 deletions src/features/search/server/global-search-response-cache.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
import type { GlobalSearchResponse } from "@/features/search/server/global-search-types";
import type { AppLocale } from "@/i18n/config";

const SEARCH_CACHE_TTL_MS = 120_000;
const MAX_SEARCH_CACHE_ENTRIES = 256;

type SearchCacheEntry = {
expiresAt: number;
value: GlobalSearchResponse;
};

const globalForSearchCache = globalThis as typeof globalThis & {
__lifeUstcGlobalSearchCache?: Map<string, SearchCacheEntry>;
};

function searchCacheStore() {
globalForSearchCache.__lifeUstcGlobalSearchCache ??= new Map();
return globalForSearchCache.__lifeUstcGlobalSearchCache;
}

function searchCacheKey(
locale: AppLocale,
query: string,
limit: number,
userId: string | null | undefined,
) {
return `${locale}:${userId ?? "anon"}:${limit}:${query}`;
}

export function readCachedGlobalSearch(
locale: AppLocale,
query: string,
limit: number,
userId: string | null | undefined,
) {
const key = searchCacheKey(locale, query, limit, userId);
const entry = searchCacheStore().get(key);
if (!entry) return null;
if (entry.expiresAt <= Date.now()) {
searchCacheStore().delete(key);
return null;
}
return entry.value;
}

export function writeCachedGlobalSearch(
locale: AppLocale,
query: string,
limit: number,
userId: string | null | undefined,
value: GlobalSearchResponse,
) {
const store = searchCacheStore();
const now = Date.now();
for (const [key, entry] of store) {
if (entry.expiresAt <= now) store.delete(key);
}
while (store.size >= MAX_SEARCH_CACHE_ENTRIES) {
const oldestKey = store.keys().next().value;
if (!oldestKey) break;
store.delete(oldestKey);
}
store.set(searchCacheKey(locale, query, limit, userId), {
expiresAt: now + SEARCH_CACHE_TTL_MS,
value,
});
}

export function resetGlobalSearchCacheForTest() {
searchCacheStore().clear();
}
56 changes: 30 additions & 26 deletions src/features/search/server/global-search-service.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,12 @@
import {
listCourseSummaries,
listSectionSummaries,
listTeacherSummaries,
} from "@/features/catalog/server/course-section-queries";
searchCoursesForGlobal,
searchSectionsForGlobal,
searchTeachersForGlobal,
} from "@/features/search/server/global-search-catalog-queries";
import {
readCachedGlobalSearch,
writeCachedGlobalSearch,
} from "@/features/search/server/global-search-response-cache";
import type {
GlobalSearchResponse,
GlobalSearchResultGroup,
Expand Down Expand Up @@ -68,40 +72,28 @@ async function searchCatalogGroups(
limit: number,
): Promise<GlobalSearchResultGroup[]> {
const [courses, sections, teachers] = await Promise.all([
listCourseSummaries({
filters: { search: query },
locale,
pagination: { page: 1, pageSize: limit },
}),
listSectionSummaries({
filters: { search: query },
locale,
pagination: { page: 1, pageSize: limit },
}),
listTeacherSummaries({
filters: { search: query },
locale,
pagination: { page: 1, pageSize: limit },
}),
searchCoursesForGlobal(query, locale, limit),
searchSectionsForGlobal(query, locale, limit),
searchTeachersForGlobal(query, locale, limit),
]);

const groups: GlobalSearchResultGroup[] = [];
if (courses.data.length > 0) {
if (courses.length > 0) {
groups.push({
type: "courses",
items: courses.data.map(toCourseItem),
items: courses.map(toCourseItem),
});
}
if (sections.data.length > 0) {
if (sections.length > 0) {
groups.push({
type: "sections",
items: sections.data.map((section) => toSectionItem(section, locale)),
items: sections.map((section) => toSectionItem(section, locale)),
});
}
if (teachers.data.length > 0) {
if (teachers.length > 0) {
groups.push({
type: "teachers",
items: teachers.data.map((teacher) => ({
items: teachers.map((teacher) => ({
id: `teacher:${teacher.id}`,
title: teacher.nameCn,
description: teacher.department?.nameCn ?? teacher.code,
Expand Down Expand Up @@ -209,17 +201,29 @@ export async function searchGlobally(input: {
return { query, groups: [] };
}

const cached = readCachedGlobalSearch(
input.locale,
query,
limit,
input.userId,
);
if (cached) {
return cached;
}

const [catalogGroups, workspaceGroups] = await Promise.all([
searchCatalogGroups(query, input.locale, limit),
input.userId
? searchWorkspaceGroups(query, input.userId, limit)
: Promise.resolve([]),
]);

return {
const response = {
query,
groups: [...catalogGroups, ...workspaceGroups],
};
writeCachedGlobalSearch(input.locale, query, limit, input.userId, response);
return response;
}

export function hasGlobalSearchQuery(query: string) {
Expand Down
9 changes: 7 additions & 2 deletions src/lib/catalog-detail-runtime-cache.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,13 @@ import {
publicDetailKvCacheKey,
} from "@/lib/public-runtime-cache";

export const PUBLIC_DETAIL_RUNTIME_CACHE_TTL_MS = 60_000;
export const PUBLIC_DETAIL_KV_CACHE_TTL_MS = 3_600_000;
const HOUR_MS = 3_600_000;

/** L1 isolate + colo Cache API TTL for anonymous catalog entity core. */
export const PUBLIC_DETAIL_RUNTIME_CACHE_TTL_MS = 24 * HOUR_MS;

/** Cross-PoP KV TTL; revision-scoped keys invalidate on static import. */
export const PUBLIC_DETAIL_KV_CACHE_TTL_MS = 24 * HOUR_MS;

export async function buildPublicDetailRuntimeCacheOptions<T>(input: {
coloCacheKey?: string;
Expand Down
80 changes: 51 additions & 29 deletions tests/unit/global-search-service.test.ts
Original file line number Diff line number Diff line change
@@ -1,27 +1,28 @@
import { beforeEach, describe, expect, it, vi } from "vitest";

const {
listCourseSummariesMock,
listSectionSummariesMock,
listTeacherSummariesMock,
searchCoursesForGlobalMock,
searchSectionsForGlobalMock,
searchTeachersForGlobalMock,
withUserDbContextMock,
} = vi.hoisted(() => ({
listCourseSummariesMock: vi.fn(),
listSectionSummariesMock: vi.fn(),
listTeacherSummariesMock: vi.fn(),
searchCoursesForGlobalMock: vi.fn(),
searchSectionsForGlobalMock: vi.fn(),
searchTeachersForGlobalMock: vi.fn(),
withUserDbContextMock: vi.fn(),
}));

vi.mock("@/features/catalog/server/course-section-queries", () => ({
listCourseSummaries: listCourseSummariesMock,
listSectionSummaries: listSectionSummariesMock,
listTeacherSummaries: listTeacherSummariesMock,
vi.mock("@/features/search/server/global-search-catalog-queries", () => ({
searchCoursesForGlobal: searchCoursesForGlobalMock,
searchSectionsForGlobal: searchSectionsForGlobalMock,
searchTeachersForGlobal: searchTeachersForGlobalMock,
}));

vi.mock("@/lib/db/prisma", () => ({
withUserDbContext: withUserDbContextMock,
}));

import { resetGlobalSearchCacheForTest } from "@/features/search/server/global-search-response-cache";
import {
hasGlobalSearchQuery,
searchGlobally,
Expand All @@ -30,9 +31,10 @@ import {
describe("global search service", () => {
beforeEach(() => {
vi.clearAllMocks();
listCourseSummariesMock.mockResolvedValue({ data: [] });
listSectionSummariesMock.mockResolvedValue({ data: [] });
listTeacherSummariesMock.mockResolvedValue({ data: [] });
resetGlobalSearchCacheForTest();
searchCoursesForGlobalMock.mockResolvedValue([]);
searchSectionsForGlobalMock.mockResolvedValue([]);
searchTeachersForGlobalMock.mockResolvedValue([]);
withUserDbContextMock.mockImplementation(
async (_userId: string, work: (tx: unknown) => Promise<unknown>) =>
work({
Expand All @@ -49,31 +51,35 @@ describe("global search service", () => {
});

expect(result.groups).toEqual([]);
expect(listCourseSummariesMock).not.toHaveBeenCalled();
expect(searchCoursesForGlobalMock).not.toHaveBeenCalled();
});

it("searches catalog entities for public users", async () => {
listCourseSummariesMock.mockResolvedValue({
data: [
{
jwId: 101,
code: "CS101",
nameCn: "数据结构",
namePrimary: "数据结构",
},
],
});
searchCoursesForGlobalMock.mockResolvedValue([
{
jwId: 101,
code: "CS101",
nameCn: "数据结构",
namePrimary: "数据结构",
},
]);

const result = await searchGlobally({
locale: "zh-cn",
query: "数据",
});

expect(listCourseSummariesMock).toHaveBeenCalledWith({
filters: { search: "数据" },
locale: "zh-cn",
pagination: { page: 1, pageSize: 5 },
});
expect(searchCoursesForGlobalMock).toHaveBeenCalledWith("数据", "zh-cn", 5);
expect(searchSectionsForGlobalMock).toHaveBeenCalledWith(
"数据",
"zh-cn",
5,
);
expect(searchTeachersForGlobalMock).toHaveBeenCalledWith(
"数据",
"zh-cn",
5,
);
expect(result.groups).toEqual([
{
type: "courses",
Expand All @@ -89,6 +95,22 @@ describe("global search service", () => {
]);
});

it("serves repeated queries from the response cache", async () => {
searchCoursesForGlobalMock.mockResolvedValue([
{
jwId: 101,
code: "CS101",
nameCn: "数据结构",
namePrimary: "数据结构",
},
]);

await searchGlobally({ locale: "zh-cn", query: "数据" });
await searchGlobally({ locale: "zh-cn", query: "数据" });

expect(searchCoursesForGlobalMock).toHaveBeenCalledTimes(1);
});

it("includes workspace groups for signed-in users", async () => {
const homeworkFindMany = vi.fn().mockResolvedValue([
{
Expand Down
Loading