diff --git a/apps/cli/package.json b/apps/cli/package.json index addd4e5..b0775e9 100644 --- a/apps/cli/package.json +++ b/apps/cli/package.json @@ -1,6 +1,6 @@ { "name": "claudemesh-cli", - "version": "1.21.0", + "version": "1.21.1", "description": "Peer mesh for Claude Code sessions — CLI + MCP server.", "keywords": [ "claude-code", diff --git a/apps/cli/src/commands/rename.ts b/apps/cli/src/commands/rename.ts index d41ad29..630cc33 100644 --- a/apps/cli/src/commands/rename.ts +++ b/apps/cli/src/commands/rename.ts @@ -63,6 +63,7 @@ export async function rename(oldSlug: string, newSlug: string): Promise if (err.status === 401) return EXIT.AUTH_FAILED; if (err.status === 403) return EXIT.PERMISSION_DENIED; if (err.status === 404) return EXIT.NOT_FOUND; + if (err.status === 409) return EXIT.ALREADY_EXISTS; if (err.status === 400) return EXIT.INVALID_ARGS; return EXIT.INTERNAL_ERROR; } diff --git a/apps/web/src/app/api/cli/meshes/[slug]/route.ts b/apps/web/src/app/api/cli/meshes/[slug]/route.ts index 695850e..d9a4865 100644 --- a/apps/web/src/app/api/cli/meshes/[slug]/route.ts +++ b/apps/web/src/app/api/cli/meshes/[slug]/route.ts @@ -130,11 +130,26 @@ export async function PATCH( // Soft-collapse: name and slug are a single concept user-facing, // so we always sync name = slug. mesh.name column stays for now // (avoids touching ~25 reader sites); a future migration drops it. - const [updated] = await db - .update(mesh) - .set({ slug: newSlug, name: newSlug }) - .where(eq(mesh.slug, slug)) - .returning({ slug: mesh.slug }); - - return NextResponse.json(updated); + // + // The DB has a unique constraint on mesh.slug (mesh_slug_unique) + // even though the schema comment incorrectly claims "NOT unique" — + // catch the duplicate-key error and surface it as 409 instead of + // a generic 500. + try { + const [updated] = await db + .update(mesh) + .set({ slug: newSlug, name: newSlug }) + .where(eq(mesh.slug, slug)) + .returning({ slug: mesh.slug }); + return NextResponse.json(updated); + } catch (e) { + const err = e as { cause?: { constraint_name?: string; code?: string } }; + if (err.cause?.constraint_name === "mesh_slug_unique" || err.cause?.code === "23505") { + return NextResponse.json( + { error: `slug "${newSlug}" is already taken` }, + { status: 409 }, + ); + } + throw e; + } } diff --git a/packages/db/src/schema/mesh.ts b/packages/db/src/schema/mesh.ts index c254023..24f85b9 100644 --- a/packages/db/src/schema/mesh.ts +++ b/packages/db/src/schema/mesh.ts @@ -78,12 +78,14 @@ export const mesh = meshSchema.table("mesh", { id: text().primaryKey().notNull().$defaultFn(generateId), name: text().notNull(), /** - * Cosmetic slug derived from name at creation. NOT unique, NOT used for - * identity — `mesh.id` is the canonical identifier everywhere (URLs, - * invites, broker lookups). Kept for display/debugging only. Two meshes - * can freely share a slug. + * URL-safe identifier — globally unique across all meshes (constraint + * `mesh_slug_unique` on the DB). `mesh.id` (UUID) remains the canonical + * primary key for FKs and broker routing, but slug is the identifier + * users see and type (CLI picker, --mesh flag, dashboard sidebar). + * v0.7.0 collapsed this with mesh.name; both columns now hold the + * same value, and a follow-up migration drops mesh.name. */ - slug: text().notNull(), + slug: text().notNull().unique(), ownerUserId: text() .references(() => user.id, { onDelete: "cascade", onUpdate: "cascade" }) .notNull(),