Our Personal Data Server from scratch!
0

Configure Feed

Select the types of activity you want to include in your feed.

feat(repo): missing $type? invent one

Lewis: May this revision serve well! <lu5a@proton.me>

author
Lewis
committer
Tangled
date (May 31, 2026, 1:02 PM +0300) commit 9b58961b parent 31ee12ec change-id kvwsrvvq
+200 -3
+5 -1
crates/tranquil-api/src/repo/record/batch.rs
··· 1 1 use super::validation::validate_record_with_status; 2 2 use super::validation_mode::{ValidationMode, deserialize_validation_mode}; 3 - use crate::repo::record::write::CommitInfo; 3 + use crate::repo::record::write::{CommitInfo, ensure_record_type}; 4 4 use axum::{Json, extract::State}; 5 5 use jacquard_repo::{mst::Mst, storage::BlockStore}; 6 6 use serde::{Deserialize, Serialize}; ··· 54 54 rkey, 55 55 value, 56 56 } => { 57 + let value = ensure_record_type(value, collection); 58 + let value = &*value; 57 59 let validation_status = if validate.should_skip() { 58 60 None 59 61 } else { ··· 117 119 rkey, 118 120 value, 119 121 } => { 122 + let value = ensure_record_type(value, collection); 123 + let value = &*value; 120 124 let validation_status = if validate.should_skip() { 121 125 None 122 126 } else {
+32 -2
crates/tranquil-api/src/repo/record/write.rs
··· 5 5 use jacquard_repo::storage::BlockStore; 6 6 use serde::{Deserialize, Serialize}; 7 7 use serde_json::json; 8 + use std::borrow::Cow; 8 9 use std::str::FromStr; 9 10 use tracing::error; 10 11 use tranquil_pds::api::error::{ApiError, DbResultExt}; ··· 62 63 }) 63 64 } 64 65 66 + pub(crate) fn ensure_record_type<'a>( 67 + record: &'a serde_json::Value, 68 + collection: &Nsid, 69 + ) -> Cow<'a, serde_json::Value> { 70 + let serde_json::Value::Object(map) = record else { 71 + return Cow::Borrowed(record); 72 + }; 73 + let needs_fill = match map.get("$type") { 74 + None | Some(serde_json::Value::Null) => true, 75 + Some(serde_json::Value::String(existing)) => existing.is_empty(), 76 + Some(_) => false, 77 + }; 78 + if !needs_fill { 79 + return Cow::Borrowed(record); 80 + } 81 + let mut map = map.clone(); 82 + map.insert( 83 + "$type".to_string(), 84 + serde_json::Value::String(collection.to_string()), 85 + ); 86 + Cow::Owned(serde_json::Value::Object(map)) 87 + } 88 + 65 89 #[derive(Deserialize)] 66 90 #[allow(dead_code)] 67 91 pub struct CreateRecordInput { ··· 95 119 pub async fn create_record( 96 120 State(state): State<AppState>, 97 121 auth: Auth<Active>, 98 - Json(input): Json<CreateRecordInput>, 122 + Json(mut input): Json<CreateRecordInput>, 99 123 ) -> Result<Json<CreateRecordOutput>, ApiError> { 124 + if let Cow::Owned(record) = ensure_record_type(&input.record, &input.collection) { 125 + input.record = record; 126 + } 100 127 let scope_proof = auth.verify_repo_create(&input.collection)?; 101 128 let repo_auth = prepare_repo_write(&state, &scope_proof, &input.repo).await?; 102 129 let did = repo_auth.did; ··· 278 305 pub async fn put_record( 279 306 State(state): State<AppState>, 280 307 auth: Auth<Active>, 281 - Json(input): Json<PutRecordInput>, 308 + Json(mut input): Json<PutRecordInput>, 282 309 ) -> Result<Json<PutRecordOutput>, ApiError> { 310 + if let Cow::Owned(record) = ensure_record_type(&input.record, &input.collection) { 311 + input.record = record; 312 + } 283 313 let upsert_proof = auth.verify_repo_upsert(&input.collection)?; 284 314 let repo_auth = prepare_repo_write(&state, &upsert_proof, &input.repo).await?; 285 315 let did = repo_auth.did;
+163
crates/tranquil-pds/tests/lifecycle_record.rs
··· 773 773 .expect("Failed with nonexistent repo"); 774 774 assert_eq!(not_found_res.status(), StatusCode::BAD_REQUEST); 775 775 } 776 + 777 + #[tokio::test] 778 + async fn test_missing_type_is_filled_from_collection() { 779 + let client = client(); 780 + let (did, jwt) = setup_new_user("missing-type").await; 781 + let now = Utc::now().to_rfc3339(); 782 + 783 + let create_res = client 784 + .post(format!( 785 + "{}/xrpc/com.atproto.repo.createRecord", 786 + base_url().await 787 + )) 788 + .bearer_auth(&jwt) 789 + .json(&json!({ 790 + "repo": did, 791 + "collection": "app.bsky.feed.post", 792 + "record": { "text": "no type set", "createdAt": now } 793 + })) 794 + .send() 795 + .await 796 + .expect("Failed to create record without $type"); 797 + assert_eq!( 798 + create_res.status(), 799 + StatusCode::OK, 800 + "createRecord should fill missing $type from collection" 801 + ); 802 + let create_body: Value = create_res.json().await.unwrap(); 803 + let create_rkey = create_body["uri"] 804 + .as_str() 805 + .unwrap() 806 + .rsplit('/') 807 + .next() 808 + .unwrap() 809 + .to_string(); 810 + 811 + let get_created = client 812 + .get(format!( 813 + "{}/xrpc/com.atproto.repo.getRecord", 814 + base_url().await 815 + )) 816 + .query(&[ 817 + ("repo", did.as_str()), 818 + ("collection", "app.bsky.feed.post"), 819 + ("rkey", &create_rkey), 820 + ]) 821 + .send() 822 + .await 823 + .expect("Failed to get created record"); 824 + let created_body: Value = get_created.json().await.unwrap(); 825 + assert_eq!(created_body["value"]["$type"], "app.bsky.feed.post"); 826 + 827 + let put_res = client 828 + .post(format!( 829 + "{}/xrpc/com.atproto.repo.putRecord", 830 + base_url().await 831 + )) 832 + .bearer_auth(&jwt) 833 + .json(&json!({ 834 + "repo": did, 835 + "collection": "app.bsky.actor.profile", 836 + "rkey": "self", 837 + "record": { "displayName": "No Type" } 838 + })) 839 + .send() 840 + .await 841 + .expect("Failed to put record without $type"); 842 + assert_eq!( 843 + put_res.status(), 844 + StatusCode::OK, 845 + "putRecord should fill missing $type from collection" 846 + ); 847 + let get_put = client 848 + .get(format!( 849 + "{}/xrpc/com.atproto.repo.getRecord", 850 + base_url().await 851 + )) 852 + .query(&[ 853 + ("repo", did.as_str()), 854 + ("collection", "app.bsky.actor.profile"), 855 + ("rkey", "self"), 856 + ]) 857 + .send() 858 + .await 859 + .expect("Failed to get put record"); 860 + let put_body: Value = get_put.json().await.unwrap(); 861 + assert_eq!(put_body["value"]["$type"], "app.bsky.actor.profile"); 862 + 863 + let apply_res = client 864 + .post(format!( 865 + "{}/xrpc/com.atproto.repo.applyWrites", 866 + base_url().await 867 + )) 868 + .bearer_auth(&jwt) 869 + .json(&json!({ 870 + "repo": did, 871 + "writes": [ 872 + { "$type": "com.atproto.repo.applyWrites#create", "collection": "app.bsky.feed.post", "rkey": "batch-no-type", "value": { "text": "batch no type", "createdAt": now } } 873 + ] 874 + })) 875 + .send() 876 + .await 877 + .expect("Failed to apply writes without $type"); 878 + assert_eq!( 879 + apply_res.status(), 880 + StatusCode::OK, 881 + "applyWrites should fill missing $type from collection" 882 + ); 883 + let get_batch = client 884 + .get(format!( 885 + "{}/xrpc/com.atproto.repo.getRecord", 886 + base_url().await 887 + )) 888 + .query(&[ 889 + ("repo", did.as_str()), 890 + ("collection", "app.bsky.feed.post"), 891 + ("rkey", "batch-no-type"), 892 + ]) 893 + .send() 894 + .await 895 + .expect("Failed to get batch record"); 896 + let batch_body: Value = get_batch.json().await.unwrap(); 897 + assert_eq!(batch_body["value"]["$type"], "app.bsky.feed.post"); 898 + 899 + let mismatch_res = client 900 + .post(format!( 901 + "{}/xrpc/com.atproto.repo.createRecord", 902 + base_url().await 903 + )) 904 + .bearer_auth(&jwt) 905 + .json(&json!({ 906 + "repo": did, 907 + "collection": "app.bsky.feed.post", 908 + "record": { "$type": "app.bsky.feed.like", "text": "wrong type", "createdAt": now } 909 + })) 910 + .send() 911 + .await 912 + .expect("Failed to send mismatch request"); 913 + assert_eq!( 914 + mismatch_res.status(), 915 + StatusCode::BAD_REQUEST, 916 + "explicit mismatched $type should still be rejected" 917 + ); 918 + 919 + let non_string_type_res = client 920 + .post(format!( 921 + "{}/xrpc/com.atproto.repo.createRecord", 922 + base_url().await 923 + )) 924 + .bearer_auth(&jwt) 925 + .json(&json!({ 926 + "repo": did, 927 + "collection": "app.bsky.feed.post", 928 + "record": { "$type": 123, "text": "non-string type", "createdAt": now } 929 + })) 930 + .send() 931 + .await 932 + .expect("Failed to send non-string type request"); 933 + assert_eq!( 934 + non_string_type_res.status(), 935 + StatusCode::BAD_REQUEST, 936 + "present non-string $type should be rejected, not overwritten" 937 + ); 938 + }