From dee2b0ad571440b27fb28dc15af02ac7a55ca796 Mon Sep 17 00:00:00 2001 From: kami Date: Fri, 31 Jul 2026 00:30:27 +0400 Subject: [PATCH] refactor: split db.service.ts into data, migrations, and behaviour MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit db.service.ts was 2085 lines, of which ~700 were not behaviour at all: the migration registry, the row-shape interfaces, and the column allowlist. That makes the file painful to review — the reviewer's note on the MIGRATIONS array. Three pure moves into backend/src/db/, which already owns schema.sql: - migrations.ts (513) — the registry, plus a named Migration type - types.ts (168) — the row shapes - updatable-columns.ts (42) — UPDATABLE_COLUMNS + allowedFields db.service.ts drops to 1378 lines and re-exports ../db/types.js, so existing `import { Track, ListenerBelief } from '../services/db.service.js'` in the routes, generators and session-director keeps working untouched. No behaviour change, and verified as such rather than asserted: the migration id list and the entire 502-line SQL body diff byte-identical against the previous commit, backend typecheck is clean and 33/33 tests pass. Co-Authored-By: Claude Opus 5 --- backend/src/db/migrations.ts | 513 +++++++++++++++++++ backend/src/db/types.ts | 168 +++++++ backend/src/db/updatable-columns.ts | 42 ++ backend/src/services/db.service.ts | 739 +--------------------------- 4 files changed, 749 insertions(+), 713 deletions(-) create mode 100644 backend/src/db/migrations.ts create mode 100644 backend/src/db/types.ts create mode 100644 backend/src/db/updatable-columns.ts diff --git a/backend/src/db/migrations.ts b/backend/src/db/migrations.ts new file mode 100644 index 0000000..053af3a --- /dev/null +++ b/backend/src/db/migrations.ts @@ -0,0 +1,513 @@ +// --------------------------------------------------------------------------- +// Migrations registry +// Add new entries at the END. Never edit or remove existing entries. +// Convention for id: "YYYYMMDD_short_description" +// --------------------------------------------------------------------------- +/** One forward-only, individually atomic schema change, recorded in `schema_migrations`. */ +export interface Migration { + id: string; + sql: string; +} + +export const MIGRATIONS: Migration[] = [ + { + id: '20260608_track_artists', + sql: ` + CREATE TABLE IF NOT EXISTS track_artists ( + track_id UUID REFERENCES tracks(id) ON DELETE CASCADE, + artist_id UUID REFERENCES artists(id) ON DELETE CASCADE, + role TEXT NOT NULL DEFAULT 'main', + PRIMARY KEY (track_id, artist_id, role) + ); + CREATE INDEX IF NOT EXISTS idx_track_artists_artist ON track_artists(artist_id); + + -- Backfill main artist from albums for tracks not yet in track_artists + INSERT INTO track_artists (track_id, artist_id, role) + SELECT t.id, al.artist_id, 'main' + FROM tracks t + JOIN albums al ON al.id = t.album_id + WHERE NOT EXISTS ( + SELECT 1 FROM track_artists ta WHERE ta.track_id = t.id + ); + `, + }, + { + id: '20260608_clear_lastfm_placeholder_images', + sql: ` + UPDATE artists SET image_path = NULL + WHERE image_path LIKE '%2a96cbd8b46e442fc41c2b86b821562f%'; + `, + }, + { + // normalize_artist() was extended (schema.sql) to also split collaboration + // separators ( ; & / ) — not just commas/feat. STORED generated columns are + // NOT recomputed when the function definition changes, so force a recompute + // by touching the base column of every dependent row. ensureSchema() (which + // installs the new function) runs before migrations, so the new definition + // is already active here. + id: '20260612_recompute_normalized_artist', + sql: ` + UPDATE artists SET name = name; + UPDATE tracks SET artist = artist; + `, + }, + { + // The name-based Wikimedia Commons image fallback (now removed from the + // enrichment chain) frequently attached the wrong photo. Clear those rows so + // they fall back to the placeholder / a better source. Verified Wikidata + // images (fetched via MBID, step 3) also live on wikimedia.org but only on + // artists that HAVE an mbid, so restricting to mbid IS NULL spares them. + id: '20260612_clear_namebased_wikimedia_images', + sql: ` + UPDATE artists SET image_path = NULL + WHERE mbid IS NULL AND image_path LIKE '%wikimedia.org%'; + `, + }, + { + // claim_fusion materialised view + compatibility views for v2 graph. + // Depends on claims, source_trust tables which are created by schema.sql + // (run before migrations). The MV resolves truth at read time as a weighted + // vote across claims per the fusion formula in spec §A.4. + id: '20260707_claim_fusion', + sql: ` + CREATE OR REPLACE 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, c.user_id; + + -- Compatibility view: track → artist credits via fusion + 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; + + -- Compatibility view: album → artist credits via fusion + 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; + + `, + }, + { + // Backfill existing data into the claims graph: + // 1. track_artists → credited_main_on / featured_on claims (source=tag) + // 2. artist_similar → same_scene_as claims (source=lastfm, confidence=match) + // This makes the graph immediately usable without waiting for re-enrichment. + id: '20260707_backfill_claims', + sql: ` + -- 1. Populate claims from track_artists (tag-derived) + INSERT INTO claims (subject_type, subject_id, predicate, object_type, object_id, source, confidence, evidence_at) + SELECT + 'track' AS subject_type, + ta.track_id AS subject_id, + CASE WHEN ta.role = 'main' THEN 'credited_main_on' ELSE 'featured_on' END AS predicate, + 'artist' AS object_type, + ta.artist_id AS object_id, + 'tag' AS source, + 1.0 AS confidence, + NOW() AS evidence_at + FROM track_artists ta + ON CONFLICT (subject_type, subject_id, predicate, object_type, object_id, source, user_id) DO NOTHING; + + -- 2. Populate claims from artist_similar (Last.fm-derived) + INSERT INTO claims (subject_type, subject_id, predicate, object_type, object_id, source, confidence, evidence_at) + SELECT + 'artist' AS subject_type, + ar.id AS subject_id, + 'same_scene_as' AS predicate, + 'artist' AS object_type, + similar_ar.id AS object_id, + 'lastfm' AS source, + LEAST(asim.match, 1.0) AS confidence, + COALESCE(asim.fetched_at, NOW()) AS evidence_at + FROM artist_similar asim + JOIN artists ar ON ar.id = asim.artist_id + -- Resolve similar_name to an artist row so object_id is a real entity + JOIN artists similar_ar ON similar_ar.normalized_name = normalize_artist(asim.similar_name) + ON CONFLICT (subject_type, subject_id, predicate, object_type, object_id, source, user_id) DO NOTHING; + + -- 3. Also write claims with source='lastfm' for similar_name that didn't + -- resolve to an artist row (store as 'artist' object_type with name in raw) + INSERT INTO claims (subject_type, subject_id, predicate, object_type, object_id, source, confidence, evidence_at, raw) + SELECT + 'artist' AS subject_type, + ar.id AS subject_id, + 'same_scene_as' AS predicate, + 'artist_name' AS object_type, + gen_random_uuid() AS object_id, + 'lastfm' AS source, + LEAST(asim.match, 1.0) AS confidence, + COALESCE(asim.fetched_at, NOW()) AS evidence_at, + jsonb_build_object('similar_name', asim.similar_name) + FROM artist_similar asim + JOIN artists ar ON ar.id = asim.artist_id + WHERE NOT EXISTS ( + SELECT 1 FROM artists a WHERE a.normalized_name = normalize_artist(asim.similar_name) + ) + ON CONFLICT (subject_type, subject_id, predicate, object_type, object_id, source, user_id) DO NOTHING; + `, + }, + { + id: '20260708_materialize_claim_fusion', + sql: ` + -- Drop old view + dependent views + DROP VIEW IF EXISTS claim_fusion CASCADE; + DROP VIEW IF EXISTS track_artists_v2 CASCADE; + DROP VIEW IF EXISTS album_artists_v2 CASCADE; + + -- Create materialized view (same query as old view) + CREATE MATERIALIZED VIEW IF NOT EXISTS 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, c.user_id; + + -- Unique index on the MV + CREATE UNIQUE INDEX IF NOT EXISTS idx_claim_fusion_pk ON claim_fusion (subject_type, subject_id, predicate, object_type, object_id, COALESCE(user_id, '00000000-0000-0000-0000-000000000000')); + + -- Recreate compatibility views (now reading from MV) + 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; + + -- Refresh function for the MV + CREATE OR REPLACE FUNCTION refresh_claim_fusion() RETURNS void AS $$ + BEGIN + REFRESH MATERIALIZED VIEW CONCURRENTLY claim_fusion; + END; + $$ LANGUAGE plpgsql; + + -- Trigger function that notifies on claims changes + CREATE OR REPLACE FUNCTION notify_claim_fusion_change() RETURNS trigger AS $$ + BEGIN + NOTIFY claim_fusion_changed; + RETURN NULL; + END; + $$ LANGUAGE plpgsql; + + -- Trigger on claims table + DROP TRIGGER IF EXISTS trg_claim_fusion_refresh ON claims; + CREATE TRIGGER trg_claim_fusion_refresh AFTER INSERT OR UPDATE OR DELETE ON claims FOR EACH STATEMENT EXECUTE FUNCTION notify_claim_fusion_change(); + `, + }, + { + id: '20260708_fix_claim_fusion_index', + sql: ` + -- Drop the expression-based unique index that blocks CONCURRENTLY refresh. + -- The MV's user_id column is already COALESCE'd (non-null) from the SELECT, + -- so we can use the plain column name instead. + DROP INDEX IF EXISTS idx_claim_fusion_pk; + CREATE UNIQUE INDEX idx_claim_fusion_pk + ON claim_fusion (subject_type, subject_id, predicate, object_type, object_id, user_id); + `, + }, + { + id: '20260709_artists_mbid_unique', + sql: ` + -- Replace the non-unique partial index on artists.mbid with a unique one, + -- so ON CONFLICT (mbid) works in MbSpineWriter.resolveArtist(). The partial + -- predicate (WHERE mbid IS NOT NULL) allows multiple artists with no MBID. + DROP INDEX IF EXISTS idx_artists_mbid; + CREATE UNIQUE INDEX IF NOT EXISTS artists_mbid_unique + 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; + `, + }, + { + id: '20260730_claims_dedup_nulls_not_distinct', + sql: ` + -- The claims uniqueness constraint was declared as a plain + -- UNIQUE (subject_type, subject_id, predicate, object_type, object_id, + -- source, user_id). Every objective claim (MB, Discogs, tags) has + -- user_id IS NULL, and Postgres treats NULLs as distinct, so none of the + -- ON CONFLICT clauses in upsertClaim() / MbSpineWriter ever fired: + -- re-enrichment inserted a fresh duplicate row every time instead of + -- reinforcing. claim_fusion is SUM(trust * confidence * recency), so a + -- duplicated edge carried N times its intended weight. + -- + -- Two phases, in this order (the constraint cannot be added while + -- duplicates exist): + -- 1. collapse each duplicate group into its most recently reinforced + -- row, carrying forward MAX(last_reinforced_at) / MAX(evidence_at) / + -- MAX(confidence) so reinforcement recency is not lost; + -- 2. replace the constraint with a NULLS NOT DISTINCT version (PG15+). + + -- Phase 1: dedup. + CREATE TEMP TABLE claims_dedup ON COMMIT DROP AS + SELECT + id, + ROW_NUMBER() OVER ordered AS rn, + -- These MUST use the unordered window: an ORDER BY in the window spec + -- makes the default frame "UNBOUNDED PRECEDING TO CURRENT ROW", turning + -- MAX() into a running maximum rather than a per-group one. + MAX(last_reinforced_at) OVER grp AS max_last_reinforced_at, + MAX(evidence_at) OVER grp AS max_evidence_at, + MAX(confidence) OVER grp AS max_confidence + FROM claims + WINDOW + grp AS ( + PARTITION BY subject_type, subject_id, predicate, object_type, object_id, + source, + COALESCE(user_id, '00000000-0000-0000-0000-000000000000'::uuid) + ), + ordered AS ( + grp ORDER BY last_reinforced_at DESC, evidence_at DESC, id + ); + + -- Keeper of each group absorbs the group's best values. + UPDATE claims c + SET last_reinforced_at = d.max_last_reinforced_at, + evidence_at = d.max_evidence_at, + confidence = d.max_confidence + FROM claims_dedup d + WHERE c.id = d.id + AND d.rn = 1; + + DELETE FROM claims c + USING claims_dedup d + WHERE c.id = d.id + AND d.rn > 1; + + -- Phase 2: replace the constraint. The live DB has drifted from + -- schema.sql, so find the existing constraint by its definition rather + -- than assuming Postgres' auto-generated name. + DO $mig$ + DECLARE + cname TEXT; + BEGIN + FOR cname IN + SELECT con.conname + FROM pg_constraint con + WHERE con.conrelid = 'claims'::regclass + AND con.contype = 'u' + AND pg_get_constraintdef(con.oid) LIKE '%subject_type%' + AND pg_get_constraintdef(con.oid) NOT LIKE '%NULLS NOT DISTINCT%' + LOOP + EXECUTE format('ALTER TABLE claims DROP CONSTRAINT %I', cname); + END LOOP; + + IF NOT EXISTS ( + SELECT 1 FROM pg_constraint + WHERE conrelid = 'claims'::regclass AND conname = 'claims_edge_source_user_key' + ) THEN + ALTER TABLE claims ADD CONSTRAINT claims_edge_source_user_key + UNIQUE NULLS NOT DISTINCT + (subject_type, subject_id, predicate, object_type, object_id, source, user_id); + END IF; + END $mig$; + `, + }, + { + id: '20260730_feedback_track_id_set_null', + sql: ` + -- feedback is an audit log, but feedback.track_id was + -- REFERENCES tracks(id) ON DELETE CASCADE. hardDeleteTrack() / + -- permanentlyDeleteTrack() insert a 'deleted_permanent' row and then + -- delete the track, so the audit row deleted itself — which is exactly + -- why the live feedback table contains zero 'deleted_permanent' rows. + -- Switch to ON DELETE SET NULL so audit rows outlive their track. Nothing + -- reads feedback.track_id expecting non-null (there are no SELECTs against + -- it at all; the only other reference is the dedup merge in + -- mergeTracks(), which rewrites track_id to the survivor). + ALTER TABLE feedback ALTER COLUMN track_id DROP NOT NULL; + + DO $mig$ + DECLARE + cname TEXT; + BEGIN + FOR cname IN + SELECT con.conname + FROM pg_constraint con + WHERE con.conrelid = 'feedback'::regclass + AND con.contype = 'f' + AND con.confrelid = 'tracks'::regclass + AND con.confdeltype <> 'n' -- 'n' = SET NULL; anything else is wrong + LOOP + EXECUTE format('ALTER TABLE feedback DROP CONSTRAINT %I', cname); + END LOOP; + + IF NOT EXISTS ( + SELECT 1 FROM pg_constraint + WHERE conrelid = 'feedback'::regclass + AND contype = 'f' + AND confrelid = 'tracks'::regclass + ) THEN + ALTER TABLE feedback + ADD CONSTRAINT feedback_track_id_fkey + FOREIGN KEY (track_id) REFERENCES tracks(id) ON DELETE SET NULL; + END IF; + END $mig$; + `, + }, + { + // Sorts after 20260730_claims_dedup_nulls_not_distinct and + // 20260730_feedback_track_id_set_null (append-only registry; 'h' > 'f' > 'c'). + id: '20260730_hard_delete_audit_trail', + sql: ` + -- Real hard deletion of disliked files (invariant §C). Three pieces: + -- + -- 1. Denormalised identity on the audit row. 20260730_feedback_track_id_set_null + -- made feedback.track_id ON DELETE SET NULL so the 'deleted_permanent' + -- audit row outlives the track — but the surviving row no longer says + -- WHICH track was destroyed. Under real (irreversible) deletion that row + -- is the only forensic record, so copy the identifying fields into it. + ALTER TABLE feedback ADD COLUMN IF NOT EXISTS track_path TEXT; + ALTER TABLE feedback ADD COLUMN IF NOT EXISTS track_title TEXT; + ALTER TABLE feedback ADD COLUMN IF NOT EXISTS track_artist TEXT; + + -- 2. An explicit "entered HIDDEN" timestamp. The pre-deletion grace period + -- (7 days, MUZICK_HARD_DELETE_GRACE_DAYS) is measured from it. Until now + -- the only nearby column was disliked_at, which lines up with HIDDEN + -- entry solely because 'HIDDEN' happens to be the state DEFAULT and + -- restoreDislike() deletes the row outright. That is far too incidental a + -- basis for an irreversible clock, so record it directly. + -- Added without a DEFAULT first: a DEFAULT would backfill existing rows + -- with now(), resetting every in-flight dislike's clock. + ALTER TABLE dislikes ADD COLUMN IF NOT EXISTS hidden_at TIMESTAMP; + UPDATE dislikes SET hidden_at = disliked_at WHERE hidden_at IS NULL; + ALTER TABLE dislikes ALTER COLUMN hidden_at SET DEFAULT CURRENT_TIMESTAMP; + + -- 3. A durable marker for files whose DB row is already gone but whose + -- bytes are not. The sweep commits the transaction FIRST and unlinks + -- afterwards, so this row is what makes the window crash-safe and an + -- unlink failure (EROFS, EACCES, EBUSY) recorded rather than swallowed. + -- Deliberately no FK to tracks: the track row no longer exists. + CREATE TABLE IF NOT EXISTS pending_file_deletions ( + path TEXT PRIMARY KEY, + track_id UUID, + track_title TEXT, + track_artist TEXT, + requested_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + attempts INTEGER NOT NULL DEFAULT 0, + last_attempt_at TIMESTAMP, + last_error TEXT + ); + CREATE INDEX IF NOT EXISTS idx_pending_file_deletions_requested + ON pending_file_deletions(requested_at); + `, + }, +]; diff --git a/backend/src/db/types.ts b/backend/src/db/types.ts new file mode 100644 index 0000000..e392baf --- /dev/null +++ b/backend/src/db/types.ts @@ -0,0 +1,168 @@ +// Row shapes for the muzick schema, extracted verbatim from db.service.ts so +// that file is about behaviour and this one about data. Re-exported from +// db.service.ts, so an existing `import { Track } from '../services/db.service.js'` +// keeps working. + +export interface Artist { + id: string; + name: string; + mbid?: string | null; + discogs_id?: string | null; + image_path?: string | null; +} + +export interface Album { + id: string; + artist_id: string; + title: string; + year?: number | null; + artwork_id?: string | null; +} + +export interface TrackArtist { + id: string; + name: string; + role: 'main' | 'featured'; +} + +export interface Track { + id: string; + path: string; + hash: string; + title: string; + artist: string; + album_id: string; + duration: number; + state: string; + play_count: number; + skip_count: number; + dislike_count: number; + last_played_at?: Date | null; + mtime?: number | null; + source_type: string; + artists?: TrackArtist[]; +} + +export const FEEDBACK_ACTIONS = ['promoted', 'disliked', 'skipped', 'deleted_permanent'] as const; +export type FeedbackAction = (typeof FEEDBACK_ACTIONS)[number]; + +export interface HistoryEntry extends Track { + history_id: string; + batch_id: string | null; + played_at: Date; + completed: boolean; +} + +export interface Genre { + id: string; + name: string; + parent_id?: string | null; + track_count?: number; +} + +export interface DislikeEntry { + track_id: string; + disliked_at: Date; + warned_at: Date | null; + deleted_at: Date | null; + grace_hours: number; + state: string; // 'HIDDEN' | 'WARNED' | 'DELETED' + track_title: string; + track_artist: string; + track_path: string; +} + +export interface ArtistWithAlbums { + id: string; + name: string; + mbid?: string | null; + discogs_id?: string | null; + image_path?: string | null; + albums: Album[]; +} + +export interface AlbumWithTracks { + id: string; + artist_id: string; + title: string; + year?: number | null; + artwork_id?: string | null; + tracks: Track[]; +} + + +// --------------------------------------------------------------------------- +// v2 Recommendation Engine types +// --------------------------------------------------------------------------- + +export interface Claim { + id: string; + user_id: string | null; + subject_type: string; + subject_id: string; + predicate: string; + object_type: string; + object_id: string; + source: string; + confidence: number; + evidence_at: Date; + last_reinforced_at: Date; + raw: unknown | null; + created_at: Date; +} + +export interface Evidence { + id: string; + user_id: string; + entity_type: string; + entity_id: string; + signal: string; + profile: string; + weight: number; + context: unknown | null; + created_at: Date; +} + +export interface ListenerBelief { + user_id: string; + profile: string; + entity_type: string; + entity_id: string; + dimension: string; + value: number; + confidence: number; + evidence_count: number; + last_reinforced_at: Date; + last_decayed_at: Date; +} + +export interface ClaimEdge { + subjectType: string; + subjectId: string; + predicate: string; + objectType: string; + objectId: string; + fusedValue: number; +} + +export interface SessionState { + session_id: string; + user_id: string; + started_at: Date; + last_interaction: Date; + context: string | null; + state_vector: Record; +} + +export interface DiversityBudget { + user_id: string; + dimension: string; + budget_share: number; + horizon_min: number; +} + +export interface RepetitionRule { + user_id: string; + dimension: string; + min_distance: number; +} diff --git a/backend/src/db/updatable-columns.ts b/backend/src/db/updatable-columns.ts new file mode 100644 index 0000000..011d695 --- /dev/null +++ b/backend/src/db/updatable-columns.ts @@ -0,0 +1,42 @@ +/** + * Per-table allowlists of columns the HTTP `PUT /artists|albums|tracks/:id` + * endpoints may write. + * + * These endpoints pass `request.body as any` straight through, and the update + * builders interpolate `Object.keys(body)` into the SET clause as quoted + * identifiers — so without an allowlist a crafted key both closes the quoted + * identifier (SQL injection) and mass-assigns columns that are not user-editable. + * + * Deliberately excluded: + * - `tracks.path` / `tracks.hash` / `tracks.mtime` — owned by the scanner; the + * path is the only link to the file on the read-only `/music` bind. + * - `tracks.state` / `quarantined_at` / `deleted_at` — owned by the dislike + * lifecycle and the integrity sweep, not by metadata editing. + * - `play_count` / `skip_count` / `dislike_count` / `last_played_at` — learning + * signal; forgeable counters would poison the recommendation engine. + * - `source_type` — decides library vs. recommendation semantics. + * - `id` / `created_at` / `updated_at` and every generated column + * (`normalized_name`, `normalized_artist` — Postgres rejects writes anyway). + */ +export const UPDATABLE_COLUMNS = { + artists: ['name', 'canonical_name', 'sort_name', 'mbid', 'discogs_id', 'image_path'], + albums: ['artist_id', 'title', 'year', 'release_date', 'artwork_id', 'mbid'], + tracks: ['title', 'artist', 'album_id', 'duration', 'release_date'], +} as const; + +/** + * Filter a partial update payload down to the allowlisted, defined columns for + * `table`. Unknown keys are dropped silently rather than raising: the callers + * take `Partial` and the routes do no error mapping, so a 500 on an extra + * key would be worse than a no-op. The subsequent "No fields to update" throw + * still surfaces a payload that was *entirely* rejected. + */ +export function allowedFields( + table: keyof typeof UPDATABLE_COLUMNS, + data: T +): (keyof T & string)[] { + const allowed = UPDATABLE_COLUMNS[table] as readonly string[]; + return Object.keys(data).filter( + (k) => allowed.includes(k) && (data as any)[k] !== undefined + ) as (keyof T & string)[]; +} diff --git a/backend/src/services/db.service.ts b/backend/src/services/db.service.ts index e1b3084..e3bd2d6 100644 --- a/backend/src/services/db.service.ts +++ b/backend/src/services/db.service.ts @@ -10,720 +10,32 @@ import { SearchService } from './search.service.js'; /** Anything with a `.query()` — either the shared Pool or a checked-out client. */ type Queryable = Pool | PoolClient; -export interface Artist { - id: string; - name: string; - mbid?: string | null; - discogs_id?: string | null; - image_path?: string | null; -} +import { MIGRATIONS } from '../db/migrations.js'; +import { allowedFields } from '../db/updatable-columns.js'; -export interface Album { - id: string; - artist_id: string; - title: string; - year?: number | null; - artwork_id?: string | null; -} - -export interface TrackArtist { - id: string; - name: string; - role: 'main' | 'featured'; -} - -export interface Track { - id: string; - path: string; - hash: string; - title: string; - artist: string; - album_id: string; - duration: number; - state: string; - play_count: number; - skip_count: number; - dislike_count: number; - last_played_at?: Date | null; - mtime?: number | null; - source_type: string; - artists?: TrackArtist[]; -} - -export const FEEDBACK_ACTIONS = ['promoted', 'disliked', 'skipped', 'deleted_permanent'] as const; -export type FeedbackAction = (typeof FEEDBACK_ACTIONS)[number]; - -export interface HistoryEntry extends Track { - history_id: string; - batch_id: string | null; - played_at: Date; - completed: boolean; -} - -export interface Genre { - id: string; - name: string; - parent_id?: string | null; - track_count?: number; -} - -export interface DislikeEntry { - track_id: string; - disliked_at: Date; - warned_at: Date | null; - deleted_at: Date | null; - grace_hours: number; - state: string; // 'HIDDEN' | 'WARNED' | 'DELETED' - track_title: string; - track_artist: string; - track_path: string; -} - -export interface ArtistWithAlbums { - id: string; - name: string; - mbid?: string | null; - discogs_id?: string | null; - image_path?: string | null; - albums: Album[]; -} - -export interface AlbumWithTracks { - id: string; - artist_id: string; - title: string; - year?: number | null; - artwork_id?: string | null; - tracks: Track[]; -} - - -// --------------------------------------------------------------------------- -// v2 Recommendation Engine types -// --------------------------------------------------------------------------- - -export interface Claim { - id: string; - user_id: string | null; - subject_type: string; - subject_id: string; - predicate: string; - object_type: string; - object_id: string; - source: string; - confidence: number; - evidence_at: Date; - last_reinforced_at: Date; - raw: unknown | null; - created_at: Date; -} - -export interface Evidence { - id: string; - user_id: string; - entity_type: string; - entity_id: string; - signal: string; - profile: string; - weight: number; - context: unknown | null; - created_at: Date; -} - -export interface ListenerBelief { - user_id: string; - profile: string; - entity_type: string; - entity_id: string; - dimension: string; - value: number; - confidence: number; - evidence_count: number; - last_reinforced_at: Date; - last_decayed_at: Date; -} - -export interface ClaimEdge { - subjectType: string; - subjectId: string; - predicate: string; - objectType: string; - objectId: string; - fusedValue: number; -} - -export interface SessionState { - session_id: string; - user_id: string; - started_at: Date; - last_interaction: Date; - context: string | null; - state_vector: Record; -} - -export interface DiversityBudget { - user_id: string; - dimension: string; - budget_share: number; - horizon_min: number; -} - -export interface RepetitionRule { - user_id: string; - dimension: string; - min_distance: number; -} - -// --------------------------------------------------------------------------- -// Migrations registry -// Add new entries at the END. Never edit or remove existing entries. -// Convention for id: "YYYYMMDD_short_description" -// --------------------------------------------------------------------------- -/** - * Per-table allowlists of columns the HTTP `PUT /artists|albums|tracks/:id` - * endpoints may write. - * - * These endpoints pass `request.body as any` straight through, and the update - * builders interpolate `Object.keys(body)` into the SET clause as quoted - * identifiers — so without an allowlist a crafted key both closes the quoted - * identifier (SQL injection) and mass-assigns columns that are not user-editable. - * - * Deliberately excluded: - * - `tracks.path` / `tracks.hash` / `tracks.mtime` — owned by the scanner; the - * path is the only link to the file on the read-only `/music` bind. - * - `tracks.state` / `quarantined_at` / `deleted_at` — owned by the dislike - * lifecycle and the integrity sweep, not by metadata editing. - * - `play_count` / `skip_count` / `dislike_count` / `last_played_at` — learning - * signal; forgeable counters would poison the recommendation engine. - * - `source_type` — decides library vs. recommendation semantics. - * - `id` / `created_at` / `updated_at` and every generated column - * (`normalized_name`, `normalized_artist` — Postgres rejects writes anyway). - */ -const UPDATABLE_COLUMNS = { - artists: ['name', 'canonical_name', 'sort_name', 'mbid', 'discogs_id', 'image_path'], - albums: ['artist_id', 'title', 'year', 'release_date', 'artwork_id', 'mbid'], - tracks: ['title', 'artist', 'album_id', 'duration', 'release_date'], -} as const; - -/** - * Filter a partial update payload down to the allowlisted, defined columns for - * `table`. Unknown keys are dropped silently rather than raising: the callers - * take `Partial` and the routes do no error mapping, so a 500 on an extra - * key would be worse than a no-op. The subsequent "No fields to update" throw - * still surfaces a payload that was *entirely* rejected. - */ -function allowedFields( - table: keyof typeof UPDATABLE_COLUMNS, - data: T -): (keyof T & string)[] { - const allowed = UPDATABLE_COLUMNS[table] as readonly string[]; - return Object.keys(data).filter( - (k) => allowed.includes(k) && (data as any)[k] !== undefined - ) as (keyof T & string)[]; -} - -const MIGRATIONS: { id: string; sql: string }[] = [ - { - id: '20260608_track_artists', - sql: ` - CREATE TABLE IF NOT EXISTS track_artists ( - track_id UUID REFERENCES tracks(id) ON DELETE CASCADE, - artist_id UUID REFERENCES artists(id) ON DELETE CASCADE, - role TEXT NOT NULL DEFAULT 'main', - PRIMARY KEY (track_id, artist_id, role) - ); - CREATE INDEX IF NOT EXISTS idx_track_artists_artist ON track_artists(artist_id); - - -- Backfill main artist from albums for tracks not yet in track_artists - INSERT INTO track_artists (track_id, artist_id, role) - SELECT t.id, al.artist_id, 'main' - FROM tracks t - JOIN albums al ON al.id = t.album_id - WHERE NOT EXISTS ( - SELECT 1 FROM track_artists ta WHERE ta.track_id = t.id - ); - `, - }, - { - id: '20260608_clear_lastfm_placeholder_images', - sql: ` - UPDATE artists SET image_path = NULL - WHERE image_path LIKE '%2a96cbd8b46e442fc41c2b86b821562f%'; - `, - }, - { - // normalize_artist() was extended (schema.sql) to also split collaboration - // separators ( ; & / ) — not just commas/feat. STORED generated columns are - // NOT recomputed when the function definition changes, so force a recompute - // by touching the base column of every dependent row. ensureSchema() (which - // installs the new function) runs before migrations, so the new definition - // is already active here. - id: '20260612_recompute_normalized_artist', - sql: ` - UPDATE artists SET name = name; - UPDATE tracks SET artist = artist; - `, - }, - { - // The name-based Wikimedia Commons image fallback (now removed from the - // enrichment chain) frequently attached the wrong photo. Clear those rows so - // they fall back to the placeholder / a better source. Verified Wikidata - // images (fetched via MBID, step 3) also live on wikimedia.org but only on - // artists that HAVE an mbid, so restricting to mbid IS NULL spares them. - id: '20260612_clear_namebased_wikimedia_images', - sql: ` - UPDATE artists SET image_path = NULL - WHERE mbid IS NULL AND image_path LIKE '%wikimedia.org%'; - `, - }, - { - // claim_fusion materialised view + compatibility views for v2 graph. - // Depends on claims, source_trust tables which are created by schema.sql - // (run before migrations). The MV resolves truth at read time as a weighted - // vote across claims per the fusion formula in spec §A.4. - id: '20260707_claim_fusion', - sql: ` - CREATE OR REPLACE 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, c.user_id; - - -- Compatibility view: track → artist credits via fusion - 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; - - -- Compatibility view: album → artist credits via fusion - 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; - - `, - }, - { - // Backfill existing data into the claims graph: - // 1. track_artists → credited_main_on / featured_on claims (source=tag) - // 2. artist_similar → same_scene_as claims (source=lastfm, confidence=match) - // This makes the graph immediately usable without waiting for re-enrichment. - id: '20260707_backfill_claims', - sql: ` - -- 1. Populate claims from track_artists (tag-derived) - INSERT INTO claims (subject_type, subject_id, predicate, object_type, object_id, source, confidence, evidence_at) - SELECT - 'track' AS subject_type, - ta.track_id AS subject_id, - CASE WHEN ta.role = 'main' THEN 'credited_main_on' ELSE 'featured_on' END AS predicate, - 'artist' AS object_type, - ta.artist_id AS object_id, - 'tag' AS source, - 1.0 AS confidence, - NOW() AS evidence_at - FROM track_artists ta - ON CONFLICT (subject_type, subject_id, predicate, object_type, object_id, source, user_id) DO NOTHING; - - -- 2. Populate claims from artist_similar (Last.fm-derived) - INSERT INTO claims (subject_type, subject_id, predicate, object_type, object_id, source, confidence, evidence_at) - SELECT - 'artist' AS subject_type, - ar.id AS subject_id, - 'same_scene_as' AS predicate, - 'artist' AS object_type, - similar_ar.id AS object_id, - 'lastfm' AS source, - LEAST(asim.match, 1.0) AS confidence, - COALESCE(asim.fetched_at, NOW()) AS evidence_at - FROM artist_similar asim - JOIN artists ar ON ar.id = asim.artist_id - -- Resolve similar_name to an artist row so object_id is a real entity - JOIN artists similar_ar ON similar_ar.normalized_name = normalize_artist(asim.similar_name) - ON CONFLICT (subject_type, subject_id, predicate, object_type, object_id, source, user_id) DO NOTHING; - - -- 3. Also write claims with source='lastfm' for similar_name that didn't - -- resolve to an artist row (store as 'artist' object_type with name in raw) - INSERT INTO claims (subject_type, subject_id, predicate, object_type, object_id, source, confidence, evidence_at, raw) - SELECT - 'artist' AS subject_type, - ar.id AS subject_id, - 'same_scene_as' AS predicate, - 'artist_name' AS object_type, - gen_random_uuid() AS object_id, - 'lastfm' AS source, - LEAST(asim.match, 1.0) AS confidence, - COALESCE(asim.fetched_at, NOW()) AS evidence_at, - jsonb_build_object('similar_name', asim.similar_name) - FROM artist_similar asim - JOIN artists ar ON ar.id = asim.artist_id - WHERE NOT EXISTS ( - SELECT 1 FROM artists a WHERE a.normalized_name = normalize_artist(asim.similar_name) - ) - ON CONFLICT (subject_type, subject_id, predicate, object_type, object_id, source, user_id) DO NOTHING; - `, - }, - { - id: '20260708_materialize_claim_fusion', - sql: ` - -- Drop old view + dependent views - DROP VIEW IF EXISTS claim_fusion CASCADE; - DROP VIEW IF EXISTS track_artists_v2 CASCADE; - DROP VIEW IF EXISTS album_artists_v2 CASCADE; - - -- Create materialized view (same query as old view) - CREATE MATERIALIZED VIEW IF NOT EXISTS 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, c.user_id; - - -- Unique index on the MV - CREATE UNIQUE INDEX IF NOT EXISTS idx_claim_fusion_pk ON claim_fusion (subject_type, subject_id, predicate, object_type, object_id, COALESCE(user_id, '00000000-0000-0000-0000-000000000000')); - - -- Recreate compatibility views (now reading from MV) - 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; - - -- Refresh function for the MV - CREATE OR REPLACE FUNCTION refresh_claim_fusion() RETURNS void AS $$ - BEGIN - REFRESH MATERIALIZED VIEW CONCURRENTLY claim_fusion; - END; - $$ LANGUAGE plpgsql; - - -- Trigger function that notifies on claims changes - CREATE OR REPLACE FUNCTION notify_claim_fusion_change() RETURNS trigger AS $$ - BEGIN - NOTIFY claim_fusion_changed; - RETURN NULL; - END; - $$ LANGUAGE plpgsql; - - -- Trigger on claims table - DROP TRIGGER IF EXISTS trg_claim_fusion_refresh ON claims; - CREATE TRIGGER trg_claim_fusion_refresh AFTER INSERT OR UPDATE OR DELETE ON claims FOR EACH STATEMENT EXECUTE FUNCTION notify_claim_fusion_change(); - `, - }, - { - id: '20260708_fix_claim_fusion_index', - sql: ` - -- Drop the expression-based unique index that blocks CONCURRENTLY refresh. - -- The MV's user_id column is already COALESCE'd (non-null) from the SELECT, - -- so we can use the plain column name instead. - DROP INDEX IF EXISTS idx_claim_fusion_pk; - CREATE UNIQUE INDEX idx_claim_fusion_pk - ON claim_fusion (subject_type, subject_id, predicate, object_type, object_id, user_id); - `, - }, - { - id: '20260709_artists_mbid_unique', - sql: ` - -- Replace the non-unique partial index on artists.mbid with a unique one, - -- so ON CONFLICT (mbid) works in MbSpineWriter.resolveArtist(). The partial - -- predicate (WHERE mbid IS NOT NULL) allows multiple artists with no MBID. - DROP INDEX IF EXISTS idx_artists_mbid; - CREATE UNIQUE INDEX IF NOT EXISTS artists_mbid_unique - 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; - `, - }, - { - id: '20260730_claims_dedup_nulls_not_distinct', - sql: ` - -- The claims uniqueness constraint was declared as a plain - -- UNIQUE (subject_type, subject_id, predicate, object_type, object_id, - -- source, user_id). Every objective claim (MB, Discogs, tags) has - -- user_id IS NULL, and Postgres treats NULLs as distinct, so none of the - -- ON CONFLICT clauses in upsertClaim() / MbSpineWriter ever fired: - -- re-enrichment inserted a fresh duplicate row every time instead of - -- reinforcing. claim_fusion is SUM(trust * confidence * recency), so a - -- duplicated edge carried N times its intended weight. - -- - -- Two phases, in this order (the constraint cannot be added while - -- duplicates exist): - -- 1. collapse each duplicate group into its most recently reinforced - -- row, carrying forward MAX(last_reinforced_at) / MAX(evidence_at) / - -- MAX(confidence) so reinforcement recency is not lost; - -- 2. replace the constraint with a NULLS NOT DISTINCT version (PG15+). - - -- Phase 1: dedup. - CREATE TEMP TABLE claims_dedup ON COMMIT DROP AS - SELECT - id, - ROW_NUMBER() OVER ordered AS rn, - -- These MUST use the unordered window: an ORDER BY in the window spec - -- makes the default frame "UNBOUNDED PRECEDING TO CURRENT ROW", turning - -- MAX() into a running maximum rather than a per-group one. - MAX(last_reinforced_at) OVER grp AS max_last_reinforced_at, - MAX(evidence_at) OVER grp AS max_evidence_at, - MAX(confidence) OVER grp AS max_confidence - FROM claims - WINDOW - grp AS ( - PARTITION BY subject_type, subject_id, predicate, object_type, object_id, - source, - COALESCE(user_id, '00000000-0000-0000-0000-000000000000'::uuid) - ), - ordered AS ( - grp ORDER BY last_reinforced_at DESC, evidence_at DESC, id - ); - - -- Keeper of each group absorbs the group's best values. - UPDATE claims c - SET last_reinforced_at = d.max_last_reinforced_at, - evidence_at = d.max_evidence_at, - confidence = d.max_confidence - FROM claims_dedup d - WHERE c.id = d.id - AND d.rn = 1; - - DELETE FROM claims c - USING claims_dedup d - WHERE c.id = d.id - AND d.rn > 1; - - -- Phase 2: replace the constraint. The live DB has drifted from - -- schema.sql, so find the existing constraint by its definition rather - -- than assuming Postgres' auto-generated name. - DO $mig$ - DECLARE - cname TEXT; - BEGIN - FOR cname IN - SELECT con.conname - FROM pg_constraint con - WHERE con.conrelid = 'claims'::regclass - AND con.contype = 'u' - AND pg_get_constraintdef(con.oid) LIKE '%subject_type%' - AND pg_get_constraintdef(con.oid) NOT LIKE '%NULLS NOT DISTINCT%' - LOOP - EXECUTE format('ALTER TABLE claims DROP CONSTRAINT %I', cname); - END LOOP; - - IF NOT EXISTS ( - SELECT 1 FROM pg_constraint - WHERE conrelid = 'claims'::regclass AND conname = 'claims_edge_source_user_key' - ) THEN - ALTER TABLE claims ADD CONSTRAINT claims_edge_source_user_key - UNIQUE NULLS NOT DISTINCT - (subject_type, subject_id, predicate, object_type, object_id, source, user_id); - END IF; - END $mig$; - `, - }, - { - id: '20260730_feedback_track_id_set_null', - sql: ` - -- feedback is an audit log, but feedback.track_id was - -- REFERENCES tracks(id) ON DELETE CASCADE. hardDeleteTrack() / - -- permanentlyDeleteTrack() insert a 'deleted_permanent' row and then - -- delete the track, so the audit row deleted itself — which is exactly - -- why the live feedback table contains zero 'deleted_permanent' rows. - -- Switch to ON DELETE SET NULL so audit rows outlive their track. Nothing - -- reads feedback.track_id expecting non-null (there are no SELECTs against - -- it at all; the only other reference is the dedup merge in - -- mergeTracks(), which rewrites track_id to the survivor). - ALTER TABLE feedback ALTER COLUMN track_id DROP NOT NULL; - - DO $mig$ - DECLARE - cname TEXT; - BEGIN - FOR cname IN - SELECT con.conname - FROM pg_constraint con - WHERE con.conrelid = 'feedback'::regclass - AND con.contype = 'f' - AND con.confrelid = 'tracks'::regclass - AND con.confdeltype <> 'n' -- 'n' = SET NULL; anything else is wrong - LOOP - EXECUTE format('ALTER TABLE feedback DROP CONSTRAINT %I', cname); - END LOOP; - - IF NOT EXISTS ( - SELECT 1 FROM pg_constraint - WHERE conrelid = 'feedback'::regclass - AND contype = 'f' - AND confrelid = 'tracks'::regclass - ) THEN - ALTER TABLE feedback - ADD CONSTRAINT feedback_track_id_fkey - FOREIGN KEY (track_id) REFERENCES tracks(id) ON DELETE SET NULL; - END IF; - END $mig$; - `, - }, - { - // Sorts after 20260730_claims_dedup_nulls_not_distinct and - // 20260730_feedback_track_id_set_null (append-only registry; 'h' > 'f' > 'c'). - id: '20260730_hard_delete_audit_trail', - sql: ` - -- Real hard deletion of disliked files (invariant §C). Three pieces: - -- - -- 1. Denormalised identity on the audit row. 20260730_feedback_track_id_set_null - -- made feedback.track_id ON DELETE SET NULL so the 'deleted_permanent' - -- audit row outlives the track — but the surviving row no longer says - -- WHICH track was destroyed. Under real (irreversible) deletion that row - -- is the only forensic record, so copy the identifying fields into it. - ALTER TABLE feedback ADD COLUMN IF NOT EXISTS track_path TEXT; - ALTER TABLE feedback ADD COLUMN IF NOT EXISTS track_title TEXT; - ALTER TABLE feedback ADD COLUMN IF NOT EXISTS track_artist TEXT; - - -- 2. An explicit "entered HIDDEN" timestamp. The pre-deletion grace period - -- (7 days, MUZICK_HARD_DELETE_GRACE_DAYS) is measured from it. Until now - -- the only nearby column was disliked_at, which lines up with HIDDEN - -- entry solely because 'HIDDEN' happens to be the state DEFAULT and - -- restoreDislike() deletes the row outright. That is far too incidental a - -- basis for an irreversible clock, so record it directly. - -- Added without a DEFAULT first: a DEFAULT would backfill existing rows - -- with now(), resetting every in-flight dislike's clock. - ALTER TABLE dislikes ADD COLUMN IF NOT EXISTS hidden_at TIMESTAMP; - UPDATE dislikes SET hidden_at = disliked_at WHERE hidden_at IS NULL; - ALTER TABLE dislikes ALTER COLUMN hidden_at SET DEFAULT CURRENT_TIMESTAMP; - - -- 3. A durable marker for files whose DB row is already gone but whose - -- bytes are not. The sweep commits the transaction FIRST and unlinks - -- afterwards, so this row is what makes the window crash-safe and an - -- unlink failure (EROFS, EACCES, EBUSY) recorded rather than swallowed. - -- Deliberately no FK to tracks: the track row no longer exists. - CREATE TABLE IF NOT EXISTS pending_file_deletions ( - path TEXT PRIMARY KEY, - track_id UUID, - track_title TEXT, - track_artist TEXT, - requested_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, - attempts INTEGER NOT NULL DEFAULT 0, - last_attempt_at TIMESTAMP, - last_error TEXT - ); - CREATE INDEX IF NOT EXISTS idx_pending_file_deletions_requested - ON pending_file_deletions(requested_at); - `, - }, -]; +// Row shapes live in ../db/types.ts; re-exported here so existing importers +// (routes, generators, session-director) need no change. +import type { + Artist, + Album, + TrackArtist, + Track, + FeedbackAction, + HistoryEntry, + Genre, + DislikeEntry, + ArtistWithAlbums, + AlbumWithTracks, + Claim, + Evidence, + ListenerBelief, + ClaimEdge, + SessionState, + DiversityBudget, + RepetitionRule, +} from '../db/types.js'; +import { FEEDBACK_ACTIONS } from '../db/types.js'; +export * from '../db/types.js'; export class DbService { /** Exposed so route handlers (e.g. settings) can query the database directly. */ @@ -2083,3 +1395,4 @@ export class DbService { return res.rowCount ?? 0; } } +