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:
kami
2026-07-30 23:49:34 +04:00
parent 1e59d21dee
commit 3ffba3f24b
2 changed files with 72 additions and 1 deletions
+30 -1
View File
@@ -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,