4ead344aec
One device holds the audio; the rest watch the same session over an event stream and act as remotes. Picking a device hands the audio over at the position the previous one reported, and that device stops. Also centre the command palette with margins instead of a translate: animate-rise sets its own transform and dropped the offset on mobile. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
855 lines
36 KiB
TypeScript
855 lines
36 KiB
TypeScript
// ---------------------------------------------------------------------------
|
|
// 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);
|
|
`,
|
|
},
|
|
{
|
|
// System E originally wrote graph_exploration claims without registering it
|
|
// in source_trust, so the FK rejected every graph walk. It also lacked a
|
|
// durable link from an acquired candidate to the track the scanner created.
|
|
id: '20260801_acquisition_pipeline',
|
|
sql: `
|
|
INSERT INTO source_trust (key, trust, description)
|
|
VALUES ('graph_exploration', 0.40,
|
|
'Muzick graph traversal provenance for discovery candidates.')
|
|
ON CONFLICT (key) DO NOTHING;
|
|
|
|
ALTER TABLE discovery_candidates
|
|
ADD COLUMN IF NOT EXISTS acquired_track_id UUID
|
|
REFERENCES tracks(id) ON DELETE SET NULL;
|
|
ALTER TABLE discovery_candidates
|
|
ADD COLUMN IF NOT EXISTS acquired_at TIMESTAMPTZ;
|
|
ALTER TABLE discovery_candidates
|
|
ADD COLUMN IF NOT EXISTS acquisition_attempts INTEGER NOT NULL DEFAULT 0;
|
|
ALTER TABLE discovery_candidates
|
|
ADD COLUMN IF NOT EXISTS last_error TEXT;
|
|
CREATE INDEX IF NOT EXISTS idx_discovery_candidates_status
|
|
ON discovery_candidates (status, first_seen_at);
|
|
`,
|
|
},
|
|
{
|
|
// Separate portrait fetching from structural metadata so an operator can
|
|
// re-enrich missing artist images without implicitly enabling MB writes.
|
|
id: '20260801_artist_image_enrichment_toggle',
|
|
sql: `
|
|
INSERT INTO settings (key, value)
|
|
VALUES ('enrich_artist_images', 'false')
|
|
ON CONFLICT (key) DO NOTHING;
|
|
`,
|
|
},
|
|
{
|
|
// Audio analysis v1 stored an arbitrary linear energy scale, which made
|
|
// nearly every mastered track look maximally energetic. Track the analysis
|
|
// contract and source hash so bounded background jobs can safely refresh
|
|
// stale results without repeatedly decoding unchanged files.
|
|
id: '20260801_audio_analysis_v2',
|
|
sql: `
|
|
ALTER TABLE track_audio_features
|
|
ADD COLUMN IF NOT EXISTS analysis_version SMALLINT NOT NULL DEFAULT 0;
|
|
ALTER TABLE track_audio_features
|
|
ADD COLUMN IF NOT EXISTS source_hash TEXT;
|
|
ALTER TABLE track_audio_features
|
|
ADD COLUMN IF NOT EXISTS analyzed_at TIMESTAMPTZ;
|
|
|
|
DO $mig$
|
|
BEGIN
|
|
IF NOT EXISTS (
|
|
SELECT 1 FROM pg_constraint
|
|
WHERE conrelid = 'track_audio_features'::regclass
|
|
AND conname = 'track_audio_features_bpm_range'
|
|
) THEN
|
|
ALTER TABLE track_audio_features
|
|
ADD CONSTRAINT track_audio_features_bpm_range
|
|
CHECK (bpm IS NULL OR (bpm >= 30 AND bpm <= 300)) NOT VALID;
|
|
END IF;
|
|
IF NOT EXISTS (
|
|
SELECT 1 FROM pg_constraint
|
|
WHERE conrelid = 'track_audio_features'::regclass
|
|
AND conname = 'track_audio_features_energy_range'
|
|
) THEN
|
|
ALTER TABLE track_audio_features
|
|
ADD CONSTRAINT track_audio_features_energy_range
|
|
CHECK (energy IS NULL OR (energy >= 0 AND energy <= 1)) NOT VALID;
|
|
END IF;
|
|
IF NOT EXISTS (
|
|
SELECT 1 FROM pg_constraint
|
|
WHERE conrelid = 'track_audio_features'::regclass
|
|
AND conname = 'track_audio_features_danceability_range'
|
|
) THEN
|
|
ALTER TABLE track_audio_features
|
|
ADD CONSTRAINT track_audio_features_danceability_range
|
|
CHECK (danceability IS NULL OR (danceability >= 0 AND danceability <= 1)) NOT VALID;
|
|
END IF;
|
|
END $mig$;
|
|
`,
|
|
},
|
|
{
|
|
// tracks.release_date is a denormalised copy of the canonical
|
|
// MusicBrainz release-group date on albums. Before this migration the
|
|
// worker populated albums only, leaving every historical track invisible
|
|
// to the novelty generator. The backfill and triggers make the canonical
|
|
// album value win deterministically for both old and future rows.
|
|
id: '20260801_track_release_dates_from_albums',
|
|
sql: `
|
|
-- The worker used to create this column lazily, but migration execution
|
|
-- must not rely on worker startup order.
|
|
ALTER TABLE albums ADD COLUMN IF NOT EXISTS release_date DATE;
|
|
|
|
-- Backfill only mismatches. IS DISTINCT FROM is null-safe and makes the
|
|
-- statement safe to rerun manually for diagnostics/recovery.
|
|
UPDATE tracks AS t
|
|
SET release_date = al.release_date
|
|
FROM albums AS al
|
|
WHERE t.album_id = al.id
|
|
AND t.release_date IS DISTINCT FROM al.release_date;
|
|
|
|
CREATE OR REPLACE FUNCTION sync_track_release_date_from_album()
|
|
RETURNS TRIGGER AS $mig$
|
|
BEGIN
|
|
SELECT release_date INTO NEW.release_date
|
|
FROM albums
|
|
WHERE id = NEW.album_id;
|
|
RETURN NEW;
|
|
END;
|
|
$mig$ LANGUAGE plpgsql;
|
|
|
|
DROP TRIGGER IF EXISTS trg_tracks_sync_release_date ON tracks;
|
|
CREATE TRIGGER trg_tracks_sync_release_date
|
|
BEFORE INSERT OR UPDATE OF album_id, release_date ON tracks
|
|
FOR EACH ROW EXECUTE FUNCTION sync_track_release_date_from_album();
|
|
|
|
CREATE OR REPLACE FUNCTION propagate_album_release_date_to_tracks()
|
|
RETURNS TRIGGER AS $mig$
|
|
BEGIN
|
|
UPDATE tracks
|
|
SET release_date = NEW.release_date
|
|
WHERE album_id = NEW.id
|
|
AND release_date IS DISTINCT FROM NEW.release_date;
|
|
RETURN NEW;
|
|
END;
|
|
$mig$ LANGUAGE plpgsql;
|
|
|
|
DROP TRIGGER IF EXISTS trg_albums_propagate_release_date ON albums;
|
|
CREATE TRIGGER trg_albums_propagate_release_date
|
|
AFTER UPDATE OF release_date ON albums
|
|
FOR EACH ROW
|
|
WHEN (OLD.release_date IS DISTINCT FROM NEW.release_date)
|
|
EXECUTE FUNCTION propagate_album_release_date_to_tracks();
|
|
`,
|
|
},
|
|
{
|
|
// Vibe v2 needs an immutable event ledger and revisioned plans. The
|
|
// legacy session_state table remains in place as a derived-state cache so
|
|
// existing v2 endpoints can migrate independently.
|
|
id: '20260801_vibe_session_persistence',
|
|
sql: `
|
|
CREATE TABLE IF NOT EXISTS vibe_sessions (
|
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
user_id UUID NOT NULL,
|
|
status TEXT NOT NULL CHECK (status IN ('active', 'paused', 'ended', 'expired', 'replaced')),
|
|
seed_track_id UUID REFERENCES tracks(id) ON DELETE SET NULL,
|
|
context JSONB NOT NULL DEFAULT '{}'::jsonb,
|
|
policy_version TEXT NOT NULL,
|
|
started_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
|
last_event_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
|
ended_at TIMESTAMPTZ
|
|
);
|
|
CREATE INDEX IF NOT EXISTS idx_vibe_sessions_user_last_event
|
|
ON vibe_sessions (user_id, last_event_at DESC);
|
|
|
|
CREATE TABLE IF NOT EXISTS vibe_events (
|
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
client_event_id UUID,
|
|
session_id UUID NOT NULL REFERENCES vibe_sessions(id) ON DELETE CASCADE,
|
|
user_id UUID NOT NULL,
|
|
track_id UUID REFERENCES tracks(id) ON DELETE SET NULL,
|
|
type TEXT NOT NULL,
|
|
occurred_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
|
position_ms INTEGER,
|
|
duration_ms INTEGER,
|
|
payload JSONB NOT NULL DEFAULT '{}'::jsonb,
|
|
UNIQUE (session_id, client_event_id)
|
|
);
|
|
CREATE INDEX IF NOT EXISTS idx_vibe_events_session_occurred
|
|
ON vibe_events (session_id, occurred_at);
|
|
CREATE INDEX IF NOT EXISTS idx_vibe_events_user_occurred
|
|
ON vibe_events (user_id, occurred_at DESC);
|
|
|
|
CREATE TABLE IF NOT EXISTS vibe_plan_versions (
|
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
session_id UUID NOT NULL REFERENCES vibe_sessions(id) ON DELETE CASCADE,
|
|
version INTEGER NOT NULL CHECK (version > 0),
|
|
reason TEXT NOT NULL,
|
|
state_snapshot JSONB NOT NULL,
|
|
objective_snapshot JSONB NOT NULL,
|
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
|
UNIQUE (session_id, version)
|
|
);
|
|
|
|
CREATE TABLE IF NOT EXISTS vibe_plan_items (
|
|
plan_version_id UUID NOT NULL REFERENCES vibe_plan_versions(id) ON DELETE CASCADE,
|
|
ordinal INTEGER NOT NULL CHECK (ordinal >= 0),
|
|
track_id UUID NOT NULL REFERENCES tracks(id) ON DELETE CASCADE,
|
|
slot_role TEXT,
|
|
candidate_source TEXT NOT NULL,
|
|
score REAL NOT NULL,
|
|
score_breakdown JSONB NOT NULL,
|
|
explanation JSONB NOT NULL,
|
|
committed BOOLEAN NOT NULL DEFAULT false,
|
|
PRIMARY KEY (plan_version_id, ordinal),
|
|
UNIQUE (plan_version_id, track_id)
|
|
);
|
|
`,
|
|
},
|
|
{
|
|
// A material Vibe event is projected into the legacy listener inputs in
|
|
// the same transaction as its ledger write. This marker makes that bridge
|
|
// auditable and exactly-once even when a client retries an event id.
|
|
id: '20260801_vibe_event_projections',
|
|
sql: `
|
|
CREATE TABLE IF NOT EXISTS vibe_event_projections (
|
|
event_id UUID PRIMARY KEY REFERENCES vibe_events(id) ON DELETE CASCADE,
|
|
projected_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
|
);
|
|
`,
|
|
},
|
|
{
|
|
// Session-specific exploration, goals, and deliberately lossy session
|
|
// fingerprints are derived from the immutable Vibe ledger. Keeping them
|
|
// separate from listener_beliefs prevents a transient session from
|
|
// rewriting permanent taste.
|
|
id: '20260802_vibe_context_memory_exploration',
|
|
sql: `
|
|
CREATE TABLE IF NOT EXISTS vibe_session_profiles (
|
|
session_id UUID PRIMARY KEY REFERENCES vibe_sessions(id) ON DELETE CASCADE,
|
|
user_id UUID NOT NULL,
|
|
fingerprint JSONB NOT NULL DEFAULT '{}'::jsonb,
|
|
goals JSONB NOT NULL DEFAULT '{"type":"discovery","target":1,"progress":0}'::jsonb,
|
|
exploration_coefficient REAL NOT NULL DEFAULT 0.30
|
|
CHECK (exploration_coefficient >= 0 AND exploration_coefficient <= 1),
|
|
discovery_radius REAL NOT NULL DEFAULT 0.38
|
|
CHECK (discovery_radius >= 0 AND discovery_radius <= 1),
|
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
|
);
|
|
CREATE INDEX IF NOT EXISTS idx_vibe_session_profiles_user_updated
|
|
ON vibe_session_profiles (user_id, updated_at DESC);
|
|
|
|
CREATE TABLE IF NOT EXISTS vibe_session_feedback_projections (
|
|
event_id UUID PRIMARY KEY REFERENCES vibe_events(id) ON DELETE CASCADE,
|
|
projected_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
|
);
|
|
`,
|
|
},
|
|
{
|
|
// The profile table was introduced after durable sessions. Backfill every
|
|
// pre-existing session before feedback can claim its exactly-once marker;
|
|
// newly created sessions receive their context-derived initial goals in
|
|
// createVibeSession's transaction.
|
|
id: '20260802_vibe_session_profile_backfill',
|
|
sql: `
|
|
INSERT INTO vibe_session_profiles (session_id, user_id)
|
|
SELECT id, user_id FROM vibe_sessions
|
|
ON CONFLICT (session_id) DO NOTHING;
|
|
`,
|
|
},
|
|
{
|
|
// Two external candidate strategies, kept as separate source_trust keys so
|
|
// meta-learning can compare their retention independently: "artists you
|
|
// already play just released something" is a much stronger prior than
|
|
// "Last.fm thinks this sounds similar", and the trust values say so.
|
|
id: '20260806_external_discovery_sources',
|
|
sql: `
|
|
INSERT INTO source_trust (key, trust, description) VALUES
|
|
('new_release', 0.60,
|
|
'New release by an artist already played from the local library.'),
|
|
('similar_recommendation', 0.45,
|
|
'External similarity (Last.fm) seeded from local play history.')
|
|
ON CONFLICT (key) DO NOTHING;
|
|
`,
|
|
},
|
|
{
|
|
// play_history is the only durable record of what was listened to and when,
|
|
// and it had two holes that only show up when you try to read a year back:
|
|
//
|
|
// 1. ON DELETE CASCADE meant the gated cleanup sweep silently erased the
|
|
// plays of every file it removed. A play happened; deleting the file
|
|
// later does not un-happen it. The FK becomes SET NULL and the track's
|
|
// identity is denormalised onto the row so it stays readable.
|
|
// 2. No duration, so listening time was only ever inferable from the
|
|
// track's current duration — itself gone once the file is.
|
|
id: '20260806_play_history_durable_facts',
|
|
sql: `
|
|
ALTER TABLE play_history ADD COLUMN IF NOT EXISTS listened_ms INTEGER;
|
|
ALTER TABLE play_history ADD COLUMN IF NOT EXISTS track_title TEXT;
|
|
ALTER TABLE play_history ADD COLUMN IF NOT EXISTS track_artist TEXT;
|
|
|
|
DO $$
|
|
DECLARE fk_name TEXT;
|
|
BEGIN
|
|
SELECT con.conname INTO fk_name
|
|
FROM pg_constraint con
|
|
JOIN pg_class rel ON rel.oid = con.conrelid
|
|
JOIN pg_attribute att ON att.attrelid = rel.oid AND att.attnum = con.conkey[1]
|
|
WHERE rel.relname = 'play_history'
|
|
AND con.contype = 'f'
|
|
AND att.attname = 'track_id'
|
|
AND con.confdeltype = 'c'
|
|
LIMIT 1;
|
|
IF fk_name IS NOT NULL THEN
|
|
EXECUTE format('ALTER TABLE play_history DROP CONSTRAINT %I', fk_name);
|
|
ALTER TABLE play_history
|
|
ADD CONSTRAINT play_history_track_id_fkey
|
|
FOREIGN KEY (track_id) REFERENCES tracks(id) ON DELETE SET NULL;
|
|
END IF;
|
|
END $$;
|
|
|
|
-- Backfill identity for rows written before the columns existed. Rows whose
|
|
-- track was already cascade-deleted are unrecoverable; this at least stops
|
|
-- the bleeding from here on.
|
|
UPDATE play_history ph
|
|
SET track_title = t.title, track_artist = t.artist
|
|
FROM tracks t
|
|
WHERE t.id = ph.track_id AND ph.track_title IS NULL;
|
|
`,
|
|
},
|
|
{
|
|
// One listener, many browsers. `playback_state` is the single authority for
|
|
// what is playing and which device owns the audio, so a phone can take over
|
|
// from a desktop mid-track. The queue is stored as whole track objects, not
|
|
// ids: the device taking over needs to render the queue immediately, and a
|
|
// snapshot of what was queued at handoff time is the honest thing to move.
|
|
id: '20260808_playback_devices',
|
|
sql: `
|
|
CREATE TABLE IF NOT EXISTS playback_devices (
|
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
user_id UUID NOT NULL,
|
|
name TEXT NOT NULL,
|
|
last_seen_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
|
);
|
|
CREATE INDEX IF NOT EXISTS idx_playback_devices_user
|
|
ON playback_devices (user_id, last_seen_at DESC);
|
|
|
|
CREATE TABLE IF NOT EXISTS playback_state (
|
|
user_id UUID PRIMARY KEY,
|
|
device_id UUID REFERENCES playback_devices(id) ON DELETE SET NULL,
|
|
track_id UUID REFERENCES tracks(id) ON DELETE SET NULL,
|
|
queue JSONB NOT NULL DEFAULT '[]'::jsonb,
|
|
queue_index INTEGER NOT NULL DEFAULT -1,
|
|
position_ms INTEGER NOT NULL DEFAULT 0,
|
|
is_playing BOOLEAN NOT NULL DEFAULT FALSE,
|
|
-- Monotonic per user. A device applies a snapshot only when it is newer
|
|
-- than the last one it saw, so a delayed delivery cannot rewind anyone.
|
|
version BIGINT NOT NULL DEFAULT 0,
|
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
|
);
|
|
`,
|
|
},
|
|
];
|