diff options
| author | Martin Kagamino Lehoux <martin@lehoux.net> | 2026-10-18 21:47:21 +0200 |
|---|---|---|
| committer | Martin Kagamino Lehoux <martin@lehoux.net> | 2026-10-18 21:47:21 +0200 |
| commit | c3c61894d2b119ae424bac605d4b3099e0b674e5 (patch) | |
| tree | c525ebf4d33cbdfac22fc8702367f20d8f7ba344 | |
| parent | c2722b62832afe2d028f1bcb6954116f7eec9cde (diff) | |
fix: report a duplicate name instead of failing the page
Adding a category or project whose name exists, or renaming an item onto an
existing name, hit the unique constraint and rendered SvelteKit's 500 page.
Translate SQLITE_CONSTRAINT_UNIQUE into a form error on the three forms.
| -rw-r--r-- | README.md | 3 | ||||
| -rw-r--r-- | src/lib/server/db/errors.test.ts | 45 | ||||
| -rw-r--r-- | src/lib/server/db/errors.ts | 13 | ||||
| -rw-r--r-- | src/lib/server/services/grocery-actions.ts | 10 | ||||
| -rw-r--r-- | src/routes/+page.server.ts | 10 | ||||
| -rw-r--r-- | src/routes/grocery/[id]/+page.server.ts | 10 |
6 files changed, 86 insertions, 5 deletions
@@ -81,8 +81,6 @@ mise run deploy ## TODO -- Duplicate-name forms return a 500 (catch the unique constraint on - groceries, categories and projects) - Purchase history (`checked_at` → "acheté il y a 3 jours", catalog ordered by last bought) - Automated database backup (cron + retention + a restore drill) @@ -94,3 +92,4 @@ mise run deploy - Auto-categorize new items (LLM-assisted) - https://automerge.org/docs/tutorial/ - Shared > Modal actions +- Result type over throws diff --git a/src/lib/server/db/errors.test.ts b/src/lib/server/db/errors.test.ts new file mode 100644 index 0000000..d19aec2 --- /dev/null +++ b/src/lib/server/db/errors.test.ts @@ -0,0 +1,45 @@ +import { describe, it, expect, beforeEach } from 'vitest'; +import { groceries, categories } from '$lib/server/db/schema'; +import { eq } from 'drizzle-orm'; +import { createTestDb, type TestDb } from '$lib/server/db/test-helper'; +import { isUniqueViolation } from './errors'; + +describe('isUniqueViolation', () => { + let db: TestDb['db']; + + beforeEach(async () => { + db = (await createTestDb()).db; + await db.insert(categories).values({ name: 'Épices' }); + await db + .insert(groceries) + .values({ name: 'olives', createdAt: '2026-01-01T00:00:00.000Z' }); + }); + + it('detects a duplicate insert on a unique column', async () => { + const error = await db + .insert(categories) + .values({ name: 'Épices' }) + .catch((e: unknown) => e); + + expect(isUniqueViolation(error)).toBe(true); + }); + + it('detects a duplicate name on update', async () => { + await db + .insert(groceries) + .values({ name: 'pain', createdAt: '2026-01-02T00:00:00.000Z' }); + + const error = await db + .update(groceries) + .set({ name: 'olives' }) + .where(eq(groceries.name, 'pain')) + .catch((e: unknown) => e); + + expect(isUniqueViolation(error)).toBe(true); + }); + + it('leaves other failures alone', () => { + expect(isUniqueViolation(new Error('network is unreachable'))).toBe(false); + expect(isUniqueViolation(undefined)).toBe(false); + }); +}); diff --git a/src/lib/server/db/errors.ts b/src/lib/server/db/errors.ts new file mode 100644 index 0000000..4b101f8 --- /dev/null +++ b/src/lib/server/db/errors.ts @@ -0,0 +1,13 @@ +/** + * True when the failure is SQLite's UNIQUE constraint. Drizzle wraps driver + * errors, so the libsql error sits at some depth of the `cause` chain. + */ +export function isUniqueViolation(error: unknown): boolean { + let current: unknown = error; + while (current instanceof Error) { + if ('code' in current && current.code === 'SQLITE_CONSTRAINT_UNIQUE') + return true; + current = current.cause; + } + return false; +} diff --git a/src/lib/server/services/grocery-actions.ts b/src/lib/server/services/grocery-actions.ts index 1cfb05d..cedb46e 100644 --- a/src/lib/server/services/grocery-actions.ts +++ b/src/lib/server/services/grocery-actions.ts @@ -2,6 +2,7 @@ import { fail, redirect, type ActionFailure } from '@sveltejs/kit'; import { addOrUncheck, loadUnchecked, toggle } from './groceries'; import { add as addCategory } from './categories'; import { numericId, parseFormString, parseOptionalNumber } from './validation'; +import { isUniqueViolation } from '$lib/server/db/errors'; export async function toggleGroceryAction(request: Request) { const data = await request.formData(); @@ -50,7 +51,14 @@ export async function addCategoryAction(request: Request) { return name; } - await addCategory(name); + try { + await addCategory(name); + } catch (error) { + if (isUniqueViolation(error)) { + return fail(400, { error: 'Cette catégorie existe déjà' }); + } + throw error; + } return { success: true }; } diff --git a/src/routes/+page.server.ts b/src/routes/+page.server.ts index fe9b4e3..008f7f3 100644 --- a/src/routes/+page.server.ts +++ b/src/routes/+page.server.ts @@ -2,6 +2,7 @@ import { fail } from '@sveltejs/kit'; import { loadOverview } from '$lib/server/services/routines'; import { add as addProject } from '$lib/server/services/projects'; import { parseFormString } from '$lib/server/services/validation'; +import { isUniqueViolation } from '$lib/server/db/errors'; import type { Actions, PageServerLoad } from './$types'; export const load: PageServerLoad = async () => { @@ -14,7 +15,14 @@ export const actions: Actions = { if (!name) { return fail(400, { error: 'Le nom est requis' }); } - await addProject(name); + try { + await addProject(name); + } catch (error) { + if (isUniqueViolation(error)) { + return fail(400, { error: 'Ce projet existe déjà' }); + } + throw error; + } return { success: true }; }, }; diff --git a/src/routes/grocery/[id]/+page.server.ts b/src/routes/grocery/[id]/+page.server.ts index a4ad377..232568c 100644 --- a/src/routes/grocery/[id]/+page.server.ts +++ b/src/routes/grocery/[id]/+page.server.ts @@ -16,6 +16,7 @@ import { parseFormString, parseOptionalNumber, } from '$lib/server/services/validation'; +import { isUniqueViolation } from '$lib/server/db/errors'; import type { Category, Grocery, Reference } from '$lib/server/services/types'; import type { Actions, PageServerLoad } from './$types'; @@ -86,7 +87,14 @@ export const actions: Actions = { return fail(400, { error: 'Le nom est requis' }); } - await updateGrocery(groceryId, name, categoryId); + try { + await updateGrocery(groceryId, name, categoryId); + } catch (error) { + if (isUniqueViolation(error)) { + return fail(400, { error: 'Un article porte déjà ce nom' }); + } + throw error; + } return { success: true, type: 'update' as const }; }, |
