Skip to content

Commit 102f6d5

Browse files
committed
fix: Double-submit could throw an uncaught unique constraint violation (#943)
1 parent 3a8f320 commit 102f6d5

4 files changed

Lines changed: 34 additions & 4 deletions

File tree

.claude/rules/prisma-db.md

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,3 +45,24 @@ paths:
4545

4646
- Use `prisma.$transaction()` for multi-step operations
4747
- Handle errors with try-catch and proper rollback
48+
49+
## Idempotent Writes
50+
51+
- Prefer `createMany({ skipDuplicates: true })` over try-catching P2002 when a unique constraint violation is expected (e.g., double-submit race condition). It maps to `INSERT ... ON CONFLICT DO NOTHING` and keeps intent clear.
52+
- Constraints: top-level `createMany` only (not nested); PostgreSQL, CockroachDB, SQLite only.
53+
54+
## Validate Constraints
55+
56+
Prisma does not support `@@check` in `schema.prisma`. To add a validate constraint:
57+
58+
1. Run `pnpm exec prisma migrate dev --create-only --name <description>` to generate the migration file without applying it
59+
2. Edit the generated `migration.sql` to add the validate constraint manually
60+
3. Run `pnpm exec prisma migrate dev` to apply
61+
62+
After adding a validate constraint, add a comment to `docs/erd.md` under the relevant entity:
63+
64+
```
65+
%% XOR constraint: workbookplacement_xor_grade_category — exactly one of taskGrade or solutionCategory must be non-null
66+
```
67+
68+
This is the only place validate constraints are visible, since Prisma omits them from `schema.prisma`.

docs/dev-notes/2026-02-28/workbook-order/plan.md

Lines changed: 1 addition & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -120,6 +120,7 @@ XOR 制約は `prisma migrate dev --create-only` で migration ファイルを
120120

121121
- バリデーションループに DB 呼び出しが入ったら N+1 を疑い、`findMany({ where: { id: { in: ids } } })` + `Map` パターンに置き換える
122122
- Prisma enum とアプリ enum は構造が同じでも TypeScript は別型として扱う。キャストが必要な箇所を残すこと
123+
- ダブルサブミットなどレースコンディションによる unique 制約違反は `try-catch` で P2002 を握りつぶすより `createMany({ skipDuplicates: true })` で冪等な書き込みを DB に委ねる。PostgreSQL/CockroachDB/SQLite のみ対応・nested createMany 不可に注意
123124

124125
### Svelte 5
125126

@@ -150,10 +151,6 @@ XOR 制約は `prisma migrate dev --create-only` で migration ファイルを
150151

151152
| 教訓 | 一般化先 | ステータス |
152153
| ------------------------------------- | ------------------------------------ | ---------- |
153-
| `redirect()` vs `error()` の使い分け | `.claude/rules/auth.md` | 実装済み |
154-
| features テスト配置コロケーション | `AGENTS.md` Project Structure | 実装済み |
155-
| DB CHECK 制約 → ERD.md コメント | `.claude/rules/prisma-db.md` | 実装済み |
156-
| スキル CLI コマンドの事前動作確認 | `.claude/rules/skills.md`(新規) | 実装済み |
157154
| `try/finally` クリーンアップ | `.claude/rules/testing.md` | 検討中 |
158155
| `z.number().int()` 整数バリデーション | `.claude/rules/testing.md` | 検討中 |
159156
| `onpointerdown` も止める(DnD) | `.claude/rules/svelte-components.md` | 検討中 |

src/features/workbooks/services/workbook_placements.test.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -214,6 +214,17 @@ describe('createInitialPlacements', () => {
214214
const callArg = vi.mocked(prisma.workBookPlacement.createMany).mock.calls[0][0];
215215
expect(callArg?.data).toHaveLength(4); // 2 curriculum + 2 solution
216216
});
217+
218+
test('calls createMany with skipDuplicates to tolerate concurrent double-submit', async () => {
219+
mockWorkBookFindManyOnce(unplacedCurriculumRows);
220+
mockWorkBookFindManyOnce(unplacedSolutionWorkbooks);
221+
vi.mocked(prisma.workBookPlacement.createMany).mockResolvedValue({ count: 4 });
222+
223+
await createInitialPlacements();
224+
225+
const callArg = vi.mocked(prisma.workBookPlacement.createMany).mock.calls[0][0];
226+
expect(callArg?.skipDuplicates).toBe(true);
227+
});
217228
});
218229

219230
describe('buildTaskMapFromCurriculumRows', () => {

src/features/workbooks/services/workbook_placements.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,7 @@ export async function createInitialPlacements(): Promise<void> {
8989

9090
await prisma.workBookPlacement.createMany({
9191
data: [...curriculumPlacements, ...solutionPlacements],
92+
skipDuplicates: true,
9293
});
9394
}
9495

0 commit comments

Comments
 (0)