Skip to content

Commit a1b55e0

Browse files
committed
fix: retry concurrent taxonomy slug collisions
1 parent 388ce2f commit a1b55e0

2 files changed

Lines changed: 65 additions & 22 deletions

File tree

packages/core/src/api/handlers/taxonomies.ts

Lines changed: 50 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,15 @@ import { fetchVisibleTermCounts } from "../../taxonomies/term-counts.js";
1919
import type { ApiResult } from "../types.js";
2020

2121
const NAME_PATTERN = /^[a-z][a-z0-9_]*$/;
22+
const MAX_GENERATED_TERM_SLUG_ATTEMPTS = 16;
23+
24+
function isTermSlugUniqueViolation(error: unknown): boolean {
25+
const message = error instanceof Error ? error.message.toLowerCase() : "";
26+
return (
27+
(message.includes("unique constraint failed") || message.includes("duplicate key")) &&
28+
message.includes("slug")
29+
);
30+
}
2231

2332
// ---------------------------------------------------------------------------
2433
// Response types
@@ -820,6 +829,7 @@ export async function handleTermCreate(
820829
translationOf?: string;
821830
},
822831
): Promise<ApiResult<TermResponse>> {
832+
let attemptedSlug = input.slug;
823833
try {
824834
const locale = resolveConfiguredLocale(input.locale ?? getI18nConfig()?.defaultLocale ?? "en");
825835
// Taxonomy definitions are per-locale, but terms can exist in any locale
@@ -829,21 +839,20 @@ export async function handleTermCreate(
829839
if (!lookup.success) return lookup;
830840

831841
const repo = new TaxonomyRepository(db);
832-
const generatedSlug = input.slug === undefined;
833-
const slug = input.slug ?? (await repo.generateUniqueSlug(taxonomyName, input.label, locale));
834842

835843
// Coerce empty-string parentId to undefined (treat as "no parent").
836844
const parentId =
837845
input.parentId === "" || input.parentId === undefined ? undefined : input.parentId;
838846

839847
// Conflict check is scoped to locale (per-locale slugs are unique).
840-
const existing = generatedSlug ? null : await repo.findBySlug(taxonomyName, slug, locale);
848+
const existing =
849+
input.slug === undefined ? null : await repo.findBySlug(taxonomyName, input.slug, locale);
841850
if (existing) {
842851
return {
843852
success: false,
844853
error: {
845854
code: "CONFLICT",
846-
message: `Term '${slug}' already exists in '${taxonomyName}' (${locale})`,
855+
message: `Term '${input.slug}' already exists in '${taxonomyName}' (${locale})`,
847856
},
848857
};
849858
}
@@ -875,15 +884,33 @@ export async function handleTermCreate(
875884
return { success: false, error: parentError };
876885
}
877886

878-
const term = await repo.create({
879-
name: taxonomyName,
880-
slug,
881-
label: input.label,
882-
parentId: parentId ?? undefined,
883-
data: input.description ? { description: input.description } : undefined,
884-
locale,
885-
translationOf: input.translationOf,
886-
});
887+
const create = (slug: string) =>
888+
repo.create({
889+
name: taxonomyName,
890+
slug,
891+
label: input.label,
892+
parentId: parentId ?? undefined,
893+
data: input.description ? { description: input.description } : undefined,
894+
locale,
895+
translationOf: input.translationOf,
896+
});
897+
let term: Awaited<ReturnType<typeof create>> | undefined;
898+
let lastSlugConflict: unknown;
899+
if (input.slug !== undefined) {
900+
term = await create(input.slug);
901+
} else {
902+
for (let attempt = 0; attempt < MAX_GENERATED_TERM_SLUG_ATTEMPTS; attempt++) {
903+
attemptedSlug = await repo.generateUniqueSlug(taxonomyName, input.label, locale);
904+
try {
905+
term = await create(attemptedSlug);
906+
break;
907+
} catch (error) {
908+
if (!isTermSlugUniqueViolation(error)) throw error;
909+
lastSlugConflict = error;
910+
}
911+
}
912+
}
913+
if (!term) throw lastSlugConflict ?? new Error("Failed to create taxonomy term");
887914

888915
invalidateTermCache();
889916

@@ -903,7 +930,16 @@ export async function handleTermCreate(
903930
},
904931
},
905932
};
906-
} catch {
933+
} catch (error) {
934+
if (isTermSlugUniqueViolation(error)) {
935+
return {
936+
success: false,
937+
error: {
938+
code: "CONFLICT",
939+
message: `Term with slug '${attemptedSlug ?? "(generated)"}' already exists in taxonomy '${taxonomyName}'`,
940+
},
941+
};
942+
}
907943
return {
908944
success: false,
909945
error: { code: "TERM_CREATE_ERROR", message: "Failed to create term" },

packages/core/tests/unit/taxonomies/term-slug-generation.test.ts

Lines changed: 15 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,13 @@
1-
import { afterEach, beforeEach, describe, expect, it } from "vitest";
1+
import { afterEach, beforeEach, expect, it } from "vitest";
22

33
import { handleTaxonomyCreate, handleTermCreate } from "../../../src/api/handlers/taxonomies.js";
4-
import { createTermBody } from "../../../src/api/schemas/taxonomies.js";
54
import {
65
describeEachDialect,
76
setupForDialectWithCollections,
87
teardownForDialect,
98
type DialectTestContext,
109
} from "../../utils/test-db.js";
1110

12-
describe("taxonomy term create schema", () => {
13-
it("allows the server to derive a slug from the label", () => {
14-
expect(createTermBody.safeParse({ label: "音楽" }).success).toBe(true);
15-
});
16-
});
17-
1811
describeEachDialect("taxonomy term slug generation", (dialect) => {
1912
let ctx: DialectTestContext;
2013

@@ -66,6 +59,20 @@ describeEachDialect("taxonomy term slug generation", (dialect) => {
6659
expect(second.data.term.slug).toBe("音楽-1");
6760
});
6861

62+
it("recovers when concurrent generated slugs collide", async () => {
63+
const results = await Promise.all(
64+
Array.from({ length: 6 }, () =>
65+
handleTermCreate(ctx.db, "tags", { label: "同時", locale: "en" }),
66+
),
67+
);
68+
69+
expect(results.every((result) => result.success)).toBe(true);
70+
const slugs = results.flatMap((result) => (result.success ? [result.data.term.slug] : []));
71+
expect(new Set(slugs)).toEqual(
72+
new Set(["同時", "同時-1", "同時-2", "同時-3", "同時-4", "同時-5"]),
73+
);
74+
});
75+
6976
it("keeps generated slug uniqueness scoped to the locale", async () => {
7077
const english = await handleTermCreate(ctx.db, "tags", { label: "音楽", locale: "en" });
7178
const japanese = await handleTermCreate(ctx.db, "tags", { label: "音楽", locale: "ja" });

0 commit comments

Comments
 (0)