From 8010f8e3cfc2fd667323d08d689cc4a8caec2ec7 Mon Sep 17 00:00:00 2001 From: juprodh Date: Fri, 22 May 2026 14:33:33 +0800 Subject: [PATCH] Handle errors duplicate notes --- src/lib/errors.ts | 9 ++++++ src/lib/orchestrators/note.ts | 38 +++++++++++++++-------- src/lib/orchestrators/wiki.ts | 34 ++++++++++++++------- src/server/db/queries/index.ts | 1 + src/server/db/queries/wiki.ts | 18 +++++++++++ tests/lib/errors.test.ts | 30 +++++++++++++++++++ tests/server/db/queries/wiki.test.ts | 45 ++++++++++++++++++++++++++++ 7 files changed, 151 insertions(+), 24 deletions(-) diff --git a/src/lib/errors.ts b/src/lib/errors.ts index 8fb9936..a09240c 100644 --- a/src/lib/errors.ts +++ b/src/lib/errors.ts @@ -11,6 +11,15 @@ export function formatError(err: unknown): string { return err instanceof Error ? err.message : String(err); } +export function isUniqueConstraintError(err: unknown): boolean { + if (!(err instanceof Error) || !("code" in err)) return false; + const code = (err as { code: unknown }).code; + return ( + code === "SQLITE_CONSTRAINT_PRIMARYKEY" || + code === "SQLITE_CONSTRAINT_UNIQUE" + ); +} + export class AppError extends Error { readonly statusCode: number; readonly i18nKey: ErrorI18nKey | null; diff --git a/src/lib/orchestrators/note.ts b/src/lib/orchestrators/note.ts index 1118b7d..7b5f6d7 100644 --- a/src/lib/orchestrators/note.ts +++ b/src/lib/orchestrators/note.ts @@ -21,7 +21,12 @@ import { } from "../attachments.ts"; import { COLLECTIONS } from "../constants.ts"; import { createDiff } from "../diff.ts"; -import { ForbiddenError, NotFoundError, ValidationError } from "../errors.ts"; +import { + ForbiddenError, + isUniqueConstraintError, + NotFoundError, + ValidationError, +} from "../errors.ts"; import { fmt, type Messages } from "../i18n/index.ts"; import { LIMITS } from "../limits.ts"; import { validateNewNote } from "../note-validation.ts"; @@ -158,18 +163,25 @@ export async function createNoteAction( }); } - createNote( - noteAtUri, - revisionAtUri, - ctx.wiki.at_uri, - ctx.wiki.slug, - noteSlug, - fields.title, - did, - fields.content, - fields.message, - blobs.length > 0 ? blobs : undefined, - ); + try { + createNote( + noteAtUri, + revisionAtUri, + ctx.wiki.at_uri, + ctx.wiki.slug, + noteSlug, + fields.title, + did, + fields.content, + fields.message, + blobs.length > 0 ? blobs : undefined, + ); + } catch (err) { + if (isUniqueConstraintError(err)) { + throw new ValidationError(fmt(msg.error.slugExists, { slug: noteSlug })); + } + throw err; + } return { noteSlug }; } diff --git a/src/lib/orchestrators/wiki.ts b/src/lib/orchestrators/wiki.ts index 1c17ee4..4020b94 100644 --- a/src/lib/orchestrators/wiki.ts +++ b/src/lib/orchestrators/wiki.ts @@ -11,6 +11,7 @@ import { createNote, deleteWikiByAtUri, getWiki, + insertWiki, listMembers, setWikiTheme, upsertMembership, @@ -21,7 +22,11 @@ import type { RequestContext, WikiRequestContext } from "../access.ts"; import { parseAtUri } from "../at-uri.ts"; import { COLLECTIONS } from "../constants.ts"; import { createDiff } from "../diff.ts"; -import { ForbiddenError, ValidationError } from "../errors.ts"; +import { + ForbiddenError, + isUniqueConstraintError, + ValidationError, +} from "../errors.ts"; import { fmt, type Messages, t } from "../i18n/index.ts"; import { LIMITS } from "../limits.ts"; import { isValidSlug, slugify } from "../slug.ts"; @@ -139,16 +144,23 @@ export async function createWikiCore( } // DB writes after all PDS writes succeed - upsertWiki( - slug, - did, - fields.name, - validVisibility, - atUri, - now, - fields.language, - description, - ); + try { + insertWiki( + slug, + did, + fields.name, + validVisibility, + atUri, + now, + fields.language, + description, + ); + } catch (err) { + if (isUniqueConstraintError(err)) { + throw new ValidationError(fmt(msg.error.wikiSlugExists, { slug })); + } + throw err; + } upsertMembership(atUri, slug, did, "admin", membershipAtUri, now); return { wikiSlug: slug, wikiAtUri: atUri, agent, did, now }; diff --git a/src/server/db/queries/index.ts b/src/server/db/queries/index.ts index c880e8a..a56212f 100644 --- a/src/server/db/queries/index.ts +++ b/src/server/db/queries/index.ts @@ -55,6 +55,7 @@ export { getWiki, getWikiByAtUri, getWikiLanguages, + insertWiki, listCollaboratingWikis, listOwnedWikis, listPublicNotesForSitemap, diff --git a/src/server/db/queries/wiki.ts b/src/server/db/queries/wiki.ts index e19492d..8c22f73 100644 --- a/src/server/db/queries/wiki.ts +++ b/src/server/db/queries/wiki.ts @@ -161,6 +161,24 @@ export function getWikiByAtUri(atUri: string): WikiRow | null { ); } +export function insertWiki( + slug: string, + did: string, + name: string, + visibility: string, + atUri: string, + createdAt: string, + language = "en", + description = "", +): void { + const db = getDb(); + db.run( + `INSERT INTO wikis (slug, did, name, visibility, language, description, at_uri, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, datetime('now'))`, + [slug, did, name, visibility, language, description, atUri, createdAt], + ); +} + export function upsertWiki( slug: string, did: string, diff --git a/tests/lib/errors.test.ts b/tests/lib/errors.test.ts index 45b8096..fc084ce 100644 --- a/tests/lib/errors.test.ts +++ b/tests/lib/errors.test.ts @@ -3,6 +3,7 @@ import { AppError, ForbiddenError, formatError, + isUniqueConstraintError, NotFoundError, PdsWriteError, ValidationError, @@ -50,3 +51,32 @@ describe("formatError", () => { expect(formatError(null)).toBe("null"); }); }); + +describe("isUniqueConstraintError", () => { + test("matches SQLITE_CONSTRAINT_PRIMARYKEY", () => { + const err = Object.assign(new Error("PK"), { + code: "SQLITE_CONSTRAINT_PRIMARYKEY", + }); + expect(isUniqueConstraintError(err)).toBe(true); + }); + + test("matches SQLITE_CONSTRAINT_UNIQUE", () => { + const err = Object.assign(new Error("UQ"), { + code: "SQLITE_CONSTRAINT_UNIQUE", + }); + expect(isUniqueConstraintError(err)).toBe(true); + }); + + test("rejects unrelated errors and non-Error values", () => { + expect(isUniqueConstraintError(new Error("plain"))).toBe(false); + expect( + isUniqueConstraintError( + Object.assign(new Error("fk"), { + code: "SQLITE_CONSTRAINT_FOREIGNKEY", + }), + ), + ).toBe(false); + expect(isUniqueConstraintError(null)).toBe(false); + expect(isUniqueConstraintError("nope")).toBe(false); + }); +}); diff --git a/tests/server/db/queries/wiki.test.ts b/tests/server/db/queries/wiki.test.ts index 054bc33..28666a3 100644 --- a/tests/server/db/queries/wiki.test.ts +++ b/tests/server/db/queries/wiki.test.ts @@ -1,10 +1,12 @@ import { afterAll, beforeAll, describe, expect, test } from "bun:test"; +import { isUniqueConstraintError } from "../../../../src/lib/errors.ts"; import { getDb } from "../../../../src/server/db/index.ts"; import { deleteWikiByAtUri, getWiki, getWikiByAtUri, getWikiLanguages, + insertWiki, listPublicWikisPaginated, upsertWiki, } from "../../../../src/server/db/queries/index.ts"; @@ -18,6 +20,8 @@ const CLEANUP_SLUGS = [ TEST_SLUG, "upsert-test-new", "upsert-test-update", + "insert-test-new", + "insert-test-dup", "delete-test-wiki", "lang-test-en", "lang-test-fr", @@ -73,6 +77,47 @@ describe("getWikiByAtUri", () => { }); }); +describe("insertWiki", () => { + test("inserts a new wiki", () => { + insertWiki( + "insert-test-new", + "did:plc:mock123", + "Insert New", + "public", + "at://did:plc:mock123/wiki.lichen.wiki/insert-test-new", + "2026-01-01T00:00:00.000Z", + ); + expect(getWiki("did:plc:mock123", "insert-test-new")?.name).toBe( + "Insert New", + ); + }); + + test("throws unique-constraint error on duplicate (did, slug)", () => { + insertWiki( + "insert-test-dup", + "did:plc:mock123", + "Dup", + "public", + "at://did:plc:mock123/wiki.lichen.wiki/insert-test-dup", + "2026-01-01T00:00:00.000Z", + ); + let caught: unknown; + try { + insertWiki( + "insert-test-dup", + "did:plc:mock123", + "Dup Retry", + "public", + "at://did:plc:mock123/wiki.lichen.wiki/insert-test-dup-retry", + "2026-01-01T00:00:00.000Z", + ); + } catch (err) { + caught = err; + } + expect(isUniqueConstraintError(caught)).toBe(true); + }); +}); + describe("upsertWiki", () => { test("inserts a new wiki", () => { upsertWiki( -- 2.51.2