// --------------------------------------------------------------------------- // 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() ); `, }, ];