duplicate-module-merge
Safe workflow for merging duplicate Patcher modules in Supabase. Use when the user asks to migrate, merge, consolidate, or delete duplicate modules by module ID, especially when rack placements, user ownership statuses, patch connections, panels, or tags may reference the duplicate. Requires read-on
Install
mkdir -p .claude/skills/duplicate-module-merge && curl -L -o skill.zip "https://agentskills.codes/api/skills/download/14877" && unzip -o skill.zip -d .claude/skills/duplicate-module-merge && rm skill.zipInstalls to .claude/skills/duplicate-module-merge
Activation
This is the description your AI agent reads to decide when to run this skill — the better it matches your request, the more reliably it fires.
Safe workflow for merging duplicate Patcher modules in Supabase. Use when the user asks to migrate, merge, consolidate, or delete duplicate modules by module ID, especially when rack placements, user ownership statuses, patch connections, panels, or tags may reference the duplicate. Requires read-only exploration first and explicit user confirmation before any write/delete.About this skill
Duplicate Module Merge
Use this skill when handling duplicate rows in public.modules, for example:
- "merge module 1803 into 1896"
- "migrate racks/ownership from one duplicate module to another"
- "delete duplicate module after moving references"
See also: the app has a dev-utils "Merge into target module…" action on module detail pages for the safe common case
(user_modules, module_tags, and rack_modules, aborting on patch ports/instances). Use this skill for read-only
preflight, unusual references, and manual data operations.
Follow AGENTS.md: Supabase inspection is allowed, but do not mutate data until the user explicitly confirms the exact migration/delete action. Never apply migrations or RLS changes as part of this workflow.
Terms
- source module: duplicate module to migrate away from and possibly delete later.
- target module: canonical module to keep.
Confirm the IDs before writes. Module detail URLs use the final path segment as the ID:
http://localhost:5556/modules/details/1803 -> module id 1803
Read-only preflight
- Identify the active Patcher Supabase project with
supabase-list_projects; use the active healthy project namedPatcher. - Compare source/target module identity:
select id, name, "manufacturerId", hp, "standard", created, updated
from public.modules
where id in (:source_id, :target_id)
order by id;
- Discover all direct foreign keys to
modules.id:
select
tc.table_schema,
tc.table_name,
kcu.column_name,
ccu.table_name as foreign_table_name,
ccu.column_name as foreign_column_name,
tc.constraint_name,
rc.update_rule,
rc.delete_rule
from information_schema.table_constraints tc
join information_schema.key_column_usage kcu
on tc.constraint_name = kcu.constraint_name
and tc.table_schema = kcu.table_schema
join information_schema.constraint_column_usage ccu
on ccu.constraint_name = tc.constraint_name
and ccu.table_schema = tc.table_schema
join information_schema.referential_constraints rc
on rc.constraint_name = tc.constraint_name
and rc.constraint_schema = tc.table_schema
where tc.constraint_type = 'FOREIGN KEY'
and ccu.table_schema = 'public'
and ccu.table_name = 'modules'
and ccu.column_name = 'id'
order by tc.table_name, kcu.column_name;
- Count references for both IDs. Known module-reference tables include:
select
'comments_duplicate' as table_name, 'moduleId' as column_name,
count(*) filter (where "moduleId" = :source_id) as source_rows,
count(*) filter (where "moduleId" = :target_id) as target_rows
from public.comments_duplicate
union all select 'module_flags','module_id', count(*) filter (where module_id = :source_id), count(*) filter (where module_id = :target_id) from public.module_flags
union all select 'module_ins','moduleid', count(*) filter (where moduleid = :source_id), count(*) filter (where moduleid = :target_id) from public.module_ins
union all select 'module_outs','moduleid', count(*) filter (where moduleid = :source_id), count(*) filter (where moduleid = :target_id) from public.module_outs
union all select 'module_panels','moduleid', count(*) filter (where moduleid = :source_id), count(*) filter (where moduleid = :target_id) from public.module_panels
union all select 'module_tags','moduleid', count(*) filter (where moduleid = :source_id), count(*) filter (where moduleid = :target_id) from public.module_tags
union all select 'patch_module_instances','module_id', count(*) filter (where module_id = :source_id), count(*) filter (where module_id = :target_id) from public.patch_module_instances
union all select 'patches_for_modules','moduleid', count(*) filter (where moduleid = :source_id), count(*) filter (where moduleid = :target_id) from public.patches_for_modules
union all select 'rack_modules','moduleid', count(*) filter (where moduleid = :source_id), count(*) filter (where moduleid = :target_id) from public.rack_modules
union all select 'user_modules','moduleid', count(*) filter (where moduleid = :source_id), count(*) filter (where moduleid = :target_id) from public.user_modules
order by table_name;
- Inspect conflict risks before proposing writes:
select 'user_modules same profile' as conflict_type, count(*) as conflict_rows
from public.user_modules src
join public.user_modules tgt
on tgt.profileid = src.profileid
and tgt.moduleid = :target_id
where src.moduleid = :source_id
union all
select 'module_tags same tagid', count(*)
from public.module_tags src
join public.module_tags tgt
on tgt.tagid = src.tagid
and tgt.moduleid = :target_id
where src.moduleid = :source_id
union all
select 'rack_modules same rack/row/column', count(*)
from public.rack_modules src
join public.rack_modules tgt
on tgt.rackid = src.rackid
and tgt.row = src.row
and tgt."column" = src."column"
and tgt.moduleid = :target_id
where src.moduleid = :source_id
union all
select 'module_ins same name/range/flags', count(*)
from public.module_ins src
join public.module_ins tgt
on tgt.moduleid = :target_id
and tgt.name is not distinct from src.name
and tgt.min is not distinct from src.min
and tgt.max is not distinct from src.max
and tgt."isDCC" is not distinct from src."isDCC"
and tgt."isAudio" is not distinct from src."isAudio"
and tgt."isVOCT" is not distinct from src."isVOCT"
where src.moduleid = :source_id
union all
select 'module_outs same name/range/flags', count(*)
from public.module_outs src
join public.module_outs tgt
on tgt.moduleid = :target_id
and tgt.name is not distinct from src.name
and tgt.min is not distinct from src.min
and tgt.max is not distinct from src.max
and tgt."isDCC" is not distinct from src."isDCC"
and tgt."isAudio" is not distinct from src."isAudio"
and tgt."isVOCT" is not distinct from src."isVOCT"
where src.moduleid = :source_id;
- If patch ports exist, inspect connector dependency tables before deleting anything:
select conrelid::regclass::text as table_name,
conname as constraint_name,
pg_get_constraintdef(oid) as definition
from pg_constraint
where connamespace = 'public'::regnamespace
and conrelid::regclass::text in (
'module_ins',
'module_outs',
'patch_module_instances',
'patch_connections'
)
order by table_name, contype, constraint_name;
- Present a concise plan and wait for explicit confirmation.
Confirmed migration pattern
For the common case where only rack placements and ownership must move, use a single statement with returning counts:
with
removed_duplicate_ownership as (
delete from public.user_modules src
using public.user_modules tgt
where src.moduleid = :source_id
and tgt.moduleid = :target_id
and tgt.profileid = src.profileid
returning src.profileid, src.kind
),
moved_ownership as (
update public.user_modules
set moduleid = :target_id
where moduleid = :source_id
returning profileid, kind
),
moved_rack_modules as (
update public.rack_modules
set moduleid = :target_id
where moduleid = :source_id
returning id, rackid, row, "column"
)
select
(select count(*) from removed_duplicate_ownership) as duplicate_ownership_rows_removed,
(select count(*) from moved_ownership) as ownership_rows_moved,
(select count(*) from moved_rack_modules) as rack_module_rows_moved;
Notes:
user_modulesprimary key is(moduleid, profileid), not(moduleid, profileid, kind). Remove same-profile duplicates before updating or the update can violate the primary key.rack_modulesgenerally has no uniqueness constraint on module position, but still check samerackid/row/columnconflicts before updating.module_tagsoften duplicate bytagid; if target already has the same tags, leave source tags for delete cascade.module_panelscan be left for delete cascade when source panels are unused/unapproved. If rack rows use a sourceselected_panel_id, decide whether to move that panel to target or remap rack selections before deletion.module_insandmodule_outsare dangerous becausepatch_connections.a/breference their IDs withON DELETE CASCADE. Do not delete a source module with source ports unless you have explicitly planned connector remapping or accepted cascade deletion.patch_module_instances.module_idhasON DELETE RESTRICT, so it will block source deletion until moved or removed.
Verification
After writes, rerun the count query and explicitly confirm:
rack_modulessource rows are0user_modulessource rows are0patch_module_instancessource rows are0before deletion- the source module still exists if the user has not confirmed deletion
Delete only after a separate explicit confirmation:
delete from public.modules
where id = :source_id
returning id, name;