fix(slugs): disambiguate duplicate set and artist slugs - #289
Conversation
Sets and artists were inserted with a purely deterministic slug, so two records with the same name collided and broke slug-based lookups (useSetBySlug/useArtistBySlug use .single()). On create, retry with a numeric counter suffix (-2, -3, ...) when the slug collides, backed by a DB-level unique constraint so concurrent creates can't still collide: sets are unique per festival edition (matching lookup scope), artists already had a global unique constraint. Closes #135 Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LBa5F78BNbcTrjeAbYt5J6
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
|
Deploy →
|
Qodo reviews are paused for this user.Troubleshooting steps vary by plan Learn more → On a Teams plan? Using GitHub Enterprise Server, GitLab Self-Managed, or Bitbucket Data Center? |
|
The Fixing this requires resetting/re-seeding the staging DB ( Generated by Claude Code |
Playwright test resultsDetails
|
Replace the app-level slug-counter logic with BEFORE INSERT triggers on sets and artists, so every insert path is deduped automatically instead of relying on each caller to pre-check or retry with a counter itself. commit_schedule__upsert_artists relied on ON CONFLICT (slug) DO UPDATE to reactivate archived artists, which would have raced the trigger (the trigger could rewrite the slug before the conflict check runs, breaking the match). Replaced it with an explicit "reactivate archived match, else insert" — safe because artistsToCreate only ever contains slugs absent among *active* artists, so a collision can only be archived. App code (useCreateSet/useCreateArtist) now just retries the plain insert a few times on 23505, as a backstop for the trigger racing a concurrent insert to the same candidate slug. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LBa5F78BNbcTrjeAbYt5J6
|
Expanded the fix per discussion: slug dedup now lives in This required touching App code is now a thin retry-on- Generated by Claude Code |
…ocks Move slug generation itself into the insert triggers (public.slugify, mirroring generateSlug/commit_schedule__slugify), not just dedup — the frontend no longer computes or passes a meaningful slug on create, so there's a single source of truth. commit_schedule__slugify now delegates to public.slugify instead of duplicating the same regex. Also close the dedup race outright: each trigger takes a transaction- scoped advisory lock keyed on (scope, base slug) before checking for a collision, so concurrent creates for the same name serialize instead of racing to the same candidate. That removes the need for the app-level retry-on-23505 loop from the previous commit — useCreateSet/ useCreateArtist are back to a single plain insert. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LBa5F78BNbcTrjeAbYt5J6
|
Simplified further per discussion:
Typecheck/lint/unit tests all still green locally. Generated by Claude Code |
The dedupe triggers were unconditionally recomputing the slug from
`name`, ignoring whatever the caller passed in. That broke
commit_schedule and its tests: they insert artists with a precomputed
slug and then look that exact artist back up by slug later in the same
call (e.g. via artistSlugs) -- recomputing from name produced a
different value and the lookup found nothing ("Unknown artist slug(s)
in payload... resolved 0").
Only fall back to deriving from `name` when the caller didn't supply a
slug (the app create paths now pass an empty placeholder); otherwise
use the caller's slug as the dedupe base, same as before this PR's
last commit.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LBa5F78BNbcTrjeAbYt5J6
|
CI caught a real regression: Fixed: the trigger now only derives from Generated by Claude Code |
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 6 out of 6 changed files in this pull request and generated no new comments.
Suppressed comments (1)
supabase/migrations/20260804101135_reactivate_archived_artists_on_upsert.sql:32
- commit_schedule__upsert_artists can still INSERT a new row even when an active artist already exists for the incoming slug (e.g., manual RPC call, or a race where the diff step was computed before another transaction created the artist). With artists_dedupe_slug now rewriting slugs on insert, that can create unexpected extra artists ("-2", "-3", …) while downstream set syncing continues to reference the original payload slugs (potentially linking sets to the pre-existing artist instead of the newly inserted one). Consider taking the same advisory lock key as the trigger and skipping the INSERT when any artist already exists for that slug.
UPDATE artists
SET name = v_elem->>'name', archived = false
WHERE slug = v_elem->>'slug' AND archived = true;
IF NOT FOUND THEN
INSERT INTO artists (name, slug, added_by)
VALUES (v_elem->>'name', v_elem->>'slug', p_user_id);
END IF;
…ments - commit_schedule__upsert_stages and commit_schedule__create_sets now call public.slugify() directly instead of going through the commit_schedule__slugify wrapper, which is dropped since it's now unused (no reason for the extra indirection). - Shorten the placeholder-slug comments in useCreateSet/useCreateArtist to one line. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LBa5F78BNbcTrjeAbYt5J6
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 7 out of 7 changed files in this pull request and generated no new comments.
Suppressed comments (5)
supabase/migrations/20260804101202_add_slug_dedupe_triggers.sql:87
- The artist slug dedupe trigger only runs on INSERT. If an artist is renamed to a name whose slug collides with another artist, the update path will currently hit the unique constraint instead of applying the same numeric disambiguation. Consider firing the trigger on UPDATE of name/slug too.
CREATE TRIGGER artists_dedupe_slug_trigger
BEFORE INSERT ON public.artists
FOR EACH ROW
EXECUTE FUNCTION public.artists_dedupe_slug();
supabase/migrations/20260804101202_add_slug_dedupe_triggers.sql:54
- The slug dedupe trigger only runs on INSERT. With the new (festival_edition_id, slug) unique constraint, renaming a set (or changing its slug) to a colliding value will now throw a constraint error instead of applying the same "-2" disambiguation behavior. Consider firing this trigger on UPDATE of name/slug as well to keep behavior consistent.
This issue also appears on line 84 of the same file.
CREATE TRIGGER sets_dedupe_slug_trigger
BEFORE INSERT ON public.sets
FOR EACH ROW
EXECUTE FUNCTION public.sets_dedupe_slug();
supabase/migrations/20260804103506_commit_schedule_slugify_delegates.sql:62
- Using only the first 8 chars of the UUID to disambiguate set slugs is not guaranteed unique; in the unlikely event of a prefix collision within an edition this UPDATE will violate the (festival_edition_id, slug) unique constraint and abort the import. Prefer appending the full UUID (as done in the sets slug dedupe migration) or a longer suffix with a uniqueness check.
UPDATE sets
SET slug = slug || '-' || SUBSTRING(v_new_set_id::text, 1, 8)
WHERE id = v_new_set_id;
supabase/migrations/20260804101135_reactivate_archived_artists_on_upsert.sql:26
- With artists_dedupe_slug in place, an INSERT here can silently get a suffixed slug (e.g. "foo-2") if the requested slug already exists, which can leave newly created artists unreferenced by the payload (since sets link artists by the original slug). Guard against inserting when any row already exists for the slug so commit_schedule stays slug-stable/idempotent.
IF NOT FOUND THEN
INSERT INTO artists (name, slug, added_by)
VALUES (v_elem->>'name', v_elem->>'slug', p_user_id);
END IF;
src/api/sets/useCreateSet.ts:31
- PR description says
pnpm testcovers "slugCandidate" counter logic insrc/lib/slug.test.ts, but the current tests only cover generateSlug/isValidSlug/sanitizeSlug and do not exercise collision disambiguation (which is now implemented in DB triggers). Please update the PR description or add a test that validates the new collision behavior.
slug: "", // auto-generated by the sets_dedupe_slug DB trigger
Disambiguates a colliding set/artist slug with a numeric counter (
-2,-3, ...) instead of inserting a duplicate. Generation and dedup now happen entirely at the DB layer — aBEFORE INSERTtrigger derives the slug (viapublic.slugify) and resolves collisions under a transaction-scoped advisory lock, backed by a unique constraint (sets: per festival edition; artists: global, already existed) so concurrent creates can't slip through either. The frontend no longer computes slugs client-side.Closes #135
Verification
-2and its detail page loads.-2and its detail page loads.supabase/functions/commit-schedule/commit-schedule.test.ts) —two sets with the same name get distinct slugsexercises the DB-level dedup via thecommit_scheduleRPC.Generated by Claude Code