diff --git a/backend/src/services/db.service.ts b/backend/src/services/db.service.ts index 160ad11..b2f705c 100644 --- a/backend/src/services/db.service.ts +++ b/backend/src/services/db.service.ts @@ -443,6 +443,67 @@ const MIGRATIONS: { id: string; sql: string }[] = [ ON artists (mbid) WHERE mbid IS NOT NULL; `, }, + { + id: '20260717_claim_fusion_group_by_coalesce', + sql: ` + -- Fix duplicate-key failures in REFRESH MATERIALIZED VIEW CONCURRENTLY. + -- The MV SELECTs COALESCE(user_id, zero-uuid) but GROUPed BY the raw + -- user_id, so a global enrichment claim (user_id NULL) and a default-user + -- behavior claim (user_id = zero-uuid, e.g. listener_behavior same_scene_as) + -- for the same edge fell into separate groups yet collapsed to the same + -- output key → two rows violating idx_claim_fusion_pk. Group by the same + -- COALESCE'd expression so they fuse into one row. + DROP MATERIALIZED VIEW IF EXISTS claim_fusion CASCADE; + + CREATE MATERIALIZED VIEW claim_fusion AS + SELECT + c.subject_type, + c.subject_id, + c.predicate, + c.object_type, + c.object_id, + COALESCE(c.user_id, '00000000-0000-0000-0000-000000000000'::uuid) AS user_id, + SUM( + st.trust * c.confidence * + GREATEST(0.1, 1.0 - EXTRACT(DAY FROM NOW() - c.last_reinforced_at) / 180.0) + ) AS fused_value, + COUNT(*) AS claim_count, + MAX(c.last_reinforced_at) AS last_reinforced_at + FROM claims c + JOIN source_trust st ON st.key = c.source + GROUP BY c.subject_type, c.subject_id, c.predicate, c.object_type, c.object_id, + COALESCE(c.user_id, '00000000-0000-0000-0000-000000000000'::uuid); + + CREATE UNIQUE INDEX idx_claim_fusion_pk + ON claim_fusion (subject_type, subject_id, predicate, object_type, object_id, user_id); + + CREATE OR REPLACE VIEW track_artists_v2 AS + SELECT t.id AS track_id, + a.id AS artist_id, + a.name AS artist_name, + CASE cf.predicate WHEN 'credited_main_on' THEN 'main' ELSE 'featured' END AS role, + cf.fused_value AS confidence + FROM tracks t + JOIN claim_fusion cf + ON cf.subject_type = 'track' AND cf.subject_id = t.id + AND cf.predicate IN ('credited_main_on', 'featured_on') + AND cf.object_type = 'artist' + JOIN artists a ON a.id = cf.object_id; + + CREATE OR REPLACE VIEW album_artists_v2 AS + SELECT al.id AS album_id, + a.id AS artist_id, + a.name AS artist_name, + CASE cf.predicate WHEN 'credited_main_on_album' THEN 'main' ELSE 'featured' END AS role, + cf.fused_value AS confidence + FROM albums al + JOIN claim_fusion cf + ON cf.subject_type = 'album' AND cf.subject_id = al.id + AND cf.predicate IN ('credited_main_on_album', 'featured_on_album') + AND cf.object_type = 'artist' + JOIN artists a ON a.id = cf.object_id; + `, + }, ]; export class DbService {