From 0d86d5e6b592cb0788685248ad61a8ff1e2e7d95 Mon Sep 17 00:00:00 2001 From: Kato Hiroki Date: Sun, 28 Sep 2025 08:47:13 +0000 Subject: [PATCH 01/26] =?UTF-8?q?=E2=9C=A8=20Create=20ContestTaskPair=20ta?= =?UTF-8?q?ble=20(#2627)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- prisma/ERD.md | 29 ++++++++++++------- .../migration.sql | 17 +++++++++++ prisma/schema.prisma | 19 ++++++++++-- 3 files changed, 52 insertions(+), 13 deletions(-) create mode 100644 prisma/migrations/20250928072152_create_contest_task_pair/migration.sql diff --git a/prisma/ERD.md b/prisma/ERD.md index d5f1e1ef6..57200d8d5 100644 --- a/prisma/ERD.md +++ b/prisma/ERD.md @@ -126,6 +126,16 @@ OTHERS OTHERS } + "contesttaskpair" { + String id "🗝️" + String contestId + String taskTableIndex + String taskId + DateTime createdAt + DateTime updatedAt + } + + "tag" { String id "🗝️" Boolean is_published @@ -195,28 +205,27 @@ OTHERS OTHERS } "user" o|--|| "Roles" : "enum:role" - "user" o{--}o "session" : "auth_session" - "user" o{--}o "key" : "key" - "user" o{--}o "taskanswer" : "taskAnswer" - "user" o{--}o "workbook" : "workBooks" + "user" o{--}o "session" : "" + "user" o{--}o "key" : "" + "user" o{--}o "taskanswer" : "" + "user" o{--}o "workbook" : "" "session" o|--|| "user" : "user" "key" o|--|| "user" : "user" "task" o|--|| "ContestType" : "enum:contest_type" "task" o|--|| "TaskGrade" : "enum:grade" "task" o|--|| "AtcoderProblemsDifficulty" : "enum:atcoder_problems_difficulty" - "task" o{--}o "tasktag" : "tags" - "task" o{--}o "taskanswer" : "task_answers" - "task" o{--}o "workbooktask" : "workBookTasks" - "tag" o{--}o "tasktag" : "tasks" + "task" o{--}o "tasktag" : "" + "task" o{--}o "taskanswer" : "" + "task" o{--}o "workbooktask" : "" + "tag" o{--}o "tasktag" : "" "tasktag" o|--|o "task" : "task" "tasktag" o|--|o "tag" : "tag" "taskanswer" o|--|o "task" : "task" "taskanswer" o|--|o "user" : "user" "taskanswer" o|--|o "submissionstatus" : "status" - "submissionstatus" o{--}o "taskanswer" : "task_answer" "workbook" o|--|| "WorkBookType" : "enum:workBookType" "workbook" o|--|| "user" : "user" - "workbook" o{--}o "workbooktask" : "workBookTasks" + "workbook" o{--}o "workbooktask" : "" "workbooktask" o|--|| "workbook" : "workBook" "workbooktask" o|--|| "task" : "task" ``` diff --git a/prisma/migrations/20250928072152_create_contest_task_pair/migration.sql b/prisma/migrations/20250928072152_create_contest_task_pair/migration.sql new file mode 100644 index 000000000..b20234887 --- /dev/null +++ b/prisma/migrations/20250928072152_create_contest_task_pair/migration.sql @@ -0,0 +1,17 @@ +-- CreateTable +CREATE TABLE "public"."contesttaskpair" ( + "id" TEXT NOT NULL, + "contestId" TEXT NOT NULL, + "taskTableIndex" TEXT NOT NULL, + "taskId" TEXT NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "contesttaskpair_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE INDEX "contesttaskpair_contestId_idx" ON "public"."contesttaskpair"("contestId"); + +-- CreateIndex +CREATE UNIQUE INDEX "contesttaskpair_contestId_taskId_key" ON "public"."contesttaskpair"("contestId", "taskId"); diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 0b910c6ad..56a68ec95 100755 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -5,9 +5,8 @@ // https://www.prisma.io/docs/concepts/components/prisma-schema/generators // https://www.prisma.io/docs/orm/prisma-client/deployment/edge/deploy-to-vercel generator client { - provider = "prisma-client-js" - binaryTargets = ["native", "rhel-openssl-3.0.x"] - previewFeatures = ["driverAdapters"] + provider = "prisma-client-js" + binaryTargets = ["native", "rhel-openssl-3.0.x"] } // See: @@ -109,6 +108,20 @@ model Task { @@map("task") } +// Handling cases where the same problem is used in different contests. +model ContestTaskPair { + id String @id @default(uuid()) + contestId String + taskTableIndex String + taskId String + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@unique([contestId, taskId]) // Prevent duplicate pairs of (contestId, taskId) + @@index([contestId]) // Add index on contestId to speed up queries filtering by contestId + @@map("contesttaskpair") +} + model Tag { id String @id @unique is_published Boolean @default(false) From 12c4bf480f0920feb66bb797380e4b365c3c62cc Mon Sep 17 00:00:00 2001 From: Kato Hiroki Date: Sun, 28 Sep 2025 08:48:00 +0000 Subject: [PATCH 02/26] =?UTF-8?q?=E2=9C=A8=20Add=20ContestTaskPair=20type?= =?UTF-8?q?=20(#2627)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/lib/types/contest_task_pair.ts | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 src/lib/types/contest_task_pair.ts diff --git a/src/lib/types/contest_task_pair.ts b/src/lib/types/contest_task_pair.ts new file mode 100644 index 000000000..60aa3c22e --- /dev/null +++ b/src/lib/types/contest_task_pair.ts @@ -0,0 +1,5 @@ +import type { ContestTaskPair as ContestTaskPairOrigin } from '@prisma/client'; + +export type ContestTaskPair = ContestTaskPairOrigin; + +export type ContestTaskPairs = ContestTaskPair[]; From 858761171777872228814448352945cf3c73c8d0 Mon Sep 17 00:00:00 2001 From: Kato Hiroki Date: Sun, 28 Sep 2025 08:52:22 +0000 Subject: [PATCH 03/26] :books: Add migration plans (#2627) --- .../contest-task-mapping/initial_plan.md | 237 ++++++ .../contest-task-pair-mapping/plan.md | 726 ++++++++++++++++++ 2 files changed, 963 insertions(+) create mode 100644 docs/dev-notes/2025-09-17/contest-task-mapping/initial_plan.md create mode 100644 docs/dev-notes/2025-09-23/contest-task-pair-mapping/plan.md diff --git a/docs/dev-notes/2025-09-17/contest-task-mapping/initial_plan.md b/docs/dev-notes/2025-09-17/contest-task-mapping/initial_plan.md new file mode 100644 index 000000000..96b4c6ce2 --- /dev/null +++ b/docs/dev-notes/2025-09-17/contest-task-mapping/initial_plan.md @@ -0,0 +1,237 @@ +# Contest-Problem Mapping Extension Plan + +## 概要 + +既存のTask テーブルのunique制約を維持しながら、複数コンテストで同一問題を扱うための拡張方針を検討。AtCoder Problems の実装を参考に、段階的な移行戦略を策定。 + +## 現状の課題 + +### 課題1: Task識別の制限 + +- **問題**: `task_id` が同じでも `contest_id` が異なるケースが多数存在 +- **制約**: Task テーブルの `task_id` unique制約は必ず維持する必要がある +- **影響**: AtCoder Problems API の Problems API で登録済みの (task_id, contest_id) の問題のみ処理可能 + +### 課題2: 影響範囲の調査 + +- 数万行のTypeScript・Svelteコード +- `map` 形式の処理が広範囲に存在 +- e2eテストがほぼ実装されていないので、手動での確認が必要 + +## 解決方針 + +### 基本アプローチ + +AtCoder Problems API を活用: + +- 問題情報: 同一の `task_id` が存在する場合は、どれか一つのコンテストに割り当て + - `https://kenkoooo.com/atcoder/resources/problems.json` +- コンテストと問題idの対応関係を網羅 + - `https://kenkoooo.com/atcoder/resources/contest-problem.json` + +### 技術的解決策 + +#### 1. 複合キーによる識別 + +**方針**: `map, TaskResult>` への移行 + +```typescript +// 型定義 +type TaskKey = `${string}:${string}`; // "contest_id:task_id" + +// ヘルパー関数 +function createTaskKey(contestId: string, taskId: string): TaskKey { + return `${contestId}:${taskId}`; +} + +// マップの型 +type TaskResultMap = Map; +``` + +**利点**: + +- 文字列連結は一般的で高速 +- TypeScriptのTuple型も利用可能だが、文字列の方が実用的 +- O(1)での参照が可能 + +#### 2. データマイグレーション戦略 + +```typescript +// 既存データの移行 +function migrateExistingData(oldMap: Map): Map { + const newMap = new Map(); + + for (const [taskId, result] of oldMap) { + const primaryContestId = result.contest_id || 'primary'; + const key = createTaskKey(primaryContestId, taskId); + newMap.set(key, result); + } + + return newMap; +} + +// 互換性レイヤー +export class TaskMapAdapter { + constructor( + private enhanced: EnhancedTaskMap, + private legacy: LegacyTaskMap, + ) {} + + get(taskId: string, contestId?: string): TaskResult | undefined { + if (contestId) { + return this.enhanced.get(createTaskKey(contestId, taskId)); + } + return this.legacy.get(taskId); + } +} +``` + +#### 3. インポート処理の分離 + +**方針**: 問題データインポートとマッピングデータインポートを別ページで処理 + +```typescript +interface ImportOptions { + includeContestMapping?: boolean; // contest-problem.json を使用 + onlyProblems?: boolean; // problems.json のみ使用 +} + +// 問題データインポート(既存制約を守る) +async function importTaskData(options: ImportOptions = {}) { + if (options.onlyProblems !== false) { + await importFromProblemsJson(); + } + + if (options.includeContestMapping) { + await importContestProblemMapping(); + } +} +``` + +**分離理由**: + +- 責務の分離(異なるAPIエンドポイント) +- エラーハンドリングの違い +- 将来のコンテストサイト固有設定への対応 + +## 実装計画 + +### Phase 1: 基盤整備 + +- [ ] 新しい型定義の作成 +- [ ] ユーティリティ関数の実装 +- [ ] 互換性レイヤーの作成 + +### Phase 2: 影響範囲調査 + +```bash +# 調査用コマンド +grep -r "taskId" src/ --include="*.ts" --include="*.svelte" +grep -r "Map; + previewMappingChanges(): Promise; + validateMappingData(): Promise; +} + +interface ImportFilter { + contestIds?: string[]; + taskIds?: string[]; + dateRange?: { from: Date; to: Date }; +} +``` + +**利点**: + +- 長期的な保守性 +- エラーハンドリングの容易さ +- 他コンテストサイト対応への拡張性 + +### ディレクトリ構成案 + +``` +src/routes/admin/import/ +├── problems/ # 問題データインポート +│ ├── +page.svelte +│ └── types.ts +├── mapping/ # コンテスト-問題マッピング +│ ├── +page.svelte +│ ├── bulk/ +│ └── selective/ +└── shared/ # 共通コンポーネント + ├── ImportStatus.svelte + └── ValidationResults.svelte +``` + +## 今後の課題 + +### 短期 + +- [ ] 影響範囲の詳細調査 +- [ ] プロトタイプの作成 +- [ ] 基本的なe2eテストの追加 + +### 中期 + +- [ ] 他コンテストサイトAPI対応 +- [ ] APIレート制限への対応 +- [ ] UI/UXの改善 + +### 長期 + +- [ ] 完全自動化システム +- [ ] 包括的なテストスイート +- [ ] パフォーマンス最適化 + +## リスク要因と対策 + +| リスク | 影響度 | 対策 | +| -------------------- | ------ | -------------------- | +| 既存機能の破綻 | 高 | 互換性レイヤーの実装 | +| データ不整合 | 中 | バリデーション強化 | +| パフォーマンス劣化 | 中 | 段階的な最適化 | +| メンテナンスコスト増 | 中 | ドキュメント整備 | + +## 決定事項 + +1. **Task テーブルのunique制約は維持** +2. **複合キー (`contest_id:task_id`) による識別** +3. **段階的移行による安全な実装** +4. **インポート機能の責務分離** +5. **Web UI による管理画面実装** + +## 参考リンク + +- [API client in AtCoder Problems](https://github.com/kenkoooo/AtCoderProblems/blob/master/atcoder-problems-frontend/src/api/APIClient.ts#L239) +- [Problems API](https://kenkoooo.com/atcoder/resources/problems.json) +- [Contest-Problem API](https://kenkoooo.com/atcoder/resources/contest-problem.json) + +--- + +**作成日**: 2025-09-17 +**最終更新**: 2025-09-17 +**ステータス**: 計画段階 diff --git a/docs/dev-notes/2025-09-23/contest-task-pair-mapping/plan.md b/docs/dev-notes/2025-09-23/contest-task-pair-mapping/plan.md new file mode 100644 index 000000000..1016122fc --- /dev/null +++ b/docs/dev-notes/2025-09-23/contest-task-pair-mapping/plan.md @@ -0,0 +1,726 @@ +# Contest-Task Pair Mapping 実装計画 + +## 概要 + +既存の Task テーブルの unique 制約を維持しながら、複数コンテストで同一問題を扱うための拡張実装計画。[前回計画](../../2025-09-17/contest-task-mapping/initial_plan.md)をベースに、互換性レイヤを用いた段階的移行戦略を詳細化。 + +## 討議内容の要約 + +### 1. 命名の決定 + +- **モデル名**: `ContestTaskPair` (「ペア」であることを明示) +- **型名**: `ContestTaskPairKey` (Prisma モデルとの衝突回避) +- **マップ型**: `TaskResultMapByContestTaskPair` (キーと値の関係を明示) + +### 2. データベース設計 + +#### Prisma スキーマ追加 + +```prisma +model ContestTaskPair { + id String @id @default(uuid()) + contestId String + taskTableIndex String + taskId String + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@unique([contestId, taskId]) // ペアの重複を防止 + @@index([contestId]) // contestId 検索を高速化 + @@map("contesttaskpair") +} +``` + +**設計判断**: + +- id 属性追加: 将来のリレーション対応・一貫性のため +- 複合一意制約: ペアレベルでの重複防止 +- インデックス: 後から追加可能だが contestId 検索の高速化 + +#### マイグレーション + +```bash +# 開発環境 +pnpm dlx prisma migrate dev --name create_contest_task_pair +``` + +#### CRUD メソッドの実装 + +**ファイル**: `src/lib/services/contest_task_pairs.ts` + +```typescript +import { default as db } from '$lib/server/database'; +import type { + ContestTaskPair, + ContestTaskPairs, + ContestTaskPairCreate, + ContestTaskPairUpdate, +} from '$lib/types/contest_task_pair'; + +/** + * 単一のContestTaskPairを取得 + */ +export async function getContestTaskPair( + contestId: string, + taskId: string, +): Promise { + const contestTaskPair = await db.contestTaskPair.findUnique({ + where: { + contestId_taskId: { + contestId, + taskId, + }, + }, + }); + + return contestTaskPair; +} + +/** + * ContestTaskPair の一覧を取得 + */ +export async function getContestTaskPairs(): Promise { + return await db.contestTaskPair.findMany(); +} + +/** + * ContestTaskPair の新規レコードを作成 + */ +export async function createContestTaskPair( + contestId: string, + taskTableIndex: string, + taskId: string, +): Promise { + try { + // 既存レコードの確認 + const existingRecord = await getContestTaskPair(contestId, taskId); + + if (existingRecord) { + console.log(`ContestTaskPair already exists: contestId=${contestId}, taskId=${taskId}`); + } + + // 新規レコード作成 + const contestTaskPair = await db.contestTaskPair.create({ + data: { + contestId, + taskTableIndex, + taskId, + }, + }); + + console.log('Created ContestTaskPair:', contestTaskPair); + } catch (error) { + console.error('Error creating ContestTaskPair:', error); + throw error; + } +} + +/** + * ContestTaskPair のレコードを更新 + */ +export async function updateContestTaskPair( + contestId: string, + taskTableIndex: string, + taskId: string, +): Promise { + try { + // 既存レコードの確認 + const existingRecord = await getContestTaskPair(contestId, taskId); + + if (!existingRecord) { + const errorMessage = `ContestTaskPair not found: contestId=${contestId}, taskId=${taskId}`; + console.log(errorMessage); + throw new Error(errorMessage); + } + + // レコード更新 + const updatedContestTaskPair = await db.contestTaskPair.update({ + where: { + contestId_taskId: { + contestId, + taskId, + }, + }, + data: { + contestId, + taskTableIndex, + taskId, + }, + }); + + console.log('Updated ContestTaskPair:', updatedContestTaskPair); + } catch (error) { + console.error('Error updating ContestTaskPair:', error); + throw error; + } +} +``` + +**特徴**: + +- DRY原則に従い `getContestTaskPair()` を共通メソッドとして切り出し +- 複合ユニーク制約 `@@unique([contestId, taskId])` を活用 +- 重複チェック・存在確認を事前に実施し、適切なログ出力 +- Prisma の自動生成型を使用して型安全性を確保 + +### 3. 型定義の更新 + +#### Prisma型に基づく型定義 + +**ファイル**: `src/lib/types/contest_task_pair.ts` + +```typescript +import type { ContestTaskPair as ContestTaskPairOrigin } from '@prisma/client'; + +export type ContestTaskPair = ContestTaskPairOrigin; + +// Prismaの自動生成型をベースとした配列型 +export type ContestTaskPairs = ContestTaskPair[]; + +// CRUD操作用のパラメータ型 +export type ContestTaskPairCreate = { + contestId: string; + taskTableIndex: string; + taskId: string; +}; + +export type ContestTaskPairUpdate = ContestTaskPairCreate; +``` + +#### レガシー型定義(マッピング用) + +```typescript +// 型定義 +type ContestTaskPairKey = `${string}:${string}`; // "contest_id:task_id" + +// ヘルパー関数 +function createContestTaskPairKey(contestId: string, taskId: string): ContestTaskPairKey { + return `${contestId}:${taskId}`; +} + +// マップの型(明示的) +type TaskResultMapByContestTaskPair = Map; +``` + +**設計判断**: + +- Prisma の自動生成型を活用することで型安全性を確保 +- クライアントサイドでも Prisma 型を使用可能(型定義のみでランタイムコード不要) +- CRUD パラメータ型を定義して、メソッド引数の型安全性を向上 + +### 4. 互換性レイヤ(TaskResultMapAdapter) + +**目的**: 既存コードを壊さずに段階的に新形式へ移行 + +```typescript +class TaskResultMapAdapter { + constructor( + private legacyMap?: Map, // 既存: taskId -> value + private enhancedMap?: Map, // 新形式: contestId:taskId -> value + ) {} + + private makeKey(contestId: string, taskId: string): ContestTaskPairKey { + return `${contestId}:${taskId}`; + } + + has(taskId: string, contestId?: string): boolean { + // enhanced を優先、なければ legacy にフォールバック + if (contestId && this.enhancedMap) { + return this.enhancedMap.has(this.makeKey(contestId, taskId)); + } + if (this.legacyMap && this.legacyMap.has(taskId)) return true; + if (this.enhancedMap) { + for (const key of this.enhancedMap.keys()) { + if (key.endsWith(`:${taskId}`)) return true; + } + } + return false; + } + + get(taskId: string, contestId?: string): T | undefined { + if (contestId && this.enhancedMap) { + const v = this.enhancedMap.get(this.makeKey(contestId, taskId)); + if (v !== undefined) return v; + } + if (this.legacyMap && this.legacyMap.has(taskId)) { + return this.legacyMap.get(taskId); + } + if (this.enhancedMap) { + for (const [key, value] of this.enhancedMap) { + if (key.endsWith(`:${taskId}`)) return value; + } + } + return undefined; + } + + // write-through を採用(移行期間の整合性確保) + set(taskId: string, value: T, contestId?: string): void { + if (this.legacyMap) this.legacyMap.set(taskId, value); + if (contestId && this.enhancedMap) { + this.enhancedMap.set(this.makeKey(contestId, taskId), value); + } + } + + delete(taskId: string, contestId?: string): boolean { + let ok = false; + if (contestId && this.enhancedMap) { + ok = this.enhancedMap.delete(this.makeKey(contestId, taskId)) || ok; + } + if (this.legacyMap) { + ok = this.legacyMap.delete(taskId) || ok; + } + return ok; + } +} +``` + +### 5. 主要な変更箇所 + +#### src/lib/utils/task_results.ts への追加(新規ファイル) + +```typescript +// TaskMapAdapter の実装 +type ContestTaskPairKey = `${string}:${string}`; +type TaskResultByContestTaskPair = Map; + +export class TaskMapAdapter { + constructor( + private legacyMap?: Map, + private enhancedMap?: TaskResultByContestTaskPair, + ) { + // 型チェック・assert + console.assert(legacyMap || enhancedMap, 'At least one map must be provided'); + + if (process.env.NODE_ENV === 'development') { + console.log( + `TaskMapAdapter initialized: legacy=${legacyMap?.size ?? 0}, enhanced=${enhancedMap?.size ?? 0}`, + ); + } + } + + get(taskId: string, contestId?: string): T | undefined { + console.assert( + typeof taskId === 'string' && taskId.length > 0, + 'taskId must be non-empty string', + ); + + const start = performance.now(); + + if (contestId && this.enhancedMap) { + const key = this.makeKey(contestId, taskId); + const result = this.enhancedMap.get(key); + if (result !== undefined) { + if (process.env.NODE_ENV === 'development') { + console.log( + `TaskMapAdapter.get: enhanced hit (${(performance.now() - start).toFixed(2)}ms)`, + ); + } + return result; + } + } + + if (this.legacyMap && this.legacyMap.has(taskId)) { + const result = this.legacyMap.get(taskId); + if (process.env.NODE_ENV === 'development') { + console.log(`TaskMapAdapter.get: legacy hit (${(performance.now() - start).toFixed(2)}ms)`); + } + return result; + } + + // enhanced スキャン(移行期間のみ) + if (this.enhancedMap) { + for (const [key, value] of this.enhancedMap) { + if (key.endsWith(`:${taskId}`)) { + if (process.env.NODE_ENV === 'development') { + console.log( + `TaskMapAdapter.get: enhanced scan hit (${(performance.now() - start).toFixed(2)}ms)`, + ); + } + return value; + } + } + } + + return undefined; + } + + private makeKey(contestId: string, taskId: string): ContestTaskPairKey { + console.assert( + typeof contestId === 'string' && contestId.length > 0, + 'contestId must be non-empty string', + ); + console.assert( + typeof taskId === 'string' && taskId.length > 0, + 'taskId must be non-empty string', + ); + + return `${contestId}:${taskId}` as ContestTaskPairKey; + } + + // 他のメソッド (has, set, delete) も同様に実装 +} + +export function createContestTaskPairKey(contestId: string, taskId: string): ContestTaskPairKey { + return `${contestId}:${taskId}`; +} +``` + +#### src/lib/services/tasks.ts への追加 + +```typescript +// コンテスト-タスクのマッピングを取得 +export async function getContestTaskPairs(): Promise> { + const rows = await db.contestTaskPair.findMany({ + select: { contestId: true, taskId: true }, + }); + + const map = new Map(); + + for (const r of rows) { + const arr = m.get(r.taskId) ?? []; + arr.push(r.contestId); + map.set(r.taskId, arr); + } + + return map; +} + +// 既存の getTasks() を wrap +export async function getMergedTasks() { + const tasks = await getTasks(); + const contestTaskPairs = await getContestTaskPairs().catch(() => new Map()); + + // contestId:taskId -> Task のマップ(TaskResult 作成で直接参照しやすい) + const contestTaskPairMap = new Map(); + for (const t of tasks) { + const contests = contestTaskPairs.get(t.task_id) ?? [t.contest_id]; + for (const c of contests) { + contestTaskPairMap.set(`${c}:${t.task_id}`, t); + } + } + + return { tasks, contestTaskPairs, contestTaskPairMap }; +} +``` + +#### src/lib/services/task_results.ts の変更 + +```typescript +import { TaskMapAdapter } from '$lib/utils/task_results'; +import { getMergedTasks } from '$lib/services/tasks'; + +// getTaskResults にFeature Flag対応を追加 +export async function getTaskResults(userId: string): Promise { + const enableContestTaskPair = process.env.ENABLE_CONTEST_TASK_PAIR === 'true'; + + if (enableContestTaskPair) { + console.time('getTaskResults-enhanced'); + console.log(`Processing enhanced mode for user ${userId}`); + + const { tasks, contestTaskPairs } = await getMergedTasks(); + const answers = await answer_crud.getAnswers(userId); + + // enhancedMap を構築 + const enhancedMap: TaskResultByContestTaskPair = new Map(); + for (const [taskId, answer] of answers) { + const contests = contestTaskPairs.get(taskId) ?? [answer.contest_id ?? '']; + for (const contestId of contests) { + if (!contestId) continue; + enhancedMap.set(`${contestId}:${taskId}`, answer); + } + } + + const results = await relateTasksAndAnswers(userId, tasks, answers, enhancedMap); + + console.log(`Generated ${results.length} task results in enhanced mode`); + console.timeEnd('getTaskResults-enhanced'); + return results; + } else { + // 既存の処理 + console.time('getTaskResults-legacy'); + const tasks = await getTasks(); + const answers = await answer_crud.getAnswers(userId); + const results = await relateTasksAndAnswers(userId, tasks, answers); + + console.log(`Generated ${results.length} task results in legacy mode`); + console.timeEnd('getTaskResults-legacy'); + return results; + } +} + +// relateTasksAndAnswers のシグネチャ変更(enhancedMap をオプション引数に) +async function relateTasksAndAnswers( + userId: string, + tasks: Tasks, + answers: Map, + enhancedMap?: TaskResultByContestTaskPair, +): Promise { + const isLoggedIn = userId !== undefined; + const adapter = new TaskMapAdapter(answers, enhancedMap); + + const taskResults = tasks.map((task: Task) => { + const taskResult = createDefaultTaskResult(userId, task); + + if (isLoggedIn && adapter.has(task.task_id, task.contest_id)) { + const answer = adapter.get(task.task_id, task.contest_id); + const status = statusById.get(answer?.status_id); + taskResult.status_name = status.status_name; + taskResult.submission_status_image_path = status.image_path; + taskResult.submission_status_label_name = status.label_name; + taskResult.is_ac = status.is_ac; + taskResult.updated_at = answer?.updated_at ?? taskResult.updated_at; + } + + return taskResult; + }); + + return taskResults; +} +``` + +### 6. 移行戦略 + +#### Phase 1: 基盤整備 + +- [✅] Prisma スキーマに ContestTaskPair モデル追加 +- [✅] マイグレーション実行 +- [ ] CURD メソッドを追加 +- [ ] TypeScript 型定義追加 + +#### Phase 2: 互換性レイヤ導入 + +- [ ] TaskMapAdapter 実装・テスト +- [ ] getMergedTasks() 実装 +- [ ] src/lib/services/task_results.ts への適用 + +#### Phase 3: 段階的移行 + +- [ ] 他の影響箇所の特定・修正 +- [ ] Feature flag による切替制御 +- [ ] ログ・モニタリング追加 + +#### Phase 4: 完全移行 + +- [ ] Legacy マップの削除 +- [ ] アダプタの簡素化 +- [ ] パフォーマンス最適化 + +### 7. テスト計画 + +#### TaskMapAdapter の単体テスト + +```typescript +// filepath: src/lib/utils/task_results.test.ts +import { describe, test, expect } from 'vitest'; +import { TaskMapAdapter, createContestTaskPairKey } from './task_results'; + +describe('TaskMapAdapter', () => { + test('expect to prioritize enhanced map when contestId is provided', () => { + const legacy = new Map([['task1', { id: 'legacy' }]]); + const enhanced = new Map([['contest1:task1', { id: 'enhanced' }]]); + const adapter = new TaskMapAdapter(legacy, enhanced); + + expect(adapter.get('task1', 'contest1')?.id).toBe('enhanced'); + expect(adapter.get('task1')?.id).toBe('legacy'); + }); + + test('expect to fallback to legacy when enhanced not found', () => { + const legacy = new Map([['task1', { id: 'legacy' }]]); + const enhanced = new Map(); + const adapter = new TaskMapAdapter(legacy, enhanced); + + expect(adapter.get('task1', 'contest1')?.id).toBe('legacy'); + }); + + test('expect to handle write-through correctly', () => { + const legacy = new Map(); + const enhanced = new Map(); + const adapter = new TaskMapAdapter(legacy, enhanced); + + adapter.set('task1', { id: 'value' }, 'contest1'); + expect(legacy.get('task1')?.id).toBe('value'); + expect(enhanced.get('contest1:task1')?.id).toBe('value'); + }); + + test('expect to scan enhanced map when no contestId provided', () => { + const legacy = new Map(); + const enhanced = new Map([['contest1:task1', { id: 'found' }]]); + const adapter = new TaskMapAdapter(legacy, enhanced); + + expect(adapter.get('task1')?.id).toBe('found'); + expect(adapter.has('task1')).toBe(true); + }); + + test('expect to validate input parameters', () => { + const adapter = new TaskMapAdapter(new Map(), new Map()); + + // TypeScript strict モードでの型安全性を確保 + expect(() => adapter.get('')).toThrow(); + expect(() => adapter.get('task1', '')).toThrow(); + }); +}); + +describe('createContestTaskPairKey', () => { + test('expect to create valid key format', () => { + expect(createContestTaskPairKey('abc001', 'abc001_a')).toBe('abc001:abc001_a'); + }); + + test('expect to validate input parameters', () => { + expect(() => createContestTaskPairKey('', 'task')).toThrow(); + expect(() => createContestTaskPairKey('contest', '')).toThrow(); + }); +}); +``` + +#### 既存テストとの互換性 + +- **場所**: `src/test/` の既存単体テスト +- **対応**: Feature Flag が false の場合は既存動作を維持 +- **確認事項**: TaskMapAdapter 導入後も全既存テストがパスすること### 8. パフォーマンス・監視計画 + +#### 開発環境での監視 + +```typescript +// console.log ベースの監視(開発環境のみ) +export async function getTaskResults(userId: string): Promise { + if (process.env.NODE_ENV === 'development') { + console.time('getTaskResults'); + console.log(`Processing ${answers.size} answers for user ${userId}`); + } + + const results = await getTaskResults(userId); + + if (process.env.NODE_ENV === 'development') { + console.log(`Generated ${results.length} task results`); + console.timeEnd('getTaskResults'); + + // メモリ使用量確認 + const memory = process.memoryUsage(); + console.log(`Memory usage: ${Math.round(memory.heapUsed / 1024 / 1024)}MB`); + } + + return results; +} +``` + +#### Staging・本番環境での監視 + +- **ログ確認**: Vercel管理者画面 +- **データ内容**: 本番データのサブセット(staging環境) +- **メトリクス**: 処理時間・データ数・エラー率を最小限ログ出力 + +#### APM導入について + +- **Sentry/DataDog**: 導入コストが高いため当面見送り +- **代替手段**: console.log + Vercelログでの監視 + +### 9. 環境設定・Feature Flag + +#### 環境変数設定 + +```bash +# .env または .env.local +ENABLE_CONTEST_TASK_PAIR=false # 開発段階では false +``` + +#### Feature Flag の適用範囲 + +- **対象関数**: `getTaskResults()` のみ(段階1) +- **切り替え**: 環境変数による制御 +- **既存動作**: Flag が false の場合は完全に既存処理を維持 + +#### データインポート方法 + +```typescript +// prisma/seed.ts での ContestTaskPair データ追加例 +async function seedContestTaskPairs() { + const contestTaskPairs = [ + { contestId: 'abc001', taskTableIndex: 'A', taskId: 'abc001_a' }, + { contestId: 'abc001', taskTableIndex: 'B', taskId: 'abc001_b' }, + // ... more data + ]; + + for (const pair of contestTaskPairs) { + await prisma.contestTaskPair.upsert({ + where: { + contestId_taskId: { + contestId: pair.contestId, + taskId: pair.taskId, + }, + }, + update: { taskTableIndex: pair.taskTableIndex }, + create: pair, + }); + } +} +``` + +## 追加討議内容の要約 + +### 実装の詳細決定事項 + +1. **ファイル構成**: `src/lib/utils/task_results.ts` に TaskMapAdapter を配置(CRUD分離・テスト容易性・移行後削除容易性) + +2. **デプロイフロー**: 開発 → GitHub Actions → staging環境(Vercel) → 本番環境での段階的検証 + +3. **テストケース**: + - `test` を使用(`it` ではなく) + - `expect to` 表現に統一(`should` を避ける) + - 既存テスト(`src/test/`)との互換性確保 + - TypeScript strict モードでの型安全性とassert追加 + +4. **パフォーマンス監視**: + - Sentry/DataDog は導入見送り + - console.log + Vercelログでの監視 + - 開発環境でのみ詳細ログ出力 + +5. **Feature Flag**: + - 環境変数(.env)での制御 + - 適用範囲は `getTaskResults()` のみ(段階1) + +6. **データインポート**: + - 第1段階: seed.ts での手動データ投入 + - 第2段階: 管理者画面での自動インポート機能 + +7. **インフラ環境**: + - ソースコード管理: Git/GitHub + - データベース: Supabase(ロールバック機能あり) + - staging環境: 本番データのサブセット使用 + +## 3. インポート処理の分離 + +**注意**: この部分は基本機能実装・動作確認後に改めて討議予定。 + +- 第1段階: seed.ts や CSV でのデータインポート優先 +- 第2段階: 管理者画面を通したインポート機能実装 +- AtCoder Problems API からのデータ取得・変換 +- 既存 Task データとの整合性確認 + +## リスク要因と対策 + +| リスク | 影響度 | 対策 | +| ------------------------ | ------ | ------------------------------------------------------ | +| 移行期間中のデータ不整合 | 高 | write-through による二重書き込み + Feature Flag制御 | +| 既存機能の破綻 | 高 | 互換性レイヤー + 既存テストでの継続検証 | +| パフォーマンス劣化 | 中 | enhanced スキャン処理を移行期間中に限定 + 監視強化 | +| テストカバレッジ不足 | 中 | TaskMapAdapter の単体テスト + TypeScript strict モード | +| ロールバックの複雑さ | 低 | Supabaseのロールバック機能 + Feature Flag即座切替 | + +## 決定事項 + +1. **モデル名**: ContestTaskPair +2. **型名**: ContestTaskPairKey, TaskResultByContestTaskPair +3. **移行方針**: TaskMapAdapter による段階的移行 +4. **アダプタ配置**: `src/lib/utils/task_results.ts`(影響局所化・テスト容易性・削除容易性) +5. **id 属性**: 将来性を考慮して追加 +6. **Feature Flag**: 環境変数制御、getTaskResults()のみ適用(段階1) +7. **テスト方針**: `test` + `expect to` + TypeScript strict + assert追加 +8. **監視手法**: console.log + Vercelログ(APM導入見送り) +9. **データ投入**: seed.ts優先 → 管理者画面は第2段階 + +--- + +**作成日**: 2025-09-23 +**更新日**: 2025-09-27 +**ベース**: [initial_plan.md (2025-09-17)](../../2025-09-17/contest-task-mapping/initial_plan.md) +**ステータス**: 実装準備段階 From 930f11c9270c5a922886543b574fc9cb9d7cc993 Mon Sep 17 00:00:00 2001 From: Kato Hiroki Date: Sun, 28 Sep 2025 09:15:22 +0000 Subject: [PATCH 04/26] :pencil2: Fix typo (#2627) --- docs/dev-notes/2025-09-23/contest-task-pair-mapping/plan.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/docs/dev-notes/2025-09-23/contest-task-pair-mapping/plan.md b/docs/dev-notes/2025-09-23/contest-task-pair-mapping/plan.md index 1016122fc..f5bd0d39e 100644 --- a/docs/dev-notes/2025-09-23/contest-task-pair-mapping/plan.md +++ b/docs/dev-notes/2025-09-23/contest-task-pair-mapping/plan.md @@ -97,6 +97,7 @@ export async function createContestTaskPair( if (existingRecord) { console.log(`ContestTaskPair already exists: contestId=${contestId}, taskId=${taskId}`); + return } // 新規レコード作成 @@ -377,7 +378,7 @@ export async function getContestTaskPairs(): Promise> { const map = new Map(); for (const r of rows) { - const arr = m.get(r.taskId) ?? []; + const arr = map.get(r.taskId) ?? []; arr.push(r.contestId); map.set(r.taskId, arr); } From 26676ffcf03cc1395d7d6a8e758fe4c36d4a314c Mon Sep 17 00:00:00 2001 From: Kato Hiroki Date: Sun, 28 Sep 2025 09:39:04 +0000 Subject: [PATCH 05/26] :books: Add validation to helper method (#2627) --- .../contest-task-pair-mapping/plan.md | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/docs/dev-notes/2025-09-23/contest-task-pair-mapping/plan.md b/docs/dev-notes/2025-09-23/contest-task-pair-mapping/plan.md index f5bd0d39e..34ef99b3e 100644 --- a/docs/dev-notes/2025-09-23/contest-task-pair-mapping/plan.md +++ b/docs/dev-notes/2025-09-23/contest-task-pair-mapping/plan.md @@ -361,7 +361,22 @@ export class TaskMapAdapter { // 他のメソッド (has, set, delete) も同様に実装 } +/** + * Creates a unique key for a ContestTaskPair using contestId and taskId. + * Throws an error if either argument is an empty string. + * + * @param contestId - The ID of the contest. + * @param taskId - The ID of the task. + * @returns A string in the format "contestId:taskId". + * @throws Will throw an error if contestId or taskId is empty. + */ export function createContestTaskPairKey(contestId: string, taskId: string): ContestTaskPairKey { + if (!contestId || contestId.trim() === '') { + throw new Error('contestId must be a non-empty string'); + } + if (!taskId || taskId.trim() === '') { + throw new Error('taskId must be a non-empty string'); + return `${contestId}:${taskId}`; } ``` @@ -569,6 +584,8 @@ describe('createContestTaskPairKey', () => { test('expect to validate input parameters', () => { expect(() => createContestTaskPairKey('', 'task')).toThrow(); expect(() => createContestTaskPairKey('contest', '')).toThrow(); + expect(() => createContestTaskPairKey(' ', 'task')).toThrow(); + expect(() => createContestTaskPairKey('contest', ' ')).toThrow(); }); }); ``` From f7485e5342134ee1da3c74c9fec2bd5d61014959 Mon Sep 17 00:00:00 2001 From: Kato Hiroki Date: Sun, 28 Sep 2025 09:45:45 +0000 Subject: [PATCH 06/26] :chore: Fix lint error (#2627) --- docs/dev-notes/2025-09-23/contest-task-pair-mapping/plan.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/dev-notes/2025-09-23/contest-task-pair-mapping/plan.md b/docs/dev-notes/2025-09-23/contest-task-pair-mapping/plan.md index 34ef99b3e..462fce864 100644 --- a/docs/dev-notes/2025-09-23/contest-task-pair-mapping/plan.md +++ b/docs/dev-notes/2025-09-23/contest-task-pair-mapping/plan.md @@ -97,7 +97,7 @@ export async function createContestTaskPair( if (existingRecord) { console.log(`ContestTaskPair already exists: contestId=${contestId}, taskId=${taskId}`); - return + return; } // 新規レコード作成 From 5daa1bfa6f35ccd2913e9abac310a4d0b4963908 Mon Sep 17 00:00:00 2001 From: Kato Hiroki Date: Sun, 28 Sep 2025 09:48:23 +0000 Subject: [PATCH 07/26] :pencil2: Fix typo (#2627) --- docs/dev-notes/2025-09-23/contest-task-pair-mapping/plan.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/dev-notes/2025-09-23/contest-task-pair-mapping/plan.md b/docs/dev-notes/2025-09-23/contest-task-pair-mapping/plan.md index 462fce864..a86536007 100644 --- a/docs/dev-notes/2025-09-23/contest-task-pair-mapping/plan.md +++ b/docs/dev-notes/2025-09-23/contest-task-pair-mapping/plan.md @@ -376,6 +376,7 @@ export function createContestTaskPairKey(contestId: string, taskId: string): Con } if (!taskId || taskId.trim() === '') { throw new Error('taskId must be a non-empty string'); + } return `${contestId}:${taskId}`; } From 676c0486c9843429c92145a473483367de89e128 Mon Sep 17 00:00:00 2001 From: Kato Hiroki Date: Wed, 15 Oct 2025 09:01:20 +0000 Subject: [PATCH 08/26] docs: Update plan (#2627) --- .../contest-task-pair-mapping/plan.md | 575 ++---------------- 1 file changed, 37 insertions(+), 538 deletions(-) diff --git a/docs/dev-notes/2025-09-23/contest-task-pair-mapping/plan.md b/docs/dev-notes/2025-09-23/contest-task-pair-mapping/plan.md index a86536007..c0848b4a5 100644 --- a/docs/dev-notes/2025-09-23/contest-task-pair-mapping/plan.md +++ b/docs/dev-notes/2025-09-23/contest-task-pair-mapping/plan.md @@ -2,19 +2,17 @@ ## 概要 -既存の Task テーブルの unique 制約を維持しながら、複数コンテストで同一問題を扱うための拡張実装計画。[前回計画](../../2025-09-17/contest-task-mapping/initial_plan.md)をベースに、互換性レイヤを用いた段階的移行戦略を詳細化。 +既存の Task テーブルの unique 制約を維持しながら、複数コンテストで同一問題を扱うための拡張実装計画。[前回計画](../../2025-09-17/contest-task-mapping/initial_plan.md)をベースに、互換性レイヤを用いた段階的移行戦略のうちデータベース設計と型定義を詳細化。 -## 討議内容の要約 - -### 1. 命名の決定 +## 1. 命名の決定 - **モデル名**: `ContestTaskPair` (「ペア」であることを明示) - **型名**: `ContestTaskPairKey` (Prisma モデルとの衝突回避) - **マップ型**: `TaskResultMapByContestTaskPair` (キーと値の関係を明示) -### 2. データベース設計 +## 2. データベース設計 -#### Prisma スキーマ追加 +### Prisma スキーマ追加 ```prisma model ContestTaskPair { @@ -37,14 +35,14 @@ model ContestTaskPair { - 複合一意制約: ペアレベルでの重複防止 - インデックス: 後から追加可能だが contestId 検索の高速化 -#### マイグレーション +### マイグレーション ```bash # 開発環境 pnpm dlx prisma migrate dev --name create_contest_task_pair ``` -#### CRUD メソッドの実装 +### CRUD メソッドの実装 **ファイル**: `src/lib/services/contest_task_pairs.ts` @@ -101,7 +99,9 @@ export async function createContestTaskPair( } // 新規レコード作成 - const contestTaskPair = await db.contestTaskPair.create({ + let contestTaskPair: ContestTaskPair | undefined; + + contestTaskPair = await db.contestTaskPair.create({ data: { contestId, taskTableIndex, @@ -111,7 +111,12 @@ export async function createContestTaskPair( console.log('Created ContestTaskPair:', contestTaskPair); } catch (error) { - console.error('Error creating ContestTaskPair:', error); + if (error && typeof error === 'object' && 'code' in error && (error as any).code === 'P2002') { + console.log(`Found ContestTaskPair (race): contestId=${contestId}, taskId=${taskId}`); + return; + } + + console.error('Failed to create ContestTaskPair:', error); throw error; } } @@ -129,7 +134,7 @@ export async function updateContestTaskPair( const existingRecord = await getContestTaskPair(contestId, taskId); if (!existingRecord) { - const errorMessage = `ContestTaskPair not found: contestId=${contestId}, taskId=${taskId}`; + const errorMessage = `Not found ContestTaskPair: contestId=${contestId}, taskId=${taskId}`; console.log(errorMessage); throw new Error(errorMessage); } @@ -143,15 +148,13 @@ export async function updateContestTaskPair( }, }, data: { - contestId, taskTableIndex, - taskId, }, }); console.log('Updated ContestTaskPair:', updatedContestTaskPair); } catch (error) { - console.error('Error updating ContestTaskPair:', error); + console.error('Failed to update ContestTaskPair:', error); throw error; } } @@ -164,9 +167,9 @@ export async function updateContestTaskPair( - 重複チェック・存在確認を事前に実施し、適切なログ出力 - Prisma の自動生成型を使用して型安全性を確保 -### 3. 型定義の更新 +## 3. 型定義の更新 -#### Prisma型に基づく型定義 +### Prisma型に基づく型定義 **ファイル**: `src/lib/types/contest_task_pair.ts` @@ -185,10 +188,12 @@ export type ContestTaskPairCreate = { taskId: string; }; +export type ContestTaskPairRead = ContestTaskPairCreate; + export type ContestTaskPairUpdate = ContestTaskPairCreate; ``` -#### レガシー型定義(マッピング用) +### マッピング用の型定義 ```typescript // 型定義 @@ -209,537 +214,31 @@ type TaskResultMapByContestTaskPair = Map; - クライアントサイドでも Prisma 型を使用可能(型定義のみでランタイムコード不要) - CRUD パラメータ型を定義して、メソッド引数の型安全性を向上 -### 4. 互換性レイヤ(TaskResultMapAdapter) - -**目的**: 既存コードを壊さずに段階的に新形式へ移行 - -```typescript -class TaskResultMapAdapter { - constructor( - private legacyMap?: Map, // 既存: taskId -> value - private enhancedMap?: Map, // 新形式: contestId:taskId -> value - ) {} - - private makeKey(contestId: string, taskId: string): ContestTaskPairKey { - return `${contestId}:${taskId}`; - } - - has(taskId: string, contestId?: string): boolean { - // enhanced を優先、なければ legacy にフォールバック - if (contestId && this.enhancedMap) { - return this.enhancedMap.has(this.makeKey(contestId, taskId)); - } - if (this.legacyMap && this.legacyMap.has(taskId)) return true; - if (this.enhancedMap) { - for (const key of this.enhancedMap.keys()) { - if (key.endsWith(`:${taskId}`)) return true; - } - } - return false; - } - - get(taskId: string, contestId?: string): T | undefined { - if (contestId && this.enhancedMap) { - const v = this.enhancedMap.get(this.makeKey(contestId, taskId)); - if (v !== undefined) return v; - } - if (this.legacyMap && this.legacyMap.has(taskId)) { - return this.legacyMap.get(taskId); - } - if (this.enhancedMap) { - for (const [key, value] of this.enhancedMap) { - if (key.endsWith(`:${taskId}`)) return value; - } - } - return undefined; - } - - // write-through を採用(移行期間の整合性確保) - set(taskId: string, value: T, contestId?: string): void { - if (this.legacyMap) this.legacyMap.set(taskId, value); - if (contestId && this.enhancedMap) { - this.enhancedMap.set(this.makeKey(contestId, taskId), value); - } - } - - delete(taskId: string, contestId?: string): boolean { - let ok = false; - if (contestId && this.enhancedMap) { - ok = this.enhancedMap.delete(this.makeKey(contestId, taskId)) || ok; - } - if (this.legacyMap) { - ok = this.legacyMap.delete(taskId) || ok; - } - return ok; - } -} -``` - -### 5. 主要な変更箇所 - -#### src/lib/utils/task_results.ts への追加(新規ファイル) - -```typescript -// TaskMapAdapter の実装 -type ContestTaskPairKey = `${string}:${string}`; -type TaskResultByContestTaskPair = Map; - -export class TaskMapAdapter { - constructor( - private legacyMap?: Map, - private enhancedMap?: TaskResultByContestTaskPair, - ) { - // 型チェック・assert - console.assert(legacyMap || enhancedMap, 'At least one map must be provided'); - - if (process.env.NODE_ENV === 'development') { - console.log( - `TaskMapAdapter initialized: legacy=${legacyMap?.size ?? 0}, enhanced=${enhancedMap?.size ?? 0}`, - ); - } - } - - get(taskId: string, contestId?: string): T | undefined { - console.assert( - typeof taskId === 'string' && taskId.length > 0, - 'taskId must be non-empty string', - ); - - const start = performance.now(); - - if (contestId && this.enhancedMap) { - const key = this.makeKey(contestId, taskId); - const result = this.enhancedMap.get(key); - if (result !== undefined) { - if (process.env.NODE_ENV === 'development') { - console.log( - `TaskMapAdapter.get: enhanced hit (${(performance.now() - start).toFixed(2)}ms)`, - ); - } - return result; - } - } - - if (this.legacyMap && this.legacyMap.has(taskId)) { - const result = this.legacyMap.get(taskId); - if (process.env.NODE_ENV === 'development') { - console.log(`TaskMapAdapter.get: legacy hit (${(performance.now() - start).toFixed(2)}ms)`); - } - return result; - } - - // enhanced スキャン(移行期間のみ) - if (this.enhancedMap) { - for (const [key, value] of this.enhancedMap) { - if (key.endsWith(`:${taskId}`)) { - if (process.env.NODE_ENV === 'development') { - console.log( - `TaskMapAdapter.get: enhanced scan hit (${(performance.now() - start).toFixed(2)}ms)`, - ); - } - return value; - } - } - } - - return undefined; - } - - private makeKey(contestId: string, taskId: string): ContestTaskPairKey { - console.assert( - typeof contestId === 'string' && contestId.length > 0, - 'contestId must be non-empty string', - ); - console.assert( - typeof taskId === 'string' && taskId.length > 0, - 'taskId must be non-empty string', - ); - - return `${contestId}:${taskId}` as ContestTaskPairKey; - } - - // 他のメソッド (has, set, delete) も同様に実装 -} - -/** - * Creates a unique key for a ContestTaskPair using contestId and taskId. - * Throws an error if either argument is an empty string. - * - * @param contestId - The ID of the contest. - * @param taskId - The ID of the task. - * @returns A string in the format "contestId:taskId". - * @throws Will throw an error if contestId or taskId is empty. - */ -export function createContestTaskPairKey(contestId: string, taskId: string): ContestTaskPairKey { - if (!contestId || contestId.trim() === '') { - throw new Error('contestId must be a non-empty string'); - } - if (!taskId || taskId.trim() === '') { - throw new Error('taskId must be a non-empty string'); - } - - return `${contestId}:${taskId}`; -} -``` - -#### src/lib/services/tasks.ts への追加 - -```typescript -// コンテスト-タスクのマッピングを取得 -export async function getContestTaskPairs(): Promise> { - const rows = await db.contestTaskPair.findMany({ - select: { contestId: true, taskId: true }, - }); - - const map = new Map(); - - for (const r of rows) { - const arr = map.get(r.taskId) ?? []; - arr.push(r.contestId); - map.set(r.taskId, arr); - } - - return map; -} - -// 既存の getTasks() を wrap -export async function getMergedTasks() { - const tasks = await getTasks(); - const contestTaskPairs = await getContestTaskPairs().catch(() => new Map()); - - // contestId:taskId -> Task のマップ(TaskResult 作成で直接参照しやすい) - const contestTaskPairMap = new Map(); - for (const t of tasks) { - const contests = contestTaskPairs.get(t.task_id) ?? [t.contest_id]; - for (const c of contests) { - contestTaskPairMap.set(`${c}:${t.task_id}`, t); - } - } - - return { tasks, contestTaskPairs, contestTaskPairMap }; -} -``` - -#### src/lib/services/task_results.ts の変更 - -```typescript -import { TaskMapAdapter } from '$lib/utils/task_results'; -import { getMergedTasks } from '$lib/services/tasks'; - -// getTaskResults にFeature Flag対応を追加 -export async function getTaskResults(userId: string): Promise { - const enableContestTaskPair = process.env.ENABLE_CONTEST_TASK_PAIR === 'true'; - - if (enableContestTaskPair) { - console.time('getTaskResults-enhanced'); - console.log(`Processing enhanced mode for user ${userId}`); - - const { tasks, contestTaskPairs } = await getMergedTasks(); - const answers = await answer_crud.getAnswers(userId); - - // enhancedMap を構築 - const enhancedMap: TaskResultByContestTaskPair = new Map(); - for (const [taskId, answer] of answers) { - const contests = contestTaskPairs.get(taskId) ?? [answer.contest_id ?? '']; - for (const contestId of contests) { - if (!contestId) continue; - enhancedMap.set(`${contestId}:${taskId}`, answer); - } - } - - const results = await relateTasksAndAnswers(userId, tasks, answers, enhancedMap); - - console.log(`Generated ${results.length} task results in enhanced mode`); - console.timeEnd('getTaskResults-enhanced'); - return results; - } else { - // 既存の処理 - console.time('getTaskResults-legacy'); - const tasks = await getTasks(); - const answers = await answer_crud.getAnswers(userId); - const results = await relateTasksAndAnswers(userId, tasks, answers); - - console.log(`Generated ${results.length} task results in legacy mode`); - console.timeEnd('getTaskResults-legacy'); - return results; - } -} - -// relateTasksAndAnswers のシグネチャ変更(enhancedMap をオプション引数に) -async function relateTasksAndAnswers( - userId: string, - tasks: Tasks, - answers: Map, - enhancedMap?: TaskResultByContestTaskPair, -): Promise { - const isLoggedIn = userId !== undefined; - const adapter = new TaskMapAdapter(answers, enhancedMap); - - const taskResults = tasks.map((task: Task) => { - const taskResult = createDefaultTaskResult(userId, task); - - if (isLoggedIn && adapter.has(task.task_id, task.contest_id)) { - const answer = adapter.get(task.task_id, task.contest_id); - const status = statusById.get(answer?.status_id); - taskResult.status_name = status.status_name; - taskResult.submission_status_image_path = status.image_path; - taskResult.submission_status_label_name = status.label_name; - taskResult.is_ac = status.is_ac; - taskResult.updated_at = answer?.updated_at ?? taskResult.updated_at; - } - - return taskResult; - }); - - return taskResults; -} -``` - -### 6. 移行戦略 - -#### Phase 1: 基盤整備 +## 4. 実行計画 - [✅] Prisma スキーマに ContestTaskPair モデル追加 - [✅] マイグレーション実行 -- [ ] CURD メソッドを追加 -- [ ] TypeScript 型定義追加 - -#### Phase 2: 互換性レイヤ導入 - -- [ ] TaskMapAdapter 実装・テスト -- [ ] getMergedTasks() 実装 -- [ ] src/lib/services/task_results.ts への適用 - -#### Phase 3: 段階的移行 - -- [ ] 他の影響箇所の特定・修正 -- [ ] Feature flag による切替制御 -- [ ] ログ・モニタリング追加 - -#### Phase 4: 完全移行 - -- [ ] Legacy マップの削除 -- [ ] アダプタの簡素化 -- [ ] パフォーマンス最適化 - -### 7. テスト計画 - -#### TaskMapAdapter の単体テスト - -```typescript -// filepath: src/lib/utils/task_results.test.ts -import { describe, test, expect } from 'vitest'; -import { TaskMapAdapter, createContestTaskPairKey } from './task_results'; - -describe('TaskMapAdapter', () => { - test('expect to prioritize enhanced map when contestId is provided', () => { - const legacy = new Map([['task1', { id: 'legacy' }]]); - const enhanced = new Map([['contest1:task1', { id: 'enhanced' }]]); - const adapter = new TaskMapAdapter(legacy, enhanced); - - expect(adapter.get('task1', 'contest1')?.id).toBe('enhanced'); - expect(adapter.get('task1')?.id).toBe('legacy'); - }); - - test('expect to fallback to legacy when enhanced not found', () => { - const legacy = new Map([['task1', { id: 'legacy' }]]); - const enhanced = new Map(); - const adapter = new TaskMapAdapter(legacy, enhanced); - - expect(adapter.get('task1', 'contest1')?.id).toBe('legacy'); - }); - - test('expect to handle write-through correctly', () => { - const legacy = new Map(); - const enhanced = new Map(); - const adapter = new TaskMapAdapter(legacy, enhanced); - - adapter.set('task1', { id: 'value' }, 'contest1'); - expect(legacy.get('task1')?.id).toBe('value'); - expect(enhanced.get('contest1:task1')?.id).toBe('value'); - }); - - test('expect to scan enhanced map when no contestId provided', () => { - const legacy = new Map(); - const enhanced = new Map([['contest1:task1', { id: 'found' }]]); - const adapter = new TaskMapAdapter(legacy, enhanced); - - expect(adapter.get('task1')?.id).toBe('found'); - expect(adapter.has('task1')).toBe(true); - }); - - test('expect to validate input parameters', () => { - const adapter = new TaskMapAdapter(new Map(), new Map()); - - // TypeScript strict モードでの型安全性を確保 - expect(() => adapter.get('')).toThrow(); - expect(() => adapter.get('task1', '')).toThrow(); - }); -}); - -describe('createContestTaskPairKey', () => { - test('expect to create valid key format', () => { - expect(createContestTaskPairKey('abc001', 'abc001_a')).toBe('abc001:abc001_a'); - }); - - test('expect to validate input parameters', () => { - expect(() => createContestTaskPairKey('', 'task')).toThrow(); - expect(() => createContestTaskPairKey('contest', '')).toThrow(); - expect(() => createContestTaskPairKey(' ', 'task')).toThrow(); - expect(() => createContestTaskPairKey('contest', ' ')).toThrow(); - }); -}); -``` - -#### 既存テストとの互換性 - -- **場所**: `src/test/` の既存単体テスト -- **対応**: Feature Flag が false の場合は既存動作を維持 -- **確認事項**: TaskMapAdapter 導入後も全既存テストがパスすること### 8. パフォーマンス・監視計画 - -#### 開発環境での監視 - -```typescript -// console.log ベースの監視(開発環境のみ) -export async function getTaskResults(userId: string): Promise { - if (process.env.NODE_ENV === 'development') { - console.time('getTaskResults'); - console.log(`Processing ${answers.size} answers for user ${userId}`); - } - - const results = await getTaskResults(userId); - - if (process.env.NODE_ENV === 'development') { - console.log(`Generated ${results.length} task results`); - console.timeEnd('getTaskResults'); - - // メモリ使用量確認 - const memory = process.memoryUsage(); - console.log(`Memory usage: ${Math.round(memory.heapUsed / 1024 / 1024)}MB`); - } - - return results; -} -``` - -#### Staging・本番環境での監視 - -- **ログ確認**: Vercel管理者画面 -- **データ内容**: 本番データのサブセット(staging環境) -- **メトリクス**: 処理時間・データ数・エラー率を最小限ログ出力 - -#### APM導入について - -- **Sentry/DataDog**: 導入コストが高いため当面見送り -- **代替手段**: console.log + Vercelログでの監視 - -### 9. 環境設定・Feature Flag - -#### 環境変数設定 - -```bash -# .env または .env.local -ENABLE_CONTEST_TASK_PAIR=false # 開発段階では false -``` - -#### Feature Flag の適用範囲 - -- **対象関数**: `getTaskResults()` のみ(段階1) -- **切り替え**: 環境変数による制御 -- **既存動作**: Flag が false の場合は完全に既存処理を維持 - -#### データインポート方法 - -```typescript -// prisma/seed.ts での ContestTaskPair データ追加例 -async function seedContestTaskPairs() { - const contestTaskPairs = [ - { contestId: 'abc001', taskTableIndex: 'A', taskId: 'abc001_a' }, - { contestId: 'abc001', taskTableIndex: 'B', taskId: 'abc001_b' }, - // ... more data - ]; - - for (const pair of contestTaskPairs) { - await prisma.contestTaskPair.upsert({ - where: { - contestId_taskId: { - contestId: pair.contestId, - taskId: pair.taskId, - }, - }, - update: { taskTableIndex: pair.taskTableIndex }, - create: pair, - }); - } -} -``` - -## 追加討議内容の要約 - -### 実装の詳細決定事項 - -1. **ファイル構成**: `src/lib/utils/task_results.ts` に TaskMapAdapter を配置(CRUD分離・テスト容易性・移行後削除容易性) - -2. **デプロイフロー**: 開発 → GitHub Actions → staging環境(Vercel) → 本番環境での段階的検証 - -3. **テストケース**: - - `test` を使用(`it` ではなく) - - `expect to` 表現に統一(`should` を避ける) - - 既存テスト(`src/test/`)との互換性確保 - - TypeScript strict モードでの型安全性とassert追加 - -4. **パフォーマンス監視**: - - Sentry/DataDog は導入見送り - - console.log + Vercelログでの監視 - - 開発環境でのみ詳細ログ出力 - -5. **Feature Flag**: - - 環境変数(.env)での制御 - - 適用範囲は `getTaskResults()` のみ(段階1) - -6. **データインポート**: - - 第1段階: seed.ts での手動データ投入 - - 第2段階: 管理者画面での自動インポート機能 - -7. **インフラ環境**: - - ソースコード管理: Git/GitHub - - データベース: Supabase(ロールバック機能あり) - - staging環境: 本番データのサブセット使用 - -## 3. インポート処理の分離 - -**注意**: この部分は基本機能実装・動作確認後に改めて討議予定。 - -- 第1段階: seed.ts や CSV でのデータインポート優先 -- 第2段階: 管理者画面を通したインポート機能実装 -- AtCoder Problems API からのデータ取得・変換 -- 既存 Task データとの整合性確認 - -## リスク要因と対策 - -| リスク | 影響度 | 対策 | -| ------------------------ | ------ | ------------------------------------------------------ | -| 移行期間中のデータ不整合 | 高 | write-through による二重書き込み + Feature Flag制御 | -| 既存機能の破綻 | 高 | 互換性レイヤー + 既存テストでの継続検証 | -| パフォーマンス劣化 | 中 | enhanced スキャン処理を移行期間中に限定 + 監視強化 | -| テストカバレッジ不足 | 中 | TaskMapAdapter の単体テスト + TypeScript strict モード | -| ロールバックの複雑さ | 低 | Supabaseのロールバック機能 + Feature Flag即座切替 | +- [✅] CURD メソッドを追加 +- [✅] TypeScript 型定義追加 ## 決定事項 1. **モデル名**: ContestTaskPair 2. **型名**: ContestTaskPairKey, TaskResultByContestTaskPair -3. **移行方針**: TaskMapAdapter による段階的移行 -4. **アダプタ配置**: `src/lib/utils/task_results.ts`(影響局所化・テスト容易性・削除容易性) -5. **id 属性**: 将来性を考慮して追加 -6. **Feature Flag**: 環境変数制御、getTaskResults()のみ適用(段階1) -7. **テスト方針**: `test` + `expect to` + TypeScript strict + assert追加 -8. **監視手法**: console.log + Vercelログ(APM導入見送り) -9. **データ投入**: seed.ts優先 → 管理者画面は第2段階 + +## 今後の課題 + +1. **移行方針**: TaskMapAdapter による段階的移行 +2. **アダプタ配置**: `src/lib/utils/task_results.ts`(影響局所化・テスト容易性・削除容易性) +3. **id 属性**: 将来性を考慮して追加 +4. **Feature Flag**: 環境変数制御、getTaskResults()のみ適用(段階1) +5. **テスト方針**: `test` + `expect to` + TypeScript strict + assert追加 +6. **監視手法**: console.log + Vercelログ(APM導入見送り) +7. **データ投入**: seed.ts優先 → 管理者画面は、その次の段階で実装 --- **作成日**: 2025-09-23 -**更新日**: 2025-09-27 +**更新日**: 2025-10-15 **ベース**: [initial_plan.md (2025-09-17)](../../2025-09-17/contest-task-mapping/initial_plan.md) -**ステータス**: 実装準備段階 +**ステータス**: 実装完了 From c3aa2d1980873bab3f72d0f135e11646d2af73b2 Mon Sep 17 00:00:00 2001 From: Kato Hiroki Date: Wed, 15 Oct 2025 09:03:49 +0000 Subject: [PATCH 09/26] feat: Define types for contest and task pair (#2627) --- src/lib/types/contest_task_pair.ts | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/src/lib/types/contest_task_pair.ts b/src/lib/types/contest_task_pair.ts index 60aa3c22e..0e16baca0 100644 --- a/src/lib/types/contest_task_pair.ts +++ b/src/lib/types/contest_task_pair.ts @@ -1,5 +1,23 @@ import type { ContestTaskPair as ContestTaskPairOrigin } from '@prisma/client'; +import type { TaskResult } from '$lib/types/task'; + export type ContestTaskPair = ContestTaskPairOrigin; export type ContestTaskPairs = ContestTaskPair[]; + +// For CRUD operation parameter types +export type ContestTaskPairCreate = { + contestId: string; + taskTableIndex: string; + taskId: string; +}; + +export type ContestTaskPairRead = ContestTaskPairCreate; + +export type ContestTaskPairUpdate = ContestTaskPairCreate; + +// For mapping and identification +export type ContestTaskPairKey = `${string}:${string}`; // "contest_id:task_id" + +export type TaskResultMapByContestTaskPair = Map; From 052d0f3511affd6dba02029cfb02e47fca8599bc Mon Sep 17 00:00:00 2001 From: Kato Hiroki Date: Wed, 15 Oct 2025 09:06:51 +0000 Subject: [PATCH 10/26] feat: Create crud for pairs of contest and task (#2627) --- src/lib/services/contest_task_pairs.ts | 122 +++++++++++++++++++++++++ 1 file changed, 122 insertions(+) create mode 100644 src/lib/services/contest_task_pairs.ts diff --git a/src/lib/services/contest_task_pairs.ts b/src/lib/services/contest_task_pairs.ts new file mode 100644 index 000000000..e1307a7db --- /dev/null +++ b/src/lib/services/contest_task_pairs.ts @@ -0,0 +1,122 @@ +import { default as db } from '$lib/server/database'; + +import type { ContestTaskPair, ContestTaskPairs } from '$lib/types/contest_task_pair'; + +/** + * Retrieves all ContestTaskPair records from the database. + * + * @returns An array of ContestTaskPair objects. + */ +export async function getContestTaskPairs(): Promise { + return await db.contestTaskPair.findMany(); +} + +/** + * Retrieves a ContestTaskPair record by contestId and taskId. + * + * @param contestId: The ID of the contest. + * @param taskId: The ID of the task. + * + * @returns The ContestTaskPair if found, otherwise null. + */ +export async function getContestTaskPair( + contestId: string, + taskId: string, +): Promise { + const contestTaskPair = await db.contestTaskPair.findUnique({ + where: { + contestId_taskId: { + contestId, + taskId, + }, + }, + }); + + return contestTaskPair; +} + +/** + * Creates a new ContestTaskPair record in the database. + * + * @param contestId - The ID of the contest. + * @param taskTableIndex - The table index of the task. + * @param taskId - The ID of the task. + * + * @throws Will throw an error if the creation fails. + */ +export async function createContestTaskPair( + contestId: string, + taskTableIndex: string, + taskId: string, +): Promise { + try { + const existingRecord = await getContestTaskPair(contestId, taskId); + + if (existingRecord) { + console.log(`ContestTaskPair already exists: contestId=${contestId}, taskId=${taskId}`); + return; + } + + let contestTaskPair: ContestTaskPair | undefined; + + contestTaskPair = await db.contestTaskPair.create({ + data: { + contestId, + taskTableIndex, + taskId, + }, + }); + + console.log('Created ContestTaskPair:', contestTaskPair); + } catch (error) { + if (error && typeof error === 'object' && 'code' in error && (error as any).code === 'P2002') { + console.log(`Found ContestTaskPair (race): contestId=${contestId}, taskId=${taskId}`); + return; + } + + console.error('Failed to create ContestTaskPair:', error); + throw error; + } +} + +/** + * Updates an existing ContestTaskPair record in the database. + * + * @param contestId: The ID of the contest. + * @param taskTableIndex: The table index of the task. + * @param taskId: The ID of the task. + * + * @throws Will throw an error if the update fails or if the record does not exist. + */ +export async function updateContestTaskPair( + contestId: string, + taskTableIndex: string, + taskId: string, +): Promise { + try { + const existingRecord = await getContestTaskPair(contestId, taskId); + + if (!existingRecord) { + const errorMessage = `Not found ContestTaskPair: contestId=${contestId}, taskId=${taskId}`; + console.log(errorMessage); + throw new Error(errorMessage); + } + + const updatedContestTaskPair = await db.contestTaskPair.update({ + where: { + contestId_taskId: { + contestId, + taskId, + }, + }, + data: { + taskTableIndex, + }, + }); + + console.log('Updated ContestTaskPair:', updatedContestTaskPair); + } catch (error) { + console.error('Failed to update ContestTaskPair:', error); + throw error; + } +} From fe6bae73e299945867259047b56d268192717996 Mon Sep 17 00:00:00 2001 From: Kato Hiroki Date: Wed, 15 Oct 2025 09:21:33 +0000 Subject: [PATCH 11/26] refactor: Read and Update types to match their semantic purposes (#2627) --- src/lib/types/contest_task_pair.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/lib/types/contest_task_pair.ts b/src/lib/types/contest_task_pair.ts index 0e16baca0..88109b06c 100644 --- a/src/lib/types/contest_task_pair.ts +++ b/src/lib/types/contest_task_pair.ts @@ -7,15 +7,15 @@ export type ContestTaskPair = ContestTaskPairOrigin; export type ContestTaskPairs = ContestTaskPair[]; // For CRUD operation parameter types +export type ContestTaskPairRead = ContestTaskPair; + export type ContestTaskPairCreate = { contestId: string; taskTableIndex: string; taskId: string; }; -export type ContestTaskPairRead = ContestTaskPairCreate; - -export type ContestTaskPairUpdate = ContestTaskPairCreate; +export type ContestTaskPairUpdate = Partial; // For mapping and identification export type ContestTaskPairKey = `${string}:${string}`; // "contest_id:task_id" From 7663b11094f4706eb19b52ffadb708de1cdb475a Mon Sep 17 00:00:00 2001 From: Kato Hiroki Date: Wed, 15 Oct 2025 09:22:24 +0000 Subject: [PATCH 12/26] chore: Simplify by returning directly (#2627) --- src/lib/services/contest_task_pairs.ts | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src/lib/services/contest_task_pairs.ts b/src/lib/services/contest_task_pairs.ts index e1307a7db..76272dd2b 100644 --- a/src/lib/services/contest_task_pairs.ts +++ b/src/lib/services/contest_task_pairs.ts @@ -23,7 +23,7 @@ export async function getContestTaskPair( contestId: string, taskId: string, ): Promise { - const contestTaskPair = await db.contestTaskPair.findUnique({ + return await db.contestTaskPair.findUnique({ where: { contestId_taskId: { contestId, @@ -31,8 +31,6 @@ export async function getContestTaskPair( }, }, }); - - return contestTaskPair; } /** From f5c2ee9c605d663bb3b77176d057614662bfc173 Mon Sep 17 00:00:00 2001 From: Kato Hiroki Date: Wed, 15 Oct 2025 09:23:14 +0000 Subject: [PATCH 13/26] chore: Remove unused variable declaration (#2627) --- src/lib/services/contest_task_pairs.ts | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src/lib/services/contest_task_pairs.ts b/src/lib/services/contest_task_pairs.ts index 76272dd2b..fcea769b2 100644 --- a/src/lib/services/contest_task_pairs.ts +++ b/src/lib/services/contest_task_pairs.ts @@ -55,9 +55,7 @@ export async function createContestTaskPair( return; } - let contestTaskPair: ContestTaskPair | undefined; - - contestTaskPair = await db.contestTaskPair.create({ + const contestTaskPair = await db.contestTaskPair.create({ data: { contestId, taskTableIndex, From ab0361c95760e341775439910fe8c5045129c97b Mon Sep 17 00:00:00 2001 From: Kato Hiroki Date: Wed, 15 Oct 2025 09:26:40 +0000 Subject: [PATCH 14/26] chore: Removing pre-check and relying on P2002 handling (#2627) --- src/lib/services/contest_task_pairs.ts | 9 +-------- 1 file changed, 1 insertion(+), 8 deletions(-) diff --git a/src/lib/services/contest_task_pairs.ts b/src/lib/services/contest_task_pairs.ts index fcea769b2..a2d44b92a 100644 --- a/src/lib/services/contest_task_pairs.ts +++ b/src/lib/services/contest_task_pairs.ts @@ -48,13 +48,6 @@ export async function createContestTaskPair( taskId: string, ): Promise { try { - const existingRecord = await getContestTaskPair(contestId, taskId); - - if (existingRecord) { - console.log(`ContestTaskPair already exists: contestId=${contestId}, taskId=${taskId}`); - return; - } - const contestTaskPair = await db.contestTaskPair.create({ data: { contestId, @@ -66,7 +59,7 @@ export async function createContestTaskPair( console.log('Created ContestTaskPair:', contestTaskPair); } catch (error) { if (error && typeof error === 'object' && 'code' in error && (error as any).code === 'P2002') { - console.log(`Found ContestTaskPair (race): contestId=${contestId}, taskId=${taskId}`); + console.log(`ContestTaskPair already exists: contestId=${contestId}, taskId=${taskId}`); return; } From 10b8de05639ee5b933a1969acc9511cf31037a5a Mon Sep 17 00:00:00 2001 From: Kato Hiroki Date: Wed, 15 Oct 2025 09:28:55 +0000 Subject: [PATCH 15/26] chore: Improve type safety (#2627) --- src/lib/services/contest_task_pairs.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/lib/services/contest_task_pairs.ts b/src/lib/services/contest_task_pairs.ts index a2d44b92a..2973c62b8 100644 --- a/src/lib/services/contest_task_pairs.ts +++ b/src/lib/services/contest_task_pairs.ts @@ -1,3 +1,5 @@ +import { Prisma } from '@prisma/client'; + import { default as db } from '$lib/server/database'; import type { ContestTaskPair, ContestTaskPairs } from '$lib/types/contest_task_pair'; @@ -58,7 +60,7 @@ export async function createContestTaskPair( console.log('Created ContestTaskPair:', contestTaskPair); } catch (error) { - if (error && typeof error === 'object' && 'code' in error && (error as any).code === 'P2002') { + if (error instanceof Prisma.PrismaClientKnownRequestError && error.code === 'P2002') { console.log(`ContestTaskPair already exists: contestId=${contestId}, taskId=${taskId}`); return; } From 715703df60baa16a7822dcba238bb7918e9c30c3 Mon Sep 17 00:00:00 2001 From: Kato Hiroki Date: Wed, 15 Oct 2025 09:31:44 +0000 Subject: [PATCH 16/26] chore: Remove redundant pre-check (#2627) --- src/lib/services/contest_task_pairs.ts | 12 ++++-------- 1 file changed, 4 insertions(+), 8 deletions(-) diff --git a/src/lib/services/contest_task_pairs.ts b/src/lib/services/contest_task_pairs.ts index 2973c62b8..1cb258133 100644 --- a/src/lib/services/contest_task_pairs.ts +++ b/src/lib/services/contest_task_pairs.ts @@ -85,14 +85,6 @@ export async function updateContestTaskPair( taskId: string, ): Promise { try { - const existingRecord = await getContestTaskPair(contestId, taskId); - - if (!existingRecord) { - const errorMessage = `Not found ContestTaskPair: contestId=${contestId}, taskId=${taskId}`; - console.log(errorMessage); - throw new Error(errorMessage); - } - const updatedContestTaskPair = await db.contestTaskPair.update({ where: { contestId_taskId: { @@ -107,6 +99,10 @@ export async function updateContestTaskPair( console.log('Updated ContestTaskPair:', updatedContestTaskPair); } catch (error) { + if (error instanceof Prisma.PrismaClientKnownRequestError && error.code === 'P2025') { + console.error(`Not found ContestTaskPair: contestId=${contestId}, taskId=${taskId}`); + } + console.error('Failed to update ContestTaskPair:', error); throw error; } From c6f7dfbed223314e7737db13c5c54f3c1833074b Mon Sep 17 00:00:00 2001 From: Kato Hiroki Date: Wed, 15 Oct 2025 09:43:44 +0000 Subject: [PATCH 17/26] chore: Return created/updated records from mutations (#2627) --- src/lib/services/contest_task_pairs.ts | 24 ++++++++++++++++++++---- 1 file changed, 20 insertions(+), 4 deletions(-) diff --git a/src/lib/services/contest_task_pairs.ts b/src/lib/services/contest_task_pairs.ts index 1cb258133..4e4f3d1de 100644 --- a/src/lib/services/contest_task_pairs.ts +++ b/src/lib/services/contest_task_pairs.ts @@ -42,13 +42,15 @@ export async function getContestTaskPair( * @param taskTableIndex - The table index of the task. * @param taskId - The ID of the task. * + * @returns The created ContestTaskPair object or the existing one if it already exists. + * * @throws Will throw an error if the creation fails. */ export async function createContestTaskPair( contestId: string, taskTableIndex: string, taskId: string, -): Promise { +): Promise { try { const contestTaskPair = await db.contestTaskPair.create({ data: { @@ -59,10 +61,18 @@ export async function createContestTaskPair( }); console.log('Created ContestTaskPair:', contestTaskPair); + + return contestTaskPair; } catch (error) { if (error instanceof Prisma.PrismaClientKnownRequestError && error.code === 'P2002') { console.log(`ContestTaskPair already exists: contestId=${contestId}, taskId=${taskId}`); - return; + const existingPair = await getContestTaskPair(contestId, taskId); + + if (!existingPair) { + throw new Error('Unexpected: record exists but cannot be fetched'); + } + + return existingPair; } console.error('Failed to create ContestTaskPair:', error); @@ -77,13 +87,15 @@ export async function createContestTaskPair( * @param taskTableIndex: The table index of the task. * @param taskId: The ID of the task. * + * @returns The updated ContestTaskPair object. + * * @throws Will throw an error if the update fails or if the record does not exist. */ export async function updateContestTaskPair( contestId: string, taskTableIndex: string, taskId: string, -): Promise { +): Promise { try { const updatedContestTaskPair = await db.contestTaskPair.update({ where: { @@ -98,9 +110,13 @@ export async function updateContestTaskPair( }); console.log('Updated ContestTaskPair:', updatedContestTaskPair); + + return updatedContestTaskPair; } catch (error) { if (error instanceof Prisma.PrismaClientKnownRequestError && error.code === 'P2025') { - console.error(`Not found ContestTaskPair: contestId=${contestId}, taskId=${taskId}`); + const errorMessage = `Not found ContestTaskPair: contestId=${contestId}, taskId=${taskId}`; + console.error(errorMessage); + throw new Error(errorMessage); } console.error('Failed to update ContestTaskPair:', error); From f2ccdad75f231925546222c2d86ad38b919e9caf Mon Sep 17 00:00:00 2001 From: Kato Hiroki Date: Wed, 15 Oct 2025 09:46:15 +0000 Subject: [PATCH 18/26] docs: Add lessons from task to plan (#2627) --- .../contest-task-pair-mapping/plan.md | 23 ++++++++++++++++--- 1 file changed, 20 insertions(+), 3 deletions(-) diff --git a/docs/dev-notes/2025-09-23/contest-task-pair-mapping/plan.md b/docs/dev-notes/2025-09-23/contest-task-pair-mapping/plan.md index c0848b4a5..f461784ed 100644 --- a/docs/dev-notes/2025-09-23/contest-task-pair-mapping/plan.md +++ b/docs/dev-notes/2025-09-23/contest-task-pair-mapping/plan.md @@ -238,7 +238,24 @@ type TaskResultMapByContestTaskPair = Map; --- -**作成日**: 2025-09-23 +## 教訓と一般化 + +以下は、実装中に得られた教訓を一般化したものです。今後の開発においても参考にしてください。 + +### 1. 防御的なチェックの再考 +- **教訓**: Prisma のエラーハンドリング(例: `P2002` や `P2025`)を活用することで、事前チェックを省略し、TOCTOU(Time-of-Check to Time-of-Use)競合を回避できる。 +- **推奨**: 事前チェックを行う代わりに、データベース操作の結果を直接利用し、エラーを適切に処理する。 + +### 2. 戻り値の活用 +- **教訓**: CRUD メソッドで作成・更新されたレコードを返すことで、呼び出し元が追加のクエリを実行せずに結果を利用できる。 +- **推奨**: `create` や `update` メソッドでは、`void` を返すのではなく、作成・更新されたレコードを返すようにする。 + +### 3. 型定義の明確化 +- **教訓**: Prisma の自動生成型を活用しつつ、必要に応じて独自の型を定義することで、型安全性と可読性を向上できる。 +- **推奨**: CRUD 操作用の型(例: `Create`, `Update`, `Read`)を明確に分離し、それぞれの目的に応じた型を定義する。 + +### 4. ログとエラーハンドリング +- **教訓**: エラー発生時には、適切なログを出力し、問題の特定を容易にする。 +- **推奨**: Prisma のエラーコード(例: `P2002`, `P2025`)を活用して、エラー内容に応じた具体的なメッセージを出力する。 + **更新日**: 2025-10-15 -**ベース**: [initial_plan.md (2025-09-17)](../../2025-09-17/contest-task-mapping/initial_plan.md) -**ステータス**: 実装完了 From 7dabae36bb13cf4d545746759bb6aaba1afd7ded Mon Sep 17 00:00:00 2001 From: Kato Hiroki Date: Wed, 15 Oct 2025 09:46:51 +0000 Subject: [PATCH 19/26] chore: Fix format (#2627) --- docs/dev-notes/2025-09-23/contest-task-pair-mapping/plan.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/docs/dev-notes/2025-09-23/contest-task-pair-mapping/plan.md b/docs/dev-notes/2025-09-23/contest-task-pair-mapping/plan.md index f461784ed..4a562c103 100644 --- a/docs/dev-notes/2025-09-23/contest-task-pair-mapping/plan.md +++ b/docs/dev-notes/2025-09-23/contest-task-pair-mapping/plan.md @@ -243,18 +243,22 @@ type TaskResultMapByContestTaskPair = Map; 以下は、実装中に得られた教訓を一般化したものです。今後の開発においても参考にしてください。 ### 1. 防御的なチェックの再考 + - **教訓**: Prisma のエラーハンドリング(例: `P2002` や `P2025`)を活用することで、事前チェックを省略し、TOCTOU(Time-of-Check to Time-of-Use)競合を回避できる。 - **推奨**: 事前チェックを行う代わりに、データベース操作の結果を直接利用し、エラーを適切に処理する。 ### 2. 戻り値の活用 + - **教訓**: CRUD メソッドで作成・更新されたレコードを返すことで、呼び出し元が追加のクエリを実行せずに結果を利用できる。 - **推奨**: `create` や `update` メソッドでは、`void` を返すのではなく、作成・更新されたレコードを返すようにする。 ### 3. 型定義の明確化 + - **教訓**: Prisma の自動生成型を活用しつつ、必要に応じて独自の型を定義することで、型安全性と可読性を向上できる。 - **推奨**: CRUD 操作用の型(例: `Create`, `Update`, `Read`)を明確に分離し、それぞれの目的に応じた型を定義する。 ### 4. ログとエラーハンドリング + - **教訓**: エラー発生時には、適切なログを出力し、問題の特定を容易にする。 - **推奨**: Prisma のエラーコード(例: `P2002`, `P2025`)を活用して、エラー内容に応じた具体的なメッセージを出力する。 From 5ef8ffc8b7c17fd3ea56aade10f2874c26e893c6 Mon Sep 17 00:00:00 2001 From: Kato Hiroki Date: Wed, 15 Oct 2025 10:14:23 +0000 Subject: [PATCH 20/26] docs: Update code examples to align with lessons learned (#2627) --- .../contest-task-pair-mapping/plan.md | 46 ++++++------------- 1 file changed, 14 insertions(+), 32 deletions(-) diff --git a/docs/dev-notes/2025-09-23/contest-task-pair-mapping/plan.md b/docs/dev-notes/2025-09-23/contest-task-pair-mapping/plan.md index 4a562c103..74f5b6a10 100644 --- a/docs/dev-notes/2025-09-23/contest-task-pair-mapping/plan.md +++ b/docs/dev-notes/2025-09-23/contest-task-pair-mapping/plan.md @@ -88,32 +88,20 @@ export async function createContestTaskPair( contestId: string, taskTableIndex: string, taskId: string, -): Promise { +): Promise { try { - // 既存レコードの確認 - const existingRecord = await getContestTaskPair(contestId, taskId); - - if (existingRecord) { - console.log(`ContestTaskPair already exists: contestId=${contestId}, taskId=${taskId}`); - return; - } - - // 新規レコード作成 - let contestTaskPair: ContestTaskPair | undefined; - - contestTaskPair = await db.contestTaskPair.create({ + return await db.contestTaskPair.create({ data: { contestId, taskTableIndex, taskId, }, }); - - console.log('Created ContestTaskPair:', contestTaskPair); } catch (error) { - if (error && typeof error === 'object' && 'code' in error && (error as any).code === 'P2002') { - console.log(`Found ContestTaskPair (race): contestId=${contestId}, taskId=${taskId}`); - return; + if (error instanceof Prisma.PrismaClientKnownRequestError && error.code === 'P2002') { + const errorMessage = `ContestTaskPair already exists: contestId=${contestId}, taskId=${taskId}`; + console.error(errorMessage); + throw new Error(errorMessage); } console.error('Failed to create ContestTaskPair:', error); @@ -128,19 +116,9 @@ export async function updateContestTaskPair( contestId: string, taskTableIndex: string, taskId: string, -): Promise { +): Promise { try { - // 既存レコードの確認 - const existingRecord = await getContestTaskPair(contestId, taskId); - - if (!existingRecord) { - const errorMessage = `Not found ContestTaskPair: contestId=${contestId}, taskId=${taskId}`; - console.log(errorMessage); - throw new Error(errorMessage); - } - - // レコード更新 - const updatedContestTaskPair = await db.contestTaskPair.update({ + return await db.contestTaskPair.update({ where: { contestId_taskId: { contestId, @@ -151,9 +129,13 @@ export async function updateContestTaskPair( taskTableIndex, }, }); - - console.log('Updated ContestTaskPair:', updatedContestTaskPair); } catch (error) { + if (error instanceof Prisma.PrismaClientKnownRequestError && error.code === 'P2025') { + const errorMessage = `Not found ContestTaskPair: contestId=${contestId}, taskId=${taskId}`; + console.error(errorMessage); + throw new Error(errorMessage); + } + console.error('Failed to update ContestTaskPair:', error); throw error; } From b54c017c604190c75070bf40e3d6d0f10bec153a Mon Sep 17 00:00:00 2001 From: Kato Hiroki Date: Wed, 15 Oct 2025 11:27:05 +0000 Subject: [PATCH 21/26] docs: Add Prisma import to make the snippet compile (#2627) --- docs/dev-notes/2025-09-23/contest-task-pair-mapping/plan.md | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/docs/dev-notes/2025-09-23/contest-task-pair-mapping/plan.md b/docs/dev-notes/2025-09-23/contest-task-pair-mapping/plan.md index 74f5b6a10..dc0513db0 100644 --- a/docs/dev-notes/2025-09-23/contest-task-pair-mapping/plan.md +++ b/docs/dev-notes/2025-09-23/contest-task-pair-mapping/plan.md @@ -47,6 +47,8 @@ pnpm dlx prisma migrate dev --name create_contest_task_pair **ファイル**: `src/lib/services/contest_task_pairs.ts` ```typescript +import { Prisma } from '@prisma/client'; + import { default as db } from '$lib/server/database'; import type { ContestTaskPair, @@ -98,7 +100,7 @@ export async function createContestTaskPair( }, }); } catch (error) { - if (error instanceof Prisma.PrismaClientKnownRequestError && error.code === 'P2002') { + if (error instanceof PrismaClientKnownRequestError && error.code === 'P2002') { const errorMessage = `ContestTaskPair already exists: contestId=${contestId}, taskId=${taskId}`; console.error(errorMessage); throw new Error(errorMessage); @@ -130,7 +132,7 @@ export async function updateContestTaskPair( }, }); } catch (error) { - if (error instanceof Prisma.PrismaClientKnownRequestError && error.code === 'P2025') { + if (error instanceof PrismaClientKnownRequestError && error.code === 'P2025') { const errorMessage = `Not found ContestTaskPair: contestId=${contestId}, taskId=${taskId}`; console.error(errorMessage); throw new Error(errorMessage); From ffc1fb8e3b0820c6108c44bde97467a4c8132b20 Mon Sep 17 00:00:00 2001 From: Kato Hiroki Date: Wed, 15 Oct 2025 11:29:18 +0000 Subject: [PATCH 22/26] docs: Fix typo (#2627) --- docs/dev-notes/2025-09-23/contest-task-pair-mapping/plan.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/dev-notes/2025-09-23/contest-task-pair-mapping/plan.md b/docs/dev-notes/2025-09-23/contest-task-pair-mapping/plan.md index dc0513db0..da0601352 100644 --- a/docs/dev-notes/2025-09-23/contest-task-pair-mapping/plan.md +++ b/docs/dev-notes/2025-09-23/contest-task-pair-mapping/plan.md @@ -100,7 +100,7 @@ export async function createContestTaskPair( }, }); } catch (error) { - if (error instanceof PrismaClientKnownRequestError && error.code === 'P2002') { + if (error instanceof Prisma.PrismaClientKnownRequestError && error.code === 'P2002') { const errorMessage = `ContestTaskPair already exists: contestId=${contestId}, taskId=${taskId}`; console.error(errorMessage); throw new Error(errorMessage); @@ -132,7 +132,7 @@ export async function updateContestTaskPair( }, }); } catch (error) { - if (error instanceof PrismaClientKnownRequestError && error.code === 'P2025') { + if (error instanceof Prisma.PrismaClientKnownRequestError && error.code === 'P2025') { const errorMessage = `Not found ContestTaskPair: contestId=${contestId}, taskId=${taskId}`; console.error(errorMessage); throw new Error(errorMessage); From e63c20975810e4bf0ef1652d82304089bb2dfbe0 Mon Sep 17 00:00:00 2001 From: Kato Hiroki Date: Wed, 15 Oct 2025 11:40:17 +0000 Subject: [PATCH 23/26] docs: Fix typos/inconsistencies in plan (#2627) --- docs/dev-notes/2025-09-23/contest-task-pair-mapping/plan.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/dev-notes/2025-09-23/contest-task-pair-mapping/plan.md b/docs/dev-notes/2025-09-23/contest-task-pair-mapping/plan.md index da0601352..e831ee12a 100644 --- a/docs/dev-notes/2025-09-23/contest-task-pair-mapping/plan.md +++ b/docs/dev-notes/2025-09-23/contest-task-pair-mapping/plan.md @@ -148,7 +148,7 @@ export async function updateContestTaskPair( - DRY原則に従い `getContestTaskPair()` を共通メソッドとして切り出し - 複合ユニーク制約 `@@unique([contestId, taskId])` を活用 -- 重複チェック・存在確認を事前に実施し、適切なログ出力 +- Prisma のエラーハンドリング(例: `P2002`, `P2025`)を活用して、事前チェックを省略し、TOCTOU (Time-of-Check to Time-of-Use)競合を回避 - Prisma の自動生成型を使用して型安全性を確保 ## 3. 型定義の更新 @@ -202,13 +202,13 @@ type TaskResultMapByContestTaskPair = Map; - [✅] Prisma スキーマに ContestTaskPair モデル追加 - [✅] マイグレーション実行 -- [✅] CURD メソッドを追加 +- [✅] CRUD メソッドを追加 - [✅] TypeScript 型定義追加 ## 決定事項 1. **モデル名**: ContestTaskPair -2. **型名**: ContestTaskPairKey, TaskResultByContestTaskPair +2. **型名**: ContestTaskPairKey, TaskResultMapByContestTaskPair ## 今後の課題 From aee00db515b1a4fd59ce9d728b6eb7cb9fdd72a7 Mon Sep 17 00:00:00 2001 From: Kato Hiroki Date: Wed, 15 Oct 2025 11:56:59 +0000 Subject: [PATCH 24/26] refactor: Use DTO (#2627) --- .../contest-task-pair-mapping/plan.md | 35 +++++++++++++------ src/lib/services/contest_task_pairs.ts | 27 +++++++------- src/lib/types/contest_task_pair.ts | 2 +- 3 files changed, 39 insertions(+), 25 deletions(-) diff --git a/docs/dev-notes/2025-09-23/contest-task-pair-mapping/plan.md b/docs/dev-notes/2025-09-23/contest-task-pair-mapping/plan.md index e831ee12a..743264e3a 100644 --- a/docs/dev-notes/2025-09-23/contest-task-pair-mapping/plan.md +++ b/docs/dev-notes/2025-09-23/contest-task-pair-mapping/plan.md @@ -87,23 +87,32 @@ export async function getContestTaskPairs(): Promise { * ContestTaskPair の新規レコードを作成 */ export async function createContestTaskPair( - contestId: string, - taskTableIndex: string, - taskId: string, + params: ContestTaskPairCreate, ): Promise { + const { contestId, taskTableIndex, taskId } = params; + try { - return await db.contestTaskPair.create({ + const contestTaskPair = await db.contestTaskPair.create({ data: { contestId, taskTableIndex, taskId, }, }); + + console.log('Created ContestTaskPair:', contestTaskPair); + + return contestTaskPair; } catch (error) { if (error instanceof Prisma.PrismaClientKnownRequestError && error.code === 'P2002') { - const errorMessage = `ContestTaskPair already exists: contestId=${contestId}, taskId=${taskId}`; - console.error(errorMessage); - throw new Error(errorMessage); + console.log(`ContestTaskPair already exists: contestId=${contestId}, taskId=${taskId}`); + const existingPair = await getContestTaskPair(contestId, taskId); + + if (!existingPair) { + throw new Error('Unexpected: record exists but cannot be fetched'); + } + + return existingPair; } console.error('Failed to create ContestTaskPair:', error); @@ -115,12 +124,12 @@ export async function createContestTaskPair( * ContestTaskPair のレコードを更新 */ export async function updateContestTaskPair( - contestId: string, - taskTableIndex: string, - taskId: string, + params: ContestTaskPairUpdate, ): Promise { + const { contestId, taskTableIndex, taskId } = params; + try { - return await db.contestTaskPair.update({ + const updatedContestTaskPair = await db.contestTaskPair.update({ where: { contestId_taskId: { contestId, @@ -131,6 +140,10 @@ export async function updateContestTaskPair( taskTableIndex, }, }); + + console.log('Updated ContestTaskPair:', updatedContestTaskPair); + + return updatedContestTaskPair; } catch (error) { if (error instanceof Prisma.PrismaClientKnownRequestError && error.code === 'P2025') { const errorMessage = `Not found ContestTaskPair: contestId=${contestId}, taskId=${taskId}`; diff --git a/src/lib/services/contest_task_pairs.ts b/src/lib/services/contest_task_pairs.ts index 4e4f3d1de..135077dcd 100644 --- a/src/lib/services/contest_task_pairs.ts +++ b/src/lib/services/contest_task_pairs.ts @@ -2,7 +2,12 @@ import { Prisma } from '@prisma/client'; import { default as db } from '$lib/server/database'; -import type { ContestTaskPair, ContestTaskPairs } from '$lib/types/contest_task_pair'; +import type { + ContestTaskPair, + ContestTaskPairs, + ContestTaskPairCreate, + ContestTaskPairUpdate, +} from '$lib/types/contest_task_pair'; /** * Retrieves all ContestTaskPair records from the database. @@ -38,19 +43,17 @@ export async function getContestTaskPair( /** * Creates a new ContestTaskPair record in the database. * - * @param contestId - The ID of the contest. - * @param taskTableIndex - The table index of the task. - * @param taskId - The ID of the task. + * @param params - The parameters for creating a ContestTaskPair. * * @returns The created ContestTaskPair object or the existing one if it already exists. * * @throws Will throw an error if the creation fails. */ export async function createContestTaskPair( - contestId: string, - taskTableIndex: string, - taskId: string, + params: ContestTaskPairCreate, ): Promise { + const { contestId, taskTableIndex, taskId } = params; + try { const contestTaskPair = await db.contestTaskPair.create({ data: { @@ -83,19 +86,17 @@ export async function createContestTaskPair( /** * Updates an existing ContestTaskPair record in the database. * - * @param contestId: The ID of the contest. - * @param taskTableIndex: The table index of the task. - * @param taskId: The ID of the task. + * @param params - The parameters for updating a ContestTaskPair. * * @returns The updated ContestTaskPair object. * * @throws Will throw an error if the update fails or if the record does not exist. */ export async function updateContestTaskPair( - contestId: string, - taskTableIndex: string, - taskId: string, + params: ContestTaskPairUpdate, ): Promise { + const { contestId, taskTableIndex, taskId } = params; + try { const updatedContestTaskPair = await db.contestTaskPair.update({ where: { diff --git a/src/lib/types/contest_task_pair.ts b/src/lib/types/contest_task_pair.ts index 88109b06c..50ca5f4b0 100644 --- a/src/lib/types/contest_task_pair.ts +++ b/src/lib/types/contest_task_pair.ts @@ -15,7 +15,7 @@ export type ContestTaskPairCreate = { taskId: string; }; -export type ContestTaskPairUpdate = Partial; +export type ContestTaskPairUpdate = ContestTaskPairCreate; // For mapping and identification export type ContestTaskPairKey = `${string}:${string}`; // "contest_id:task_id" From c9f2d6bad320da89822b49f9f403928064255971 Mon Sep 17 00:00:00 2001 From: Kato Hiroki Date: Wed, 15 Oct 2025 12:00:47 +0000 Subject: [PATCH 25/26] chore: Preserve original error as cause when rethrowing (#2627) --- docs/dev-notes/2025-09-23/contest-task-pair-mapping/plan.md | 2 +- src/lib/services/contest_task_pairs.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/dev-notes/2025-09-23/contest-task-pair-mapping/plan.md b/docs/dev-notes/2025-09-23/contest-task-pair-mapping/plan.md index 743264e3a..19316ec68 100644 --- a/docs/dev-notes/2025-09-23/contest-task-pair-mapping/plan.md +++ b/docs/dev-notes/2025-09-23/contest-task-pair-mapping/plan.md @@ -148,7 +148,7 @@ export async function updateContestTaskPair( if (error instanceof Prisma.PrismaClientKnownRequestError && error.code === 'P2025') { const errorMessage = `Not found ContestTaskPair: contestId=${contestId}, taskId=${taskId}`; console.error(errorMessage); - throw new Error(errorMessage); + throw new Error(errorMessage, { cause: error as Error }); } console.error('Failed to update ContestTaskPair:', error); diff --git a/src/lib/services/contest_task_pairs.ts b/src/lib/services/contest_task_pairs.ts index 135077dcd..c9c23a7e9 100644 --- a/src/lib/services/contest_task_pairs.ts +++ b/src/lib/services/contest_task_pairs.ts @@ -117,7 +117,7 @@ export async function updateContestTaskPair( if (error instanceof Prisma.PrismaClientKnownRequestError && error.code === 'P2025') { const errorMessage = `Not found ContestTaskPair: contestId=${contestId}, taskId=${taskId}`; console.error(errorMessage); - throw new Error(errorMessage); + throw new Error(errorMessage, { cause: error as Error }); } console.error('Failed to update ContestTaskPair:', error); From 0c431f4ec23a2429134519f960d2baf4a8972545 Mon Sep 17 00:00:00 2001 From: Kato Hiroki Date: Wed, 15 Oct 2025 12:02:17 +0000 Subject: [PATCH 26/26] docs: Fix typo (#2627) --- .../2025-09-23/contest-task-pair-mapping/plan.md | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/docs/dev-notes/2025-09-23/contest-task-pair-mapping/plan.md b/docs/dev-notes/2025-09-23/contest-task-pair-mapping/plan.md index 19316ec68..919f0c41c 100644 --- a/docs/dev-notes/2025-09-23/contest-task-pair-mapping/plan.md +++ b/docs/dev-notes/2025-09-23/contest-task-pair-mapping/plan.md @@ -193,16 +193,18 @@ export type ContestTaskPairUpdate = ContestTaskPairCreate; ### マッピング用の型定義 ```typescript +import type { TaskResult } from '$lib/types/task_result'; + // 型定義 -type ContestTaskPairKey = `${string}:${string}`; // "contest_id:task_id" +export type ContestTaskPairKey = `${string}:${string}`; // "contest_id:task_id" // ヘルパー関数 -function createContestTaskPairKey(contestId: string, taskId: string): ContestTaskPairKey { +export function createContestTaskPairKey(contestId: string, taskId: string): ContestTaskPairKey { return `${contestId}:${taskId}`; } // マップの型(明示的) -type TaskResultMapByContestTaskPair = Map; +export type TaskResultMapByContestTaskPair = Map; ``` **設計判断**: