feat: make hard deletion of disliked tracks real

The dislike lifecycle promised WARNED -> HIDDEN -> deleted, but nothing ever
removed a file: cleanup.service logged its intent behind
MUZICK_ALLOW_HARD_DELETE and returned, and the two backend delete paths
(hardDeleteTrack, permanentlyDeleteTrack) disagreed about what deletion
meant. The review recommended dropping hard deletion and making HIDDEN
terminal; the owner chose to make deletion real instead.

  - cleanup.service performs a true unlink() — no trash directory — for
    tracks that have been HIDDEN for a 7-day grace period, then settles the
    row. This is the single unlink() call site in the system.
  - permanentlyDeleteTrack is the one delete path; hardDeleteTrack is gone.
  - a deleted_permanent audit row records what was removed, and
    migration 20260730_hard_delete_audit_trail backs it.

MUZICK_ALLOW_HARD_DELETE remains OFF: the docker-compose entry is commented
out, there is no enabling default in code, and the worker's /music bind is
the only writable one. Deletion stays dry-run until the owner opts in
deliberately.

REVIEW-2026-07-30.md open decision: dislike lifecycle.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
kami
2026-07-30 23:56:49 +04:00
parent 27e8acc592
commit 963f845733
6 changed files with 450 additions and 103 deletions
+7
View File
@@ -211,8 +211,15 @@ CREATE TABLE IF NOT EXISTS track_genre (
CREATE TABLE IF NOT EXISTS dislikes (
track_id UUID PRIMARY KEY REFERENCES tracks(id) ON DELETE CASCADE,
disliked_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
-- When the track entered HIDDEN. The pre-deletion grace period
-- (MUZICK_HARD_DELETE_GRACE_DAYS, default 7 days) is measured from here —
-- separate from, and much longer than, grace_hours below. Recorded
-- explicitly rather than inferred from disliked_at, because an irreversible
-- clock must not rest on 'HIDDEN' merely being the state DEFAULT.
hidden_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
warned_at TIMESTAMP,
deleted_at TIMESTAMP,
-- HIDDEN -> WARNED delay (the ntfy warning), NOT the deletion delay.
grace_hours INTEGER DEFAULT 48,
state dislike_state DEFAULT 'HIDDEN'
);
+138 -69
View File
@@ -1,4 +1,7 @@
import { readFile, unlink } from 'fs/promises';
// No `unlink` here, deliberately: the backend mounts /music `:ro` and must never
// remove a library file. Deletions are queued into pending_file_deletions and
// carried out by the worker's gated cleanup sweep.
import { readFile } from 'fs/promises';
import { fileURLToPath } from 'url';
import { dirname, join } from 'path';
import { Pool, PoolClient } from 'pg';
@@ -673,6 +676,53 @@ const MIGRATIONS: { id: string; sql: string }[] = [
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);
`,
},
];
export class DbService {
@@ -1066,42 +1116,13 @@ export class DbService {
return (res.rows[0] as DislikeEntry) || null;
}
/**
* Permanently delete a track: remove from DB (cascades to related tables)
* and delete the physical file. Used by the cleanup_sweep worker after the
* full lifecycle (grace period + 24h warning) has elapsed.
*/
async hardDeleteTrack(userId: string, trackId: string, filePath: string): Promise<void> {
const { unlink } = await import('fs/promises');
// Log the permanent deletion feedback event first (before the track is gone)
await this.pgClient.query(
"INSERT INTO feedback (user_id, track_id, action) VALUES ($1, $2, 'deleted_permanent')",
[userId, trackId]
);
// Write evidence: manual_deleted → negative profile (strongest negative
// signal, spec §B.3). entity_id has no FK to tracks, so the row survives.
await this.recordEvidence({
user_id: userId,
entity_type: 'track',
entity_id: trackId,
signal: 'manual_deleted',
profile: 'negative',
weight: -0.90,
});
// Delete DB record (ON DELETE CASCADE handles track_genre, play_history,
// feedback, track_audio_features, track_lyrics)
await this.pgClient.query('DELETE FROM tracks WHERE id = $1', [trackId]);
// Delete physical file from disk
try {
await unlink(filePath);
} catch {
// File may already be gone (e.g. missing). Log but don't abort.
}
}
// NOTE: hardDeleteTrack() used to live here as a second, unreferenced deletion
// path with the same insert-then-delete shape as permanentlyDeleteTrack(), but
// with the audit insert outside any transaction and the unlink after the DELETE
// and silently swallowed. It had no callers. Removed rather than fixed twice
// over: there is now exactly one deletion path in the backend
// (permanentlyDeleteTrack, below) and exactly one unlink site in the whole
// system (workers/src/cleanup.service.ts).
async recordPlay(userId: string, trackId: string, completed: boolean, batchId?: string): Promise<string> {
if (!completed) {
@@ -1423,26 +1444,67 @@ export class DbService {
}
/**
* Permanently delete a disliked track: remove the file from disk, then cascade-
* delete the DB record (which also removes the dislikes row via ON DELETE CASCADE).
* Logs a 'deleted_permanent' feedback event. Atomic: DB deletion only happens after
* the file is gone (or the file was already missing).
* Permanently delete a disliked track, skipping the grace period. Cascade-
* deletes the DB record (which also removes the dislikes row), writes the
* 'deleted_permanent' audit row, and hands the file itself to the worker.
*
* This method does NOT unlink. The backend mounts /music `:ro` on purpose —
* nothing reachable from an HTTP request may write to the library — so the
* unlink is queued as a pending_file_deletions row and performed by the
* cleanup sweep in the worker, the one process with an rw mount, and only when
* MUZICK_ALLOW_HARD_DELETE is on. Until then the row is visible evidence of an
* orphaned file rather than a lost one.
*
* Ordering: everything durable commits in one transaction, and the
* irreversible filesystem act happens strictly afterwards. The previous
* implementation unlinked first, which on this deployment meant EROFS and a
* 500 with the track still present.
*
* `filePath` is accepted for call-site compatibility but the path is re-read
* from the row under the transaction, so the queued deletion can never target
* a stale or caller-supplied path.
*/
async permanentlyDeleteTrack(userId: string, trackId: string, filePath: string): Promise<void> {
try {
await unlink(filePath);
} catch (err: any) {
if (err.code !== 'ENOENT') throw err;
}
async permanentlyDeleteTrack(userId: string, trackId: string, _filePath?: string): Promise<void> {
await this.withTransaction(async (client) => {
await client.query(
"INSERT INTO feedback (user_id, track_id, action) VALUES ($1, $2, 'deleted_permanent')",
[userId, trackId]
const trackRes = await client.query(
'SELECT path, title, artist FROM tracks WHERE id = $1 FOR UPDATE',
[trackId]
);
// CASCADE deletes dislikes, play_history, feedback, etc.
const track = trackRes.rows[0] as
| { path: string; title: string | null; artist: string | null }
| undefined;
if (!track) return; // Already gone; nothing to audit or unlink.
// Denormalised identity: track_id is ON DELETE SET NULL, so without these
// the surviving audit row would not say which track was destroyed.
await client.query(
`INSERT INTO feedback (user_id, track_id, action, track_path, track_title, track_artist)
VALUES ($1, $2, 'deleted_permanent', $3, $4, $5)`,
[userId, trackId, track.path, track.title, track.artist]
);
await client.query(
`INSERT INTO pending_file_deletions (path, track_id, track_title, track_artist)
VALUES ($1, $2, $3, $4)
ON CONFLICT (path) DO UPDATE SET requested_at = NOW()`,
[track.path, trackId, track.title, track.artist]
);
// CASCADE deletes dislikes, play_history, track_genre, etc.
await client.query('DELETE FROM tracks WHERE id = $1', [trackId]);
});
// Strongest negative signal (spec §B.3). entity_id has no FK to tracks, so
// the row survives the deletion. Outside the transaction: a failure here
// must not resurrect an already-audited deletion.
await this.recordEvidence({
user_id: userId,
entity_type: 'track',
entity_id: trackId,
signal: 'manual_deleted',
profile: 'negative',
weight: -0.90,
});
}
/**
@@ -1526,29 +1588,36 @@ export class DbService {
* remaining files from disk, then hard-delete the rest. All inside a
* transaction so a partial failure leaves no orphans.
*
* File deletion happens BEFORE the transaction (best-effort: we delete the
* file, then commit the DB changes; if the DB commit fails the file is
* already gone but the track stays orphaned in the DB — a subsequent
* integrity sweep will catch it and mark it MISSING).
* Like permanentlyDeleteTrack(), this no longer unlinks: the loser files are
* queued into pending_file_deletions inside the same transaction and removed
* later by the worker's gated cleanup sweep. Previously the unlink ran BEFORE
* the transaction and rethrew anything other than ENOENT, so on this
* deployment (/music mounted `:ro` in the backend) every dedup merge failed
* with EROFS before touching the DB at all.
*/
async mergeDuplicates(keepId: string, deleteIds: string[]): Promise<void> {
// 1. Fetch paths for the tracks being deleted (before they're gone).
const { rows: losers } = await this.pgClient.query<{ id: string; path: string }>(
`SELECT id, path FROM tracks WHERE id = ANY($1::uuid[])`,
// 1. Fetch paths + identity for the tracks being deleted (before they go).
const { rows: losers } = await this.pgClient.query<{
id: string;
path: string;
title: string | null;
artist: string | null;
}>(
`SELECT id, path, title, artist FROM tracks WHERE id = ANY($1::uuid[])`,
[deleteIds]
);
// 2. Delete files from disk (best-effort — tolerate missing files).
for (const row of losers) {
try {
await unlink(row.path);
} catch (err: any) {
if (err.code !== 'ENOENT') throw err;
}
}
// 3. DB transaction: re-parent history + delete rows.
// 2. DB transaction: queue the file removals, re-parent history, delete rows.
await this.withTransaction(async (client) => {
for (const row of losers) {
await client.query(
`INSERT INTO pending_file_deletions (path, track_id, track_title, track_artist)
VALUES ($1, $2, $3, $4)
ON CONFLICT (path) DO UPDATE SET requested_at = NOW()`,
[row.path, row.id, row.title, row.artist]
);
}
for (const id of deleteIds) {
await client.query(
`UPDATE play_history SET track_id = $1 WHERE track_id = $2`,