From 142d2907f8b49a92a942ec7ceb8d0bdf125d4a37 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 4 Aug 2026 09:05:41 +0000 Subject: [PATCH 1/8] fix(slugs): disambiguate duplicate set and artist slugs on create 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 Claude-Session: https://claude.ai/code/session_01LBa5F78BNbcTrjeAbYt5J6 --- src/api/artists/useCreateArtist.ts | 51 ++++++++++----- src/api/sets/useCreateSet.ts | 62 +++++++++++-------- src/lib/slug.test.ts | 14 ++++- src/lib/slug.ts | 8 +++ .../20260804090308_add_sets_slug_unique.sql | 30 +++++++++ 5 files changed, 124 insertions(+), 41 deletions(-) create mode 100644 supabase/migrations/20260804090308_add_sets_slug_unique.sql diff --git a/src/api/artists/useCreateArtist.ts b/src/api/artists/useCreateArtist.ts index 16d9ced5..e06a6e9e 100644 --- a/src/api/artists/useCreateArtist.ts +++ b/src/api/artists/useCreateArtist.ts @@ -1,10 +1,16 @@ import { useMutation, useQueryClient } from "@tanstack/react-query"; import { useToast } from "@/hooks/use-toast"; import { supabase } from "@/integrations/supabase/client"; -import { generateSlug } from "@/lib/slug"; +import type { Database } from "@/integrations/supabase/types"; +import { generateSlug, slugCandidate } from "@/lib/slug"; import type { Artist } from "./types"; import { artistsKeys } from "./types"; +type ArtistRow = Database["public"]["Tables"]["artists"]["Row"]; + +const UNIQUE_VIOLATION = "23505"; +const MAX_SLUG_ATTEMPTS = 50; + // Mutation function async function createArtist( artistData: Omit< @@ -27,21 +33,36 @@ async function createArtist( }, ): Promise { const { genre_ids, ...artist } = artistData; - // First, create the artist without slug - const { data, error } = await supabase - .from("artists") - .insert({ - ...artist, - slug: generateSlug(artist.name), - created_at: new Date().toISOString(), - updated_at: new Date().toISOString(), - }) - .select("*") - .single(); + const baseSlug = generateSlug(artist.name); + + let data: ArtistRow | null = null; + + for (let attempt = 1; attempt <= MAX_SLUG_ATTEMPTS; attempt++) { + const result = await supabase + .from("artists") + .insert({ + ...artist, + slug: slugCandidate(baseSlug, attempt), + created_at: new Date().toISOString(), + updated_at: new Date().toISOString(), + }) + .select("*") + .single(); + + if (result.error) { + if (result.error.code === UNIQUE_VIOLATION) { + continue; + } + console.error("Error creating artist:", result.error); + throw new Error("Failed to create artist"); + } + + data = result.data; + break; + } - if (error) { - console.error("Error creating artist:", error); - throw new Error("Failed to create artist"); + if (!data) { + throw new Error("Failed to create artist: could not find a unique slug"); } if (genre_ids.length > 0) { diff --git a/src/api/sets/useCreateSet.ts b/src/api/sets/useCreateSet.ts index 2e9611ae..ceb2789d 100644 --- a/src/api/sets/useCreateSet.ts +++ b/src/api/sets/useCreateSet.ts @@ -2,11 +2,14 @@ import { useMutation, useQueryClient } from "@tanstack/react-query"; import { useToast } from "@/hooks/use-toast"; import { supabase } from "@/integrations/supabase/client"; import type { Database } from "@/integrations/supabase/types"; -import { generateSlug } from "@/lib/slug"; +import { generateSlug, slugCandidate } from "@/lib/slug"; import { FestivalSet, setsKeys } from "./types"; type SetInsert = Database["public"]["Tables"]["sets"]["Insert"]; +const UNIQUE_VIOLATION = "23505"; +const MAX_SLUG_ATTEMPTS = 50; + // Mutation function async function createSet( setData: Omit< @@ -21,34 +24,43 @@ async function createSet( | "slug" >, ): Promise { - const insertData: SetInsert = { - name: setData.name, - description: setData.description, - festival_edition_id: setData.festival_edition_id, - stage_id: setData.stage_id, - time_start: setData.time_start, - time_end: setData.time_end, - created_by: setData.created_by, - slug: generateSlug(setData.name), - archived: false, - }; + const baseSlug = generateSlug(setData.name); + + for (let attempt = 1; attempt <= MAX_SLUG_ATTEMPTS; attempt++) { + const insertData: SetInsert = { + name: setData.name, + description: setData.description, + festival_edition_id: setData.festival_edition_id, + stage_id: setData.stage_id, + time_start: setData.time_start, + time_end: setData.time_end, + created_by: setData.created_by, + slug: slugCandidate(baseSlug, attempt), + archived: false, + }; + + const { data, error } = await supabase + .from("sets") + .insert(insertData) + .select() + .single(); - const { data, error } = await supabase - .from("sets") - .insert(insertData) - .select() - .single(); + if (error) { + if (error.code === UNIQUE_VIOLATION) { + continue; + } + console.error("Error creating set:", error); + throw new Error("Failed to create set"); + } - if (error) { - console.error("Error creating set:", error); - throw new Error("Failed to create set"); + return { + ...data, + artists: [], + votes: [], + }; } - return { - ...data, - artists: [], - votes: [], - }; + throw new Error("Failed to create set: could not find a unique slug"); } // Hook diff --git a/src/lib/slug.test.ts b/src/lib/slug.test.ts index ba22ce17..a7bf696d 100644 --- a/src/lib/slug.test.ts +++ b/src/lib/slug.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { generateSlug, isValidSlug, sanitizeSlug } from "./slug"; +import { generateSlug, isValidSlug, sanitizeSlug, slugCandidate } from "./slug"; describe("generateSlug", () => { it("converts basic text to lowercase slug", () => { @@ -58,6 +58,18 @@ describe("generateSlug", () => { }); }); +describe("slugCandidate", () => { + it("returns the bare slug for attempt 1", () => { + expect(slugCandidate("my-set", 1)).toBe("my-set"); + }); + + it("appends the attempt number for attempt 2 and beyond", () => { + expect(slugCandidate("my-set", 2)).toBe("my-set-2"); + expect(slugCandidate("my-set", 3)).toBe("my-set-3"); + expect(slugCandidate("my-set", 10)).toBe("my-set-10"); + }); +}); + describe("isValidSlug", () => { it("validates correct slugs", () => { expect(isValidSlug("hello-world")).toBe(true); diff --git a/src/lib/slug.ts b/src/lib/slug.ts index e70fbad1..8c919403 100644 --- a/src/lib/slug.ts +++ b/src/lib/slug.ts @@ -15,6 +15,14 @@ export function generateSlug(text: string): string { ); } +/** + * Build the Nth candidate slug when disambiguating a collision: + * attempt 1 is the bare slug, attempt 2+ appends a numeric counter. + */ +export function slugCandidate(baseSlug: string, attempt: number): string { + return attempt <= 1 ? baseSlug : `${baseSlug}-${attempt}`; +} + /** * Validate that a slug is URL-safe */ diff --git a/supabase/migrations/20260804090308_add_sets_slug_unique.sql b/supabase/migrations/20260804090308_add_sets_slug_unique.sql new file mode 100644 index 00000000..f2459270 --- /dev/null +++ b/supabase/migrations/20260804090308_add_sets_slug_unique.sql @@ -0,0 +1,30 @@ +-- Add unique constraint on sets(festival_edition_id, slug). +-- Dedupe first: append the full id (guaranteed unique) to any slug that +-- collides with another set in the same edition, keeping the oldest row on +-- its original slug so existing slug-based links don't break. +UPDATE public.sets s +SET slug = s.slug || '-' || s.id::text +WHERE s.id IN ( + SELECT id + FROM ( + SELECT id, ROW_NUMBER() OVER ( + PARTITION BY festival_edition_id, slug ORDER BY created_at ASC, id + ) AS rn + FROM public.sets + ) ranked + WHERE rn > 1 +); + +DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 + FROM pg_constraint + WHERE conname = 'sets_festival_edition_id_slug_unique' + AND conrelid = 'public.sets'::regclass + ) THEN + ALTER TABLE public.sets + ADD CONSTRAINT sets_festival_edition_id_slug_unique + UNIQUE (festival_edition_id, slug); + END IF; +END$$; From 3977a77a75dd2fb03117334fa5547fa071d3e863 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 4 Aug 2026 10:13:44 +0000 Subject: [PATCH 2/8] fix(slugs): move slug dedupe into DB triggers, cover all insert paths MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_01LBa5F78BNbcTrjeAbYt5J6 --- src/api/artists/useCreateArtist.ts | 13 ++-- src/api/sets/useCreateSet.ts | 33 ++++---- src/lib/slug.test.ts | 14 +--- src/lib/slug.ts | 8 -- ..._reactivate_archived_artists_on_upsert.sql | 35 +++++++++ ...0260804101202_add_slug_dedupe_triggers.sql | 76 +++++++++++++++++++ 6 files changed, 137 insertions(+), 42 deletions(-) create mode 100644 supabase/migrations/20260804101135_reactivate_archived_artists_on_upsert.sql create mode 100644 supabase/migrations/20260804101202_add_slug_dedupe_triggers.sql diff --git a/src/api/artists/useCreateArtist.ts b/src/api/artists/useCreateArtist.ts index e06a6e9e..19457e03 100644 --- a/src/api/artists/useCreateArtist.ts +++ b/src/api/artists/useCreateArtist.ts @@ -2,14 +2,17 @@ import { useMutation, useQueryClient } from "@tanstack/react-query"; import { useToast } from "@/hooks/use-toast"; import { supabase } from "@/integrations/supabase/client"; import type { Database } from "@/integrations/supabase/types"; -import { generateSlug, slugCandidate } from "@/lib/slug"; +import { generateSlug } from "@/lib/slug"; import type { Artist } from "./types"; import { artistsKeys } from "./types"; type ArtistRow = Database["public"]["Tables"]["artists"]["Row"]; const UNIQUE_VIOLATION = "23505"; -const MAX_SLUG_ATTEMPTS = 50; +// The DB trigger dedupes the slug against a snapshot of existing rows, so a +// concurrent insert can still race it to the same candidate; retrying lets +// the trigger recompute against the now-committed row. +const MAX_INSERT_ATTEMPTS = 5; // Mutation function async function createArtist( @@ -33,16 +36,16 @@ async function createArtist( }, ): Promise { const { genre_ids, ...artist } = artistData; - const baseSlug = generateSlug(artist.name); + const slug = generateSlug(artist.name); let data: ArtistRow | null = null; - for (let attempt = 1; attempt <= MAX_SLUG_ATTEMPTS; attempt++) { + for (let attempt = 1; attempt <= MAX_INSERT_ATTEMPTS; attempt++) { const result = await supabase .from("artists") .insert({ ...artist, - slug: slugCandidate(baseSlug, attempt), + slug, created_at: new Date().toISOString(), updated_at: new Date().toISOString(), }) diff --git a/src/api/sets/useCreateSet.ts b/src/api/sets/useCreateSet.ts index ceb2789d..15b2accb 100644 --- a/src/api/sets/useCreateSet.ts +++ b/src/api/sets/useCreateSet.ts @@ -2,13 +2,16 @@ import { useMutation, useQueryClient } from "@tanstack/react-query"; import { useToast } from "@/hooks/use-toast"; import { supabase } from "@/integrations/supabase/client"; import type { Database } from "@/integrations/supabase/types"; -import { generateSlug, slugCandidate } from "@/lib/slug"; +import { generateSlug } from "@/lib/slug"; import { FestivalSet, setsKeys } from "./types"; type SetInsert = Database["public"]["Tables"]["sets"]["Insert"]; const UNIQUE_VIOLATION = "23505"; -const MAX_SLUG_ATTEMPTS = 50; +// The DB trigger dedupes the slug against a snapshot of existing rows, so a +// concurrent insert can still race it to the same candidate; retrying lets +// the trigger recompute against the now-committed row. +const MAX_INSERT_ATTEMPTS = 5; // Mutation function async function createSet( @@ -24,21 +27,19 @@ async function createSet( | "slug" >, ): Promise { - const baseSlug = generateSlug(setData.name); - - for (let attempt = 1; attempt <= MAX_SLUG_ATTEMPTS; attempt++) { - const insertData: SetInsert = { - name: setData.name, - description: setData.description, - festival_edition_id: setData.festival_edition_id, - stage_id: setData.stage_id, - time_start: setData.time_start, - time_end: setData.time_end, - created_by: setData.created_by, - slug: slugCandidate(baseSlug, attempt), - archived: false, - }; + const insertData: SetInsert = { + name: setData.name, + description: setData.description, + festival_edition_id: setData.festival_edition_id, + stage_id: setData.stage_id, + time_start: setData.time_start, + time_end: setData.time_end, + created_by: setData.created_by, + slug: generateSlug(setData.name), + archived: false, + }; + for (let attempt = 1; attempt <= MAX_INSERT_ATTEMPTS; attempt++) { const { data, error } = await supabase .from("sets") .insert(insertData) diff --git a/src/lib/slug.test.ts b/src/lib/slug.test.ts index a7bf696d..ba22ce17 100644 --- a/src/lib/slug.test.ts +++ b/src/lib/slug.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { generateSlug, isValidSlug, sanitizeSlug, slugCandidate } from "./slug"; +import { generateSlug, isValidSlug, sanitizeSlug } from "./slug"; describe("generateSlug", () => { it("converts basic text to lowercase slug", () => { @@ -58,18 +58,6 @@ describe("generateSlug", () => { }); }); -describe("slugCandidate", () => { - it("returns the bare slug for attempt 1", () => { - expect(slugCandidate("my-set", 1)).toBe("my-set"); - }); - - it("appends the attempt number for attempt 2 and beyond", () => { - expect(slugCandidate("my-set", 2)).toBe("my-set-2"); - expect(slugCandidate("my-set", 3)).toBe("my-set-3"); - expect(slugCandidate("my-set", 10)).toBe("my-set-10"); - }); -}); - describe("isValidSlug", () => { it("validates correct slugs", () => { expect(isValidSlug("hello-world")).toBe(true); diff --git a/src/lib/slug.ts b/src/lib/slug.ts index 8c919403..e70fbad1 100644 --- a/src/lib/slug.ts +++ b/src/lib/slug.ts @@ -15,14 +15,6 @@ export function generateSlug(text: string): string { ); } -/** - * Build the Nth candidate slug when disambiguating a collision: - * attempt 1 is the bare slug, attempt 2+ appends a numeric counter. - */ -export function slugCandidate(baseSlug: string, attempt: number): string { - return attempt <= 1 ? baseSlug : `${baseSlug}-${attempt}`; -} - /** * Validate that a slug is URL-safe */ diff --git a/supabase/migrations/20260804101135_reactivate_archived_artists_on_upsert.sql b/supabase/migrations/20260804101135_reactivate_archived_artists_on_upsert.sql new file mode 100644 index 00000000..ccce4cd9 --- /dev/null +++ b/supabase/migrations/20260804101135_reactivate_archived_artists_on_upsert.sql @@ -0,0 +1,35 @@ +-- Replace ON CONFLICT (slug) upsert with an explicit reactivate-or-insert. +-- +-- p_artists_to_create only ever contains slugs the diff step didn't find +-- among *active* artists (diff-schedule/index.ts queries archived = false), +-- so a slug collision here can only be against an archived artist -- the +-- ON CONFLICT branch existed purely to unarchive+rename that row. Doing the +-- match explicitly (instead of relying on the insert itself colliding) +-- means the INSERT branch below is a genuinely-new-artist insert, so the +-- slug-dedupe trigger added in the next migration can safely apply to it +-- without breaking this reactivation match. +CREATE OR REPLACE FUNCTION public.commit_schedule__upsert_artists( + p_artists_to_create JSONB, + p_user_id UUID +) +RETURNS VOID +LANGUAGE plpgsql +SET search_path = public +AS $$ +DECLARE + v_elem JSONB; +BEGIN + FOR v_elem IN + SELECT value FROM jsonb_array_elements(COALESCE(p_artists_to_create, '[]'::jsonb)) + LOOP + 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; + END LOOP; +END; +$$; diff --git a/supabase/migrations/20260804101202_add_slug_dedupe_triggers.sql b/supabase/migrations/20260804101202_add_slug_dedupe_triggers.sql new file mode 100644 index 00000000..36e38272 --- /dev/null +++ b/supabase/migrations/20260804101202_add_slug_dedupe_triggers.sql @@ -0,0 +1,76 @@ +-- Dedupe slugs at the DB layer on insert, so every insert path (app code, +-- the commit_schedule RPC, seed scripts, ...) gets a unique slug for free +-- instead of relying on each caller to pre-check or retry individually. +-- On a collision within scope, append a numeric counter: try the given +-- slug, then `-2`, `-3`, etc, until one is free. +-- +-- Safe for commit_schedule__create_sets (plain INSERT, no conflict target) +-- and, as of the previous migration, commit_schedule__upsert_artists too +-- (its reactivate-archived-artist match now happens before the INSERT, via +-- an explicit UPDATE, not via ON CONFLICT on the possibly-rewritten slug). +-- +-- This can still race under concurrent inserts (two transactions can both +-- see a slug as free and pick the same candidate); the unique constraints +-- added two migrations ago are the actual backstop for that -- the loser +-- gets a 23505 the caller can retry. + +CREATE OR REPLACE FUNCTION public.sets_dedupe_slug() +RETURNS TRIGGER +LANGUAGE plpgsql +SET search_path = public +AS $$ +DECLARE + v_base TEXT := NEW.slug; + v_candidate TEXT := NEW.slug; + v_attempt INT := 1; +BEGIN + WHILE EXISTS ( + SELECT 1 FROM public.sets + WHERE festival_edition_id = NEW.festival_edition_id + AND slug = v_candidate + AND id IS DISTINCT FROM NEW.id + ) LOOP + v_attempt := v_attempt + 1; + v_candidate := v_base || '-' || v_attempt; + END LOOP; + + NEW.slug := v_candidate; + RETURN NEW; +END; +$$; + +DROP TRIGGER IF EXISTS sets_dedupe_slug_trigger ON public.sets; +CREATE TRIGGER sets_dedupe_slug_trigger + BEFORE INSERT ON public.sets + FOR EACH ROW + EXECUTE FUNCTION public.sets_dedupe_slug(); + +CREATE OR REPLACE FUNCTION public.artists_dedupe_slug() +RETURNS TRIGGER +LANGUAGE plpgsql +SET search_path = public +AS $$ +DECLARE + v_base TEXT := NEW.slug; + v_candidate TEXT := NEW.slug; + v_attempt INT := 1; +BEGIN + WHILE EXISTS ( + SELECT 1 FROM public.artists + WHERE slug = v_candidate + AND id IS DISTINCT FROM NEW.id + ) LOOP + v_attempt := v_attempt + 1; + v_candidate := v_base || '-' || v_attempt; + END LOOP; + + NEW.slug := v_candidate; + RETURN NEW; +END; +$$; + +DROP TRIGGER IF EXISTS artists_dedupe_slug_trigger ON public.artists; +CREATE TRIGGER artists_dedupe_slug_trigger + BEFORE INSERT ON public.artists + FOR EACH ROW + EXECUTE FUNCTION public.artists_dedupe_slug(); From 78799cac1e8b693cdd02136d95673184b680b5c2 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 4 Aug 2026 10:36:45 +0000 Subject: [PATCH 3/8] fix(slugs): generate slugs in the DB, serialize dedup with advisory locks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_01LBa5F78BNbcTrjeAbYt5J6 --- src/api/artists/useCreateArtist.ts | 54 ++++++------------- src/api/sets/useCreateSet.ts | 44 ++++++--------- ...0260804101202_add_slug_dedupe_triggers.sql | 54 ++++++++++++++----- ...3506_commit_schedule_slugify_delegates.sql | 11 ++++ 4 files changed, 84 insertions(+), 79 deletions(-) create mode 100644 supabase/migrations/20260804103506_commit_schedule_slugify_delegates.sql diff --git a/src/api/artists/useCreateArtist.ts b/src/api/artists/useCreateArtist.ts index 19457e03..6ebe383b 100644 --- a/src/api/artists/useCreateArtist.ts +++ b/src/api/artists/useCreateArtist.ts @@ -1,19 +1,9 @@ import { useMutation, useQueryClient } from "@tanstack/react-query"; import { useToast } from "@/hooks/use-toast"; import { supabase } from "@/integrations/supabase/client"; -import type { Database } from "@/integrations/supabase/types"; -import { generateSlug } from "@/lib/slug"; import type { Artist } from "./types"; import { artistsKeys } from "./types"; -type ArtistRow = Database["public"]["Tables"]["artists"]["Row"]; - -const UNIQUE_VIOLATION = "23505"; -// The DB trigger dedupes the slug against a snapshot of existing rows, so a -// concurrent insert can still race it to the same candidate; retrying lets -// the trigger recompute against the now-committed row. -const MAX_INSERT_ATTEMPTS = 5; - // Mutation function async function createArtist( artistData: Omit< @@ -36,36 +26,22 @@ async function createArtist( }, ): Promise { const { genre_ids, ...artist } = artistData; - const slug = generateSlug(artist.name); - - let data: ArtistRow | null = null; - - for (let attempt = 1; attempt <= MAX_INSERT_ATTEMPTS; attempt++) { - const result = await supabase - .from("artists") - .insert({ - ...artist, - slug, - created_at: new Date().toISOString(), - updated_at: new Date().toISOString(), - }) - .select("*") - .single(); - - if (result.error) { - if (result.error.code === UNIQUE_VIOLATION) { - continue; - } - console.error("Error creating artist:", result.error); - throw new Error("Failed to create artist"); - } - - data = result.data; - break; - } + const { data, error } = await supabase + .from("artists") + .insert({ + ...artist, + // The artists_dedupe_slug trigger derives the real slug from `name` + // and overwrites this; the column is just NOT NULL with no DB default. + slug: "", + created_at: new Date().toISOString(), + updated_at: new Date().toISOString(), + }) + .select("*") + .single(); - if (!data) { - throw new Error("Failed to create artist: could not find a unique slug"); + if (error) { + console.error("Error creating artist:", error); + throw new Error("Failed to create artist"); } if (genre_ids.length > 0) { diff --git a/src/api/sets/useCreateSet.ts b/src/api/sets/useCreateSet.ts index 15b2accb..40e35606 100644 --- a/src/api/sets/useCreateSet.ts +++ b/src/api/sets/useCreateSet.ts @@ -2,17 +2,10 @@ import { useMutation, useQueryClient } from "@tanstack/react-query"; import { useToast } from "@/hooks/use-toast"; import { supabase } from "@/integrations/supabase/client"; import type { Database } from "@/integrations/supabase/types"; -import { generateSlug } from "@/lib/slug"; import { FestivalSet, setsKeys } from "./types"; type SetInsert = Database["public"]["Tables"]["sets"]["Insert"]; -const UNIQUE_VIOLATION = "23505"; -// The DB trigger dedupes the slug against a snapshot of existing rows, so a -// concurrent insert can still race it to the same candidate; retrying lets -// the trigger recompute against the now-committed row. -const MAX_INSERT_ATTEMPTS = 5; - // Mutation function async function createSet( setData: Omit< @@ -35,33 +28,28 @@ async function createSet( time_start: setData.time_start, time_end: setData.time_end, created_by: setData.created_by, - slug: generateSlug(setData.name), + // The sets_dedupe_slug trigger derives the real slug from `name` and + // overwrites this; the column is just NOT NULL with no DB default. + slug: "", archived: false, }; - for (let attempt = 1; attempt <= MAX_INSERT_ATTEMPTS; attempt++) { - const { data, error } = await supabase - .from("sets") - .insert(insertData) - .select() - .single(); - - if (error) { - if (error.code === UNIQUE_VIOLATION) { - continue; - } - console.error("Error creating set:", error); - throw new Error("Failed to create set"); - } + const { data, error } = await supabase + .from("sets") + .insert(insertData) + .select() + .single(); - return { - ...data, - artists: [], - votes: [], - }; + if (error) { + console.error("Error creating set:", error); + throw new Error("Failed to create set"); } - throw new Error("Failed to create set: could not find a unique slug"); + return { + ...data, + artists: [], + votes: [], + }; } // Hook diff --git a/supabase/migrations/20260804101202_add_slug_dedupe_triggers.sql b/supabase/migrations/20260804101202_add_slug_dedupe_triggers.sql index 36e38272..1cb88e75 100644 --- a/supabase/migrations/20260804101202_add_slug_dedupe_triggers.sql +++ b/supabase/migrations/20260804101202_add_slug_dedupe_triggers.sql @@ -1,18 +1,42 @@ --- Dedupe slugs at the DB layer on insert, so every insert path (app code, --- the commit_schedule RPC, seed scripts, ...) gets a unique slug for free --- instead of relying on each caller to pre-check or retry individually. --- On a collision within scope, append a numeric counter: try the given +-- Generate + dedupe slugs entirely at the DB layer on insert, so every +-- insert path (app code, the commit_schedule RPC, seed scripts, ...) gets a +-- correct, unique slug for free instead of relying on each caller to +-- compute one and pre-check or retry individually. Whatever slug the caller +-- passes in is ignored -- the trigger always derives it fresh from `name`. +-- On a collision within scope, append a numeric counter: try the base -- slug, then `-2`, `-3`, etc, until one is free. -- +-- public.slugify() mirrors src/lib/slug.ts generateSlug and +-- commit_schedule__slugify (which now delegates to it, see next migration) +-- so all three stay byte-for-byte identical by construction. +CREATE OR REPLACE FUNCTION public.slugify(p_name TEXT) +RETURNS TEXT +LANGUAGE sql +IMMUTABLE +SET search_path = public +AS $$ + SELECT TRIM( + BOTH '-' FROM + REGEXP_REPLACE( + REGEXP_REPLACE(LOWER(TRIM(p_name)), '[^a-z0-9]+', '-', 'g'), + '-+', '-', 'g' + ) + ); +$$; + -- Safe for commit_schedule__create_sets (plain INSERT, no conflict target) -- and, as of the previous migration, commit_schedule__upsert_artists too -- (its reactivate-archived-artist match now happens before the INSERT, via -- an explicit UPDATE, not via ON CONFLICT on the possibly-rewritten slug). -- --- This can still race under concurrent inserts (two transactions can both --- see a slug as free and pick the same candidate); the unique constraints --- added two migrations ago are the actual backstop for that -- the loser --- gets a 23505 the caller can retry. +-- Two concurrent inserts for the same scope+base-slug could otherwise both +-- see it as free and race to claim it, so each function takes a +-- transaction-scoped advisory lock keyed on (scope, base slug) before +-- checking for a collision -- a second concurrent insert for the same key +-- blocks until the first transaction commits or rolls back, then makes its +-- decision against up-to-date data. The unique constraints added in the +-- previous migration remain the hard backstop, but callers shouldn't need +-- to handle a 23505 from this in practice. CREATE OR REPLACE FUNCTION public.sets_dedupe_slug() RETURNS TRIGGER @@ -20,10 +44,14 @@ LANGUAGE plpgsql SET search_path = public AS $$ DECLARE - v_base TEXT := NEW.slug; - v_candidate TEXT := NEW.slug; + v_base TEXT := public.slugify(NEW.name); + v_candidate TEXT := v_base; v_attempt INT := 1; BEGIN + PERFORM pg_advisory_xact_lock(hashtextextended( + 'sets:' || NEW.festival_edition_id::text || ':' || v_base, 0 + )); + WHILE EXISTS ( SELECT 1 FROM public.sets WHERE festival_edition_id = NEW.festival_edition_id @@ -51,10 +79,12 @@ LANGUAGE plpgsql SET search_path = public AS $$ DECLARE - v_base TEXT := NEW.slug; - v_candidate TEXT := NEW.slug; + v_base TEXT := public.slugify(NEW.name); + v_candidate TEXT := v_base; v_attempt INT := 1; BEGIN + PERFORM pg_advisory_xact_lock(hashtextextended('artists:' || v_base, 0)); + WHILE EXISTS ( SELECT 1 FROM public.artists WHERE slug = v_candidate diff --git a/supabase/migrations/20260804103506_commit_schedule_slugify_delegates.sql b/supabase/migrations/20260804103506_commit_schedule_slugify_delegates.sql new file mode 100644 index 00000000..280bbbed --- /dev/null +++ b/supabase/migrations/20260804103506_commit_schedule_slugify_delegates.sql @@ -0,0 +1,11 @@ +-- commit_schedule__slugify duplicated the same regex logic public.slugify() +-- now has (added in the previous migration). Delegate instead of +-- maintaining two copies that could drift apart. +CREATE OR REPLACE FUNCTION public.commit_schedule__slugify(p_name TEXT) +RETURNS TEXT +LANGUAGE sql +IMMUTABLE +SET search_path = public +AS $$ + SELECT public.slugify(p_name); +$$; From 2bcfdab9fbae17b12debb113c42032887a36af87 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 4 Aug 2026 11:34:07 +0000 Subject: [PATCH 4/8] fix(slugs): trust a caller-supplied slug instead of always recomputing 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 Claude-Session: https://claude.ai/code/session_01LBa5F78BNbcTrjeAbYt5J6 --- ...0260804101202_add_slug_dedupe_triggers.sql | 20 ++++++++++--------- 1 file changed, 11 insertions(+), 9 deletions(-) diff --git a/supabase/migrations/20260804101202_add_slug_dedupe_triggers.sql b/supabase/migrations/20260804101202_add_slug_dedupe_triggers.sql index 1cb88e75..f76fe6fe 100644 --- a/supabase/migrations/20260804101202_add_slug_dedupe_triggers.sql +++ b/supabase/migrations/20260804101202_add_slug_dedupe_triggers.sql @@ -1,10 +1,12 @@ --- Generate + dedupe slugs entirely at the DB layer on insert, so every --- insert path (app code, the commit_schedule RPC, seed scripts, ...) gets a --- correct, unique slug for free instead of relying on each caller to --- compute one and pre-check or retry individually. Whatever slug the caller --- passes in is ignored -- the trigger always derives it fresh from `name`. --- On a collision within scope, append a numeric counter: try the base --- slug, then `-2`, `-3`, etc, until one is free. +-- Generate + dedupe slugs at the DB layer on insert. If the caller didn't +-- supply a slug (the app create paths pass an empty placeholder -- they no +-- longer compute one client-side), derive it from `name`. If the caller did +-- supply one, trust it as the base instead of overriding it: commit_schedule +-- threads its own precomputed slug through the same call (e.g. to look an +-- artist back up via artistSlugs right after inserting it), so recomputing +-- from `name` there would silently diverge from what the caller expects to +-- find. Either way, on a collision within scope, append a numeric counter: +-- try the base slug, then `-2`, `-3`, etc, until one is free. -- -- public.slugify() mirrors src/lib/slug.ts generateSlug and -- commit_schedule__slugify (which now delegates to it, see next migration) @@ -44,7 +46,7 @@ LANGUAGE plpgsql SET search_path = public AS $$ DECLARE - v_base TEXT := public.slugify(NEW.name); + v_base TEXT := COALESCE(NULLIF(TRIM(NEW.slug), ''), public.slugify(NEW.name)); v_candidate TEXT := v_base; v_attempt INT := 1; BEGIN @@ -79,7 +81,7 @@ LANGUAGE plpgsql SET search_path = public AS $$ DECLARE - v_base TEXT := public.slugify(NEW.name); + v_base TEXT := COALESCE(NULLIF(TRIM(NEW.slug), ''), public.slugify(NEW.name)); v_candidate TEXT := v_base; v_attempt INT := 1; BEGIN From e2d8c6da8c41ee965288a782069242ad5ea2ca15 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 4 Aug 2026 19:07:09 +0000 Subject: [PATCH 5/8] fix(slugs): address review feedback - call slugify directly, trim comments - 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 Claude-Session: https://claude.ai/code/session_01LBa5F78BNbcTrjeAbYt5J6 --- src/api/artists/useCreateArtist.ts | 4 +- src/api/sets/useCreateSet.ts | 4 +- ...0260804101202_add_slug_dedupe_triggers.sql | 6 +- ...3506_commit_schedule_slugify_delegates.sql | 78 +++++++++++++++++-- 4 files changed, 76 insertions(+), 16 deletions(-) diff --git a/src/api/artists/useCreateArtist.ts b/src/api/artists/useCreateArtist.ts index 6ebe383b..ac9e74b6 100644 --- a/src/api/artists/useCreateArtist.ts +++ b/src/api/artists/useCreateArtist.ts @@ -30,9 +30,7 @@ async function createArtist( .from("artists") .insert({ ...artist, - // The artists_dedupe_slug trigger derives the real slug from `name` - // and overwrites this; the column is just NOT NULL with no DB default. - slug: "", + slug: "", // auto-generated by the artists_dedupe_slug DB trigger created_at: new Date().toISOString(), updated_at: new Date().toISOString(), }) diff --git a/src/api/sets/useCreateSet.ts b/src/api/sets/useCreateSet.ts index 40e35606..9cb41501 100644 --- a/src/api/sets/useCreateSet.ts +++ b/src/api/sets/useCreateSet.ts @@ -28,9 +28,7 @@ async function createSet( time_start: setData.time_start, time_end: setData.time_end, created_by: setData.created_by, - // The sets_dedupe_slug trigger derives the real slug from `name` and - // overwrites this; the column is just NOT NULL with no DB default. - slug: "", + slug: "", // auto-generated by the sets_dedupe_slug DB trigger archived: false, }; diff --git a/supabase/migrations/20260804101202_add_slug_dedupe_triggers.sql b/supabase/migrations/20260804101202_add_slug_dedupe_triggers.sql index f76fe6fe..a2a6aced 100644 --- a/supabase/migrations/20260804101202_add_slug_dedupe_triggers.sql +++ b/supabase/migrations/20260804101202_add_slug_dedupe_triggers.sql @@ -8,9 +8,9 @@ -- find. Either way, on a collision within scope, append a numeric counter: -- try the base slug, then `-2`, `-3`, etc, until one is free. -- --- public.slugify() mirrors src/lib/slug.ts generateSlug and --- commit_schedule__slugify (which now delegates to it, see next migration) --- so all three stay byte-for-byte identical by construction. +-- public.slugify() mirrors src/lib/slug.ts generateSlug (commit_schedule's +-- helpers call this directly too, see next migration) so they stay +-- byte-for-byte identical by construction. CREATE OR REPLACE FUNCTION public.slugify(p_name TEXT) RETURNS TEXT LANGUAGE sql diff --git a/supabase/migrations/20260804103506_commit_schedule_slugify_delegates.sql b/supabase/migrations/20260804103506_commit_schedule_slugify_delegates.sql index 280bbbed..1ca3fd6c 100644 --- a/supabase/migrations/20260804103506_commit_schedule_slugify_delegates.sql +++ b/supabase/migrations/20260804103506_commit_schedule_slugify_delegates.sql @@ -1,11 +1,75 @@ --- commit_schedule__slugify duplicated the same regex logic public.slugify() --- now has (added in the previous migration). Delegate instead of --- maintaining two copies that could drift apart. -CREATE OR REPLACE FUNCTION public.commit_schedule__slugify(p_name TEXT) -RETURNS TEXT +-- Call public.slugify() directly instead of going through +-- commit_schedule__slugify -- that wrapper only duplicated the same regex +-- public.slugify() now has, so there's no reason to keep the indirection. +CREATE OR REPLACE FUNCTION public.commit_schedule__upsert_stages( + p_festival_edition_id UUID, + p_stages_to_create JSONB +) +RETURNS VOID LANGUAGE sql -IMMUTABLE SET search_path = public AS $$ - SELECT public.slugify(p_name); + INSERT INTO stages (festival_edition_id, name, slug) + SELECT + p_festival_edition_id, + elem->>'name', + public.slugify(elem->>'name') + FROM jsonb_array_elements(COALESCE(p_stages_to_create, '[]'::jsonb)) AS elem + ON CONFLICT (festival_edition_id, name) DO UPDATE + SET archived = false; $$; + +CREATE OR REPLACE FUNCTION public.commit_schedule__create_sets( + p_festival_edition_id UUID, + p_user_id UUID, + p_sets_to_create JSONB +) +RETURNS INT +LANGUAGE plpgsql +SET search_path = public +AS $$ +DECLARE + v_set_elem JSONB; + v_new_set_id UUID; + v_created INT := 0; +BEGIN + FOR v_set_elem IN + SELECT value FROM jsonb_array_elements(COALESCE(p_sets_to_create, '[]'::jsonb)) + LOOP + INSERT INTO sets ( + festival_edition_id, name, slug, description, stage_id, + time_start, time_end, created_by + ) + VALUES ( + p_festival_edition_id, + v_set_elem->>'name', + public.slugify(v_set_elem->>'name'), + NULLIF(v_set_elem->>'description', ''), + commit_schedule__resolve_stage_id( + p_festival_edition_id, v_set_elem->>'stageName' + ), + commit_schedule__parse_ts(v_set_elem->>'timeStart'), + commit_schedule__parse_ts(v_set_elem->>'timeEnd'), + p_user_id + ) + RETURNING id INTO v_new_set_id; + + -- Always suffix the slug with a short id chunk so two sets with the same + -- name (common when an artist plays multiple days) don't collide on the + -- (edition, slug) lookup used by the set detail pages. + UPDATE sets + SET slug = slug || '-' || SUBSTRING(v_new_set_id::text, 1, 8) + WHERE id = v_new_set_id; + + v_created := v_created + 1; + + PERFORM commit_schedule__sync_set_artists( + v_new_set_id, p_festival_edition_id, v_set_elem->'artistSlugs' + ); + END LOOP; + + RETURN v_created; +END; +$$; + +DROP FUNCTION IF EXISTS public.commit_schedule__slugify(TEXT); From a4c972417c2614af2d7a7f153e18196b66220858 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 5 Aug 2026 06:29:35 +0000 Subject: [PATCH 6/8] refactor(sql): trim redundant comments in slug dedupe migrations --- ..._reactivate_archived_artists_on_upsert.sql | 14 +++----- ...0260804101202_add_slug_dedupe_triggers.sql | 33 ++++--------------- 2 files changed, 10 insertions(+), 37 deletions(-) diff --git a/supabase/migrations/20260804101135_reactivate_archived_artists_on_upsert.sql b/supabase/migrations/20260804101135_reactivate_archived_artists_on_upsert.sql index ccce4cd9..6080d8b7 100644 --- a/supabase/migrations/20260804101135_reactivate_archived_artists_on_upsert.sql +++ b/supabase/migrations/20260804101135_reactivate_archived_artists_on_upsert.sql @@ -1,13 +1,7 @@ --- Replace ON CONFLICT (slug) upsert with an explicit reactivate-or-insert. --- --- p_artists_to_create only ever contains slugs the diff step didn't find --- among *active* artists (diff-schedule/index.ts queries archived = false), --- so a slug collision here can only be against an archived artist -- the --- ON CONFLICT branch existed purely to unarchive+rename that row. Doing the --- match explicitly (instead of relying on the insert itself colliding) --- means the INSERT branch below is a genuinely-new-artist insert, so the --- slug-dedupe trigger added in the next migration can safely apply to it --- without breaking this reactivation match. +-- p_artists_to_create: JSONB array of {name, slug}. A slug collision here can +-- only be against an archived artist (diff-schedule only proposes creates for +-- slugs it didn't find among active ones), so match+reactivate explicitly +-- instead of relying on ON CONFLICT. CREATE OR REPLACE FUNCTION public.commit_schedule__upsert_artists( p_artists_to_create JSONB, p_user_id UUID diff --git a/supabase/migrations/20260804101202_add_slug_dedupe_triggers.sql b/supabase/migrations/20260804101202_add_slug_dedupe_triggers.sql index a2a6aced..1c907da8 100644 --- a/supabase/migrations/20260804101202_add_slug_dedupe_triggers.sql +++ b/supabase/migrations/20260804101202_add_slug_dedupe_triggers.sql @@ -1,16 +1,4 @@ --- Generate + dedupe slugs at the DB layer on insert. If the caller didn't --- supply a slug (the app create paths pass an empty placeholder -- they no --- longer compute one client-side), derive it from `name`. If the caller did --- supply one, trust it as the base instead of overriding it: commit_schedule --- threads its own precomputed slug through the same call (e.g. to look an --- artist back up via artistSlugs right after inserting it), so recomputing --- from `name` there would silently diverge from what the caller expects to --- find. Either way, on a collision within scope, append a numeric counter: --- try the base slug, then `-2`, `-3`, etc, until one is free. --- --- public.slugify() mirrors src/lib/slug.ts generateSlug (commit_schedule's --- helpers call this directly too, see next migration) so they stay --- byte-for-byte identical by construction. +-- Mirrors src/lib/slug.ts generateSlug; keep them in sync. CREATE OR REPLACE FUNCTION public.slugify(p_name TEXT) RETURNS TEXT LANGUAGE sql @@ -26,30 +14,20 @@ AS $$ ); $$; --- Safe for commit_schedule__create_sets (plain INSERT, no conflict target) --- and, as of the previous migration, commit_schedule__upsert_artists too --- (its reactivate-archived-artist match now happens before the INSERT, via --- an explicit UPDATE, not via ON CONFLICT on the possibly-rewritten slug). --- --- Two concurrent inserts for the same scope+base-slug could otherwise both --- see it as free and race to claim it, so each function takes a --- transaction-scoped advisory lock keyed on (scope, base slug) before --- checking for a collision -- a second concurrent insert for the same key --- blocks until the first transaction commits or rolls back, then makes its --- decision against up-to-date data. The unique constraints added in the --- previous migration remain the hard backstop, but callers shouldn't need --- to handle a 23505 from this in practice. - CREATE OR REPLACE FUNCTION public.sets_dedupe_slug() RETURNS TRIGGER LANGUAGE plpgsql SET search_path = public AS $$ DECLARE + -- Trust a caller-supplied slug as-is (commit_schedule passes its own + -- precomputed slug and looks the row back up by it); otherwise derive one. v_base TEXT := COALESCE(NULLIF(TRIM(NEW.slug), ''), public.slugify(NEW.name)); v_candidate TEXT := v_base; v_attempt INT := 1; BEGIN + -- Serializes concurrent inserts for the same base slug so two can't both + -- see it as free; the unique constraint is the backstop either way. PERFORM pg_advisory_xact_lock(hashtextextended( 'sets:' || NEW.festival_edition_id::text || ':' || v_base, 0 )); @@ -85,6 +63,7 @@ DECLARE v_candidate TEXT := v_base; v_attempt INT := 1; BEGIN + -- Serializes concurrent inserts for the same base slug; see sets_dedupe_slug. PERFORM pg_advisory_xact_lock(hashtextextended('artists:' || v_base, 0)); WHILE EXISTS ( From c6c3e433690c01ffb782b508a44d7242c9c6d533 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 5 Aug 2026 14:33:48 +0000 Subject: [PATCH 7/8] test(commit-schedule): verify duplicate set names get distinct slugs --- .../commit-schedule/commit-schedule.test.ts | 58 +++++++++++++++++++ 1 file changed, 58 insertions(+) diff --git a/supabase/functions/commit-schedule/commit-schedule.test.ts b/supabase/functions/commit-schedule/commit-schedule.test.ts index cd179206..cf5637e2 100644 --- a/supabase/functions/commit-schedule/commit-schedule.test.ts +++ b/supabase/functions/commit-schedule/commit-schedule.test.ts @@ -186,6 +186,64 @@ Deno.test("commit_schedule: archives orphaned sets", async () => { await db.from("sets").delete().eq("id", set!.id); }); +Deno.test( + "commit_schedule: two sets with the same name get distinct slugs", + async () => { + const db = adminClient(); + const editionId = await getTestEditionId(db); + const userId = await getTestUserId(db); + const setName = `Dup Name Set ${Date.now()}`; + + const { data, error } = await db.rpc("commit_schedule", { + p_festival_edition_id: editionId, + p_user_id: userId, + p_artists_to_create: [], + p_stages_to_create: [], + p_sets_to_create: [ + { + name: setName, + description: null, + stageName: null, + timeStart: null, + timeEnd: null, + artistSlugs: [], + }, + { + name: setName, + description: null, + stageName: null, + timeStart: null, + timeEnd: null, + artistSlugs: [], + }, + ], + p_sets_to_update: [], + p_set_ids_to_archive: [], + }); + + assertEquals(error, null); + assertEquals(data.setsCreated, 2); + + const { data: sets } = await db + .from("sets") + .select("id, slug") + .eq("festival_edition_id", editionId) + .eq("name", setName); + + assertEquals(sets?.length, 2); + assertExists(sets![0].slug); + assertExists(sets![1].slug); + assertEquals(sets![0].slug === sets![1].slug, false); + + // Cleanup + await db + .from("sets") + .delete() + .eq("festival_edition_id", editionId) + .eq("name", setName); + }, +); + Deno.test( "commit_schedule: midnight-crossing times stored correctly", async () => { From 0bf203f5226a935a82351a4c21dd562e55a51e66 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 13 Aug 2026 07:56:51 +0000 Subject: [PATCH 8/8] fix(test): give commit_schedule dup-slug test a non-empty artist roster --- .../functions/commit-schedule/commit-schedule.test.ts | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/supabase/functions/commit-schedule/commit-schedule.test.ts b/supabase/functions/commit-schedule/commit-schedule.test.ts index cf5637e2..dc60ec85 100644 --- a/supabase/functions/commit-schedule/commit-schedule.test.ts +++ b/supabase/functions/commit-schedule/commit-schedule.test.ts @@ -193,6 +193,11 @@ Deno.test( const editionId = await getTestEditionId(db); const userId = await getTestUserId(db); const setName = `Dup Name Set ${Date.now()}`; + const artistSlug = `test-dup-set-artist-${Date.now()}`; + + await db + .from("artists") + .insert({ name: "Dup Set Artist", slug: artistSlug, added_by: userId }); const { data, error } = await db.rpc("commit_schedule", { p_festival_edition_id: editionId, @@ -206,7 +211,7 @@ Deno.test( stageName: null, timeStart: null, timeEnd: null, - artistSlugs: [], + artistSlugs: [artistSlug], }, { name: setName, @@ -214,7 +219,7 @@ Deno.test( stageName: null, timeStart: null, timeEnd: null, - artistSlugs: [], + artistSlugs: [artistSlug], }, ], p_sets_to_update: [], @@ -241,6 +246,7 @@ Deno.test( .delete() .eq("festival_edition_id", editionId) .eq("name", setName); + await db.from("artists").delete().eq("slug", artistSlug); }, );