aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--README.md3
-rw-r--r--src/lib/server/db/errors.test.ts45
-rw-r--r--src/lib/server/db/errors.ts13
-rw-r--r--src/lib/server/services/grocery-actions.ts10
-rw-r--r--src/routes/+page.server.ts10
-rw-r--r--src/routes/grocery/[id]/+page.server.ts10
6 files changed, 86 insertions, 5 deletions
diff --git a/README.md b/README.md
index 936453a..f3f1321 100644
--- a/README.md
+++ b/README.md
@@ -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 };
},