refactor: split db.service.ts into data, migrations, and behaviour
db.service.ts was 2085 lines, of which ~700 were not behaviour at all: the
migration registry, the row-shape interfaces, and the column allowlist. That
makes the file painful to review — the reviewer's note on the MIGRATIONS
array.
Three pure moves into backend/src/db/, which already owns schema.sql:
- migrations.ts (513) — the registry, plus a named Migration type
- types.ts (168) — the row shapes
- updatable-columns.ts (42) — UPDATABLE_COLUMNS + allowedFields
db.service.ts drops to 1378 lines and re-exports ../db/types.js, so existing
`import { Track, ListenerBelief } from '../services/db.service.js'` in the
routes, generators and session-director keeps working untouched.
No behaviour change, and verified as such rather than asserted: the migration
id list and the entire 502-line SQL body diff byte-identical against the
previous commit, backend typecheck is clean and 33/33 tests pass.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,513 @@
|
||||
// ---------------------------------------------------------------------------
|
||||
// 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);
|
||||
`,
|
||||
},
|
||||
];
|
||||
@@ -0,0 +1,168 @@
|
||||
// Row shapes for the muzick schema, extracted verbatim from db.service.ts so
|
||||
// that file is about behaviour and this one about data. Re-exported from
|
||||
// db.service.ts, so an existing `import { Track } from '../services/db.service.js'`
|
||||
// keeps working.
|
||||
|
||||
export interface Artist {
|
||||
id: string;
|
||||
name: string;
|
||||
mbid?: string | null;
|
||||
discogs_id?: string | null;
|
||||
image_path?: string | null;
|
||||
}
|
||||
|
||||
export interface Album {
|
||||
id: string;
|
||||
artist_id: string;
|
||||
title: string;
|
||||
year?: number | null;
|
||||
artwork_id?: string | null;
|
||||
}
|
||||
|
||||
export interface TrackArtist {
|
||||
id: string;
|
||||
name: string;
|
||||
role: 'main' | 'featured';
|
||||
}
|
||||
|
||||
export interface Track {
|
||||
id: string;
|
||||
path: string;
|
||||
hash: string;
|
||||
title: string;
|
||||
artist: string;
|
||||
album_id: string;
|
||||
duration: number;
|
||||
state: string;
|
||||
play_count: number;
|
||||
skip_count: number;
|
||||
dislike_count: number;
|
||||
last_played_at?: Date | null;
|
||||
mtime?: number | null;
|
||||
source_type: string;
|
||||
artists?: TrackArtist[];
|
||||
}
|
||||
|
||||
export const FEEDBACK_ACTIONS = ['promoted', 'disliked', 'skipped', 'deleted_permanent'] as const;
|
||||
export type FeedbackAction = (typeof FEEDBACK_ACTIONS)[number];
|
||||
|
||||
export interface HistoryEntry extends Track {
|
||||
history_id: string;
|
||||
batch_id: string | null;
|
||||
played_at: Date;
|
||||
completed: boolean;
|
||||
}
|
||||
|
||||
export interface Genre {
|
||||
id: string;
|
||||
name: string;
|
||||
parent_id?: string | null;
|
||||
track_count?: number;
|
||||
}
|
||||
|
||||
export interface DislikeEntry {
|
||||
track_id: string;
|
||||
disliked_at: Date;
|
||||
warned_at: Date | null;
|
||||
deleted_at: Date | null;
|
||||
grace_hours: number;
|
||||
state: string; // 'HIDDEN' | 'WARNED' | 'DELETED'
|
||||
track_title: string;
|
||||
track_artist: string;
|
||||
track_path: string;
|
||||
}
|
||||
|
||||
export interface ArtistWithAlbums {
|
||||
id: string;
|
||||
name: string;
|
||||
mbid?: string | null;
|
||||
discogs_id?: string | null;
|
||||
image_path?: string | null;
|
||||
albums: Album[];
|
||||
}
|
||||
|
||||
export interface AlbumWithTracks {
|
||||
id: string;
|
||||
artist_id: string;
|
||||
title: string;
|
||||
year?: number | null;
|
||||
artwork_id?: string | null;
|
||||
tracks: Track[];
|
||||
}
|
||||
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// v2 Recommendation Engine types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface Claim {
|
||||
id: string;
|
||||
user_id: string | null;
|
||||
subject_type: string;
|
||||
subject_id: string;
|
||||
predicate: string;
|
||||
object_type: string;
|
||||
object_id: string;
|
||||
source: string;
|
||||
confidence: number;
|
||||
evidence_at: Date;
|
||||
last_reinforced_at: Date;
|
||||
raw: unknown | null;
|
||||
created_at: Date;
|
||||
}
|
||||
|
||||
export interface Evidence {
|
||||
id: string;
|
||||
user_id: string;
|
||||
entity_type: string;
|
||||
entity_id: string;
|
||||
signal: string;
|
||||
profile: string;
|
||||
weight: number;
|
||||
context: unknown | null;
|
||||
created_at: Date;
|
||||
}
|
||||
|
||||
export interface ListenerBelief {
|
||||
user_id: string;
|
||||
profile: string;
|
||||
entity_type: string;
|
||||
entity_id: string;
|
||||
dimension: string;
|
||||
value: number;
|
||||
confidence: number;
|
||||
evidence_count: number;
|
||||
last_reinforced_at: Date;
|
||||
last_decayed_at: Date;
|
||||
}
|
||||
|
||||
export interface ClaimEdge {
|
||||
subjectType: string;
|
||||
subjectId: string;
|
||||
predicate: string;
|
||||
objectType: string;
|
||||
objectId: string;
|
||||
fusedValue: number;
|
||||
}
|
||||
|
||||
export interface SessionState {
|
||||
session_id: string;
|
||||
user_id: string;
|
||||
started_at: Date;
|
||||
last_interaction: Date;
|
||||
context: string | null;
|
||||
state_vector: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface DiversityBudget {
|
||||
user_id: string;
|
||||
dimension: string;
|
||||
budget_share: number;
|
||||
horizon_min: number;
|
||||
}
|
||||
|
||||
export interface RepetitionRule {
|
||||
user_id: string;
|
||||
dimension: string;
|
||||
min_distance: number;
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
/**
|
||||
* Per-table allowlists of columns the HTTP `PUT /artists|albums|tracks/:id`
|
||||
* endpoints may write.
|
||||
*
|
||||
* These endpoints pass `request.body as any` straight through, and the update
|
||||
* builders interpolate `Object.keys(body)` into the SET clause as quoted
|
||||
* identifiers — so without an allowlist a crafted key both closes the quoted
|
||||
* identifier (SQL injection) and mass-assigns columns that are not user-editable.
|
||||
*
|
||||
* Deliberately excluded:
|
||||
* - `tracks.path` / `tracks.hash` / `tracks.mtime` — owned by the scanner; the
|
||||
* path is the only link to the file on the read-only `/music` bind.
|
||||
* - `tracks.state` / `quarantined_at` / `deleted_at` — owned by the dislike
|
||||
* lifecycle and the integrity sweep, not by metadata editing.
|
||||
* - `play_count` / `skip_count` / `dislike_count` / `last_played_at` — learning
|
||||
* signal; forgeable counters would poison the recommendation engine.
|
||||
* - `source_type` — decides library vs. recommendation semantics.
|
||||
* - `id` / `created_at` / `updated_at` and every generated column
|
||||
* (`normalized_name`, `normalized_artist` — Postgres rejects writes anyway).
|
||||
*/
|
||||
export const UPDATABLE_COLUMNS = {
|
||||
artists: ['name', 'canonical_name', 'sort_name', 'mbid', 'discogs_id', 'image_path'],
|
||||
albums: ['artist_id', 'title', 'year', 'release_date', 'artwork_id', 'mbid'],
|
||||
tracks: ['title', 'artist', 'album_id', 'duration', 'release_date'],
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Filter a partial update payload down to the allowlisted, defined columns for
|
||||
* `table`. Unknown keys are dropped silently rather than raising: the callers
|
||||
* take `Partial<T>` and the routes do no error mapping, so a 500 on an extra
|
||||
* key would be worse than a no-op. The subsequent "No fields to update" throw
|
||||
* still surfaces a payload that was *entirely* rejected.
|
||||
*/
|
||||
export function allowedFields<T extends object>(
|
||||
table: keyof typeof UPDATABLE_COLUMNS,
|
||||
data: T
|
||||
): (keyof T & string)[] {
|
||||
const allowed = UPDATABLE_COLUMNS[table] as readonly string[];
|
||||
return Object.keys(data).filter(
|
||||
(k) => allowed.includes(k) && (data as any)[k] !== undefined
|
||||
) as (keyof T & string)[];
|
||||
}
|
||||
@@ -10,720 +10,32 @@ import { SearchService } from './search.service.js';
|
||||
/** Anything with a `.query()` — either the shared Pool or a checked-out client. */
|
||||
type Queryable = Pool | PoolClient;
|
||||
|
||||
export interface Artist {
|
||||
id: string;
|
||||
name: string;
|
||||
mbid?: string | null;
|
||||
discogs_id?: string | null;
|
||||
image_path?: string | null;
|
||||
}
|
||||
import { MIGRATIONS } from '../db/migrations.js';
|
||||
import { allowedFields } from '../db/updatable-columns.js';
|
||||
|
||||
export interface Album {
|
||||
id: string;
|
||||
artist_id: string;
|
||||
title: string;
|
||||
year?: number | null;
|
||||
artwork_id?: string | null;
|
||||
}
|
||||
|
||||
export interface TrackArtist {
|
||||
id: string;
|
||||
name: string;
|
||||
role: 'main' | 'featured';
|
||||
}
|
||||
|
||||
export interface Track {
|
||||
id: string;
|
||||
path: string;
|
||||
hash: string;
|
||||
title: string;
|
||||
artist: string;
|
||||
album_id: string;
|
||||
duration: number;
|
||||
state: string;
|
||||
play_count: number;
|
||||
skip_count: number;
|
||||
dislike_count: number;
|
||||
last_played_at?: Date | null;
|
||||
mtime?: number | null;
|
||||
source_type: string;
|
||||
artists?: TrackArtist[];
|
||||
}
|
||||
|
||||
export const FEEDBACK_ACTIONS = ['promoted', 'disliked', 'skipped', 'deleted_permanent'] as const;
|
||||
export type FeedbackAction = (typeof FEEDBACK_ACTIONS)[number];
|
||||
|
||||
export interface HistoryEntry extends Track {
|
||||
history_id: string;
|
||||
batch_id: string | null;
|
||||
played_at: Date;
|
||||
completed: boolean;
|
||||
}
|
||||
|
||||
export interface Genre {
|
||||
id: string;
|
||||
name: string;
|
||||
parent_id?: string | null;
|
||||
track_count?: number;
|
||||
}
|
||||
|
||||
export interface DislikeEntry {
|
||||
track_id: string;
|
||||
disliked_at: Date;
|
||||
warned_at: Date | null;
|
||||
deleted_at: Date | null;
|
||||
grace_hours: number;
|
||||
state: string; // 'HIDDEN' | 'WARNED' | 'DELETED'
|
||||
track_title: string;
|
||||
track_artist: string;
|
||||
track_path: string;
|
||||
}
|
||||
|
||||
export interface ArtistWithAlbums {
|
||||
id: string;
|
||||
name: string;
|
||||
mbid?: string | null;
|
||||
discogs_id?: string | null;
|
||||
image_path?: string | null;
|
||||
albums: Album[];
|
||||
}
|
||||
|
||||
export interface AlbumWithTracks {
|
||||
id: string;
|
||||
artist_id: string;
|
||||
title: string;
|
||||
year?: number | null;
|
||||
artwork_id?: string | null;
|
||||
tracks: Track[];
|
||||
}
|
||||
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// v2 Recommendation Engine types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface Claim {
|
||||
id: string;
|
||||
user_id: string | null;
|
||||
subject_type: string;
|
||||
subject_id: string;
|
||||
predicate: string;
|
||||
object_type: string;
|
||||
object_id: string;
|
||||
source: string;
|
||||
confidence: number;
|
||||
evidence_at: Date;
|
||||
last_reinforced_at: Date;
|
||||
raw: unknown | null;
|
||||
created_at: Date;
|
||||
}
|
||||
|
||||
export interface Evidence {
|
||||
id: string;
|
||||
user_id: string;
|
||||
entity_type: string;
|
||||
entity_id: string;
|
||||
signal: string;
|
||||
profile: string;
|
||||
weight: number;
|
||||
context: unknown | null;
|
||||
created_at: Date;
|
||||
}
|
||||
|
||||
export interface ListenerBelief {
|
||||
user_id: string;
|
||||
profile: string;
|
||||
entity_type: string;
|
||||
entity_id: string;
|
||||
dimension: string;
|
||||
value: number;
|
||||
confidence: number;
|
||||
evidence_count: number;
|
||||
last_reinforced_at: Date;
|
||||
last_decayed_at: Date;
|
||||
}
|
||||
|
||||
export interface ClaimEdge {
|
||||
subjectType: string;
|
||||
subjectId: string;
|
||||
predicate: string;
|
||||
objectType: string;
|
||||
objectId: string;
|
||||
fusedValue: number;
|
||||
}
|
||||
|
||||
export interface SessionState {
|
||||
session_id: string;
|
||||
user_id: string;
|
||||
started_at: Date;
|
||||
last_interaction: Date;
|
||||
context: string | null;
|
||||
state_vector: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface DiversityBudget {
|
||||
user_id: string;
|
||||
dimension: string;
|
||||
budget_share: number;
|
||||
horizon_min: number;
|
||||
}
|
||||
|
||||
export interface RepetitionRule {
|
||||
user_id: string;
|
||||
dimension: string;
|
||||
min_distance: number;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Migrations registry
|
||||
// Add new entries at the END. Never edit or remove existing entries.
|
||||
// Convention for id: "YYYYMMDD_short_description"
|
||||
// ---------------------------------------------------------------------------
|
||||
/**
|
||||
* Per-table allowlists of columns the HTTP `PUT /artists|albums|tracks/:id`
|
||||
* endpoints may write.
|
||||
*
|
||||
* These endpoints pass `request.body as any` straight through, and the update
|
||||
* builders interpolate `Object.keys(body)` into the SET clause as quoted
|
||||
* identifiers — so without an allowlist a crafted key both closes the quoted
|
||||
* identifier (SQL injection) and mass-assigns columns that are not user-editable.
|
||||
*
|
||||
* Deliberately excluded:
|
||||
* - `tracks.path` / `tracks.hash` / `tracks.mtime` — owned by the scanner; the
|
||||
* path is the only link to the file on the read-only `/music` bind.
|
||||
* - `tracks.state` / `quarantined_at` / `deleted_at` — owned by the dislike
|
||||
* lifecycle and the integrity sweep, not by metadata editing.
|
||||
* - `play_count` / `skip_count` / `dislike_count` / `last_played_at` — learning
|
||||
* signal; forgeable counters would poison the recommendation engine.
|
||||
* - `source_type` — decides library vs. recommendation semantics.
|
||||
* - `id` / `created_at` / `updated_at` and every generated column
|
||||
* (`normalized_name`, `normalized_artist` — Postgres rejects writes anyway).
|
||||
*/
|
||||
const UPDATABLE_COLUMNS = {
|
||||
artists: ['name', 'canonical_name', 'sort_name', 'mbid', 'discogs_id', 'image_path'],
|
||||
albums: ['artist_id', 'title', 'year', 'release_date', 'artwork_id', 'mbid'],
|
||||
tracks: ['title', 'artist', 'album_id', 'duration', 'release_date'],
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Filter a partial update payload down to the allowlisted, defined columns for
|
||||
* `table`. Unknown keys are dropped silently rather than raising: the callers
|
||||
* take `Partial<T>` and the routes do no error mapping, so a 500 on an extra
|
||||
* key would be worse than a no-op. The subsequent "No fields to update" throw
|
||||
* still surfaces a payload that was *entirely* rejected.
|
||||
*/
|
||||
function allowedFields<T extends object>(
|
||||
table: keyof typeof UPDATABLE_COLUMNS,
|
||||
data: T
|
||||
): (keyof T & string)[] {
|
||||
const allowed = UPDATABLE_COLUMNS[table] as readonly string[];
|
||||
return Object.keys(data).filter(
|
||||
(k) => allowed.includes(k) && (data as any)[k] !== undefined
|
||||
) as (keyof T & string)[];
|
||||
}
|
||||
|
||||
const MIGRATIONS: { id: string; sql: string }[] = [
|
||||
{
|
||||
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);
|
||||
`,
|
||||
},
|
||||
];
|
||||
// Row shapes live in ../db/types.ts; re-exported here so existing importers
|
||||
// (routes, generators, session-director) need no change.
|
||||
import type {
|
||||
Artist,
|
||||
Album,
|
||||
TrackArtist,
|
||||
Track,
|
||||
FeedbackAction,
|
||||
HistoryEntry,
|
||||
Genre,
|
||||
DislikeEntry,
|
||||
ArtistWithAlbums,
|
||||
AlbumWithTracks,
|
||||
Claim,
|
||||
Evidence,
|
||||
ListenerBelief,
|
||||
ClaimEdge,
|
||||
SessionState,
|
||||
DiversityBudget,
|
||||
RepetitionRule,
|
||||
} from '../db/types.js';
|
||||
import { FEEDBACK_ACTIONS } from '../db/types.js';
|
||||
export * from '../db/types.js';
|
||||
|
||||
export class DbService {
|
||||
/** Exposed so route handlers (e.g. settings) can query the database directly. */
|
||||
@@ -2083,3 +1395,4 @@ export class DbService {
|
||||
return res.rowCount ?? 0;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user