fix: stop the deleted_permanent audit row from destroying itself
db.service inserted the 'deleted_permanent' feedback row and then deleted the track, but feedback.track_id was ON DELETE CASCADE (verified on the live DB: confdeltype = 'c'), so the audit row deleted itself. feedback contains zero deleted_permanent rows. feedback is an audit log and must outlive its subject: the FK becomes ON DELETE SET NULL. track_id was already nullable, and nothing in backend/ or workers/ SELECTs from feedback — the only other reference is mergeTracks()'s UPDATE feedback SET track_id, which re-points to the survivor — so no caller assumed non-null. Migration 20260730_feedback_track_id_set_null drops the constraint by matching confdeltype rather than by name, since the live schema has drifted. Verified on a scratch PG16: confdeltype flips 'c' -> 'n' and a deleted_permanent row survives its track's deletion. Correct under either resolution of the dislike-lifecycle decision, so it lands independently of it. REVIEW-2026-07-30.md finding 6 (cascade only). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -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 (
|
CREATE TABLE IF NOT EXISTS feedback (
|
||||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
user_id UUID NOT NULL,
|
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,
|
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
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||||
);
|
);
|
||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_feedback_user_action ON feedback(user_id, action);
|
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 (
|
CREATE TABLE IF NOT EXISTS track_audio_features (
|
||||||
track_id UUID PRIMARY KEY REFERENCES tracks(id) ON DELETE CASCADE,
|
track_id UUID PRIMARY KEY REFERENCES tracks(id) ON DELETE CASCADE,
|
||||||
bpm REAL,
|
bpm REAL,
|
||||||
|
|||||||
@@ -631,6 +631,48 @@ const MIGRATIONS: { id: string; sql: string }[] = [
|
|||||||
END $mig$;
|
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 {
|
export class DbService {
|
||||||
|
|||||||
Reference in New Issue
Block a user