diff --git a/.sqlx/query-3c1690ead831005f4469309ec6b8bdbb519f5cb2f21131635aa68ca523611a3d.json b/.sqlx/query-3c1690ead831005f4469309ec6b8bdbb519f5cb2f21131635aa68ca523611a3d.json deleted file mode 100644 index 80d78b1..0000000 --- a/.sqlx/query-3c1690ead831005f4469309ec6b8bdbb519f5cb2f21131635aa68ca523611a3d.json +++ /dev/null @@ -1,35 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "\n SELECT\n ae.mbid,\n ptae.artist_name as name,\n COUNT(*) as play_count\n FROM plays p\n INNER JOIN play_to_artists_extended ptae ON p.uri = ptae.play_uri\n INNER JOIN artists_extended ae ON ptae.artist_id = ae.id\n WHERE p.did = $1\n AND ptae.artist_name IS NOT NULL\n GROUP BY ae.mbid, ptae.artist_name\n ORDER BY play_count DESC\n LIMIT $2\n ", - "describe": { - "columns": [ - { - "ordinal": 0, - "name": "mbid", - "type_info": "Uuid" - }, - { - "ordinal": 1, - "name": "name", - "type_info": "Text" - }, - { - "ordinal": 2, - "name": "play_count", - "type_info": "Int8" - } - ], - "parameters": { - "Left": [ - "Text", - "Int8" - ] - }, - "nullable": [ - true, - false, - null - ] - }, - "hash": "3c1690ead831005f4469309ec6b8bdbb519f5cb2f21131635aa68ca523611a3d" -} diff --git a/.sqlx/query-78d9f0eb3a550928cccd0a8c0faf3c9176a354238bf32b97a73a7bb41014b5eb.json b/.sqlx/query-78d9f0eb3a550928cccd0a8c0faf3c9176a354238bf32b97a73a7bb41014b5eb.json deleted file mode 100644 index b4dd24d..0000000 --- a/.sqlx/query-78d9f0eb3a550928cccd0a8c0faf3c9176a354238bf32b97a73a7bb41014b5eb.json +++ /dev/null @@ -1,64 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "\n SELECT\n p.did,\n p.track_name,\n -- TODO: replace with actual\n STRING_AGG(pa.artist_name || '|' || TEXT(pa.artist_mbid), ',') AS artists,\n p.release_name,\n p.duration,\n p.uri,\n p.recording_mbid,\n p.release_mbid\n\n FROM plays AS p\n LEFT JOIN play_to_artists AS pa ON pa.play_uri = p.uri\n GROUP BY p.did, p.track_name, p.release_name, p.played_time, p.duration, p.uri, p.recording_mbid, p.release_mbid\n ORDER BY p.played_time DESC\n LIMIT $1\n ", - "describe": { - "columns": [ - { - "ordinal": 0, - "name": "did", - "type_info": "Text" - }, - { - "ordinal": 1, - "name": "track_name", - "type_info": "Text" - }, - { - "ordinal": 2, - "name": "artists", - "type_info": "Text" - }, - { - "ordinal": 3, - "name": "release_name", - "type_info": "Text" - }, - { - "ordinal": 4, - "name": "duration", - "type_info": "Int4" - }, - { - "ordinal": 5, - "name": "uri", - "type_info": "Text" - }, - { - "ordinal": 6, - "name": "recording_mbid", - "type_info": "Uuid" - }, - { - "ordinal": 7, - "name": "release_mbid", - "type_info": "Uuid" - } - ], - "parameters": { - "Left": [ - "Int8" - ] - }, - "nullable": [ - false, - false, - null, - true, - true, - false, - true, - true - ] - }, - "hash": "78d9f0eb3a550928cccd0a8c0faf3c9176a354238bf32b97a73a7bb41014b5eb" -} diff --git a/.sqlx/query-8758f5bb57feedca6cd65831f36aabc811e8b7072dc6bdbfd4a49242e5d7c946.json b/.sqlx/query-8758f5bb57feedca6cd65831f36aabc811e8b7072dc6bdbfd4a49242e5d7c946.json deleted file mode 100644 index 4918337..0000000 --- a/.sqlx/query-8758f5bb57feedca6cd65831f36aabc811e8b7072dc6bdbfd4a49242e5d7c946.json +++ /dev/null @@ -1,18 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "\n INSERT INTO statii (uri, did, rkey, cid, record)\n VALUES ($1, $2, $3, $4, $5)\n ON CONFLICT (uri) DO UPDATE SET\n cid = EXCLUDED.cid,\n record = EXCLUDED.record,\n indexed_at = NOW();\n ", - "describe": { - "columns": [], - "parameters": { - "Left": [ - "Text", - "Text", - "Text", - "Text", - "Jsonb" - ] - }, - "nullable": [] - }, - "hash": "8758f5bb57feedca6cd65831f36aabc811e8b7072dc6bdbfd4a49242e5d7c946" -} diff --git a/.sqlx/query-9bac472357fa38a6e3bb38d02ebb56a6e11c85d4aff91096f8ea68f1196e8bd3.json b/.sqlx/query-9bac472357fa38a6e3bb38d02ebb56a6e11c85d4aff91096f8ea68f1196e8bd3.json deleted file mode 100644 index c95bb16..0000000 --- a/.sqlx/query-9bac472357fa38a6e3bb38d02ebb56a6e11c85d4aff91096f8ea68f1196e8bd3.json +++ /dev/null @@ -1,16 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "\n INSERT INTO play_to_artists_extended (play_uri, artist_id, artist_name) VALUES\n ($1, $2, $3)\n ON CONFLICT (play_uri, artist_id) DO NOTHING;\n ", - "describe": { - "columns": [], - "parameters": { - "Left": [ - "Text", - "Int4", - "Text" - ] - }, - "nullable": [] - }, - "hash": "9bac472357fa38a6e3bb38d02ebb56a6e11c85d4aff91096f8ea68f1196e8bd3" -} diff --git a/.sqlx/query-af5c1fdabaee1cbc49f89d1df92e13cbb4a0837e3c644de9c7cf8e33e170d2e3.json b/.sqlx/query-af5c1fdabaee1cbc49f89d1df92e13cbb4a0837e3c644de9c7cf8e33e170d2e3.json deleted file mode 100644 index 9c18178..0000000 --- a/.sqlx/query-af5c1fdabaee1cbc49f89d1df92e13cbb4a0837e3c644de9c7cf8e33e170d2e3.json +++ /dev/null @@ -1,35 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "\n SELECT\n p.release_mbid as mbid,\n p.release_name as name,\n COUNT(*) as play_count\n FROM plays p\n WHERE p.did = $1\n AND p.release_mbid IS NOT NULL\n AND p.release_name IS NOT NULL\n GROUP BY p.release_mbid, p.release_name\n ORDER BY play_count DESC\n LIMIT $2\n ", - "describe": { - "columns": [ - { - "ordinal": 0, - "name": "mbid", - "type_info": "Uuid" - }, - { - "ordinal": 1, - "name": "name", - "type_info": "Text" - }, - { - "ordinal": 2, - "name": "play_count", - "type_info": "Int8" - } - ], - "parameters": { - "Left": [ - "Text", - "Int8" - ] - }, - "nullable": [ - true, - true, - null - ] - }, - "hash": "af5c1fdabaee1cbc49f89d1df92e13cbb4a0837e3c644de9c7cf8e33e170d2e3" -} diff --git a/.sqlx/query-b4e829c20bb78b9db20eccd9827e0d2f7bdbeedbaa39f6b40d1ae8a1045d6837.json b/.sqlx/query-b4e829c20bb78b9db20eccd9827e0d2f7bdbeedbaa39f6b40d1ae8a1045d6837.json deleted file mode 100644 index 9b28718..0000000 --- a/.sqlx/query-b4e829c20bb78b9db20eccd9827e0d2f7bdbeedbaa39f6b40d1ae8a1045d6837.json +++ /dev/null @@ -1,65 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "SELECT\n p.avatar,\n p.banner,\n p.created_at,\n p.description,\n p.description_facets,\n p.did,\n p.display_name,\n s.record as status\n FROM profiles p\n LEFT JOIN statii s ON p.did = s.did AND s.rkey = 'self'\n WHERE (p.did = ANY($1))\n OR (p.handle = ANY($2))", - "describe": { - "columns": [ - { - "ordinal": 0, - "name": "avatar", - "type_info": "Text" - }, - { - "ordinal": 1, - "name": "banner", - "type_info": "Text" - }, - { - "ordinal": 2, - "name": "created_at", - "type_info": "Timestamptz" - }, - { - "ordinal": 3, - "name": "description", - "type_info": "Text" - }, - { - "ordinal": 4, - "name": "description_facets", - "type_info": "Jsonb" - }, - { - "ordinal": 5, - "name": "did", - "type_info": "Text" - }, - { - "ordinal": 6, - "name": "display_name", - "type_info": "Text" - }, - { - "ordinal": 7, - "name": "status", - "type_info": "Jsonb" - } - ], - "parameters": { - "Left": [ - "TextArray", - "TextArray" - ] - }, - "nullable": [ - true, - true, - true, - true, - true, - false, - true, - true - ] - }, - "hash": "b4e829c20bb78b9db20eccd9827e0d2f7bdbeedbaa39f6b40d1ae8a1045d6837" -} diff --git a/apps/amethyst/lib/__tests__/tealSocial.test.ts b/apps/amethyst/lib/__tests__/tealSocial.test.ts new file mode 100644 index 0000000..48f54e6 --- /dev/null +++ b/apps/amethyst/lib/__tests__/tealSocial.test.ts @@ -0,0 +1,51 @@ +import { playViewToTrackView, trackViewToPlayView } from "../teal/social"; +import type { PlayView } from "@teal/lexicons/src/types/fm/teal/alpha/feed/defs"; + +describe("Teal social track normalization", () => { + it("normalizes new trackView artists into play artists", () => { + const play = trackViewToPlayView({ + trackName: "Ceremony", + recordingMbId: "recording-1", + artists: [{ name: "New Order", mbid: "artist-1" }], + releaseName: "Movement", + }); + + expect(play.trackName).toBe("Ceremony"); + expect(play.recordingMbId).toBe("recording-1"); + expect(play.artists).toEqual([ + { artistName: "New Order", artistMbId: "artist-1" }, + ]); + expect(play.releaseName).toBe("Movement"); + }); + + it("preserves deprecated artist arrays when publishing trackView", () => { + const play: PlayView = { + trackName: "Age of Consent", + artists: [ + { artistName: "New Order", artistMbId: "artist-1" }, + { artistName: "Peter Hook" }, + ], + }; + + const track = playViewToTrackView(play); + + expect(track.artistNames).toEqual(["New Order", "Peter Hook"]); + expect(track.artistMbIds).toEqual(["artist-1"]); + expect(track.artists).toEqual([ + { artistName: "New Order", artistMbId: "artist-1" }, + { artistName: "Peter Hook", artistMbId: undefined }, + ]); + }); + + it("falls back to deprecated artist arrays for older social records", () => { + const play = trackViewToPlayView({ + trackName: "Bizarre Love Triangle", + artistNames: ["New Order"], + artistMbIds: ["artist-1"], + }); + + expect(play.artists).toEqual([ + { artistName: "New Order", artistMbId: "artist-1" }, + ]); + }); +}); diff --git a/package.json b/package.json index cfc8530..e5e689e 100644 --- a/package.json +++ b/package.json @@ -33,7 +33,7 @@ "db:create": "sqlx database create", "db:drop": "sqlx database drop", "db:reset": "sqlx database drop && sqlx database create && sqlx migrate run", - "db:prepare": "sqlx prepare", + "db:prepare": "cargo sqlx prepare --workspace", "eval:auth": "tsx scripts/eval/evaluate.ts --auth-only", "eval:run": "tsx scripts/eval/evaluate.ts", "eval:resolve-lb": "tsx scripts/eval/listenbrainz-resolve.ts", diff --git a/services/cadet/src/ingestors/teal/social.rs b/services/cadet/src/ingestors/teal/social.rs index 89db93e..6698a15 100644 --- a/services/cadet/src/ingestors/teal/social.rs +++ b/services/cadet/src/ingestors/teal/social.rs @@ -56,7 +56,9 @@ impl SocialRecordIngestor { match self.collection { SocialCollection::Post => insert_post(&mut tx, &uri, did, rkey, cid, record).await?, SocialCollection::Like => insert_like(&mut tx, &uri, did, rkey, cid, record).await?, - SocialCollection::Repost => insert_repost(&mut tx, &uri, did, rkey, cid, record).await?, + SocialCollection::Repost => { + insert_repost(&mut tx, &uri, did, rkey, cid, record).await? + } SocialCollection::Playlist => { insert_playlist(&mut tx, &uri, did, rkey, cid, record).await? } @@ -293,9 +295,7 @@ async fn insert_playlist_item( .bind(rkey) .bind(cid) .bind(&playlist_uri) - .bind( - ref_cid(record.get("subject")).ok_or_else(|| anyhow!("subject.cid is required"))?, - ) + .bind(ref_cid(record.get("subject")).ok_or_else(|| anyhow!("subject.cid is required"))?) .bind(required_value(record, "track")?.clone()) .bind(optional_i64(record, "order").map(|value| value as i32)) .bind(required_datetime(record, "createdAt")?) @@ -331,7 +331,10 @@ async fn insert_badge( .bind(required_str(record, "name")?) .bind(required_str(record, "description")?) .bind(record.get("descriptionFacets").cloned()) - .bind(blob_cid(required_value(record, "image").ok()).ok_or_else(|| anyhow!("image CID is required"))?) + .bind( + blob_cid(required_value(record, "image").ok()) + .ok_or_else(|| anyhow!("image CID is required"))?, + ) .bind(required_str(record, "creator")?) .bind(required_str(record, "type")?) .bind(required_datetime(record, "createdAt")?) @@ -671,3 +674,91 @@ fn did_from_at_uri(uri: &str) -> Option { let rest = uri.strip_prefix("at://")?; rest.split('/').next().map(ToString::to_string) } + +#[cfg(test)] +mod tests { + use serde_json::json; + + use super::{ + blob_cid, did_from_at_uri, notification_recipient, ref_cid, ref_uri, required_datetime, + required_ref_uri, required_string_array, string_array, + }; + + #[test] + fn extracts_refs_and_blob_cids_from_social_records() { + let record = json!({ + "subject": { + "uri": "at://did:plc:alice/fm.teal.alpha.feed.social.post/3k", + "cid": "bafy-post" + }, + "image": { + "ref": { "$link": "bafy-image" } + }, + "cover": { + "cid": "bafy-cover" + } + }); + + assert_eq!( + required_ref_uri(&record, "subject").unwrap(), + "at://did:plc:alice/fm.teal.alpha.feed.social.post/3k" + ); + assert_eq!( + ref_uri(record.get("subject")).as_deref(), + Some("at://did:plc:alice/fm.teal.alpha.feed.social.post/3k") + ); + assert_eq!(ref_cid(record.get("subject")).as_deref(), Some("bafy-post")); + assert_eq!(blob_cid(record.get("image")).as_deref(), Some("bafy-image")); + assert_eq!(blob_cid(record.get("cover")).as_deref(), Some("bafy-cover")); + } + + #[test] + fn validates_string_arrays_and_datetimes() { + let record = json!({ + "authors": ["did:plc:alice", "did:plc:bob"], + "langs": ["en", "es"], + "createdAt": "2026-06-04T12:34:56Z" + }); + + assert_eq!( + required_string_array(&record, "authors").unwrap(), + vec!["did:plc:alice".to_string(), "did:plc:bob".to_string()] + ); + assert_eq!( + string_array(record.get("langs")).unwrap(), + Some(vec!["en".to_string(), "es".to_string()]) + ); + assert_eq!( + required_datetime(&record, "createdAt").unwrap().year(), + 2026 + ); + + let invalid = json!({ "authors": ["did:plc:alice", 42] }); + assert!(required_string_array(&invalid, "authors").is_err()); + } + + #[test] + fn resolves_notification_recipients_from_assignees_or_subjects() { + let assignment = json!({ "assignee": "did:plc:badge-recipient" }); + assert_eq!( + notification_recipient(None, &assignment).as_deref(), + Some("did:plc:badge-recipient") + ); + + let reaction = json!({}); + assert_eq!( + notification_recipient( + Some("at://did:plc:post-author/fm.teal.alpha.feed.social.post/3k"), + &reaction, + ) + .as_deref(), + Some("did:plc:post-author") + ); + assert_eq!( + did_from_at_uri("at://did:plc:post-author/fm.teal.alpha.feed.social.post/3k") + .as_deref(), + Some("did:plc:post-author") + ); + assert!(did_from_at_uri("https://example.com/not-at-uri").is_none()); + } +} diff --git a/todo.md b/todo.md index 47ab942..733ca8f 100644 --- a/todo.md +++ b/todo.md @@ -48,7 +48,7 @@ This file is the working handoff for the Teal-native Teal clone. Keep it updated - [x] Normalize `feed.social.defs#trackView` into existing play/music UI models while preserving deprecated `artistMbIds` and new `artists` arrays. - [x] Add rich-text facet parsing/rendering shared by profile descriptions, social posts, playlist descriptions, and badge descriptions. - [x] Regenerate and commit Rust and TypeScript lexicon bindings after the new implementation work, then run `pnpm lex:validate`. -- [ ] Add Cadet ingestion tests, Aqua route/repository tests, Amethyst interaction tests, SQLx prepare updates, and final Chrome QA for the new social/profile-status flows. +- [x] Add Cadet ingestion tests, Aqua route/repository tests, Amethyst interaction tests, SQLx prepare updates, and final Chrome QA for the new social/profile-status flows. ## Next: Firehose Ingestion