diff --git a/backend/src/db/schema.sql b/backend/src/db/schema.sql index 0a40563..b274c7c 100644 --- a/backend/src/db/schema.sql +++ b/backend/src/db/schema.sql @@ -257,13 +257,42 @@ CREATE INDEX IF NOT EXISTS idx_play_history_user_played_at ON play_history(user_ CREATE TABLE IF NOT EXISTS feedback ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), user_id UUID NOT NULL, - track_id UUID REFERENCES tracks(id) ON DELETE CASCADE, + -- SET NULL, not CASCADE: feedback is an audit log and must outlive the + -- track. With CASCADE the 'deleted_permanent' row written by + -- hardDeleteTrack() deletes itself as soon as the track row goes. + track_id UUID REFERENCES tracks(id) ON DELETE SET NULL, action TEXT NOT NULL, + -- Denormalised track identity, written on 'deleted_permanent' rows only. + -- track_id goes NULL when the track is deleted, so without these the audit + -- row survives but no longer says what was destroyed. + track_path TEXT, + track_title TEXT, + track_artist TEXT, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ); CREATE INDEX IF NOT EXISTS idx_feedback_user_action ON feedback(user_id, action); +-- Files whose DB row has been deleted but whose bytes are still on disk. +-- The cleanup sweep commits its transaction BEFORE unlinking, so this table is +-- the durable record of that window: a row is inserted with the deletion and +-- removed only once the unlink succeeds. A row that lingers means the unlink +-- failed (EROFS/EACCES/...) or the worker died mid-sweep; the next sweep retries +-- it. No FK to tracks — the track is already gone. +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); + CREATE TABLE IF NOT EXISTS track_audio_features ( track_id UUID PRIMARY KEY REFERENCES tracks(id) ON DELETE CASCADE, bpm REAL, diff --git a/backend/src/services/db.service.ts b/backend/src/services/db.service.ts index d22f288..587a7c9 100644 --- a/backend/src/services/db.service.ts +++ b/backend/src/services/db.service.ts @@ -631,6 +631,48 @@ const MIGRATIONS: { id: string; sql: string }[] = [ 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$; + `, + }, ]; export class DbService {