From d8f7a950b5ccb1778b26622769c90959541e74c0 Mon Sep 17 00:00:00 2001 From: fujii-yi Date: Sat, 22 Mar 2025 18:48:02 +0900 Subject: [PATCH 1/3] =?UTF-8?q?=E4=BF=AE=E6=AD=A31?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- workspaces/client/src/app/createRoutes.tsx | 12 +-- .../episode/services/episodeService.ts | 90 ++++++++++++------ .../program/services/programService.ts | 4 +- .../src/features/requests/schedulePlugin.ts | 8 +- .../src/pages/home/components/HomePage.tsx | 5 +- .../components/ProgramDetailDialog.tsx | 93 +++++++++++-------- 6 files changed, 129 insertions(+), 83 deletions(-) diff --git a/workspaces/client/src/app/createRoutes.tsx b/workspaces/client/src/app/createRoutes.tsx index a81e12561..7c3e54d39 100644 --- a/workspaces/client/src/app/createRoutes.tsx +++ b/workspaces/client/src/app/createRoutes.tsx @@ -13,7 +13,7 @@ export function createRoutes(store: ReturnType): RouteObject async lazy() { const { HomePage, prefetch } = await lazy( import('@wsh-2025/client/src/pages/home/components/HomePage'), - 1000, + 100, ); return { Component: HomePage, @@ -27,7 +27,7 @@ export function createRoutes(store: ReturnType): RouteObject async lazy() { const { EpisodePage, prefetch } = await lazy( import('@wsh-2025/client/src/pages/episode/components/EpisodePage'), - 1000, + 100, ); return { Component: EpisodePage, @@ -42,7 +42,7 @@ export function createRoutes(store: ReturnType): RouteObject async lazy() { const { prefetch, ProgramPage } = await lazy( import('@wsh-2025/client/src/pages/program/components/ProgramPage'), - 1000, + 100, ); return { Component: ProgramPage, @@ -57,7 +57,7 @@ export function createRoutes(store: ReturnType): RouteObject async lazy() { const { prefetch, SeriesPage } = await lazy( import('@wsh-2025/client/src/pages/series/components/SeriesPage'), - 1000, + 100, ); return { Component: SeriesPage, @@ -72,7 +72,7 @@ export function createRoutes(store: ReturnType): RouteObject async lazy() { const { prefetch, TimetablePage } = await lazy( import('@wsh-2025/client/src/pages/timetable/components/TimetablePage'), - 1000, + 100, ); return { Component: TimetablePage, @@ -87,7 +87,7 @@ export function createRoutes(store: ReturnType): RouteObject async lazy() { const { NotFoundPage, prefetch } = await lazy( import('@wsh-2025/client/src/pages/not_found/components/NotFoundPage'), - 1000, + 100, ); return { Component: NotFoundPage, diff --git a/workspaces/client/src/features/episode/services/episodeService.ts b/workspaces/client/src/features/episode/services/episodeService.ts index 3a21b8194..82b31524a 100644 --- a/workspaces/client/src/features/episode/services/episodeService.ts +++ b/workspaces/client/src/features/episode/services/episodeService.ts @@ -1,7 +1,6 @@ import { createFetch, createSchema } from '@better-fetch/fetch'; import { StandardSchemaV1 } from '@standard-schema/spec'; import * as schema from '@wsh-2025/schema/src/api/schema'; -import * as batshit from '@yornaath/batshit'; import { schedulePlugin } from '@wsh-2025/client/src/features/requests/schedulePlugin'; @@ -15,47 +14,80 @@ const $fetch = createFetch({ }, '/episodes/:episodeId': { output: schema.getEpisodeByIdResponse, + params: schema.getEpisodeByIdRequestParams, }, }), throw: true, }); -const batcher = batshit.create({ - async fetcher(queries: { episodeId: string }[]) { - const data = await $fetch('/episodes', { - query: { - episodeIds: queries.map((q) => q.episodeId).join(','), - }, - }); - return data; - }, - resolver(items, query: { episodeId: string }) { - const item = items.find((item) => item.id === query.episodeId); - if (item == null) { - throw new Error('Episode is not found.'); - } - return item; - }, - scheduler: batshit.windowedFiniteBatchScheduler({ - maxBatchSize: 100, - windowMs: 1000, - }), -}); - interface EpisodeService { - fetchEpisodeById: (query: { + fetchEpisodeById: (params: { episodeId: string; }) => Promise>; - fetchEpisodes: () => Promise>; + fetchEpisodes: (params: { + episodeIds?: string[]; + }) => Promise>; } +// バッチ処理用のキューとキャッシュ +let batchQueue: string[] = []; +let batchTimeout: NodeJS.Timeout | null = null; +const episodeCache = new Map>(); +const BATCH_WINDOW = 5; // バッチウィンドウを5msに短縮 +const MAX_BATCH_SIZE = 50; // 最大バッチサイズを制限 + export const episodeService: EpisodeService = { async fetchEpisodeById({ episodeId }) { - const channel = await batcher.fetch({ episodeId }); - return channel; + // キャッシュをチェック + const cachedEpisode = episodeCache.get(episodeId); + if (cachedEpisode) { + return cachedEpisode; + } + + // バッチキューに追加 + batchQueue.push(episodeId); + + // 既存のタイマーをクリア + if (batchTimeout) { + clearTimeout(batchTimeout); + } + + // 新しいタイマーを設定 + return new Promise((resolve, reject) => { + batchTimeout = setTimeout(async () => { + // 重複を除去し、最大サイズを制限 + const uniqueIds = [...new Set(batchQueue)].slice(0, MAX_BATCH_SIZE); + + try { + const episodes = await this.fetchEpisodes({ episodeIds: uniqueIds }); + + // キャッシュを更新 + episodes.forEach(episode => { + episodeCache.set(episode.id, episode); + }); + + const episode = episodes.find(e => e.id === episodeId); + if (!episode) { + reject(new Error(`Episode not found: ${episodeId}`)); + return; + } + resolve(episode); + } catch (error) { + console.error('Error fetching episodes:', error); + reject(error); + } finally { + batchQueue = []; + } + }, BATCH_WINDOW); + }); }, - async fetchEpisodes() { - const data = await $fetch('/episodes', { query: {} }); + + async fetchEpisodes({ episodeIds }) { + if (!episodeIds?.length) return []; + + const data = await $fetch('/episodes', { + query: { episodeIds: episodeIds.join(',') }, + }); return data; }, }; diff --git a/workspaces/client/src/features/program/services/programService.ts b/workspaces/client/src/features/program/services/programService.ts index dc17a25c0..f2ed9e7c0 100644 --- a/workspaces/client/src/features/program/services/programService.ts +++ b/workspaces/client/src/features/program/services/programService.ts @@ -38,8 +38,8 @@ const batcher = batshit.create({ return item; }, scheduler: batshit.windowedFiniteBatchScheduler({ - maxBatchSize: 100, - windowMs: 1000, + maxBatchSize: 500, + windowMs: 0, }), }); diff --git a/workspaces/client/src/features/requests/schedulePlugin.ts b/workspaces/client/src/features/requests/schedulePlugin.ts index 0c6cb6c6d..9816168d0 100644 --- a/workspaces/client/src/features/requests/schedulePlugin.ts +++ b/workspaces/client/src/features/requests/schedulePlugin.ts @@ -6,13 +6,9 @@ export const schedulePlugin = { const scheduler = typeof window !== 'undefined' ? window.scheduler : undefined; if (scheduler) { - return await scheduler.postTask(() => request, { delay: 1000 }); + return await scheduler.postTask(() => request); } else { - return await new Promise((resolve) => { - queueMicrotask(() => { - resolve(request); - }); - }); + return request; } }, }, diff --git a/workspaces/client/src/pages/home/components/HomePage.tsx b/workspaces/client/src/pages/home/components/HomePage.tsx index 86cb77e6c..2f025882f 100644 --- a/workspaces/client/src/pages/home/components/HomePage.tsx +++ b/workspaces/client/src/pages/home/components/HomePage.tsx @@ -5,7 +5,10 @@ import { useRecommended } from '@wsh-2025/client/src/features/recommended/hooks/ export const prefetch = async (store: ReturnType) => { const modules = await store .getState() - .features.recommended.fetchRecommendedModulesByReferenceId({ referenceId: 'entrance' }); + .features.recommended.fetchRecommendedModulesByReferenceId({ + referenceId: 'entrance', + limit: 1 // 最初のモジュールのみを取得 + }); return { modules }; }; diff --git a/workspaces/client/src/pages/timetable/components/ProgramDetailDialog.tsx b/workspaces/client/src/pages/timetable/components/ProgramDetailDialog.tsx index 41e0ec14c..fecb97835 100644 --- a/workspaces/client/src/pages/timetable/components/ProgramDetailDialog.tsx +++ b/workspaces/client/src/pages/timetable/components/ProgramDetailDialog.tsx @@ -1,6 +1,6 @@ import { StandardSchemaV1 } from '@standard-schema/spec'; import * as schema from '@wsh-2025/schema/src/api/schema'; -import { ReactElement } from 'react'; +import { ReactElement, useCallback, useEffect, useMemo } from 'react'; import { Link } from 'react-router'; import { ArrayValues } from 'type-fest'; @@ -17,51 +17,66 @@ export const ProgramDetailDialog = ({ isOpen, program }: Props): ReactElement => const episode = useEpisode(program.episodeId); const [, setProgram] = useSelectedProgramId(); - const onClose = () => { + const onClose = useCallback(() => { setProgram(null); - }; + }, [setProgram]); - return ( - -
-

番組詳細

+ // エピソードデータをメモ化 + const episodeData = useMemo(() => { + if (!episode) return null; + return { + title: episode.title, + description: episode.description, + thumbnailUrl: episode.thumbnailUrl, + }; + }, [episode]); -

{program.title}

-
-
{program.description}
-
- + // ダイアログの内容をメモ化 + const dialogContent = useMemo(() => ( +
+

番組詳細

+ +

{program.title}

+
+
{program.description}
+
+ - {episode != null ? ( - <> -

番組で放送するエピソード

+ {episodeData != null ? ( + <> +

番組で放送するエピソード

-

{episode.title}

-
-
{episode.description}
-
- - - ) : null} +

{episodeData.title}

+
+
{episodeData.description}
+
+ + + ) : null} -
- - 番組をみる - -
+
+ + 番組をみる +
+
+ ), [program, episodeData, onClose]); + + return ( + + {dialogContent} ); }; From edbe5d1482489fbed00422f27efb797861e6f25c Mon Sep 17 00:00:00 2001 From: fujii-kazumi Date: Sun, 23 Mar 2025 13:36:08 +0900 Subject: [PATCH 2/3] =?UTF-8?q?=E4=BF=AE=E6=AD=A32?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../episode/services/episodeService.ts | 17 +++- .../services/recommendedService.ts | 59 +++++++++++- .../recommended/stores/recommendedStore.ts | 26 +++++ .../components/ProgramDetailDialog.tsx | 20 +++- workspaces/server/src/api.ts | 94 +++++++++++++++++++ 5 files changed, 203 insertions(+), 13 deletions(-) create mode 100644 workspaces/client/src/features/recommended/stores/recommendedStore.ts diff --git a/workspaces/client/src/features/episode/services/episodeService.ts b/workspaces/client/src/features/episode/services/episodeService.ts index 82b31524a..7c022d098 100644 --- a/workspaces/client/src/features/episode/services/episodeService.ts +++ b/workspaces/client/src/features/episode/services/episodeService.ts @@ -29,18 +29,23 @@ interface EpisodeService { }) => Promise>; } -// バッチ処理用のキューとキャッシュ +// キャッシュの設定 +const CACHE_TTL = 5 * 60 * 1000; // キャッシュの有効期限を5分に設定 +const episodeCache = new Map>(); +const cacheTimestamps = new Map(); + +// バッチ処理用のキュー let batchQueue: string[] = []; let batchTimeout: NodeJS.Timeout | null = null; -const episodeCache = new Map>(); -const BATCH_WINDOW = 5; // バッチウィンドウを5msに短縮 -const MAX_BATCH_SIZE = 50; // 最大バッチサイズを制限 +const BATCH_WINDOW = 1; // バッチウィンドウを1msに短縮 +const MAX_BATCH_SIZE = 10; // 最大バッチサイズを制限 export const episodeService: EpisodeService = { async fetchEpisodeById({ episodeId }) { // キャッシュをチェック const cachedEpisode = episodeCache.get(episodeId); - if (cachedEpisode) { + const timestamp = cacheTimestamps.get(episodeId); + if (cachedEpisode && timestamp && Date.now() - timestamp < CACHE_TTL) { return cachedEpisode; } @@ -62,8 +67,10 @@ export const episodeService: EpisodeService = { const episodes = await this.fetchEpisodes({ episodeIds: uniqueIds }); // キャッシュを更新 + const now = Date.now(); episodes.forEach(episode => { episodeCache.set(episode.id, episode); + cacheTimestamps.set(episode.id, now); }); const episode = episodes.find(e => e.id === episodeId); diff --git a/workspaces/client/src/features/recommended/services/recommendedService.ts b/workspaces/client/src/features/recommended/services/recommendedService.ts index 494ad911f..70f3e5663 100644 --- a/workspaces/client/src/features/recommended/services/recommendedService.ts +++ b/workspaces/client/src/features/recommended/services/recommendedService.ts @@ -10,6 +10,7 @@ const $fetch = createFetch({ schema: createSchema({ '/recommended/:referenceId': { output: schema.getRecommendedModulesResponse, + params: schema.getRecommendedModulesRequestParams, }, }), throw: true, @@ -21,11 +22,63 @@ interface RecommendedService { }) => Promise>; } +// キャッシュの設定 +const CACHE_TTL = 5 * 60 * 1000; // キャッシュの有効期限を5分に設定 +const recommendedCache = new Map>(); +const cacheTimestamps = new Map(); + +// バッチ処理用のキュー +let batchQueue: string[] = []; +let batchTimeout: NodeJS.Timeout | null = null; +const BATCH_WINDOW = 0; // バッチウィンドウを0msに設定(即時実行) +const MAX_BATCH_SIZE = 5; // 最大バッチサイズを制限 + +// プリフェッチ用のキュー +const prefetchQueue = new Set(); + export const recommendedService: RecommendedService = { async fetchRecommendedModulesByReferenceId({ referenceId }) { - const data = await $fetch('/recommended/:referenceId', { - params: { referenceId }, + // キャッシュをチェック + const cachedData = recommendedCache.get(referenceId); + const timestamp = cacheTimestamps.get(referenceId); + if (cachedData && timestamp && Date.now() - timestamp < CACHE_TTL) { + return cachedData; + } + + // プリフェッチキューに追加 + prefetchQueue.add(referenceId); + + // バッチキューに追加 + batchQueue.push(referenceId); + + // 既存のタイマーをクリア + if (batchTimeout) { + clearTimeout(batchTimeout); + } + + // 新しいタイマーを設定 + return new Promise((resolve, reject) => { + batchTimeout = setTimeout(async () => { + // 重複を除去し、最大サイズを制限 + const uniqueIds = [...new Set(batchQueue)].slice(0, MAX_BATCH_SIZE); + + try { + const data = await $fetch('/recommended/:referenceId', { + params: { referenceId }, + }); + + // キャッシュを更新 + recommendedCache.set(referenceId, data); + cacheTimestamps.set(referenceId, Date.now()); + + resolve(data); + } catch (error) { + console.error('Error fetching recommended modules:', error); + reject(error); + } finally { + batchQueue = []; + } + }, BATCH_WINDOW); }); - return data; }, }; diff --git a/workspaces/client/src/features/recommended/stores/recommendedStore.ts b/workspaces/client/src/features/recommended/stores/recommendedStore.ts new file mode 100644 index 000000000..9bc8fc9ba --- /dev/null +++ b/workspaces/client/src/features/recommended/stores/recommendedStore.ts @@ -0,0 +1,26 @@ +import { create } from 'zustand'; +import { StandardSchemaV1 } from '@standard-schema/spec'; +import * as schema from '@wsh-2025/schema/src/api/schema'; +import { recommendedService } from '../services/recommendedService'; + +type RecommendedState = { + modules: StandardSchemaV1.InferOutput | null; + isLoading: boolean; + error: Error | null; + fetchModules: (referenceId: string) => Promise; +}; + +export const useRecommendedStore = create((set) => ({ + modules: null, + isLoading: false, + error: null, + fetchModules: async (referenceId: string) => { + try { + set({ isLoading: true, error: null }); + const modules = await recommendedService.fetchRecommendedModulesByReferenceId(referenceId); + set({ modules, isLoading: false }); + } catch (error) { + set({ error: error as Error, isLoading: false }); + } + }, +})); \ No newline at end of file diff --git a/workspaces/client/src/pages/timetable/components/ProgramDetailDialog.tsx b/workspaces/client/src/pages/timetable/components/ProgramDetailDialog.tsx index fecb97835..996cc68fb 100644 --- a/workspaces/client/src/pages/timetable/components/ProgramDetailDialog.tsx +++ b/workspaces/client/src/pages/timetable/components/ProgramDetailDialog.tsx @@ -21,6 +21,14 @@ export const ProgramDetailDialog = ({ isOpen, program }: Props): ReactElement => setProgram(null); }, [setProgram]); + // プログラムデータをメモ化 + const programData = useMemo(() => ({ + title: program.title, + description: program.description, + thumbnailUrl: program.thumbnailUrl, + id: program.id, + }), [program.title, program.description, program.thumbnailUrl, program.id]); + // エピソードデータをメモ化 const episodeData = useMemo(() => { if (!episode) return null; @@ -36,14 +44,15 @@ export const ProgramDetailDialog = ({ isOpen, program }: Props): ReactElement =>

番組詳細

-

{program.title}

+

{programData.title}

-
{program.description}
+
{programData.description}
{episodeData != null ? ( @@ -58,6 +67,7 @@ export const ProgramDetailDialog = ({ isOpen, program }: Props): ReactElement => alt="" className="mb-[24px] w-full rounded-[8px] border-[2px] border-solid border-[#FFFFFF1F]" src={episodeData.thumbnailUrl} + loading="lazy" /> ) : null} @@ -65,14 +75,14 @@ export const ProgramDetailDialog = ({ isOpen, program }: Props): ReactElement =>
番組をみる
- ), [program, episodeData, onClose]); + ), [programData, episodeData, onClose]); return ( diff --git a/workspaces/server/src/api.ts b/workspaces/server/src/api.ts index 635624b1f..4fa2b426c 100644 --- a/workspaces/server/src/api.ts +++ b/workspaces/server/src/api.ts @@ -23,6 +23,20 @@ import { z } from 'zod'; import type { ZodOpenApiVersion } from 'zod-openapi'; import { getDatabase, initializeDatabase } from '@wsh-2025/server/src/drizzle/database'; +import { StandardSchemaV1 } from '@standard-schema/spec'; +import NodeCache from 'node-cache'; +import { recommendedModule, recommendedItem, series, episode } from '@wsh-2025/schema/src/database/schema'; +import { eq, and } from 'drizzle-orm'; +import { db } from './db'; +import type { InferSelectModel } from 'drizzle-orm'; + +// キャッシュの設定 +const recommendedCache = new NodeCache({ stdTTL: 300 }); // 5分間キャッシュ + +type RecommendedModule = InferSelectModel; +type RecommendedItem = InferSelectModel; +type Series = InferSelectModel; +type Episode = InferSelectModel; export async function registerApi(app: FastifyInstance): Promise { app.setValidatorCompiler(validatorCompiler); @@ -648,3 +662,83 @@ export async function registerApi(app: FastifyInstance): Promise { /* eslint-enable sort/object-properties */ } + +export const getRecommendedModules = async ( + fastify: FastifyInstance, + params: StandardSchemaV1.InferInput, +) => { + const { referenceId } = params; + const db = getDatabase(); + + // キャッシュをチェック + const cachedData = recommendedCache.get(referenceId); + if (cachedData) { + return cachedData; + } + + // モジュールを取得 + const modules = await db + .select() + .from(recommendedModule) + .where(eq(recommendedModule.referenceId, referenceId)) + .orderBy(recommendedModule.order); + + // 各モジュールのアイテムを取得 + const modulesWithItems = await Promise.all( + modules.map(async (module: RecommendedModule) => { + const items = await db + .select() + .from(recommendedItem) + .where(eq(recommendedItem.moduleId, module.id)) + .orderBy(recommendedItem.order); + + // 各アイテムの関連データを取得 + const itemsWithRelations = await Promise.all( + items.map(async (item: RecommendedItem) => { + const [seriesData, episodeData] = await Promise.all([ + item.seriesId + ? db + .select({ + id: series.id, + title: series.title, + description: series.description, + thumbnailUrl: series.thumbnailUrl, + }) + .from(series) + .where(eq(series.id, item.seriesId)) + .limit(1) + : null, + item.episodeId + ? db + .select({ + id: episode.id, + title: episode.title, + description: episode.description, + thumbnailUrl: episode.thumbnailUrl, + }) + .from(episode) + .where(eq(episode.id, item.episodeId)) + .limit(1) + : null, + ]); + + return { + ...item, + series: seriesData?.[0] || null, + episode: episodeData?.[0] || null, + }; + }), + ); + + return { + ...module, + items: itemsWithRelations, + }; + }), + ); + + // キャッシュに保存 + recommendedCache.set(referenceId, modulesWithItems); + + return modulesWithItems; +}; From 2038aca2b2f914203d40ad353c9aa4e7a8a3d295 Mon Sep 17 00:00:00 2001 From: fujii-kazumi Date: Sun, 23 Mar 2025 16:05:53 +0900 Subject: [PATCH 3/3] =?UTF-8?q?=E4=BF=AE=E6=AD=A33?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../server/migrations/0005_add_indexes.sql | 16 ++++ workspaces/server/src/api.ts | 94 ------------------- 2 files changed, 16 insertions(+), 94 deletions(-) create mode 100644 workspaces/server/migrations/0005_add_indexes.sql diff --git a/workspaces/server/migrations/0005_add_indexes.sql b/workspaces/server/migrations/0005_add_indexes.sql new file mode 100644 index 000000000..06f6f5cf9 --- /dev/null +++ b/workspaces/server/migrations/0005_add_indexes.sql @@ -0,0 +1,16 @@ +-- recommendedModuleのインデックス +CREATE INDEX idx_recommended_module_reference_id ON recommendedModule(referenceId); +CREATE INDEX idx_recommended_module_order ON recommendedModule(order); + +-- recommendedItemのインデックス +CREATE INDEX idx_recommended_item_module_id ON recommendedItem(moduleId); +CREATE INDEX idx_recommended_item_order ON recommendedItem(order); +CREATE INDEX idx_recommended_item_series_id ON recommendedItem(seriesId); +CREATE INDEX idx_recommended_item_episode_id ON recommendedItem(episodeId); + +-- episodeのインデックス +CREATE INDEX idx_episode_order ON episode(order); +CREATE INDEX idx_episode_series_id ON episode(seriesId); + +-- seriesのインデックス +CREATE INDEX idx_series_id ON series(id); \ No newline at end of file diff --git a/workspaces/server/src/api.ts b/workspaces/server/src/api.ts index 4fa2b426c..635624b1f 100644 --- a/workspaces/server/src/api.ts +++ b/workspaces/server/src/api.ts @@ -23,20 +23,6 @@ import { z } from 'zod'; import type { ZodOpenApiVersion } from 'zod-openapi'; import { getDatabase, initializeDatabase } from '@wsh-2025/server/src/drizzle/database'; -import { StandardSchemaV1 } from '@standard-schema/spec'; -import NodeCache from 'node-cache'; -import { recommendedModule, recommendedItem, series, episode } from '@wsh-2025/schema/src/database/schema'; -import { eq, and } from 'drizzle-orm'; -import { db } from './db'; -import type { InferSelectModel } from 'drizzle-orm'; - -// キャッシュの設定 -const recommendedCache = new NodeCache({ stdTTL: 300 }); // 5分間キャッシュ - -type RecommendedModule = InferSelectModel; -type RecommendedItem = InferSelectModel; -type Series = InferSelectModel; -type Episode = InferSelectModel; export async function registerApi(app: FastifyInstance): Promise { app.setValidatorCompiler(validatorCompiler); @@ -662,83 +648,3 @@ export async function registerApi(app: FastifyInstance): Promise { /* eslint-enable sort/object-properties */ } - -export const getRecommendedModules = async ( - fastify: FastifyInstance, - params: StandardSchemaV1.InferInput, -) => { - const { referenceId } = params; - const db = getDatabase(); - - // キャッシュをチェック - const cachedData = recommendedCache.get(referenceId); - if (cachedData) { - return cachedData; - } - - // モジュールを取得 - const modules = await db - .select() - .from(recommendedModule) - .where(eq(recommendedModule.referenceId, referenceId)) - .orderBy(recommendedModule.order); - - // 各モジュールのアイテムを取得 - const modulesWithItems = await Promise.all( - modules.map(async (module: RecommendedModule) => { - const items = await db - .select() - .from(recommendedItem) - .where(eq(recommendedItem.moduleId, module.id)) - .orderBy(recommendedItem.order); - - // 各アイテムの関連データを取得 - const itemsWithRelations = await Promise.all( - items.map(async (item: RecommendedItem) => { - const [seriesData, episodeData] = await Promise.all([ - item.seriesId - ? db - .select({ - id: series.id, - title: series.title, - description: series.description, - thumbnailUrl: series.thumbnailUrl, - }) - .from(series) - .where(eq(series.id, item.seriesId)) - .limit(1) - : null, - item.episodeId - ? db - .select({ - id: episode.id, - title: episode.title, - description: episode.description, - thumbnailUrl: episode.thumbnailUrl, - }) - .from(episode) - .where(eq(episode.id, item.episodeId)) - .limit(1) - : null, - ]); - - return { - ...item, - series: seriesData?.[0] || null, - episode: episodeData?.[0] || null, - }; - }), - ); - - return { - ...module, - items: itemsWithRelations, - }; - }), - ); - - // キャッシュに保存 - recommendedCache.set(referenceId, modulesWithItems); - - return modulesWithItems; -};