From 9b58961bba7fa6116595b64a1762cf3d091e6ead Mon Sep 17 00:00:00 2001 From: Lewis Date: Sun, 31 May 2026 12:02:29 +0300 Subject: [PATCH] feat(repo): missing $type? invent one Lewis: May this revision serve well! --- crates/tranquil-api/src/repo/record/batch.rs | 6 +- crates/tranquil-api/src/repo/record/write.rs | 34 +++- crates/tranquil-pds/tests/lifecycle_record.rs | 163 ++++++++++++++++++ 3 files changed, 200 insertions(+), 3 deletions(-) diff --git a/crates/tranquil-api/src/repo/record/batch.rs b/crates/tranquil-api/src/repo/record/batch.rs index e91a090..68a2b82 100644 --- a/crates/tranquil-api/src/repo/record/batch.rs +++ b/crates/tranquil-api/src/repo/record/batch.rs @@ -1,6 +1,6 @@ use super::validation::validate_record_with_status; use super::validation_mode::{ValidationMode, deserialize_validation_mode}; -use crate::repo::record::write::CommitInfo; +use crate::repo::record::write::{CommitInfo, ensure_record_type}; use axum::{Json, extract::State}; use jacquard_repo::{mst::Mst, storage::BlockStore}; use serde::{Deserialize, Serialize}; @@ -54,6 +54,8 @@ async fn process_single_write( rkey, value, } => { + let value = ensure_record_type(value, collection); + let value = &*value; let validation_status = if validate.should_skip() { None } else { @@ -117,6 +119,8 @@ async fn process_single_write( rkey, value, } => { + let value = ensure_record_type(value, collection); + let value = &*value; let validation_status = if validate.should_skip() { None } else { diff --git a/crates/tranquil-api/src/repo/record/write.rs b/crates/tranquil-api/src/repo/record/write.rs index 2a1624f..702f188 100644 --- a/crates/tranquil-api/src/repo/record/write.rs +++ b/crates/tranquil-api/src/repo/record/write.rs @@ -5,6 +5,7 @@ use cid::Cid; use jacquard_repo::storage::BlockStore; use serde::{Deserialize, Serialize}; use serde_json::json; +use std::borrow::Cow; use std::str::FromStr; use tracing::error; use tranquil_pds::api::error::{ApiError, DbResultExt}; @@ -62,6 +63,29 @@ pub async fn prepare_repo_write( }) } +pub(crate) fn ensure_record_type<'a>( + record: &'a serde_json::Value, + collection: &Nsid, +) -> Cow<'a, serde_json::Value> { + let serde_json::Value::Object(map) = record else { + return Cow::Borrowed(record); + }; + let needs_fill = match map.get("$type") { + None | Some(serde_json::Value::Null) => true, + Some(serde_json::Value::String(existing)) => existing.is_empty(), + Some(_) => false, + }; + if !needs_fill { + return Cow::Borrowed(record); + } + let mut map = map.clone(); + map.insert( + "$type".to_string(), + serde_json::Value::String(collection.to_string()), + ); + Cow::Owned(serde_json::Value::Object(map)) +} + #[derive(Deserialize)] #[allow(dead_code)] pub struct CreateRecordInput { @@ -95,8 +119,11 @@ pub struct CreateRecordOutput { pub async fn create_record( State(state): State, auth: Auth, - Json(input): Json, + Json(mut input): Json, ) -> Result, ApiError> { + if let Cow::Owned(record) = ensure_record_type(&input.record, &input.collection) { + input.record = record; + } let scope_proof = auth.verify_repo_create(&input.collection)?; let repo_auth = prepare_repo_write(&state, &scope_proof, &input.repo).await?; let did = repo_auth.did; @@ -278,8 +305,11 @@ pub struct PutRecordOutput { pub async fn put_record( State(state): State, auth: Auth, - Json(input): Json, + Json(mut input): Json, ) -> Result, ApiError> { + if let Cow::Owned(record) = ensure_record_type(&input.record, &input.collection) { + input.record = record; + } let upsert_proof = auth.verify_repo_upsert(&input.collection)?; let repo_auth = prepare_repo_write(&state, &upsert_proof, &input.repo).await?; let did = repo_auth.did; diff --git a/crates/tranquil-pds/tests/lifecycle_record.rs b/crates/tranquil-pds/tests/lifecycle_record.rs index 3077855..b74310d 100644 --- a/crates/tranquil-pds/tests/lifecycle_record.rs +++ b/crates/tranquil-pds/tests/lifecycle_record.rs @@ -773,3 +773,166 @@ async fn test_list_records_comprehensive() { .expect("Failed with nonexistent repo"); assert_eq!(not_found_res.status(), StatusCode::BAD_REQUEST); } + +#[tokio::test] +async fn test_missing_type_is_filled_from_collection() { + let client = client(); + let (did, jwt) = setup_new_user("missing-type").await; + let now = Utc::now().to_rfc3339(); + + let create_res = client + .post(format!( + "{}/xrpc/com.atproto.repo.createRecord", + base_url().await + )) + .bearer_auth(&jwt) + .json(&json!({ + "repo": did, + "collection": "app.bsky.feed.post", + "record": { "text": "no type set", "createdAt": now } + })) + .send() + .await + .expect("Failed to create record without $type"); + assert_eq!( + create_res.status(), + StatusCode::OK, + "createRecord should fill missing $type from collection" + ); + let create_body: Value = create_res.json().await.unwrap(); + let create_rkey = create_body["uri"] + .as_str() + .unwrap() + .rsplit('/') + .next() + .unwrap() + .to_string(); + + let get_created = client + .get(format!( + "{}/xrpc/com.atproto.repo.getRecord", + base_url().await + )) + .query(&[ + ("repo", did.as_str()), + ("collection", "app.bsky.feed.post"), + ("rkey", &create_rkey), + ]) + .send() + .await + .expect("Failed to get created record"); + let created_body: Value = get_created.json().await.unwrap(); + assert_eq!(created_body["value"]["$type"], "app.bsky.feed.post"); + + let put_res = client + .post(format!( + "{}/xrpc/com.atproto.repo.putRecord", + base_url().await + )) + .bearer_auth(&jwt) + .json(&json!({ + "repo": did, + "collection": "app.bsky.actor.profile", + "rkey": "self", + "record": { "displayName": "No Type" } + })) + .send() + .await + .expect("Failed to put record without $type"); + assert_eq!( + put_res.status(), + StatusCode::OK, + "putRecord should fill missing $type from collection" + ); + let get_put = client + .get(format!( + "{}/xrpc/com.atproto.repo.getRecord", + base_url().await + )) + .query(&[ + ("repo", did.as_str()), + ("collection", "app.bsky.actor.profile"), + ("rkey", "self"), + ]) + .send() + .await + .expect("Failed to get put record"); + let put_body: Value = get_put.json().await.unwrap(); + assert_eq!(put_body["value"]["$type"], "app.bsky.actor.profile"); + + let apply_res = client + .post(format!( + "{}/xrpc/com.atproto.repo.applyWrites", + base_url().await + )) + .bearer_auth(&jwt) + .json(&json!({ + "repo": did, + "writes": [ + { "$type": "com.atproto.repo.applyWrites#create", "collection": "app.bsky.feed.post", "rkey": "batch-no-type", "value": { "text": "batch no type", "createdAt": now } } + ] + })) + .send() + .await + .expect("Failed to apply writes without $type"); + assert_eq!( + apply_res.status(), + StatusCode::OK, + "applyWrites should fill missing $type from collection" + ); + let get_batch = client + .get(format!( + "{}/xrpc/com.atproto.repo.getRecord", + base_url().await + )) + .query(&[ + ("repo", did.as_str()), + ("collection", "app.bsky.feed.post"), + ("rkey", "batch-no-type"), + ]) + .send() + .await + .expect("Failed to get batch record"); + let batch_body: Value = get_batch.json().await.unwrap(); + assert_eq!(batch_body["value"]["$type"], "app.bsky.feed.post"); + + let mismatch_res = client + .post(format!( + "{}/xrpc/com.atproto.repo.createRecord", + base_url().await + )) + .bearer_auth(&jwt) + .json(&json!({ + "repo": did, + "collection": "app.bsky.feed.post", + "record": { "$type": "app.bsky.feed.like", "text": "wrong type", "createdAt": now } + })) + .send() + .await + .expect("Failed to send mismatch request"); + assert_eq!( + mismatch_res.status(), + StatusCode::BAD_REQUEST, + "explicit mismatched $type should still be rejected" + ); + + let non_string_type_res = client + .post(format!( + "{}/xrpc/com.atproto.repo.createRecord", + base_url().await + )) + .bearer_auth(&jwt) + .json(&json!({ + "repo": did, + "collection": "app.bsky.feed.post", + "record": { "$type": 123, "text": "non-string type", "createdAt": now } + })) + .send() + .await + .expect("Failed to send non-string type request"); + assert_eq!( + non_string_type_res.status(), + StatusCode::BAD_REQUEST, + "present non-string $type should be rejected, not overwritten" + ); +}