diff --git a/Cargo.lock b/Cargo.lock index df15250b..dfcb8ccf 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3123,14 +3123,25 @@ dependencies = [ "jacquard-common", "moka", "multibase", + "parakeet-db-macros", "postgres-types", "serde", "serde_json", "sha2", + "thiserror 2.0.12", "tokio", "zstd", ] +[[package]] +name = "parakeet-db-macros" +version = "0.1.0" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.96", +] + [[package]] name = "parking" version = "2.2.1" diff --git a/Cargo.toml b/Cargo.toml index 39440a54..7543e26a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -6,7 +6,7 @@ members = [ "parakeet-appview-macros", "parakeet-consumer", "parakeet-db" -] +, "parakeet-db-macros"] [workspace.dependencies] jacquard = "0.9.5" diff --git a/parakeet-appview/src/entity/implementations.rs b/parakeet-appview/src/entity/implementations.rs index 64bd8a61..d0878ec6 100644 --- a/parakeet-appview/src/entity/implementations.rs +++ b/parakeet-appview/src/entity/implementations.rs @@ -6,7 +6,7 @@ use async_trait::async_trait; use diesel_async::AsyncPgConnection; use eyre::Result; -use parakeet_db::models::{Actor, Post}; +use parakeet_db::domain::{Actor, Post}; use super::traits::{CachedEntity, ResolvableEntity, InvalidatableEntity}; diff --git a/parakeet-db-macros/Cargo.toml b/parakeet-db-macros/Cargo.toml new file mode 100644 index 00000000..938ef952 --- /dev/null +++ b/parakeet-db-macros/Cargo.toml @@ -0,0 +1,12 @@ +[package] +name = "parakeet-db-macros" +version = "0.1.0" +edition = "2021" + +[lib] +proc-macro = true + +[dependencies] +syn = { version = "2.0", features = ["full", "extra-traits"] } +quote = "1.0" +proc-macro2 = "1.0" diff --git a/parakeet-db-macros/src/lib.rs b/parakeet-db-macros/src/lib.rs new file mode 100644 index 00000000..3ca34111 --- /dev/null +++ b/parakeet-db-macros/src/lib.rs @@ -0,0 +1,429 @@ +use proc_macro::TokenStream; +use quote::quote; +use syn::{parse_macro_input, Data, DeriveInput, Fields, Ident, Type}; + +#[proc_macro_derive(PostgresComposite, attributes(composite))] +pub fn derive_postgres_composite(input: TokenStream) -> TokenStream { + let input = parse_macro_input!(input as DeriveInput); + + let name = &input.ident; + let (sql_type, field_count) = extract_metadata(&input); + let fields = extract_fields(&input); + + let from_reader_impl = generate_from_reader(&fields); + let write_to_impl = generate_write_to(&fields); + let write_pg_impl = generate_write_pg(&fields); + + let field_count_i32 = field_count as i32; + + let expanded = quote! { + impl FromSql<#sql_type, Pg> for #name { + fn from_sql(bytes: ::RawValue<'_>) -> deserialize::Result { + let bytes = bytes.as_bytes(); + let mut reader = CompositeReader::new(bytes); + + let field_count = reader.read_field_count()?; + if field_count != #field_count_i32 { + return Err(format!("{}: expected {} fields, got {}", + stringify!(#name), #field_count_i32, field_count).into()); + } + + Ok(Self { + #from_reader_impl + }) + } + } + + impl ToSql<#sql_type, Pg> for #name { + fn to_sql<'b>(&'b self, out: &mut Output<'b, '_, Pg>) -> serialize::Result { + CompositeWriter::write_header(out, #field_count_i32)?; + let mut writer = CompositeWriter::with_output(out); + #write_to_impl + Ok(serialize::IsNull::No) + } + } + + impl PgToSql for #name { + fn to_sql(&self, _ty: &Type, out: &mut BytesMut) -> Result> { + out.extend_from_slice(&(#field_count_i32).to_be_bytes()); + #write_pg_impl + Ok(IsNull::No) + } + + fn accepts(_ty: &Type) -> bool { + true + } + + postgres_types::to_sql_checked!(); + } + }; + + TokenStream::from(expanded) +} + +fn extract_metadata(input: &DeriveInput) -> (Ident, usize) { + // Extract SQL type from #[composite(sql_type = "...")] attribute + let sql_type = input + .attrs + .iter() + .find(|attr| attr.path().is_ident("composite")) + .and_then(|attr| { + attr.parse_args::().ok().and_then(|meta| { + if let syn::Meta::NameValue(nv) = meta { + if nv.path.is_ident("sql_type") { + if let syn::Expr::Lit(lit) = nv.value { + if let syn::Lit::Str(s) = lit.lit { + return Some(syn::Ident::new(&s.value(), s.span())); + } + } + } + } + None + }) + }) + .expect("Missing #[composite(sql_type = \"...\")] attribute"); + + // Count fields + let field_count = match &input.data { + Data::Struct(data) => match &data.fields { + Fields::Named(fields) => fields.named.len(), + _ => panic!("Only named fields are supported"), + }, + _ => panic!("Only structs are supported"), + }; + + (sql_type, field_count) +} + +fn extract_fields(input: &DeriveInput) -> Vec<(Ident, Type)> { + match &input.data { + Data::Struct(data) => match &data.fields { + Fields::Named(fields) => fields + .named + .iter() + .map(|f| (f.ident.clone().unwrap(), f.ty.clone())) + .collect(), + _ => panic!("Only named fields are supported"), + }, + _ => panic!("Only structs are supported"), + } +} + +fn generate_from_reader(fields: &[(Ident, Type)]) -> proc_macro2::TokenStream { + let field_reads = fields.iter().map(|(name, ty)| { + let read_expr = generate_read_expr(name, ty); + quote! { #name: #read_expr, } + }); + + quote! { #(#field_reads)* } +} + +fn generate_write_to(fields: &[(Ident, Type)]) -> proc_macro2::TokenStream { + let field_writes = fields.iter().map(|(name, ty)| { + generate_write_expr(name, ty) + }); + + quote! { #(#field_writes)* } +} + +fn generate_write_pg(fields: &[(Ident, Type)]) -> proc_macro2::TokenStream { + let field_writes = fields.iter().map(|(name, ty)| { + generate_pg_write_expr(name, ty) + }); + + quote! { #(#field_writes)* } +} + +fn generate_read_expr(name: &Ident, ty: &Type) -> proc_macro2::TokenStream { + let type_str = quote!(#ty).to_string(); + + // Handle different types + match type_str.as_str() { + "String" => quote! { + reader.read_text_field()?.ok_or(concat!(stringify!(#name), " cannot be NULL"))? + }, + "Option < String >" | "Option" => quote! { + reader.read_text_field()? + }, + "Vec < u8 >" | "Vec" => quote! { + reader.read_bytea_field()?.ok_or(concat!(stringify!(#name), " cannot be NULL"))? + }, + "Option < Vec < u8 > >" | "Option>" => quote! { + reader.read_bytea_field()? + }, + "i32" => quote! { + reader.read_int_field()?.ok_or(concat!(stringify!(#name), " cannot be NULL"))? + }, + "Option < i32 >" | "Option" => quote! { + reader.read_int_field()? + }, + "i64" => quote! { + reader.read_bigint_field()?.ok_or(concat!(stringify!(#name), " cannot be NULL"))? + }, + "Option < i64 >" | "Option" => quote! { + reader.read_bigint_field()? + }, + "bool" => quote! { + reader.read_bool_field()?.ok_or(concat!(stringify!(#name), " cannot be NULL"))? + }, + "Option < bool >" | "Option" => quote! { + reader.read_bool_field()? + }, + "DateTime < Utc >" | "DateTime" => quote! { + reader.read_timestamptz_field()?.ok_or(concat!(stringify!(#name), " cannot be NULL"))? + }, + "Option < DateTime < Utc > >" | "Option>" => quote! { + reader.read_timestamptz_field()? + }, + "Option < serde_json :: Value >" | "Option" => quote! { + reader.read_jsonb_field()? + }, + _ => { + // Check if it's an Option type + if type_str.starts_with("Option <") || type_str.starts_with("Option<") { + // Extract inner type + if let Type::Path(path) = ty { + if let Some(segment) = path.path.segments.last() { + if segment.ident == "Option" { + if let syn::PathArguments::AngleBracketed(args) = &segment.arguments { + if let Some(syn::GenericArgument::Type(inner_ty)) = args.args.first() { + // Check if inner type is VideoCaption (composite type) + let inner_type_str = quote!(#inner_ty).to_string(); + if inner_type_str == "VideoCaption" { + return quote! { reader.read_composite_field::<#inner_ty, PostVideoCaption>()? }; + } else { + return quote! { reader.read_enum_field::<#inner_ty>()? }; + } + } + } + } + } + } + quote! { reader.read_enum_field::<#ty>()? } + } else { + // Check if it's VideoCaption or another composite type + if type_str == "VideoCaption" { + quote! { + reader.read_composite_field::<#ty, PostVideoCaption>()?.ok_or(concat!(stringify!(#name), " cannot be NULL"))? + } + } else { + // Assume it's a required enum type + quote! { + reader.read_enum_field::<#ty>()?.ok_or(concat!(stringify!(#name), " cannot be NULL"))? + } + } + } + } + } +} + +fn generate_write_expr(name: &Ident, ty: &Type) -> proc_macro2::TokenStream { + let type_str = quote!(#ty).to_string(); + + match type_str.as_str() { + "String" => quote! { writer.write_text_field(Some(&self.#name))?; }, + "Option < String >" | "Option" => quote! { writer.write_text_field(self.#name.as_deref())?; }, + "Vec < u8 >" | "Vec" => quote! { writer.write_bytea_field(Some(&self.#name))?; }, + "Option < Vec < u8 > >" | "Option>" => quote! { writer.write_bytea_field(self.#name.as_deref())?; }, + "i32" => quote! { writer.write_int_field(Some(self.#name))?; }, + "Option < i32 >" | "Option" => quote! { writer.write_int_field(self.#name)?; }, + "i64" => quote! { writer.write_bigint_field(Some(self.#name))?; }, + "Option < i64 >" | "Option" => quote! { writer.write_bigint_field(self.#name)?; }, + "bool" => quote! { writer.write_bool_field(Some(self.#name))?; }, + "Option < bool >" | "Option" => quote! { writer.write_bool_field(self.#name)?; }, + "DateTime < Utc >" | "DateTime" => quote! { writer.write_timestamptz_field(Some(&self.#name))?; }, + "Option < DateTime < Utc > >" | "Option>" => quote! { writer.write_timestamptz_field(self.#name.as_ref())?; }, + "Option < serde_json :: Value >" | "Option" => quote! { writer.write_jsonb_field(self.#name.as_ref())?; }, + _ => { + // Check if it's an Option type + if type_str.starts_with("Option <") || type_str.starts_with("Option<") { + // Check if it's Option + if type_str.contains("VideoCaption") { + quote! { writer.write_composite_field::(self.#name.as_ref())?; } + } else { + quote! { writer.write_enum_field(self.#name.as_ref())?; } + } + } else if type_str == "VideoCaption" { + // Required VideoCaption + quote! { writer.write_composite_field::(Some(&self.#name))?; } + } else { + // Required enum + quote! { writer.write_enum_field(Some(&self.#name))?; } + } + } + } +} + +fn generate_pg_write_expr(name: &Ident, ty: &Type) -> proc_macro2::TokenStream { + let type_str = quote!(#ty).to_string(); + + match type_str.as_str() { + "String" => quote! { + out.extend_from_slice(&25i32.to_be_bytes()); + let bytes = self.#name.as_bytes(); + out.extend_from_slice(&(bytes.len() as i32).to_be_bytes()); + out.extend_from_slice(bytes); + }, + "Option < String >" | "Option" => quote! { + out.extend_from_slice(&25i32.to_be_bytes()); + if let Some(ref val) = self.#name { + let bytes = val.as_bytes(); + out.extend_from_slice(&(bytes.len() as i32).to_be_bytes()); + out.extend_from_slice(bytes); + } else { + out.extend_from_slice(&(-1i32).to_be_bytes()); + } + }, + "Vec < u8 >" | "Vec" => quote! { + out.extend_from_slice(&17i32.to_be_bytes()); + out.extend_from_slice(&(self.#name.len() as i32).to_be_bytes()); + out.extend_from_slice(&self.#name); + }, + "Option < Vec < u8 > >" | "Option>" => quote! { + out.extend_from_slice(&17i32.to_be_bytes()); + if let Some(ref val) = self.#name { + out.extend_from_slice(&(val.len() as i32).to_be_bytes()); + out.extend_from_slice(val); + } else { + out.extend_from_slice(&(-1i32).to_be_bytes()); + } + }, + "i32" => quote! { + out.extend_from_slice(&23i32.to_be_bytes()); + out.extend_from_slice(&4i32.to_be_bytes()); + out.extend_from_slice(&self.#name.to_be_bytes()); + }, + "Option < i32 >" | "Option" => quote! { + out.extend_from_slice(&23i32.to_be_bytes()); + if let Some(val) = self.#name { + out.extend_from_slice(&4i32.to_be_bytes()); + out.extend_from_slice(&val.to_be_bytes()); + } else { + out.extend_from_slice(&(-1i32).to_be_bytes()); + } + }, + "i64" => quote! { + out.extend_from_slice(&20i32.to_be_bytes()); + out.extend_from_slice(&8i32.to_be_bytes()); + out.extend_from_slice(&self.#name.to_be_bytes()); + }, + "Option < i64 >" | "Option" => quote! { + out.extend_from_slice(&20i32.to_be_bytes()); + if let Some(val) = self.#name { + out.extend_from_slice(&8i32.to_be_bytes()); + out.extend_from_slice(&val.to_be_bytes()); + } else { + out.extend_from_slice(&(-1i32).to_be_bytes()); + } + }, + "bool" => quote! { + out.extend_from_slice(&16i32.to_be_bytes()); + out.extend_from_slice(&1i32.to_be_bytes()); + out.extend_from_slice(&[if self.#name { 1 } else { 0 }]); + }, + "Option < bool >" | "Option" => quote! { + out.extend_from_slice(&16i32.to_be_bytes()); + if let Some(val) = self.#name { + out.extend_from_slice(&1i32.to_be_bytes()); + out.extend_from_slice(&[if val { 1 } else { 0 }]); + } else { + out.extend_from_slice(&(-1i32).to_be_bytes()); + } + }, + "DateTime < Utc >" | "DateTime" => quote! { + const PG_EPOCH_SECS: i64 = 946684800; + let timestamp_secs = self.#name.timestamp(); + let timestamp_micros = (timestamp_secs - PG_EPOCH_SECS) * 1_000_000 + (self.#name.timestamp_subsec_micros() as i64); + out.extend_from_slice(&1184i32.to_be_bytes()); + out.extend_from_slice(&8i32.to_be_bytes()); + out.extend_from_slice(×tamp_micros.to_be_bytes()); + }, + "Option < DateTime < Utc > >" | "Option>" => quote! { + out.extend_from_slice(&1184i32.to_be_bytes()); + if let Some(ref dt) = self.#name { + const PG_EPOCH_SECS: i64 = 946684800; + let timestamp_secs = dt.timestamp(); + let timestamp_micros = (timestamp_secs - PG_EPOCH_SECS) * 1_000_000 + (dt.timestamp_subsec_micros() as i64); + out.extend_from_slice(&8i32.to_be_bytes()); + out.extend_from_slice(×tamp_micros.to_be_bytes()); + } else { + out.extend_from_slice(&(-1i32).to_be_bytes()); + } + }, + "Option < serde_json :: Value >" | "Option" => quote! { + out.extend_from_slice(&3802i32.to_be_bytes()); + if let Some(ref val) = self.#name { + let json_bytes = serde_json::to_vec(val)?; + let total_len = 1 + json_bytes.len(); + out.extend_from_slice(&(total_len as i32).to_be_bytes()); + out.extend_from_slice(&[1u8]); + out.extend_from_slice(&json_bytes); + } else { + out.extend_from_slice(&(-1i32).to_be_bytes()); + } + }, + _ => { + // Check for VideoCaption composite type + if type_str.contains("VideoCaption") { + // Handle nested composite types + if type_str.starts_with("Option <") || type_str.starts_with("Option<") { + // Optional nested composite + quote! { + out.extend_from_slice(&0i32.to_be_bytes()); // Composite OID (0 for embedded composites) + if let Some(ref caption) = self.#name { + // Serialize nested composite: field count + fields + let mut nested_buf = BytesMut::new(); + nested_buf.extend_from_slice(&3i32.to_be_bytes()); // VideoCaption has 3 fields + + // lang field (enum as text) + nested_buf.extend_from_slice(&25i32.to_be_bytes()); + let lang_bytes = caption.lang.to_string().into_bytes(); + nested_buf.extend_from_slice(&(lang_bytes.len() as i32).to_be_bytes()); + nested_buf.extend_from_slice(&lang_bytes); + + // mime_type field (enum as text) + nested_buf.extend_from_slice(&25i32.to_be_bytes()); + let mime_bytes = caption.mime_type.to_string().into_bytes(); + nested_buf.extend_from_slice(&(mime_bytes.len() as i32).to_be_bytes()); + nested_buf.extend_from_slice(&mime_bytes); + + // cid field (bytea) + nested_buf.extend_from_slice(&17i32.to_be_bytes()); + nested_buf.extend_from_slice(&(caption.cid.len() as i32).to_be_bytes()); + nested_buf.extend_from_slice(&caption.cid); + + out.extend_from_slice(&(nested_buf.len() as i32).to_be_bytes()); + out.extend_from_slice(&nested_buf); + } else { + out.extend_from_slice(&(-1i32).to_be_bytes()); // NULL + } + } + } else { + // Required nested composite - shouldn't happen for VideoCaption in our schema + quote! { + compile_error!("Required nested composite types not yet supported in PgToSql") + } + } + } else if type_str.starts_with("Option <") || type_str.starts_with("Option<") { + // For optional enums, serialize as text + quote! { + out.extend_from_slice(&25i32.to_be_bytes()); + if let Some(ref val) = self.#name { + let bytes = val.to_string().into_bytes(); + out.extend_from_slice(&(bytes.len() as i32).to_be_bytes()); + out.extend_from_slice(&bytes); + } else { + out.extend_from_slice(&(-1i32).to_be_bytes()); + } + } + } else { + quote! { + out.extend_from_slice(&25i32.to_be_bytes()); + let bytes = self.#name.to_string().into_bytes(); + out.extend_from_slice(&(bytes.len() as i32).to_be_bytes()); + out.extend_from_slice(&bytes); + } + } + } + } +} \ No newline at end of file diff --git a/parakeet-db/Cargo.toml b/parakeet-db/Cargo.toml index 342a62fd..99aec9be 100644 --- a/parakeet-db/Cargo.toml +++ b/parakeet-db/Cargo.toml @@ -4,6 +4,7 @@ version = "0.1.0" edition = "2021" [dependencies] +parakeet-db-macros = { path = "../parakeet-db-macros" } bytes = "1" chrono = { version = "0.4.39", features = ["serde"] } cid = "0.11" @@ -19,6 +20,7 @@ postgres-types = { version = "0.2.9" } serde = { version = "1.0.217", features = ["derive"] } serde_json = "1.0.134" sha2 = "0.10" +thiserror = "2.0" tokio = { version = "1.42.0", features = ["sync"] } zstd = "0.13" diff --git a/parakeet-db/src/application/views/actor.rs b/parakeet-db/src/application/views/actor.rs new file mode 100644 index 00000000..38dfb45b --- /dev/null +++ b/parakeet-db/src/application/views/actor.rs @@ -0,0 +1,82 @@ +//! Actor view conversions +//! +//! Extension traits and implementations for converting Actor domain entities +//! to Jacquard AT Protocol view representations. + +use crate::domain::entities::Actor; +use crate::domain::value_objects::cid; +use chrono::{DateTime, Utc}; +use jacquard_api::app_bsky; +use jacquard_common::types::{ + did::Did, + handle::Handle, + string::{Datetime, Uri}, +}; +use jacquard_common::url::Url; +use jacquard_common::{CowStr, IntoStatic}; + +/// Extension trait for converting Actor entities to Jacquard views +pub trait ActorViewExt { + /// Convert to basic ProfileView + fn to_profile_view(&self) -> app_bsky::actor::ProfileView<'static>; +} + +impl ActorViewExt for Actor { + /// Convert to basic ProfileView + /// + /// Converts the Actor model to a Jacquard ProfileView, including: + /// - Converting avatar/banner CID digests to CDN URLs + /// - Handling missing/invalid handles gracefully + /// - Setting proper indexed_at timestamps + fn to_profile_view(&self) -> app_bsky::actor::ProfileView<'static> { + // Convert avatar CID to CDN URL + let avatar_url = self + .profile_avatar_cid + .as_ref() + .and_then(|avatar_cid| cid::digest_to_blob_cid_string(avatar_cid)) + .and_then(|cid_str| { + Url::parse(&format!( + "https://cdn.bsky.social/img/avatar/plain/{}/{}@jpeg", + self.did, cid_str + )) + .ok() + .map(Uri::Https) + }); + + // Note: ProfileView doesn't have a banner field in the current API + // Banner would be in ProfileViewDetailed + + app_bsky::actor::ProfileView::new() + .did(Did::new(&self.did).expect("Invalid DID")) + .handle( + self.handle + .as_ref() + .and_then(|h| Handle::new(h).ok()) + .unwrap_or_else(|| Handle::new("invalid.handle").unwrap()), + ) + .display_name( + self.profile_display_name + .as_ref() + .map(|s| CowStr::from(s.as_str())), + ) + .description( + self.profile_description + .as_ref() + .map(|s| CowStr::from(s.as_str())), + ) + .avatar(avatar_url) + .indexed_at(self.last_indexed.map(to_datetime)) + .labels(vec![]) + .build() + .into_static() + } +} + +// ============================================================================= +// UTILITY FUNCTIONS +// ============================================================================= + +/// Convert chrono DateTime to Jacquard Datetime +fn to_datetime(dt: DateTime) -> Datetime { + Datetime::new(dt.fixed_offset()) +} \ No newline at end of file diff --git a/parakeet-db/src/application/views/list.rs b/parakeet-db/src/application/views/list.rs new file mode 100644 index 00000000..6b8564ad --- /dev/null +++ b/parakeet-db/src/application/views/list.rs @@ -0,0 +1,112 @@ +//! List view conversions +//! +//! Extension traits and implementations for converting List domain entities +//! to Jacquard AT Protocol view representations. + +use crate::domain::entities::List; +use crate::infrastructure::persistence::types::ListType; +use crate::domain::value_objects::cid; +use chrono::{DateTime, Utc}; +use jacquard_api::app_bsky; +use jacquard_common::types::string::{AtUri, Cid, Datetime, Uri}; +use jacquard_common::url::Url; +use jacquard_common::{CowStr, IntoStatic}; + +/// Extension trait for converting List entities to Jacquard views +pub trait ListViewExt { + /// Convert to ListView with creator profile + fn to_list_view( + &self, + creator: app_bsky::actor::ProfileView<'static>, + ) -> app_bsky::graph::ListView<'static>; +} + +impl ListViewExt for List { + /// Convert to ListView with creator profile + /// + /// This converts the List model to a Jacquard ListView, including: + /// - Building the AT URI from owner DID and list rkey + /// - Converting CID digest to string + /// - Mapping ListType to ListPurpose + /// - Converting avatar CID to CDN URL + fn to_list_view( + &self, + creator: app_bsky::actor::ProfileView<'static>, + ) -> app_bsky::graph::ListView<'static> { + // Build the AT URI for the list + let uri = AtUri::new_owned(format!( + "at://{}/app.bsky.graph.list/{}", + creator.did.as_str(), + self.rkey + )) + .expect("Valid AT URI"); + + // Convert CID digest to string + let cid = cid::digest_to_record_cid_string(&self.cid) + .map(|s| Cid::from(CowStr::from(s))) + .unwrap_or_else(|| Cid::from(CowStr::from("bafyreilist"))); + + // Map ListType to ListPurpose + let purpose = match self.list_type.as_ref() { + Some(ListType::CurateList) => app_bsky::graph::ListPurpose::AppBskyGraphDefsCuratelist, + Some(ListType::ModList) => app_bsky::graph::ListPurpose::AppBskyGraphDefsModlist, + Some(ListType::ReferenceList) => { + app_bsky::graph::ListPurpose::AppBskyGraphDefsReferencelist + } + None => app_bsky::graph::ListPurpose::AppBskyGraphDefsCuratelist, // Default for stubs + }; + + // Convert avatar CID to CDN URL if present + let avatar = self + .avatar_cid + .as_ref() + .and_then(|avatar_cid| cid::digest_to_blob_cid_string(avatar_cid)) + .and_then(|cid_str| { + Url::parse(&format!( + "https://cdn.bsky.social/img/avatar/plain/{}/{}@jpeg", + creator.did.as_str(), + cid_str + )) + .ok() + .map(Uri::Https) + }); + + // Get or estimate created_at timestamp + let indexed_at = to_datetime(self.created_at()); + + let mut builder = app_bsky::graph::ListView::new() + .uri(uri) + .cid(cid) + .name(CowStr::from( + self.name + .as_ref() + .cloned() + .unwrap_or_else(|| "Untitled List".to_string()), + )) + .creator(creator) + .purpose(purpose) + .list_item_count(Some(0)) // TODO: Get actual count from list_items table + .indexed_at(indexed_at) + .labels(vec![]); + + // Add optional fields + if let Some(desc) = &self.description { + builder = builder.description(Some(CowStr::from(desc.as_str()))); + } + + if let Some(avatar_url) = avatar { + builder = builder.avatar(Some(avatar_url)); + } + + builder.build().into_static() + } +} + +// ============================================================================= +// UTILITY FUNCTIONS +// ============================================================================= + +/// Convert chrono DateTime to Jacquard Datetime +fn to_datetime(dt: DateTime) -> Datetime { + Datetime::new(dt.fixed_offset()) +} \ No newline at end of file diff --git a/parakeet-db/src/application/views/post.rs b/parakeet-db/src/application/views/post.rs new file mode 100644 index 00000000..1a2e5172 --- /dev/null +++ b/parakeet-db/src/application/views/post.rs @@ -0,0 +1,88 @@ +//! Post view conversions +//! +//! Extension traits and implementations for converting Post domain entities +//! to Jacquard AT Protocol view representations. + +use crate::domain::entities::Post; +use crate::domain::value_objects::{cid, tid}; +use crate::infrastructure::compression::PostContentCodec; +use crate::domain::value_objects::tid_traits::HasTidRkey; +use chrono::{DateTime, Utc}; +use jacquard_api::app_bsky; +use jacquard_common::types::string::{AtUri, Cid, Datetime}; +use jacquard_common::{CowStr, Data, IntoStatic}; + +/// Extension trait for converting Post entities to Jacquard views +pub trait PostViewExt { + /// Convert to PostView with author profile + fn to_post_view( + &self, + author: app_bsky::actor::ProfileViewBasic<'static>, + ) -> app_bsky::feed::PostView<'static>; +} + +impl PostViewExt for Post { + /// Convert to PostView with author profile + /// + /// This converts the Post model to a Jacquard PostView, including: + /// - Building the AT URI from author DID and post TID + /// - Converting CID digest to string + /// - Decompressing and parsing post content + /// - Setting engagement counts + fn to_post_view( + &self, + author: app_bsky::actor::ProfileViewBasic<'static>, + ) -> app_bsky::feed::PostView<'static> { + // Build the AT URI for the post + let uri = AtUri::new_owned(format!( + "at://{}/app.bsky.feed.post/{}", + author.did.as_str(), + self.rkey_str() + )) + .expect("Valid AT URI"); + + // Convert CID digest to string (using record CID format for posts) + let cid = cid::digest_to_record_cid_string(&self.cid) + .map(|s| Cid::from(CowStr::from(s))) + .unwrap_or_else(|| Cid::from(CowStr::from("bafyreiunknown"))); + + // Decompress and parse the post content if available + let record = if let Some(content_bytes) = &self.content { + let codec = PostContentCodec::new(); + codec + .decompress(content_bytes) + .ok() + .map(|json_str| Data::from(json_str.as_bytes().to_vec())) + .unwrap_or_else(|| Data::from(b"{}".to_vec())) + } else { + // Stub post with no content + Data::from(b"{}".to_vec()) + }; + + // Get created_at timestamp from TID + let indexed_at = to_datetime(self.created_at()); + + app_bsky::feed::PostView::new() + .uri(uri) + .cid(cid) + .author(author) + .record(record) + .reply_count(Some(self.reply_count as i64)) + .repost_count(Some(self.repost_count as i64)) + .like_count(Some(self.like_count as i64)) + .quote_count(Some(self.quote_count as i64)) + .indexed_at(indexed_at) + .labels(vec![]) + .build() + .into_static() + } +} + +// ============================================================================= +// UTILITY FUNCTIONS +// ============================================================================= + +/// Convert chrono DateTime to Jacquard Datetime +fn to_datetime(dt: DateTime) -> Datetime { + Datetime::new(dt.fixed_offset()) +} \ No newline at end of file diff --git a/parakeet-db/src/composite/mod.rs b/parakeet-db/src/composite/mod.rs deleted file mode 100644 index f478aade..00000000 --- a/parakeet-db/src/composite/mod.rs +++ /dev/null @@ -1,1544 +0,0 @@ -// ============================================================================= -// COMPOSITE_TYPES.RS - PostgreSQL Composite Type Definitions for Embeds -// ============================================================================= -// -// This file contains Rust struct definitions for PostgreSQL composite types -// used to denormalize embed data into the posts table. -// -// KEY PRINCIPLES: -// -// 1. **Wire Protocol Implementation**: Manual FromSql/ToSql for composite types -// - PostgreSQL composite types use a specific wire format -// - Field count (i32) + OIDs + lengths + values -// - Based on https://github.com/diesel-rs/diesel/issues/1732#issuecomment-392414618 -// -// 2. **Storage Optimization**: Reduces 89 columns to 17 -// - NULL columns compress to ~0 bytes in TimescaleDB -// - Dictionary compression for composite types -// - Logically grouped fields (all image fields together) -// -// 3. **Type Safety**: Matches database schema exactly -// - Uses existing ENUM types (ImageMimeType, VideoMimeType, FacetType) -// - Enforces protocol constraints at compile time -// -// ============================================================================= - -use crate::schema::sql_types::{ - PostExtEmbed, PostVideoEmbed, PostImageEmbed, PostFacetEmbed, PostVideoCaption, - LabelerDefRecord, FollowRecord, - MuteRecord, BlockRecord, BookmarkRecord, // Phase 4: User preferences (diesel-generated) - ThreadMuteRecord, ListMuteRecord, ListBlockRecord, // Phase 5: List moderation (diesel-generated) - PostLabel, ActorLabel, // Phase 7: Label composite types (diesel-generated) -}; -use crate::types::{ - ImageMimeType, VideoMimeType, FacetType, CaptionMimeType, LanguageCode, - LabelSeverity, LabelBlurs, LabelDefaultSetting, // For labeler_def_record -}; -use chrono::{DateTime, Utc}; -use diesel::deserialize::{self, FromSql}; -use diesel::pg::Pg; -use diesel::serialize::{self, ToSql, Output}; -use diesel::sql_types::SqlType; -use diesel::AsExpression; -use std::io::Write; -use bytes::BytesMut; -use postgres_types::{ToSql as PgToSql, Type, IsNull}; - -// ============================================================================= -// External Link Embed -// ============================================================================= - -#[derive(Debug, Clone, PartialEq, AsExpression)] -#[derive(serde::Serialize, serde::Deserialize)] -#[diesel(sql_type = PostExtEmbed)] -pub struct ExtEmbed { - pub uri: String, - pub title: Option, - pub description: Option, - pub thumb_mime_type: Option, - pub thumb_cid: Option>, -} - -// Diesel FromSql implementation -impl FromSql for ExtEmbed { - fn from_sql(bytes: ::RawValue<'_>) -> deserialize::Result { - // PostgreSQL composite type wire format: - // - i32: number of fields - // - For each field: - // - i32: OID (type identifier) - // - i32: length (-1 for NULL) - // - [u8]: value bytes (if length > 0) - // - // We'll implement a simplified parser that reads the field values - // assuming the schema matches our struct definition - - let bytes = bytes.as_bytes(); - let mut reader = CompositeReader::new(bytes); - - let field_count = reader.read_field_count()?; - if field_count != 5 { - return Err("ext_embed: expected 5 fields".into()); - } - - let uri = reader.read_text_field()?.ok_or("ext_embed.uri cannot be NULL")?; - let title = reader.read_text_field()?; - let description = reader.read_text_field()?; - let thumb_mime_type = reader.read_enum_field::()?; - let thumb_cid = reader.read_bytea_field()?; - - Ok(ExtEmbed { - uri, - title, - description, - thumb_mime_type, - thumb_cid, - }) - } -} - -// Diesel ToSql implementation -impl ToSql for ExtEmbed { - fn to_sql<'b>(&'b self, out: &mut Output<'b, '_, Pg>) -> serialize::Result { - CompositeWriter::write_header(out,5)?; - let mut writer = CompositeWriter::with_output(out); - - writer.write_text_field(Some(&self.uri))?; - writer.write_text_field(self.title.as_deref())?; - writer.write_text_field(self.description.as_deref())?; - writer.write_enum_field(self.thumb_mime_type.as_ref())?; - writer.write_bytea_field(self.thumb_cid.as_deref())?; - - Ok(serialize::IsNull::No) - } -} - -// postgres-types ToSql implementation (for tokio-postgres compatibility) -impl PgToSql for ExtEmbed { - fn to_sql(&self, _ty: &Type, out: &mut BytesMut) -> Result> { - // Field count - out.extend_from_slice(&5i32.to_be_bytes()); - - // uri (TEXT) - out.extend_from_slice(&25i32.to_be_bytes()); // TEXT OID - let uri_bytes = self.uri.as_bytes(); - out.extend_from_slice(&(uri_bytes.len() as i32).to_be_bytes()); - out.extend_from_slice(uri_bytes); - - // title (TEXT, nullable) - out.extend_from_slice(&25i32.to_be_bytes()); - if let Some(ref title) = self.title { - let title_bytes = title.as_bytes(); - out.extend_from_slice(&(title_bytes.len() as i32).to_be_bytes()); - out.extend_from_slice(title_bytes); - } else { - out.extend_from_slice(&(-1i32).to_be_bytes()); - } - - // description (TEXT, nullable) - out.extend_from_slice(&25i32.to_be_bytes()); - if let Some(ref desc) = self.description { - let desc_bytes = desc.as_bytes(); - out.extend_from_slice(&(desc_bytes.len() as i32).to_be_bytes()); - out.extend_from_slice(desc_bytes); - } else { - out.extend_from_slice(&(-1i32).to_be_bytes()); - } - - // thumb_mime_type (ENUM as TEXT, nullable) - out.extend_from_slice(&25i32.to_be_bytes()); - if let Some(ref mime) = self.thumb_mime_type { - let mime_str = mime.to_string(); - let mime_bytes = mime_str.as_bytes(); - out.extend_from_slice(&(mime_bytes.len() as i32).to_be_bytes()); - out.extend_from_slice(mime_bytes); - } else { - out.extend_from_slice(&(-1i32).to_be_bytes()); - } - - // thumb_cid (BYTEA, nullable) - out.extend_from_slice(&17i32.to_be_bytes()); // BYTEA OID - if let Some(ref cid) = self.thumb_cid { - out.extend_from_slice(&(cid.len() as i32).to_be_bytes()); - out.extend_from_slice(cid); - } else { - out.extend_from_slice(&(-1i32).to_be_bytes()); - } - - Ok(IsNull::No) - } - - fn accepts(_ty: &Type) -> bool { - true // Accept any type (the database will validate) - } - - postgres_types::to_sql_checked!(); -} - -// ============================================================================= -// Video Embed -// ============================================================================= - -// ============================================================================= -// VIDEO CAPTION COMPOSITE (post_video_caption) -// ============================================================================= - -#[derive(Debug, Clone, PartialEq, AsExpression)] -#[derive(serde::Serialize, serde::Deserialize)] -#[diesel(sql_type = PostVideoCaption)] -pub struct VideoCaption { - pub lang: LanguageCode, - pub mime_type: CaptionMimeType, - pub cid: Vec, -} - -impl FromSql for VideoCaption { - fn from_sql(bytes: ::RawValue<'_>) -> deserialize::Result { - let bytes = bytes.as_bytes(); - let mut reader = CompositeReader::new(bytes); - - let field_count = reader.read_field_count()?; - if field_count != 3 { - return Err("video_caption: expected 3 fields".into()); - } - - let lang = reader.read_enum_field::()?.ok_or("video_caption.lang cannot be NULL")?; - let mime_type = reader.read_enum_field::()?.ok_or("video_caption.mime_type cannot be NULL")?; - let cid = reader.read_bytea_field()?.ok_or("video_caption.cid cannot be NULL")?; - - Ok(VideoCaption { - lang, - mime_type, - cid, - }) - } -} - -impl ToSql for VideoCaption { - fn to_sql<'b>(&'b self, out: &mut Output<'b, '_, Pg>) -> serialize::Result { - CompositeWriter::write_header(out,3)?; - let mut writer = CompositeWriter::with_output(out); - - writer.write_enum_field(Some(&self.lang))?; - writer.write_enum_field(Some(&self.mime_type))?; - writer.write_bytea_field(Some(&self.cid))?; - - Ok(serialize::IsNull::No) - } -} - -// postgres-types ToSql implementation for VideoCaption -impl PgToSql for VideoCaption { - fn to_sql(&self, _ty: &Type, out: &mut BytesMut) -> Result> { - out.extend_from_slice(&3i32.to_be_bytes()); - // lang (TEXT) - out.extend_from_slice(&25i32.to_be_bytes()); - let lang_bytes = self.lang.to_string().into_bytes(); - out.extend_from_slice(&(lang_bytes.len() as i32).to_be_bytes()); - out.extend_from_slice(&lang_bytes); - // mime_type (TEXT) - out.extend_from_slice(&25i32.to_be_bytes()); - let mime_bytes = self.mime_type.to_string().into_bytes(); - out.extend_from_slice(&(mime_bytes.len() as i32).to_be_bytes()); - out.extend_from_slice(&mime_bytes); - // cid (BYTEA) - out.extend_from_slice(&17i32.to_be_bytes()); - out.extend_from_slice(&(self.cid.len() as i32).to_be_bytes()); - out.extend_from_slice(&self.cid); - Ok(IsNull::No) - } - fn accepts(_ty: &Type) -> bool { true } - postgres_types::to_sql_checked!(); -} - -// ============================================================================= -// VIDEO EMBED COMPOSITE (post_video_embed) -// ============================================================================= - -#[derive(Debug, Clone, PartialEq, AsExpression)] -#[derive(serde::Serialize, serde::Deserialize)] -#[diesel(sql_type = PostVideoEmbed)] -pub struct VideoEmbed { - pub mime_type: VideoMimeType, - pub cid: Vec, - pub alt: Option, - pub width: Option, - pub height: Option, - pub caption_1: Option, - pub caption_2: Option, - pub caption_3: Option, -} - -impl FromSql for VideoEmbed { - fn from_sql(bytes: ::RawValue<'_>) -> deserialize::Result { - let bytes = bytes.as_bytes(); - let mut reader = CompositeReader::new(bytes); - - let field_count = reader.read_field_count()?; - if field_count != 8 { - return Err("video_embed: expected 8 fields".into()); - } - - let mime_type = reader.read_enum_field::()?.ok_or("video_embed.mime_type cannot be NULL")?; - let cid = reader.read_bytea_field()?.ok_or("video_embed.cid cannot be NULL")?; - let alt = reader.read_text_field()?; - let width = reader.read_int_field()?; - let height = reader.read_int_field()?; - let caption_1 = reader.read_composite_field::()?; - let caption_2 = reader.read_composite_field::()?; - let caption_3 = reader.read_composite_field::()?; - - Ok(VideoEmbed { - mime_type, - cid, - alt, - width, - height, - caption_1, - caption_2, - caption_3, - }) - } -} - -impl ToSql for VideoEmbed { - fn to_sql<'b>(&'b self, out: &mut Output<'b, '_, Pg>) -> serialize::Result { - CompositeWriter::write_header(out,8)?; - let mut writer = CompositeWriter::with_output(out); - - writer.write_enum_field(Some(&self.mime_type))?; - writer.write_bytea_field(Some(&self.cid))?; - writer.write_text_field(self.alt.as_deref())?; - writer.write_int_field(self.width)?; - writer.write_int_field(self.height)?; - writer.write_composite_field::(self.caption_1.as_ref())?; - writer.write_composite_field::(self.caption_2.as_ref())?; - writer.write_composite_field::(self.caption_3.as_ref())?; - - Ok(serialize::IsNull::No) - } -} - -// postgres-types ToSql implementation -impl PgToSql for VideoEmbed { - fn to_sql(&self, _ty: &Type, out: &mut BytesMut) -> Result> { - out.extend_from_slice(&8i32.to_be_bytes()); - // mime_type (TEXT) - out.extend_from_slice(&25i32.to_be_bytes()); - let mime_bytes = self.mime_type.to_string().into_bytes(); - out.extend_from_slice(&(mime_bytes.len() as i32).to_be_bytes()); - out.extend_from_slice(&mime_bytes); - // cid (BYTEA) - out.extend_from_slice(&17i32.to_be_bytes()); - out.extend_from_slice(&(self.cid.len() as i32).to_be_bytes()); - out.extend_from_slice(&self.cid); - // alt (TEXT, nullable) - out.extend_from_slice(&25i32.to_be_bytes()); - if let Some(ref alt) = self.alt { - let alt_bytes = alt.as_bytes(); - out.extend_from_slice(&(alt_bytes.len() as i32).to_be_bytes()); - out.extend_from_slice(alt_bytes); - } else { - out.extend_from_slice(&(-1i32).to_be_bytes()); - } - // width (INT4, nullable) - out.extend_from_slice(&23i32.to_be_bytes()); - if let Some(w) = self.width { - out.extend_from_slice(&4i32.to_be_bytes()); - out.extend_from_slice(&w.to_be_bytes()); - } else { - out.extend_from_slice(&(-1i32).to_be_bytes()); - } - // height (INT4, nullable) - out.extend_from_slice(&23i32.to_be_bytes()); - if let Some(h) = self.height { - out.extend_from_slice(&4i32.to_be_bytes()); - out.extend_from_slice(&h.to_be_bytes()); - } else { - out.extend_from_slice(&(-1i32).to_be_bytes()); - } - // caption_1 (post_video_caption, nullable) - // Composite types use OID 0 as placeholder - out.extend_from_slice(&0i32.to_be_bytes()); - if let Some(ref cap) = self.caption_1 { - let mut cap_bytes = BytesMut::new(); - PgToSql::to_sql(cap, &postgres_types::Type::ANY, &mut cap_bytes)?; - out.extend_from_slice(&(cap_bytes.len() as i32).to_be_bytes()); - out.extend_from_slice(&cap_bytes); - } else { - out.extend_from_slice(&(-1i32).to_be_bytes()); - } - // caption_2 (post_video_caption, nullable) - out.extend_from_slice(&0i32.to_be_bytes()); - if let Some(ref cap) = self.caption_2 { - let mut cap_bytes = BytesMut::new(); - PgToSql::to_sql(cap, &postgres_types::Type::ANY, &mut cap_bytes)?; - out.extend_from_slice(&(cap_bytes.len() as i32).to_be_bytes()); - out.extend_from_slice(&cap_bytes); - } else { - out.extend_from_slice(&(-1i32).to_be_bytes()); - } - // caption_3 (post_video_caption, nullable) - out.extend_from_slice(&0i32.to_be_bytes()); - if let Some(ref cap) = self.caption_3 { - let mut cap_bytes = BytesMut::new(); - PgToSql::to_sql(cap, &postgres_types::Type::ANY, &mut cap_bytes)?; - out.extend_from_slice(&(cap_bytes.len() as i32).to_be_bytes()); - out.extend_from_slice(&cap_bytes); - } else { - out.extend_from_slice(&(-1i32).to_be_bytes()); - } - Ok(IsNull::No) - } - fn accepts(_ty: &Type) -> bool { true } - postgres_types::to_sql_checked!(); -} - -// ============================================================================= -// Image Embed -// ============================================================================= - -#[derive(Debug, Clone, PartialEq, AsExpression)] -#[derive(serde::Serialize, serde::Deserialize)] -#[diesel(sql_type = PostImageEmbed)] -pub struct ImageEmbed { - pub mime_type: ImageMimeType, - pub cid: Vec, - pub alt: Option, - pub width: Option, - pub height: Option, -} - -impl FromSql for ImageEmbed { - fn from_sql(bytes: ::RawValue<'_>) -> deserialize::Result { - let bytes = bytes.as_bytes(); - let mut reader = CompositeReader::new(bytes); - - let field_count = reader.read_field_count()?; - if field_count != 5 { - return Err("image_embed: expected 5 fields".into()); - } - - let mime_type = reader.read_enum_field::()?.ok_or("image_embed.mime_type cannot be NULL")?; - let cid = reader.read_bytea_field()?.ok_or("image_embed.cid cannot be NULL")?; - let alt = reader.read_text_field()?; - let width = reader.read_int_field()?; - let height = reader.read_int_field()?; - - Ok(ImageEmbed { - mime_type, - cid, - alt, - width, - height, - }) - } -} - -impl ToSql for ImageEmbed { - fn to_sql<'b>(&'b self, out: &mut Output<'b, '_, Pg>) -> serialize::Result { - CompositeWriter::write_header(out,5)?; - let mut writer = CompositeWriter::with_output(out); - - writer.write_enum_field(Some(&self.mime_type))?; - writer.write_bytea_field(Some(&self.cid))?; - writer.write_text_field(self.alt.as_deref())?; - writer.write_int_field(self.width)?; - writer.write_int_field(self.height)?; - - Ok(serialize::IsNull::No) - } -} - -// postgres-types ToSql implementation -impl PgToSql for ImageEmbed { - fn to_sql(&self, _ty: &Type, out: &mut BytesMut) -> Result> { - out.extend_from_slice(&5i32.to_be_bytes()); - // mime_type (TEXT) - out.extend_from_slice(&25i32.to_be_bytes()); - let mime_bytes = self.mime_type.to_string().into_bytes(); - out.extend_from_slice(&(mime_bytes.len() as i32).to_be_bytes()); - out.extend_from_slice(&mime_bytes); - // cid (BYTEA) - out.extend_from_slice(&17i32.to_be_bytes()); - out.extend_from_slice(&(self.cid.len() as i32).to_be_bytes()); - out.extend_from_slice(&self.cid); - // alt (TEXT, nullable) - out.extend_from_slice(&25i32.to_be_bytes()); - if let Some(ref alt) = self.alt { - let alt_bytes = alt.as_bytes(); - out.extend_from_slice(&(alt_bytes.len() as i32).to_be_bytes()); - out.extend_from_slice(alt_bytes); - } else { - out.extend_from_slice(&(-1i32).to_be_bytes()); - } - // width (INT4, nullable) - out.extend_from_slice(&23i32.to_be_bytes()); - if let Some(w) = self.width { - out.extend_from_slice(&4i32.to_be_bytes()); - out.extend_from_slice(&w.to_be_bytes()); - } else { - out.extend_from_slice(&(-1i32).to_be_bytes()); - } - // height (INT4, nullable) - out.extend_from_slice(&23i32.to_be_bytes()); - if let Some(h) = self.height { - out.extend_from_slice(&4i32.to_be_bytes()); - out.extend_from_slice(&h.to_be_bytes()); - } else { - out.extend_from_slice(&(-1i32).to_be_bytes()); - } - Ok(IsNull::No) - } - fn accepts(_ty: &Type) -> bool { true } - postgres_types::to_sql_checked!(); -} - -// ============================================================================= -// Facet Embed -// ============================================================================= - -#[derive(Debug, Clone, PartialEq, AsExpression)] -#[derive(serde::Serialize, serde::Deserialize)] -#[diesel(sql_type = PostFacetEmbed)] -pub struct FacetEmbed { - pub facet_type: FacetType, - pub index_start: i32, - pub index_end: i32, - pub link_uri: Option, - pub mention_actor_id: Option, - pub tag: Option, -} - -impl FromSql for FacetEmbed { - fn from_sql(bytes: ::RawValue<'_>) -> deserialize::Result { - let bytes = bytes.as_bytes(); - let mut reader = CompositeReader::new(bytes); - - let field_count = reader.read_field_count()?; - if field_count != 6 { - return Err("facet_embed: expected 6 fields".into()); - } - - let facet_type = reader.read_enum_field::()?.ok_or("facet_embed.facet_type cannot be NULL")?; - let index_start = reader.read_int_field()?.ok_or("facet_embed.index_start cannot be NULL")?; - let index_end = reader.read_int_field()?.ok_or("facet_embed.index_end cannot be NULL")?; - let link_uri = reader.read_text_field()?; - let mention_actor_id = reader.read_int_field()?; - let tag = reader.read_text_field()?; - - Ok(FacetEmbed { - facet_type, - index_start, - index_end, - link_uri, - mention_actor_id, - tag, - }) - } -} - -impl ToSql for FacetEmbed { - fn to_sql<'b>(&'b self, out: &mut Output<'b, '_, Pg>) -> serialize::Result { - CompositeWriter::write_header(out,6)?; - let mut writer = CompositeWriter::with_output(out); - - writer.write_enum_field(Some(&self.facet_type))?; - writer.write_int_field(Some(self.index_start))?; - writer.write_int_field(Some(self.index_end))?; - writer.write_text_field(self.link_uri.as_deref())?; - writer.write_int_field(self.mention_actor_id)?; - writer.write_text_field(self.tag.as_deref())?; - - Ok(serialize::IsNull::No) - } -} - -// postgres-types ToSql implementation -impl PgToSql for FacetEmbed { - fn to_sql(&self, _ty: &Type, out: &mut BytesMut) -> Result> { - out.extend_from_slice(&6i32.to_be_bytes()); - // facet_type (TEXT) - out.extend_from_slice(&25i32.to_be_bytes()); - let ft_bytes = self.facet_type.to_string().into_bytes(); - out.extend_from_slice(&(ft_bytes.len() as i32).to_be_bytes()); - out.extend_from_slice(&ft_bytes); - // index_start (INT4) - out.extend_from_slice(&23i32.to_be_bytes()); - out.extend_from_slice(&4i32.to_be_bytes()); - out.extend_from_slice(&self.index_start.to_be_bytes()); - // index_end (INT4) - out.extend_from_slice(&23i32.to_be_bytes()); - out.extend_from_slice(&4i32.to_be_bytes()); - out.extend_from_slice(&self.index_end.to_be_bytes()); - // link_uri (TEXT, nullable) - out.extend_from_slice(&25i32.to_be_bytes()); - if let Some(ref uri) = self.link_uri { - let uri_bytes = uri.as_bytes(); - out.extend_from_slice(&(uri_bytes.len() as i32).to_be_bytes()); - out.extend_from_slice(uri_bytes); - } else { - out.extend_from_slice(&(-1i32).to_be_bytes()); - } - // mention_actor_id (INT4, nullable) - out.extend_from_slice(&23i32.to_be_bytes()); - if let Some(aid) = self.mention_actor_id { - out.extend_from_slice(&4i32.to_be_bytes()); - out.extend_from_slice(&aid.to_be_bytes()); - } else { - out.extend_from_slice(&(-1i32).to_be_bytes()); - } - // tag (TEXT, nullable) - out.extend_from_slice(&25i32.to_be_bytes()); - if let Some(ref tag) = self.tag { - let tag_bytes = tag.as_bytes(); - out.extend_from_slice(&(tag_bytes.len() as i32).to_be_bytes()); - out.extend_from_slice(tag_bytes); - } else { - out.extend_from_slice(&(-1i32).to_be_bytes()); - } - Ok(IsNull::No) - } - fn accepts(_ty: &Type) -> bool { true } - postgres_types::to_sql_checked!(); -} - -// ============================================================================= -// Helper: Composite Reader (PostgreSQL wire format parser) -// ============================================================================= - -struct CompositeReader<'a> { - bytes: &'a [u8], - offset: usize, -} - -impl<'a> CompositeReader<'a> { - fn new(bytes: &'a [u8]) -> Self { - Self { bytes, offset: 0 } - } - - fn read_i32(&mut self) -> deserialize::Result { - if self.offset + 4 > self.bytes.len() { - return Err("unexpected end of input".into()); - } - let val = i32::from_be_bytes([ - self.bytes[self.offset], - self.bytes[self.offset + 1], - self.bytes[self.offset + 2], - self.bytes[self.offset + 3], - ]); - self.offset += 4; - Ok(val) - } - - fn read_field_count(&mut self) -> deserialize::Result { - self.read_i32() - } - - fn read_field_header(&mut self) -> deserialize::Result<(i32, i32)> { - let oid = self.read_i32()?; - let len = self.read_i32()?; - Ok((oid, len)) - } - - fn read_text_field(&mut self) -> deserialize::Result> { - let (_oid, len) = self.read_field_header()?; - if len == -1 { - return Ok(None); - } - let len = len as usize; - if self.offset + len > self.bytes.len() { - return Err("unexpected end of input".into()); - } - let s = std::str::from_utf8(&self.bytes[self.offset..self.offset + len]) - .map_err(|e| Box::new(e) as Box)?; - self.offset += len; - Ok(Some(s.to_string())) - } - - fn read_bytea_field(&mut self) -> deserialize::Result>> { - let (_oid, len) = self.read_field_header()?; - if len == -1 { - return Ok(None); - } - let len = len as usize; - if self.offset + len > self.bytes.len() { - return Err("unexpected end of input".into()); - } - let bytes = self.bytes[self.offset..self.offset + len].to_vec(); - self.offset += len; - Ok(Some(bytes)) - } - - fn read_int_field(&mut self) -> deserialize::Result> { - let (_oid, len) = self.read_field_header()?; - if len == -1 { - return Ok(None); - } - if len != 4 { - return Err(format!("expected 4 bytes for i32, got {}", len).into()); - } - let val = self.read_i32()?; - Ok(Some(val)) - } - - fn read_enum_field(&mut self) -> deserialize::Result> - where - T: std::str::FromStr, - ::Err: std::fmt::Display, - { - match self.read_text_field()? { - Some(s) => { - let val = s.parse::() - .map_err(|e| format!("Failed to parse enum: {}", e))?; - Ok(Some(val)) - } - None => Ok(None), - } - } - - fn read_composite_field(&mut self) -> deserialize::Result> - where - T: FromSql, - ST: SqlType, - { - let (_oid, len) = self.read_field_header()?; - if len == -1 { - return Ok(None); - } - let len = len as usize; - if self.offset + len > self.bytes.len() { - return Err("unexpected end of input".into()); - } - let composite_bytes = &self.bytes[self.offset..self.offset + len]; - self.offset += len; - - // Parse the nested composite by creating a new reader for the nested bytes - // This recursively calls the FromSql implementation for the nested type - let mut nested_reader = CompositeReader::new(composite_bytes); - - // For VideoCaption specifically, we know it's 3 fields - // This is a bit hacky but avoids needing to expose Diesel internals - let field_count = nested_reader.read_field_count()?; - if field_count != 3 { - return Err(format!("nested composite: expected 3 fields, got {}", field_count).into()); - } - - // Read the 3 fields: lang, mime_type, cid - let lang = nested_reader.read_enum_field::()?.ok_or("caption.lang cannot be NULL")?; - let mime_type = nested_reader.read_enum_field::()?.ok_or("caption.mime_type cannot be NULL")?; - let cid = nested_reader.read_bytea_field()?.ok_or("caption.cid cannot be NULL")?; - - // Reconstruct VideoCaption and cast to T - // SAFETY: This function is only called with T=VideoCaption, so this cast is safe - let caption = Box::new(VideoCaption { lang, mime_type, cid }); - let result = unsafe { Box::from_raw(Box::into_raw(caption) as *mut T) }; - Ok(Some(*result)) - } - - fn read_bigint_field(&mut self) -> deserialize::Result> { - let (_oid, len) = self.read_field_header()?; - if len == -1 { - return Ok(None); - } - if len != 8 { - return Err(format!("expected 8 bytes for i64, got {}", len).into()); - } - if self.offset + 8 > self.bytes.len() { - return Err("unexpected end of input".into()); - } - let val = i64::from_be_bytes([ - self.bytes[self.offset], - self.bytes[self.offset + 1], - self.bytes[self.offset + 2], - self.bytes[self.offset + 3], - self.bytes[self.offset + 4], - self.bytes[self.offset + 5], - self.bytes[self.offset + 6], - self.bytes[self.offset + 7], - ]); - self.offset += 8; - Ok(Some(val)) - } - - fn read_bool_field(&mut self) -> deserialize::Result> { - let (_oid, len) = self.read_field_header()?; - if len == -1 { - return Ok(None); - } - if len != 1 { - return Err(format!("expected 1 byte for bool, got {}", len).into()); - } - if self.offset + 1 > self.bytes.len() { - return Err("unexpected end of input".into()); - } - let val = self.bytes[self.offset] != 0; - self.offset += 1; - Ok(Some(val)) - } - - fn read_timestamptz_field(&mut self) -> deserialize::Result>> { - let (_oid, len) = self.read_field_header()?; - if len == -1 { - return Ok(None); - } - if len != 8 { - return Err(format!("expected 8 bytes for timestamptz, got {}", len).into()); - } - if self.offset + 8 > self.bytes.len() { - return Err("unexpected end of input".into()); - } - - // PostgreSQL stores timestamps as microseconds since 2000-01-01 00:00:00 UTC - let micros = i64::from_be_bytes([ - self.bytes[self.offset], - self.bytes[self.offset + 1], - self.bytes[self.offset + 2], - self.bytes[self.offset + 3], - self.bytes[self.offset + 4], - self.bytes[self.offset + 5], - self.bytes[self.offset + 6], - self.bytes[self.offset + 7], - ]); - self.offset += 8; - - // PostgreSQL epoch: 2000-01-01 00:00:00 UTC - const PG_EPOCH_SECS: i64 = 946684800; - let total_micros = PG_EPOCH_SECS * 1_000_000 + micros; - let secs = total_micros / 1_000_000; - let nsecs = ((total_micros % 1_000_000) * 1000) as u32; - - let timestamp = chrono::DateTime::from_timestamp(secs, nsecs) - .ok_or("Invalid timestamp")?; - Ok(Some(timestamp)) - } - - fn read_jsonb_field(&mut self) -> deserialize::Result> { - let (_oid, len) = self.read_field_header()?; - if len == -1 { - return Ok(None); - } - let len = len as usize; - if self.offset + len > self.bytes.len() { - return Err("unexpected end of input".into()); - } - - // JSONB has a 1-byte version header (usually 0x01) - if len < 1 { - return Err("JSONB data too short".into()); - } - - let jsonb_bytes = &self.bytes[self.offset + 1..self.offset + len]; - self.offset += len; - - let value: serde_json::Value = serde_json::from_slice(jsonb_bytes) - .map_err(|e| format!("Failed to parse JSONB: {}", e))?; - Ok(Some(value)) - } -} - -// ============================================================================= -// Helper: Composite Writer (PostgreSQL wire format builder) -// ============================================================================= - -struct CompositeWriter<'a, 'b, 'c> { - out: &'a mut Output<'b, 'c, Pg>, -} - -impl<'a, 'b, 'c> CompositeWriter<'a, 'b, 'c> { - fn write_header(out: &'a mut Output<'b, 'c, Pg>, field_count: i32) -> serialize::Result { - out.write_all(&field_count.to_be_bytes())?; - Ok(serialize::IsNull::No) - } - - fn with_output(out: &'a mut Output<'b, 'c, Pg>) -> Self { - Self { out } - } - - fn write_field_header(&mut self, oid: i32, len: i32) -> serialize::Result { - self.out.write_all(&oid.to_be_bytes())?; - self.out.write_all(&len.to_be_bytes())?; - Ok(serialize::IsNull::No) - } - - fn write_text_field(&mut self, value: Option<&str>) -> serialize::Result { - const TEXT_OID: i32 = 25; // PostgreSQL TEXT type OID - match value { - Some(s) => { - let bytes = s.as_bytes(); - self.write_field_header(TEXT_OID, bytes.len() as i32)?; - self.out.write_all(bytes)?; - } - None => { - self.write_field_header(TEXT_OID, -1)?; - } - } - Ok(serialize::IsNull::No) - } - - fn write_bytea_field(&mut self, value: Option<&[u8]>) -> serialize::Result { - const BYTEA_OID: i32 = 17; // PostgreSQL BYTEA type OID - match value { - Some(bytes) => { - self.write_field_header(BYTEA_OID, bytes.len() as i32)?; - self.out.write_all(bytes)?; - } - None => { - self.write_field_header(BYTEA_OID, -1)?; - } - } - Ok(serialize::IsNull::No) - } - - fn write_int_field(&mut self, value: Option) -> serialize::Result { - const INT4_OID: i32 = 23; // PostgreSQL INT4 type OID - match value { - Some(val) => { - self.write_field_header(INT4_OID, 4)?; - self.out.write_all(&val.to_be_bytes())?; - } - None => { - self.write_field_header(INT4_OID, -1)?; - } - } - Ok(serialize::IsNull::No) - } - - fn write_enum_field(&mut self, value: Option<&T>) -> serialize::Result - where - T: std::fmt::Display, - { - // ENUMs are transmitted as TEXT in PostgreSQL wire protocol - const TEXT_OID: i32 = 25; - match value { - Some(val) => { - let s = val.to_string(); - let bytes = s.as_bytes(); - self.write_field_header(TEXT_OID, bytes.len() as i32)?; - self.out.write_all(bytes)?; - } - None => { - self.write_field_header(TEXT_OID, -1)?; - } - } - Ok(serialize::IsNull::No) - } - - fn write_composite_field(&mut self, value: Option<&T>) -> serialize::Result - where - T: ToSql, - ST: SqlType, - { - // Composite types use a generic OID - we'll use 0 as a placeholder - // The actual OID is determined by PostgreSQL during transmission - const COMPOSITE_OID: i32 = 0; - - match value { - Some(val) => { - // Serialize the nested composite to a buffer - // For VideoCaption specifically, we manually serialize it - let mut buffer = Vec::new(); - - // Cast T to VideoCaption (we know this is safe from the call site) - let caption = unsafe { &*(val as *const T as *const VideoCaption) }; - - // Write field count - buffer.extend_from_slice(&3i32.to_be_bytes()); - - // Write lang (TEXT/ENUM) - buffer.extend_from_slice(&25i32.to_be_bytes()); // TEXT OID - let lang_str = caption.lang.to_string(); - buffer.extend_from_slice(&(lang_str.len() as i32).to_be_bytes()); - buffer.extend_from_slice(lang_str.as_bytes()); - - // Write mime_type (TEXT/ENUM) - buffer.extend_from_slice(&25i32.to_be_bytes()); // TEXT OID - let mime_str = caption.mime_type.to_string(); - buffer.extend_from_slice(&(mime_str.len() as i32).to_be_bytes()); - buffer.extend_from_slice(mime_str.as_bytes()); - - // Write cid (BYTEA) - buffer.extend_from_slice(&17i32.to_be_bytes()); // BYTEA OID - buffer.extend_from_slice(&(caption.cid.len() as i32).to_be_bytes()); - buffer.extend_from_slice(&caption.cid); - - self.write_field_header(COMPOSITE_OID, buffer.len() as i32)?; - self.out.write_all(&buffer)?; - } - None => { - self.write_field_header(COMPOSITE_OID, -1)?; - } - } - Ok(serialize::IsNull::No) - } - - fn write_bigint_field(&mut self, value: Option) -> serialize::Result { - const INT8_OID: i32 = 20; // PostgreSQL INT8/BIGINT type OID - match value { - Some(val) => { - self.write_field_header(INT8_OID, 8)?; - self.out.write_all(&val.to_be_bytes())?; - } - None => { - self.write_field_header(INT8_OID, -1)?; - } - } - Ok(serialize::IsNull::No) - } - - fn write_bool_field(&mut self, value: Option) -> serialize::Result { - const BOOL_OID: i32 = 16; // PostgreSQL BOOL type OID - match value { - Some(val) => { - self.write_field_header(BOOL_OID, 1)?; - self.out.write_all(&[if val { 1 } else { 0 }])?; - } - None => { - self.write_field_header(BOOL_OID, -1)?; - } - } - Ok(serialize::IsNull::No) - } - - fn write_timestamptz_field(&mut self, value: Option<&DateTime>) -> serialize::Result { - const TIMESTAMPTZ_OID: i32 = 1184; // PostgreSQL TIMESTAMPTZ type OID - match value { - Some(dt) => { - // PostgreSQL epoch: 2000-01-01 00:00:00 UTC - const PG_EPOCH_SECS: i64 = 946684800; - let timestamp_secs = dt.timestamp(); - let timestamp_micros = (timestamp_secs - PG_EPOCH_SECS) * 1_000_000 + (dt.timestamp_subsec_micros() as i64); - - self.write_field_header(TIMESTAMPTZ_OID, 8)?; - self.out.write_all(×tamp_micros.to_be_bytes())?; - } - None => { - self.write_field_header(TIMESTAMPTZ_OID, -1)?; - } - } - Ok(serialize::IsNull::No) - } - - fn write_jsonb_field(&mut self, value: Option<&serde_json::Value>) -> serialize::Result { - const JSONB_OID: i32 = 3802; // PostgreSQL JSONB type OID - match value { - Some(val) => { - let json_bytes = serde_json::to_vec(val) - .map_err(|e| Box::new(e) as Box)?; - - // JSONB format: 1-byte version (0x01) + JSON data - let total_len = 1 + json_bytes.len(); - self.write_field_header(JSONB_OID, total_len as i32)?; - self.out.write_all(&[1u8])?; // JSONB version 1 - self.out.write_all(&json_bytes)?; - } - None => { - self.write_field_header(JSONB_OID, -1)?; - } - } - Ok(serialize::IsNull::No) - } -} - -// ============================================================================= -// Actor Denormalization Composite Types -// ============================================================================= - -// ============================================================================= -// FollowRecord - Social graph following/followers -// ============================================================================= - -#[derive(Debug, Clone, PartialEq, Eq, Hash, AsExpression)] -#[derive(serde::Serialize, serde::Deserialize)] -#[diesel(sql_type = FollowRecord)] -pub struct Follow { - pub subject_actor_id: i32, - pub rkey: i64, -} - -impl FromSql for Follow { - fn from_sql(bytes: ::RawValue<'_>) -> deserialize::Result { - let bytes = bytes.as_bytes(); - let mut reader = CompositeReader::new(bytes); - - let field_count = reader.read_field_count()?; - if field_count != 2 { - return Err("follow_record: expected 2 fields".into()); - } - - let subject_actor_id = reader.read_int_field()?.ok_or("follow_record.subject_actor_id cannot be NULL")?; - let rkey = reader.read_bigint_field()?.ok_or("follow_record.rkey cannot be NULL")?; - - Ok(Follow { - subject_actor_id, - rkey, - }) - } -} - -impl ToSql for Follow { - fn to_sql<'b>(&'b self, out: &mut Output<'b, '_, Pg>) -> serialize::Result { - CompositeWriter::write_header(out, 2)?; - let mut writer = CompositeWriter::with_output(out); - - writer.write_int_field(Some(self.subject_actor_id))?; - writer.write_bigint_field(Some(self.rkey))?; - - Ok(serialize::IsNull::No) - } -} - -// ============================================================================= -// MuteRecord - User mutes -// ============================================================================= - -#[derive(Debug, Clone, PartialEq, AsExpression)] -#[derive(serde::Serialize, serde::Deserialize)] -#[diesel(sql_type = MuteRecord)] -pub struct Mute { - pub subject_actor_id: i32, - pub created_at: DateTime, -} - -impl FromSql for Mute { - fn from_sql(bytes: ::RawValue<'_>) -> deserialize::Result { - let bytes = bytes.as_bytes(); - let mut reader = CompositeReader::new(bytes); - - let field_count = reader.read_field_count()?; - if field_count != 2 { - return Err("mute_record: expected 2 fields".into()); - } - - let subject_actor_id = reader.read_int_field()?.ok_or("mute_record.subject_actor_id cannot be NULL")?; - let created_at = reader.read_timestamptz_field()?.ok_or("mute_record.created_at cannot be NULL")?; - - Ok(Mute { - subject_actor_id, - created_at, - }) - } -} - -impl ToSql for Mute { - fn to_sql<'b>(&'b self, out: &mut Output<'b, '_, Pg>) -> serialize::Result { - CompositeWriter::write_header(out, 2)?; - let mut writer = CompositeWriter::with_output(out); - - writer.write_int_field(Some(self.subject_actor_id))?; - writer.write_timestamptz_field(Some(&self.created_at))?; - - Ok(serialize::IsNull::No) - } -} - -// ============================================================================= -// BlockRecord - User blocks -// ============================================================================= - -#[derive(Debug, Clone, PartialEq, Eq, Hash, AsExpression)] -#[derive(serde::Serialize, serde::Deserialize)] -#[diesel(sql_type = BlockRecord)] -pub struct Block { - pub subject_actor_id: i32, - pub rkey: i64, -} - -impl FromSql for Block { - fn from_sql(bytes: ::RawValue<'_>) -> deserialize::Result { - let bytes = bytes.as_bytes(); - let mut reader = CompositeReader::new(bytes); - - let field_count = reader.read_field_count()?; - if field_count != 2 { - return Err("block_record: expected 2 fields".into()); - } - - let subject_actor_id = reader.read_int_field()?.ok_or("block_record.subject_actor_id cannot be NULL")?; - let rkey = reader.read_bigint_field()?.ok_or("block_record.rkey cannot be NULL")?; - - Ok(Block { - subject_actor_id, - rkey, - }) - } -} - -impl ToSql for Block { - fn to_sql<'b>(&'b self, out: &mut Output<'b, '_, Pg>) -> serialize::Result { - CompositeWriter::write_header(out, 2)?; - let mut writer = CompositeWriter::with_output(out); - - writer.write_int_field(Some(self.subject_actor_id))?; - writer.write_bigint_field(Some(self.rkey))?; - - Ok(serialize::IsNull::No) - } -} - -// ============================================================================= -// BookmarkRecord - User bookmarks -// ============================================================================= - -#[derive(Debug, Clone, PartialEq, Eq, Hash, AsExpression)] -#[derive(serde::Serialize, serde::Deserialize)] -#[diesel(sql_type = BookmarkRecord)] -pub struct Bookmark { - pub post_actor_id: i32, - pub post_rkey: i64, - pub rkey: i64, -} - -impl FromSql for Bookmark { - fn from_sql(bytes: ::RawValue<'_>) -> deserialize::Result { - let bytes = bytes.as_bytes(); - let mut reader = CompositeReader::new(bytes); - - let field_count = reader.read_field_count()?; - if field_count != 3 { - return Err("bookmark_record: expected 3 fields".into()); - } - - let post_actor_id = reader.read_int_field()?.ok_or("bookmark_record.post_actor_id cannot be NULL")?; - let post_rkey = reader.read_bigint_field()?.ok_or("bookmark_record.post_rkey cannot be NULL")?; - let rkey = reader.read_bigint_field()?.ok_or("bookmark_record.rkey cannot be NULL")?; - - Ok(Bookmark { - post_actor_id, - post_rkey, - rkey, - }) - } -} - -impl ToSql for Bookmark { - fn to_sql<'b>(&'b self, out: &mut Output<'b, '_, Pg>) -> serialize::Result { - CompositeWriter::write_header(out, 3)?; - let mut writer = CompositeWriter::with_output(out); - - writer.write_int_field(Some(self.post_actor_id))?; - writer.write_bigint_field(Some(self.post_rkey))?; - writer.write_bigint_field(Some(self.rkey))?; - - Ok(serialize::IsNull::No) - } -} - -// ============================================================================= -// ThreadMuteRecord - Thread mutes -// ============================================================================= - -#[derive(Debug, Clone, PartialEq, AsExpression)] -#[derive(serde::Serialize, serde::Deserialize)] -#[diesel(sql_type = ThreadMuteRecord)] -pub struct ThreadMute { - pub root_post_actor_id: i32, - pub root_post_rkey: i64, - pub created_at: DateTime, -} - -impl FromSql for ThreadMute { - fn from_sql(bytes: ::RawValue<'_>) -> deserialize::Result { - let bytes = bytes.as_bytes(); - let mut reader = CompositeReader::new(bytes); - - let field_count = reader.read_field_count()?; - if field_count != 3 { - return Err("thread_mute_record: expected 3 fields".into()); - } - - let root_post_actor_id = reader.read_int_field()?.ok_or("thread_mute_record.root_post_actor_id cannot be NULL")?; - let root_post_rkey = reader.read_bigint_field()?.ok_or("thread_mute_record.root_post_rkey cannot be NULL")?; - let created_at = reader.read_timestamptz_field()?.ok_or("thread_mute_record.created_at cannot be NULL")?; - - Ok(ThreadMute { - root_post_actor_id, - root_post_rkey, - created_at, - }) - } -} - -impl ToSql for ThreadMute { - fn to_sql<'b>(&'b self, out: &mut Output<'b, '_, Pg>) -> serialize::Result { - CompositeWriter::write_header(out, 3)?; - let mut writer = CompositeWriter::with_output(out); - - writer.write_int_field(Some(self.root_post_actor_id))?; - writer.write_bigint_field(Some(self.root_post_rkey))?; - writer.write_timestamptz_field(Some(&self.created_at))?; - - Ok(serialize::IsNull::No) - } -} - -// ============================================================================= -// ListMuteRecord - List mutes -// ============================================================================= - -#[derive(Debug, Clone, PartialEq, AsExpression)] -#[derive(serde::Serialize, serde::Deserialize)] -#[diesel(sql_type = ListMuteRecord)] -pub struct ListMute { - pub list_actor_id: i32, - pub list_rkey: String, - pub created_at: DateTime, -} - -impl FromSql for ListMute { - fn from_sql(bytes: ::RawValue<'_>) -> deserialize::Result { - let bytes = bytes.as_bytes(); - let mut reader = CompositeReader::new(bytes); - - let field_count = reader.read_field_count()?; - if field_count != 3 { - return Err("list_mute_record: expected 3 fields".into()); - } - - let list_actor_id = reader.read_int_field()?.ok_or("list_mute_record.list_actor_id cannot be NULL")?; - let list_rkey = reader.read_text_field()?.ok_or("list_mute_record.list_rkey cannot be NULL")?; - let created_at = reader.read_timestamptz_field()?.ok_or("list_mute_record.created_at cannot be NULL")?; - - Ok(ListMute { - list_actor_id, - list_rkey, - created_at, - }) - } -} - -impl ToSql for ListMute { - fn to_sql<'b>(&'b self, out: &mut Output<'b, '_, Pg>) -> serialize::Result { - CompositeWriter::write_header(out, 3)?; - let mut writer = CompositeWriter::with_output(out); - - writer.write_int_field(Some(self.list_actor_id))?; - writer.write_text_field(Some(&self.list_rkey))?; - writer.write_timestamptz_field(Some(&self.created_at))?; - - Ok(serialize::IsNull::No) - } -} - -// ============================================================================= -// ListBlockRecord - List blocks -// ============================================================================= - -#[derive(Debug, Clone, PartialEq, AsExpression)] -#[derive(serde::Serialize, serde::Deserialize)] -#[diesel(sql_type = ListBlockRecord)] -pub struct ListBlock { - pub list_actor_id: i32, - pub list_rkey: String, - pub rkey: i64, - pub cid: Vec, -} - -impl FromSql for ListBlock { - fn from_sql(bytes: ::RawValue<'_>) -> deserialize::Result { - let bytes = bytes.as_bytes(); - let mut reader = CompositeReader::new(bytes); - - let field_count = reader.read_field_count()?; - if field_count != 4 { - return Err("list_block_record: expected 4 fields".into()); - } - - let list_actor_id = reader.read_int_field()?.ok_or("list_block_record.list_actor_id cannot be NULL")?; - let list_rkey = reader.read_text_field()?.ok_or("list_block_record.list_rkey cannot be NULL")?; - let rkey = reader.read_bigint_field()?.ok_or("list_block_record.rkey cannot be NULL")?; - let cid = reader.read_bytea_field()?.ok_or("list_block_record.cid cannot be NULL")?; - - Ok(ListBlock { - list_actor_id, - list_rkey, - rkey, - cid, - }) - } -} - -impl ToSql for ListBlock { - fn to_sql<'b>(&'b self, out: &mut Output<'b, '_, Pg>) -> serialize::Result { - CompositeWriter::write_header(out, 4)?; - let mut writer = CompositeWriter::with_output(out); - - writer.write_int_field(Some(self.list_actor_id))?; - writer.write_text_field(Some(&self.list_rkey))?; - writer.write_bigint_field(Some(self.rkey))?; - writer.write_bytea_field(Some(&self.cid))?; - - Ok(serialize::IsNull::No) - } -} - -// ============================================================================= -// LabelerDefRecord - Labeler definitions -// ============================================================================= - -#[derive(Debug, Clone, PartialEq, AsExpression)] -#[derive(serde::Serialize, serde::Deserialize)] -#[diesel(sql_type = LabelerDefRecord)] -pub struct LabelerDef { - pub label_identifier: String, - pub severity: Option, - pub blurs: Option, - pub default_setting: Option, - pub adult_only: bool, - pub locales: Option, - pub created_at: DateTime, -} - -impl FromSql for LabelerDef { - fn from_sql(bytes: ::RawValue<'_>) -> deserialize::Result { - let bytes = bytes.as_bytes(); - let mut reader = CompositeReader::new(bytes); - - let field_count = reader.read_field_count()?; - if field_count != 7 { - return Err("labeler_def_record: expected 7 fields".into()); - } - - let label_identifier = reader.read_text_field()?.ok_or("labeler_def_record.label_identifier cannot be NULL")?; - let severity = reader.read_enum_field::()?; - let blurs = reader.read_enum_field::()?; - let default_setting = reader.read_enum_field::()?; - let adult_only = reader.read_bool_field()?.ok_or("labeler_def_record.adult_only cannot be NULL")?; - let locales = reader.read_jsonb_field()?; - let created_at = reader.read_timestamptz_field()?.ok_or("labeler_def_record.created_at cannot be NULL")?; - - Ok(LabelerDef { - label_identifier, - severity, - blurs, - default_setting, - adult_only, - locales, - created_at, - }) - } -} - -impl ToSql for LabelerDef { - fn to_sql<'b>(&'b self, out: &mut Output<'b, '_, Pg>) -> serialize::Result { - CompositeWriter::write_header(out, 7)?; - let mut writer = CompositeWriter::with_output(out); - - writer.write_text_field(Some(&self.label_identifier))?; - writer.write_enum_field(self.severity.as_ref())?; - writer.write_enum_field(self.blurs.as_ref())?; - writer.write_enum_field(self.default_setting.as_ref())?; - writer.write_bool_field(Some(self.adult_only))?; - writer.write_jsonb_field(self.locales.as_ref())?; - writer.write_timestamptz_field(Some(&self.created_at))?; - - Ok(serialize::IsNull::No) - } -} - -// ============================================================================= -// PostLabel & ActorLabel - Label arrays -// ============================================================================= - -#[derive(Debug, Clone, PartialEq, AsExpression)] -#[derive(serde::Serialize, serde::Deserialize)] -#[diesel(sql_type = PostLabel)] -pub struct PostLabelRecord { - pub labeler_actor_id: i32, - pub label: String, - pub created_at: DateTime, - pub negated: bool, - pub expires: Option>, -} - -impl FromSql for PostLabelRecord { - fn from_sql(bytes: ::RawValue<'_>) -> deserialize::Result { - let bytes = bytes.as_bytes(); - let mut reader = CompositeReader::new(bytes); - - let field_count = reader.read_field_count()?; - if field_count != 5 { - return Err("post_label: expected 5 fields".into()); - } - - let labeler_actor_id = reader.read_int_field()?.ok_or("post_label.labeler_actor_id cannot be NULL")?; - let label = reader.read_text_field()?.ok_or("post_label.label cannot be NULL")?; - let created_at = reader.read_timestamptz_field()?.ok_or("post_label.created_at cannot be NULL")?; - let negated = reader.read_bool_field()?.ok_or("post_label.negated cannot be NULL")?; - let expires = reader.read_timestamptz_field()?; - - Ok(PostLabelRecord { - labeler_actor_id, - label, - created_at, - negated, - expires, - }) - } -} - -impl ToSql for PostLabelRecord { - fn to_sql<'b>(&'b self, out: &mut Output<'b, '_, Pg>) -> serialize::Result { - CompositeWriter::write_header(out, 5)?; - let mut writer = CompositeWriter::with_output(out); - - writer.write_int_field(Some(self.labeler_actor_id))?; - writer.write_text_field(Some(&self.label))?; - writer.write_timestamptz_field(Some(&self.created_at))?; - writer.write_bool_field(Some(self.negated))?; - writer.write_timestamptz_field(self.expires.as_ref())?; - - Ok(serialize::IsNull::No) - } -} - -#[derive(Debug, Clone, PartialEq, AsExpression)] -#[derive(serde::Serialize, serde::Deserialize)] -#[diesel(sql_type = ActorLabel)] -pub struct ActorLabelRecord { - pub labeler_actor_id: i32, - pub label: String, - pub created_at: DateTime, - pub negated: bool, - pub expires: Option>, -} - -impl FromSql for ActorLabelRecord { - fn from_sql(bytes: ::RawValue<'_>) -> deserialize::Result { - let bytes = bytes.as_bytes(); - let mut reader = CompositeReader::new(bytes); - - let field_count = reader.read_field_count()?; - if field_count != 5 { - return Err("actor_label: expected 5 fields".into()); - } - - let labeler_actor_id = reader.read_int_field()?.ok_or("actor_label.labeler_actor_id cannot be NULL")?; - let label = reader.read_text_field()?.ok_or("actor_label.label cannot be NULL")?; - let created_at = reader.read_timestamptz_field()?.ok_or("actor_label.created_at cannot be NULL")?; - let negated = reader.read_bool_field()?.ok_or("actor_label.negated cannot be NULL")?; - let expires = reader.read_timestamptz_field()?; - - Ok(ActorLabelRecord { - labeler_actor_id, - label, - created_at, - negated, - expires, - }) - } -} - -impl ToSql for ActorLabelRecord { - fn to_sql<'b>(&'b self, out: &mut Output<'b, '_, Pg>) -> serialize::Result { - CompositeWriter::write_header(out, 5)?; - let mut writer = CompositeWriter::with_output(out); - - writer.write_int_field(Some(self.labeler_actor_id))?; - writer.write_text_field(Some(&self.label))?; - writer.write_timestamptz_field(Some(&self.created_at))?; - writer.write_bool_field(Some(self.negated))?; - writer.write_timestamptz_field(self.expires.as_ref())?; - - Ok(serialize::IsNull::No) - } -} diff --git a/parakeet-db/src/domain/aggregates/actor_profile.rs b/parakeet-db/src/domain/aggregates/actor_profile.rs new file mode 100644 index 00000000..f37bd1ba --- /dev/null +++ b/parakeet-db/src/domain/aggregates/actor_profile.rs @@ -0,0 +1,83 @@ +//! Actor Profile Aggregate - Actor with their relationships and stats + +use crate::domain::entities::Actor; +use crate::domain::value_objects::ProfileStats; + +/// Actor Profile Aggregate - Maintains consistency of actor and relationships +pub struct ActorProfile { + pub actor: Actor, + pub stats: ProfileStats, +} + +impl ActorProfile { + /// Create from an Actor entity + pub fn from_actor(actor: Actor) -> Self { + let stats = ProfileStats { + followers: actor.followers_count.unwrap_or(0), + following: actor.following_count.unwrap_or(0), + posts: actor.posts_count.unwrap_or(0), + lists: actor.lists_count.unwrap_or(0) as i32, + }; + + Self { actor, stats } + } + + /// Follow another actor (domain logic) + pub fn follow(&mut self, target_actor_id: i32) -> Result<(), DomainError> { + // Check invariants + if self.actor.id == target_actor_id { + return Err(DomainError::CannotFollowSelf); + } + + if self.actor.has_blocked(target_actor_id) { + return Err(DomainError::CannotFollowBlockedActor); + } + + // Update state + self.stats.following += 1; + self.actor.following_count = Some(self.stats.following); + + Ok(()) + } + + /// Unfollow an actor + pub fn unfollow(&mut self, target_actor_id: i32) -> Result<(), DomainError> { + if !self.actor.follows(target_actor_id) { + return Err(DomainError::NotFollowing); + } + + self.stats.following = (self.stats.following - 1).max(0); + self.actor.following_count = Some(self.stats.following); + + Ok(()) + } + + /// Block an actor + pub fn block(&mut self, target_actor_id: i32) -> Result<(), DomainError> { + if self.actor.id == target_actor_id { + return Err(DomainError::CannotBlockSelf); + } + + // Auto-unfollow when blocking + if self.actor.follows(target_actor_id) { + self.unfollow(target_actor_id)?; + } + + Ok(()) + } +} + +#[derive(Debug, thiserror::Error)] +pub enum DomainError { + #[error("Cannot follow yourself")] + CannotFollowSelf, + + #[error("Cannot follow a blocked actor")] + CannotFollowBlockedActor, + + #[error("Not following this actor")] + NotFollowing, + + #[error("Cannot block yourself")] + CannotBlockSelf, +} diff --git a/parakeet-db/src/domain/aggregates/post_thread.rs b/parakeet-db/src/domain/aggregates/post_thread.rs new file mode 100644 index 00000000..80b2a1a7 --- /dev/null +++ b/parakeet-db/src/domain/aggregates/post_thread.rs @@ -0,0 +1,87 @@ +//! Post Thread Aggregate - Post with its engagement and moderation + +use crate::domain::entities::{Actor, Post}; +use crate::domain::value_objects::PostStats; + +/// Post Thread Aggregate - Maintains consistency of post and engagement +pub struct PostThread { + pub post: Post, + pub author: Actor, + pub stats: PostStats, +} + +impl PostThread { + /// Create from Post and Actor entities + pub fn new(post: Post, author: Actor) -> Self { + let stats = PostStats::from_post(&post); + Self { post, author, stats } + } + + /// Add a like to this post + pub fn add_like(&mut self, liker: &Actor) -> Result<(), ThreadError> { + // Check invariants + if self.author.id == liker.id { + // Note: Self-likes are actually allowed in AT Protocol + } + + if self.post.is_liked_by(liker.id) { + return Err(ThreadError::AlreadyLiked); + } + + // Update counts + self.post.like_count += 1; + self.stats.likes += 1; + + Ok(()) + } + + /// Remove a like + pub fn remove_like(&mut self, liker: &Actor) -> Result<(), ThreadError> { + if !self.post.is_liked_by(liker.id) { + return Err(ThreadError::NotLiked); + } + + self.post.like_count = (self.post.like_count - 1).max(0); + self.stats.likes = (self.stats.likes - 1).max(0); + + Ok(()) + } + + /// Add a reply + pub fn add_reply(&mut self, replier: &Actor) -> Result<(), ThreadError> { + // Check threadgate rules + if !self.post.can_reply(replier.id) { + return Err(ThreadError::ThreadgateViolation); + } + + self.post.reply_count += 1; + self.stats.replies += 1; + + Ok(()) + } + + /// Add a repost + pub fn add_repost(&mut self, _reposter: &Actor) -> Result<(), ThreadError> { + self.post.repost_count += 1; + self.stats.reposts += 1; + + Ok(()) + } + + /// Check if post is available for viewing + pub fn is_viewable(&self) -> bool { + self.post.is_available() && self.author.is_active() + } +} + +#[derive(Debug, thiserror::Error)] +pub enum ThreadError { + #[error("Post already liked")] + AlreadyLiked, + + #[error("Post not liked")] + NotLiked, + + #[error("Threadgate rules prevent reply")] + ThreadgateViolation, +} \ No newline at end of file diff --git a/parakeet-db/src/domain/entities/actor.rs b/parakeet-db/src/domain/entities/actor.rs new file mode 100644 index 00000000..ac7b6b83 --- /dev/null +++ b/parakeet-db/src/domain/entities/actor.rs @@ -0,0 +1,210 @@ +//! Actor Entity - Core identity in the AT Protocol +//! +//! An Actor represents a user account in the AT Protocol network. +//! Actors are identified by their DID (Decentralized Identifier) which never changes. + +use crate::infrastructure::persistence::composite::{Block, Bookmark, Follow, LabelerDef, Mute}; +use crate::infrastructure::persistence::types::*; +use chrono::{DateTime, Utc}; +use diesel::prelude::*; + +/// Actor Entity - Represents a user account in the AT Protocol +/// +/// # Invariants +/// - DID must be valid and never changes +/// - Handle can change but must be a valid domain +/// - Status transitions must follow AT Protocol rules +#[derive(Debug, Clone, Queryable, Selectable, Identifiable)] +#[diesel(table_name = crate::infrastructure::schema::actors)] +#[diesel(primary_key(id))] +#[diesel(check_for_backend(diesel::pg::Pg))] +pub struct Actor { + // === Identity (Immutable) === + pub id: i32, // Internal database ID + pub did: String, // Decentralized Identifier (never changes) + + // === Mutable Attributes === + pub handle: Option, // Handle (can change) + pub status: ActorStatus, // active | takendown | suspended | deleted | deactivated + pub sync_state: ActorSyncState, // synced | dirty | partial | processing + + // === Repository State === + pub repo_rev: Option, // Repository revision + pub repo_cid: Option>, // Repository root CID (32 bytes) + pub last_indexed: Option>, + pub account_created_at: Option>, + + // === Profile (app.bsky.actor.profile) === + pub profile_cid: Option>, + pub profile_created_at: Option>, + pub profile_avatar_cid: Option>, + pub profile_banner_cid: Option>, + pub profile_display_name: Option, + pub profile_description: Option, + pub profile_pinned_post_rkey: Option, + pub profile_joined_sp_id: Option, + pub profile_pronouns: Option, + pub profile_website: Option, + + // === Status (app.bsky.actor.status) === + pub status_cid: Option>, + pub status_created_at: Option>, + pub status_type: Option, + pub status_duration: Option, + pub status_embed_post_actor_id: Option, + pub status_embed_post_rkey: Option, + pub status_thumb_mime_type: Option, + pub status_thumb_cid: Option>, + + // === Chat Settings === + pub chat_allow_incoming: Option, + pub chat_created_at: Option>, + + // === Notification Settings === + pub notif_decl_allow_subscriptions: Option, + pub notif_decl_created_at: Option>, + pub notif_seen_at: Option>, + pub notif_unread_count: Option, + + // === Statistics (Denormalized) === + pub followers_count: Option, + pub following_count: Option, + pub posts_count: Option, + pub lists_count: Option, + pub feeds_count: Option, + pub starterpacks_count: Option, + + // === Labeler Fields (NULL for non-labelers) === + pub labeler_cid: Option>, + pub labeler_created_at: Option>, + pub labeler_reasons: Option>>, + pub labeler_subject_types: Option>>, + pub labeler_subject_collections: Option>>, + pub labeler_status: Option, + pub labeler_like_count: Option, + pub labeler_defs: Option>>, + + // === Social Graph (Denormalized Arrays) === + pub following: Option>>, // Who this actor follows + pub followers: Option>>, // Who follows this actor + + // === User Preferences (Denormalized Arrays) === + pub mutes: Option>>, // Muted actors + pub blocks: Option>>, // Blocked actors + pub bookmarks: Option>>, // Bookmarked posts + + // === List Moderation (Denormalized Arrays) === + pub thread_mutes: Option>>, + pub list_mutes: Option>>, + pub list_blocks: Option>>, + + // === Content References === + pub post_rkeys: Option>>, // All post rkeys owned by this actor + pub repost_rkeys: Option>>, // All repost rkeys owned by this actor + + // === Labels === + pub labels: Option>>, +} + +// === Domain Logic === + +impl Actor { + /// Check if this actor is active + pub fn is_active(&self) -> bool { + matches!(self.status, ActorStatus::Active) + } + + /// Check if this actor is a labeler + pub fn is_labeler(&self) -> bool { + self.labeler_status.is_some() + && matches!(self.labeler_status, Some(LabelerStatus::Complete)) + } + + /// Check if this actor follows another actor + pub fn follows(&self, actor_id: i32) -> bool { + if let Some(ref following) = self.following { + following.iter().any(|f| { + if let Some(ref follow) = f { + follow.subject_actor_id == actor_id + } else { + false + } + }) + } else { + false + } + } + + /// Check if this actor is followed by another actor + pub fn is_followed_by(&self, actor_id: i32) -> bool { + if let Some(ref followers) = self.followers { + followers.iter().any(|f| { + if let Some(ref follow) = f { + // In followers array, the subject is the one following this actor + // So we need to check if any follower's actor_id matches + false // This needs the proper follow structure + } else { + false + } + }) + } else { + false + } + } + + /// Check if this actor has blocked another actor + pub fn has_blocked(&self, actor_id: i32) -> bool { + if let Some(ref blocks) = self.blocks { + blocks.iter().any(|b| { + if let Some(ref block) = b { + block.subject_actor_id == actor_id + } else { + false + } + }) + } else { + false + } + } + + /// Check if this actor has muted another actor + pub fn has_muted(&self, actor_id: i32) -> bool { + if let Some(ref mutes) = self.mutes { + mutes.iter().any(|m| { + if let Some(ref mute) = m { + mute.subject_actor_id == actor_id + } else { + false + } + }) + } else { + false + } + } + + /// Get the display name or fall back to handle + pub fn display_name_or_handle(&self) -> &str { + self.profile_display_name + .as_deref() + .filter(|s| !s.is_empty()) + .or(self.handle.as_deref()) + .unwrap_or(&self.did) + } + + /// Check if the actor can be synced + pub fn can_sync(&self) -> bool { + self.is_active() + && matches!( + self.sync_state, + ActorSyncState::Synced | ActorSyncState::Dirty + ) + } + + /// Check if the actor is on the allowlist (fully synced) + pub fn is_allowlisted(&self) -> bool { + matches!( + self.sync_state, + ActorSyncState::Synced | ActorSyncState::Dirty | ActorSyncState::Processing + ) + } +} diff --git a/parakeet-db/src/domain/entities/feed_gen.rs b/parakeet-db/src/domain/entities/feed_gen.rs new file mode 100644 index 00000000..911a518c --- /dev/null +++ b/parakeet-db/src/domain/entities/feed_gen.rs @@ -0,0 +1,29 @@ +//! FeedGen Entity - Custom feed generators + +use chrono::{DateTime, Utc}; +use diesel::prelude::*; +use crate::infrastructure::persistence::types::*; + +/// FeedGen Entity - Represents a custom feed generator +#[derive(Debug, Clone, Queryable, Selectable, Identifiable)] +#[diesel(table_name = crate::infrastructure::schema::feedgens)] +#[diesel(primary_key(actor_id, rkey))] +#[diesel(check_for_backend(diesel::pg::Pg))] +pub struct FeedGen { + pub actor_id: i32, + pub rkey: String, + pub cid: Vec, + pub created_at: DateTime, + pub owner_actor_id: i32, + pub service_actor_id: i32, + pub content_mode: Option, + pub name: Option, + pub description: Option, + pub description_facets: Option, + pub avatar_cid: Option>, + pub accepts_interactions: bool, + pub status: FeedgenStatus, + pub like_count: i32, + pub like_actor_ids: Option>>, + pub like_rkeys: Option>>, +} \ No newline at end of file diff --git a/parakeet-db/src/domain/entities/label.rs b/parakeet-db/src/domain/entities/label.rs new file mode 100644 index 00000000..3c5a4287 --- /dev/null +++ b/parakeet-db/src/domain/entities/label.rs @@ -0,0 +1,21 @@ +//! Label Entity - Moderation labels + +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use crate::infrastructure::persistence::types::*; + +/// Label - Currently stored as denormalized arrays on actors/posts +/// This is a placeholder for future label table implementation +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Label { + pub id: i64, + pub labeler_actor_id: i32, + pub subject_type: SubjectType, + pub subject_actor_id: Option, + pub subject_post_actor_id: Option, + pub subject_post_rkey: Option, + pub label: String, + pub neg: bool, + pub created_at: DateTime, + pub expires_at: Option>, +} \ No newline at end of file diff --git a/parakeet-db/src/domain/entities/list.rs b/parakeet-db/src/domain/entities/list.rs new file mode 100644 index 00000000..628962d2 --- /dev/null +++ b/parakeet-db/src/domain/entities/list.rs @@ -0,0 +1,70 @@ +//! List Entity - Curated lists, mod lists, and reference lists +//! +//! Lists are collections of actors created by users for various purposes. + +use diesel::prelude::*; +use serde::{Deserialize, Serialize}; +use crate::infrastructure::persistence::types::*; +use crate::domain::value_objects::tid_traits::{HasTidRkey, tid_to_i64}; +use crate::{impl_tid_rkey, impl_tid_created_at}; +use chrono::{DateTime, Utc}; + +/// List Entity - Represents a curated or moderation list +#[derive(Debug, Clone, Serialize, Deserialize, Queryable, Selectable, Identifiable)] +#[diesel(table_name = crate::infrastructure::schema::lists)] +#[diesel(primary_key(actor_id, rkey))] +#[diesel(check_for_backend(diesel::pg::Pg))] +pub struct List { + pub actor_id: i32, // Creator of the list + pub rkey: String, // Record key (TID or custom string) + pub cid: Vec, // Content identifier + pub owner_actor_id: i32, // Owner (denormalized) + pub list_type: Option, // curatelist | modlist | referencelist + pub name: Option, + pub description: Option, + pub description_facets: Option, + pub avatar_cid: Option>, + pub status: RecordStatus, +} + +/// List Item - Member of a list +#[derive(Debug, Queryable, Selectable, Identifiable)] +#[diesel(table_name = crate::infrastructure::schema::list_items)] +#[diesel(primary_key(actor_id, rkey))] +#[diesel(check_for_backend(diesel::pg::Pg))] +pub struct ListItem { + pub actor_id: i32, // List item creator + pub rkey: i64, // Item record key + pub cid: Vec, + pub subject_actor_id: i32, // Actor in the list + pub list_owner_actor_id: i32, // List owner + pub list_rkey: String, // List identifier +} + +impl List { + /// Check if this is a moderation list + pub fn is_mod_list(&self) -> bool { + matches!(self.list_type, Some(ListType::ModList)) + } + + /// Check if this is a curated list + pub fn is_curate_list(&self) -> bool { + matches!(self.list_type, Some(ListType::CurateList)) + } + + /// Check if this list is active + pub fn is_active(&self) -> bool { + matches!(self.status, RecordStatus::Complete) + } + + /// Get created_at timestamp derived from rkey if it's a TID, or epoch for non-TID rkeys + pub fn created_at(&self) -> DateTime { + tid_to_i64(&self.rkey) + .map(crate::domain::value_objects::tid::tid_to_datetime) + .unwrap_or_else(|_| DateTime::::UNIX_EPOCH) + } +} + +// Implement TID traits for ListItem +impl_tid_rkey!(ListItem); +impl_tid_created_at!(ListItem); \ No newline at end of file diff --git a/parakeet-db/src/notifications.rs b/parakeet-db/src/domain/entities/notification.rs similarity index 87% rename from parakeet-db/src/notifications.rs rename to parakeet-db/src/domain/entities/notification.rs index 0bab96cf..f749fd91 100644 --- a/parakeet-db/src/notifications.rs +++ b/parakeet-db/src/domain/entities/notification.rs @@ -1,7 +1,4 @@ -//! Shared notification types and queries -//! -//! This module provides types and database operations for notifications that are shared -//! between the consumer (write operations) and parakeet (read operations). +//! Notification Entity //! //! Notifications are stored in a normalized format: //! - Actor IDs instead of DIDs (join with actors table to get DIDs) @@ -11,10 +8,10 @@ //! - CID digests stored as BYTEA (32 bytes) instead of full base32 strings use chrono::{DateTime, Utc}; +use diesel::prelude::*; use serde::{Deserialize, Serialize}; -use crate::types::{NotificationReason, NotificationRecordType}; -use crate::tid_util::encode_tid; -use crate::cid_util::digest_to_record_cid_string; +use crate::infrastructure::persistence::types::*; +use crate::domain::value_objects::{tid::encode_tid, cid::digest_to_record_cid_string}; /// Minimal notification stored in database (normalized format) /// @@ -22,8 +19,8 @@ use crate::cid_util::digest_to_record_cid_string; /// 1. JOINs with actors table to get DIDs /// 2. Reconstructs AT-URIs from components /// 3. Hydrates author profiles and record data when serving to clients -#[derive(Debug, Clone, Serialize, Deserialize, diesel::Queryable, diesel::Selectable)] -#[diesel(table_name = crate::schema::notifications)] +#[derive(Debug, Clone, Serialize, Deserialize, Queryable, Selectable)] +#[diesel(table_name = crate::infrastructure::schema::notifications)] #[diesel(check_for_backend(diesel::pg::Pg))] pub struct Notification { /// Database ID @@ -128,15 +125,7 @@ impl Notification { } } -/// Notification state for a user (normalized) -#[derive(Debug, Clone)] -pub struct NotificationState { - pub actor_id: i32, - pub seen_at: Option>, - pub unread_count: i32, -} - -/// Notification reason types +/// Notification reason constants pub mod reasons { pub const LIKE: &str = "like"; pub const REPOST: &str = "repost"; @@ -144,4 +133,4 @@ pub mod reasons { pub const REPLY: &str = "reply"; pub const MENTION: &str = "mention"; pub const QUOTE: &str = "quote"; -} +} \ No newline at end of file diff --git a/parakeet-db/src/domain/entities/post.rs b/parakeet-db/src/domain/entities/post.rs new file mode 100644 index 00000000..1ad08361 --- /dev/null +++ b/parakeet-db/src/domain/entities/post.rs @@ -0,0 +1,208 @@ +//! Post Entity - Content created by actors in the AT Protocol +//! +//! A Post represents a piece of content (text, images, etc.) created by an actor. +//! Posts are identified by their actor_id + rkey combination. + +use diesel::prelude::*; +use serde::{Deserialize, Serialize}; +use crate::infrastructure::persistence::arrays; +use crate::infrastructure::persistence::composite::{ExtEmbed, VideoEmbed, ImageEmbed, FacetEmbed, PostLabelRecord}; +use crate::infrastructure::persistence::types::*; +use crate::domain::value_objects::tid; +use crate::domain::value_objects::tid_traits::HasTidRkey; +use crate::{impl_tid_rkey, impl_tid_created_at}; + +/// Post Entity - Represents content in the AT Protocol +/// +/// # Invariants +/// - actor_id + rkey combination must be unique +/// - Parent/root references must form valid thread structures +/// - Engagement counts must match array lengths when arrays are present +#[derive(Clone, Debug, Serialize, Deserialize, Queryable, Selectable, Identifiable)] +#[diesel(table_name = crate::infrastructure::schema::posts)] +#[diesel(primary_key(actor_id, rkey))] +#[diesel(check_for_backend(diesel::pg::Pg))] +pub struct Post { + // === Identity === + pub actor_id: i32, // Author of the post + pub rkey: i64, // Record key (TID as i64) + pub cid: Vec, // Content identifier (32-byte digest) + + // === Content === + pub content: Option>, // Zstd-compressed post content + pub langs: arrays::LanguageCodeArray, // Language codes (ISO 639-1) + pub tags: arrays::TextArray, // Hashtags + pub tokens: Option, // Search tokens + + // === Embeds === + pub embed_type: Option, + pub embed_subtype: Option, + pub ext_embed: Option, + pub video_embed: Option, + pub image_1: Option, + pub image_2: Option, + pub image_3: Option, + pub image_4: Option, + + // === Facets (rich text) === + pub facet_1: Option, + pub facet_2: Option, + pub facet_3: Option, + pub facet_4: Option, + pub facet_5: Option, + pub facet_6: Option, + pub facet_7: Option, + pub facet_8: Option, + pub mentions: Option>>, + + // === Thread Structure === + pub parent_post_actor_id: Option, + pub parent_post_rkey: Option, + pub root_post_actor_id: Option, + pub root_post_rkey: Option, + pub embedded_post_actor_id: Option, // Quote post + pub embedded_post_rkey: Option, + + // === Status === + pub status: PostStatus, + pub violates_threadgate: bool, + pub record_detached: Option, + + // === Engagement Arrays (Denormalized) === + pub like_actor_ids: Option>>, + pub like_rkeys: Option>>, + pub like_via_repost_data: Option, + pub reply_actor_ids: Option>>, + pub reply_rkeys: Option>>, + pub quote_actor_ids: Option>>, + pub quote_rkeys: Option>>, + pub repost_actor_ids: Option>>, + pub repost_rkeys: Option>>, + + // === Moderation === + pub threadgate_allow: Option, + pub threadgate_hidden_actor_ids: Option>>, + pub threadgate_hidden_rkeys: Option>>, + pub postgate_rules: Option, + pub postgate_detached_actor_ids: Option>>, + pub postgate_detached_rkeys: Option>>, + pub labels: Option>>, + + // === Engagement Counts === + pub like_count: i32, + pub repost_count: i32, + pub reply_count: i32, + pub quote_count: i32, +} + +/// Information about a like on a post +#[derive(Debug, Clone)] +pub struct PostLikeInfo { + pub actor_id: i32, + pub rkey: i64, + pub via_repost_actor_id: Option, + pub via_repost_rkey: Option, +} + +// === Domain Logic === + +impl Post { + /// Check if this is a reply + pub fn is_reply(&self) -> bool { + self.parent_post_actor_id.is_some() + } + + /// Check if this is a quote post + pub fn is_quote(&self) -> bool { + self.embedded_post_actor_id.is_some() + } + + /// Check if this post has images + pub fn has_images(&self) -> bool { + self.image_1.is_some() || + matches!(self.embed_type, Some(EmbedType::Images)) + } + + /// Check if this post has video + pub fn has_video(&self) -> bool { + self.video_embed.is_some() || + matches!(self.embed_type, Some(EmbedType::Video)) + } + + /// Count the number of images + pub fn image_count(&self) -> usize { + let mut count = 0; + if self.image_1.is_some() { count += 1; } + if self.image_2.is_some() { count += 1; } + if self.image_3.is_some() { count += 1; } + if self.image_4.is_some() { count += 1; } + count + } + + /// Get a specific like by index + pub fn get_like(&self, idx: usize) -> Option { + let actor_ids = self.like_actor_ids.as_ref()?; + let rkeys = self.like_rkeys.as_ref()?; + + let actor_id = *actor_ids.get(idx)?.as_ref()?; + let rkey = *rkeys.get(idx)?.as_ref()?; + + // Extract via_repost from JSONB if it exists + let (via_repost_actor_id, via_repost_rkey) = self + .like_via_repost_data + .as_ref() + .and_then(|json| json.get(actor_id.to_string())) + .and_then(|data| { + let actor = data.get("actor_id")?.as_i64()? as i32; + let rkey = data.get("rkey")?.as_i64()?; + Some((Some(actor), Some(rkey))) + }) + .unwrap_or((None, None)); + + Some(PostLikeInfo { + actor_id, + rkey, + via_repost_actor_id, + via_repost_rkey, + }) + } + + /// Check if a specific actor has liked this post + pub fn is_liked_by(&self, actor_id: i32) -> bool { + if let Some(ref like_actors) = self.like_actor_ids { + like_actors.iter().any(|a| a == &Some(actor_id)) + } else { + false + } + } + + /// Check if a specific actor has reposted this post + pub fn is_reposted_by(&self, actor_id: i32) -> bool { + if let Some(ref repost_actors) = self.repost_actor_ids { + repost_actors.iter().any(|a| a == &Some(actor_id)) + } else { + false + } + } + + /// Check if this post is available (not deleted/forbidden) + pub fn is_available(&self) -> bool { + matches!(self.status, PostStatus::Complete | PostStatus::Stub) + } + + /// Check if threadgate allows a specific actor to reply + pub fn can_reply(&self, _actor_id: i32) -> bool { + // If no threadgate, anyone can reply + if self.threadgate_allow.is_none() { + return true; + } + + // TODO: Implement threadgate rule checking + // This would need to check the rules array + true + } +} + +// Implement TID traits for Post +impl_tid_rkey!(Post); +impl_tid_created_at!(Post); \ No newline at end of file diff --git a/parakeet-db/src/domain/entities/starter_pack.rs b/parakeet-db/src/domain/entities/starter_pack.rs new file mode 100644 index 00000000..8ac78b3e --- /dev/null +++ b/parakeet-db/src/domain/entities/starter_pack.rs @@ -0,0 +1,39 @@ +//! StarterPack Entity - Onboarding starter packs + +use diesel::prelude::*; +use serde::{Deserialize, Serialize}; +use crate::infrastructure::persistence::types::*; +use crate::domain::value_objects::tid_traits::HasTidRkey; +use crate::{impl_tid_rkey, impl_tid_created_at}; + +#[derive(Debug, Clone, Serialize, Deserialize, Queryable, Selectable, Identifiable)] +#[diesel(table_name = crate::infrastructure::schema::starterpacks)] +#[diesel(primary_key(actor_id, rkey))] +#[diesel(check_for_backend(diesel::pg::Pg))] +pub struct StarterPack { + pub actor_id: i32, + pub rkey: i64, + pub cid: Vec, + pub owner_actor_id: i32, + pub name: Option, + pub description: Option, + pub description_facets: Option, + pub list_actor_id: Option, + pub list_rkey: Option, + pub status: RecordStatus, +} + +#[derive(Debug, Clone, Queryable, Selectable)] +#[diesel(table_name = crate::infrastructure::schema::starterpack_feeds)] +#[diesel(primary_key(starterpack_id, position))] +#[diesel(check_for_backend(diesel::pg::Pg))] +pub struct StarterPackFeed { + pub starterpack_id: i64, + pub position: i16, + pub feed_actor_id: i32, + pub feed_rkey: String, +} + +// Implement TID traits for StarterPack +impl_tid_rkey!(StarterPack); +impl_tid_created_at!(StarterPack); \ No newline at end of file diff --git a/parakeet-db/src/domain/entities/verification.rs b/parakeet-db/src/domain/entities/verification.rs new file mode 100644 index 00000000..401777e3 --- /dev/null +++ b/parakeet-db/src/domain/entities/verification.rs @@ -0,0 +1,56 @@ +//! Verification Entity - Account verification records +//! +//! Verifications represent account verification relationships between actors. + +use diesel::prelude::*; +use serde::{Deserialize, Serialize}; +use crate::domain::value_objects::tid_traits::HasTidRkey; +use crate::{impl_tid_rkey, impl_tid_created_at}; + +/// Verification Entity - Account verification record +#[derive(Clone, Debug, Serialize, Deserialize, Queryable, Selectable, Identifiable)] +#[diesel(table_name = crate::infrastructure::schema::verification)] +#[diesel(primary_key(id))] +#[diesel(check_for_backend(diesel::pg::Pg))] +pub struct Verification { + /// Unique identifier + pub id: i64, + + /// Actor being verified + pub actor_id: i32, + + /// Record key (TID as INT8) + pub rkey: i64, + + /// Content identifier (32-byte CID digest) + pub cid: Vec, + + /// Actor who performed the verification + pub verifier_actor_id: i32, + + /// Subject actor of the verification + pub subject_actor_id: i32, + + /// Handle being verified + pub handle: String, + + /// Display name + pub display_name: String, +} + +impl Verification { + /// Check if this verification is by the same actor (self-verification) + pub fn is_self_verification(&self) -> bool { + self.actor_id == self.verifier_actor_id + } + + /// Get CID as base32 string + pub fn cid_str(&self) -> String { + crate::domain::value_objects::cid::digest_to_record_cid_string(&self.cid) + .unwrap_or_else(|| "invalid_cid".to_string()) + } +} + +// Implement TID traits for Verification +impl_tid_rkey!(Verification); +impl_tid_created_at!(Verification); \ No newline at end of file diff --git a/parakeet-db/src/utils/at_uri.rs b/parakeet-db/src/domain/value_objects/at_uri.rs similarity index 100% rename from parakeet-db/src/utils/at_uri.rs rename to parakeet-db/src/domain/value_objects/at_uri.rs diff --git a/parakeet-db/src/utils/cid.rs b/parakeet-db/src/domain/value_objects/cid.rs similarity index 82% rename from parakeet-db/src/utils/cid.rs rename to parakeet-db/src/domain/value_objects/cid.rs index b3afb36d..0f4a7e7d 100644 --- a/parakeet-db/src/utils/cid.rs +++ b/parakeet-db/src/domain/value_objects/cid.rs @@ -146,178 +146,72 @@ pub fn digest_to_record_cid_string(digest: &[u8]) -> Option { Some(multibase::encode(multibase::Base::Base32Lower, &full_cid)) } -/// Generate a deterministic 32-byte digest for a like record +/// Generate a deterministic CID digest for any record type /// -/// Creates a synthetic CID digest based on the like's unique identifiers (actor_id, rkey). -/// This allows us to generate protocol-compliant CIDs without storing the actual record CID. -/// -/// The digest is computed as: SHA-256("parakeet:like:{actor_id}:{rkey}") -/// -/// # Arguments -/// * `actor_id` - The actor who created the like -/// * `rkey` - The like's rkey (TID as i64) -/// -/// # Returns -/// * `Vec` - A deterministic 32-byte SHA-256 digest -pub fn generate_like_cid_digest(actor_id: i32, rkey: i64) -> Vec { +/// Computes SHA-256("parakeet:{record_type}:{actor_id}:{rkey}") +#[inline] +fn generate_cid_digest(record_type: &str, actor_id: i32, rkey: i64) -> Vec { use sha2::{Digest, Sha256}; - let data = format!("parakeet:like:{}:{}", actor_id, rkey); + let data = format!("parakeet:{}:{}:{}", record_type, actor_id, rkey); Sha256::digest(data.as_bytes()).to_vec() } -/// Generate a synthetic CID string for a like record -/// -/// Creates a protocol-compliant CID string (bafyrei...) without requiring the actual -/// record content. The CID is deterministic based on the like's (actor_id, rkey) pair. -/// -/// # Arguments -/// * `actor_id` - The actor who created the like -/// * `rkey` - The like's rkey (TID as i64) -/// -/// # Returns -/// * `String` - A base32-encoded record CID string (e.g., "bafyrei...") -/// -/// # Example -/// ``` -/// use parakeet_db::cid_util::like_cid_string; -/// let cid = like_cid_string(12345, 3000000000000); -/// assert!(cid.starts_with("bafyrei")); -/// ``` +/// Generate a deterministic digest for a like record +pub fn generate_like_cid_digest(actor_id: i32, rkey: i64) -> Vec { + generate_cid_digest("like", actor_id, rkey) +} + +/// Generate a synthetic CID string for a like record (returns "bafyrei...") pub fn like_cid_string(actor_id: i32, rkey: i64) -> String { let digest = generate_like_cid_digest(actor_id, rkey); digest_to_record_cid_string(&digest) .unwrap_or_else(|| String::from("bafyrei_invalid_cid")) } -/// Generate a deterministic 32-byte digest for a repost record -/// -/// Creates a synthetic CID digest based on the repost's unique identifiers (actor_id, rkey). -/// This allows us to generate protocol-compliant CIDs without storing the actual record CID. -/// -/// The digest is computed as: SHA-256("parakeet:repost:{actor_id}:{rkey}") -/// -/// # Arguments -/// * `actor_id` - The actor who created the repost -/// * `rkey` - The repost's rkey (TID as i64) -/// -/// # Returns -/// * `Vec` - A deterministic 32-byte SHA-256 digest +/// Generate a deterministic digest for a repost record pub fn generate_repost_cid_digest(actor_id: i32, rkey: i64) -> Vec { - use sha2::{Digest, Sha256}; - let data = format!("parakeet:repost:{}:{}", actor_id, rkey); - Sha256::digest(data.as_bytes()).to_vec() + generate_cid_digest("repost", actor_id, rkey) } -/// Generate a synthetic CID string for a repost record -/// -/// Creates a protocol-compliant CID string (bafyrei...) without requiring the actual -/// record content. The CID is deterministic based on the repost's (actor_id, rkey) pair. -/// -/// # Arguments -/// * `actor_id` - The actor who created the repost -/// * `rkey` - The repost's rkey (TID as i64) -/// -/// # Returns -/// * `String` - A base32-encoded record CID string (e.g., "bafyrei...") +/// Generate a synthetic CID string for a repost record (returns "bafyrei...") pub fn repost_cid_string(actor_id: i32, rkey: i64) -> String { let digest = generate_repost_cid_digest(actor_id, rkey); digest_to_record_cid_string(&digest) .unwrap_or_else(|| String::from("bafyrei_invalid_cid")) } -/// Generate a deterministic 32-byte digest for a follow record -/// -/// Creates a synthetic CID digest based on the follow's unique identifiers (actor_id, rkey). -/// -/// The digest is computed as: SHA-256("parakeet:follow:{actor_id}:{rkey}") -/// -/// # Arguments -/// * `actor_id` - The actor who created the follow -/// * `rkey` - The follow's rkey (TID as i64) -/// -/// # Returns -/// * `Vec` - A deterministic 32-byte SHA-256 digest +/// Generate a deterministic digest for a follow record pub fn generate_follow_cid_digest(actor_id: i32, rkey: i64) -> Vec { - use sha2::{Digest, Sha256}; - let data = format!("parakeet:follow:{}:{}", actor_id, rkey); - Sha256::digest(data.as_bytes()).to_vec() + generate_cid_digest("follow", actor_id, rkey) } -/// Generate a synthetic CID string for a follow record -/// -/// # Arguments -/// * `actor_id` - The actor who created the follow -/// * `rkey` - The follow's rkey (TID as i64) -/// -/// # Returns -/// * `String` - A base32-encoded record CID string (e.g., "bafyrei...") +/// Generate a synthetic CID string for a follow record (returns "bafyrei...") pub fn follow_cid_string(actor_id: i32, rkey: i64) -> String { let digest = generate_follow_cid_digest(actor_id, rkey); digest_to_record_cid_string(&digest) .unwrap_or_else(|| String::from("bafyrei_invalid_cid")) } -/// Generate a deterministic 32-byte digest for a block record -/// -/// Creates a synthetic CID digest based on the block's unique identifiers (actor_id, rkey). -/// -/// The digest is computed as: SHA-256("parakeet:block:{actor_id}:{rkey}") -/// -/// # Arguments -/// * `actor_id` - The actor who created the block -/// * `rkey` - The block's rkey (TID as i64) -/// -/// # Returns -/// * `Vec` - A deterministic 32-byte SHA-256 digest +/// Generate a deterministic digest for a block record pub fn generate_block_cid_digest(actor_id: i32, rkey: i64) -> Vec { - use sha2::{Digest, Sha256}; - let data = format!("parakeet:block:{}:{}", actor_id, rkey); - Sha256::digest(data.as_bytes()).to_vec() + generate_cid_digest("block", actor_id, rkey) } -/// Generate a synthetic CID string for a block record -/// -/// # Arguments -/// * `actor_id` - The actor who created the block -/// * `rkey` - The block's rkey (TID as i64) -/// -/// # Returns -/// * `String` - A base32-encoded record CID string (e.g., "bafyrei...") +/// Generate a synthetic CID string for a block record (returns "bafyrei...") pub fn block_cid_string(actor_id: i32, rkey: i64) -> String { let digest = generate_block_cid_digest(actor_id, rkey); digest_to_record_cid_string(&digest) .unwrap_or_else(|| String::from("bafyrei_invalid_cid")) } -/// Generate a deterministic 32-byte digest for a post record -/// -/// Creates a synthetic CID digest based on the post's unique identifiers (actor_id, rkey). -/// -/// The digest is computed as: SHA-256("parakeet:post:{actor_id}:{rkey}") -/// -/// Note: This is a simplified synthetic CID. Real posts would ideally hash the content, -/// but for AppView purposes, the (actor_id, rkey) pair is sufficient for uniqueness. -/// -/// # Arguments -/// * `actor_id` - The actor who created the post -/// * `rkey` - The post's rkey (TID as i64) +/// Generate a deterministic digest for a post record /// -/// # Returns -/// * `Vec` - A deterministic 32-byte SHA-256 digest +/// Note: This is a synthetic CID based on (actor_id, rkey) for AppView purposes. pub fn generate_post_cid_digest(actor_id: i32, rkey: i64) -> Vec { - use sha2::{Digest, Sha256}; - let data = format!("parakeet:post:{}:{}", actor_id, rkey); - Sha256::digest(data.as_bytes()).to_vec() + generate_cid_digest("post", actor_id, rkey) } -/// Generate a synthetic CID string for a post record -/// -/// # Arguments -/// * `actor_id` - The actor who created the post -/// * `rkey` - The post's rkey (TID as i64) -/// -/// # Returns -/// * `String` - A base32-encoded record CID string (e.g., "bafyrei...") +/// Generate a synthetic CID string for a post record (returns "bafyrei...") pub fn post_cid_string(actor_id: i32, rkey: i64) -> String { let digest = generate_post_cid_digest(actor_id, rkey); digest_to_record_cid_string(&digest) diff --git a/parakeet-db/src/domain/value_objects/repost.rs b/parakeet-db/src/domain/value_objects/repost.rs new file mode 100644 index 00000000..5a256532 --- /dev/null +++ b/parakeet-db/src/domain/value_objects/repost.rs @@ -0,0 +1,26 @@ +//! Repost - Actually an entity, but lightweight + +use crate::domain::value_objects::tid_traits::HasTidRkey; +use crate::infrastructure::persistence::types::*; +use crate::{impl_tid_created_at, impl_tid_rkey}; +use diesel::prelude::*; + +/// Repost Entity - Represents a repost of a post +#[derive(Debug, Clone, Queryable, Selectable, Identifiable)] +#[diesel(table_name = crate::infrastructure::schema::reposts)] +#[diesel(primary_key(actor_id, rkey))] +#[diesel(check_for_backend(diesel::pg::Pg))] +pub struct Repost { + pub actor_id: i32, + pub rkey: i64, + pub cid: Vec, + pub post_actor_id: i32, + pub post_rkey: i64, + pub via_repost_actor_id: Option, + pub via_repost_rkey: Option, + pub status: RepostStatus, +} + +// Implement TID traits for Repost +impl_tid_rkey!(Repost); +impl_tid_created_at!(Repost); diff --git a/parakeet-db/src/domain/value_objects/stats.rs b/parakeet-db/src/domain/value_objects/stats.rs new file mode 100644 index 00000000..04ca836a --- /dev/null +++ b/parakeet-db/src/domain/value_objects/stats.rs @@ -0,0 +1,50 @@ +//! Statistics Value Objects + +use crate::domain::entities::Post; + +/// Post engagement statistics +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct PostStats { + pub likes: i32, + pub replies: i32, + pub reposts: i32, + pub quotes: i32, +} + +impl PostStats { + /// Create stats from a Post entity + pub fn from_post(post: &Post) -> Self { + Self { + likes: post.like_count, + replies: post.reply_count, + reposts: post.repost_count, + quotes: post.quote_count, + } + } + + /// Total engagement count + pub fn total_engagement(&self) -> i32 { + self.likes + self.replies + self.reposts + self.quotes + } +} + +/// Actor profile statistics +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct ProfileStats { + pub followers: i32, + pub following: i32, + pub posts: i32, + pub lists: i32, +} + +impl ProfileStats { + /// Create empty stats + pub fn empty() -> Self { + Self { + followers: 0, + following: 0, + posts: 0, + lists: 0, + } + } +} \ No newline at end of file diff --git a/parakeet-db/src/domain/value_objects/threadgate.rs b/parakeet-db/src/domain/value_objects/threadgate.rs new file mode 100644 index 00000000..b79503ea --- /dev/null +++ b/parakeet-db/src/domain/value_objects/threadgate.rs @@ -0,0 +1,15 @@ +//! Threadgate Value Object - Reply restrictions for posts + +use serde::{Deserialize, Serialize}; + +/// Threadgate - Controls who can reply to a post +/// This is a value object used for denormalized data, not a database table +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct Threadgate { + pub actor_id: i32, + pub rkey: i64, + pub cid: Vec, + pub allow: Option, + pub post_actor_id: i32, + pub post_rkey: i64, +} diff --git a/parakeet-db/src/utils/tid.rs b/parakeet-db/src/domain/value_objects/tid.rs similarity index 98% rename from parakeet-db/src/utils/tid.rs rename to parakeet-db/src/domain/value_objects/tid.rs index 73cf9f20..e81c2ea5 100644 --- a/parakeet-db/src/utils/tid.rs +++ b/parakeet-db/src/domain/value_objects/tid.rs @@ -143,7 +143,7 @@ impl std::error::Error for TidError {} /// /// # Example /// ``` -/// use parakeet_db::tid_util::is_valid_tid; +/// use parakeet_db::utils::tid::is_valid_tid; /// /// assert!(is_valid_tid("3m4fsspghex2n")); /// assert!(!is_valid_tid("invalid")); @@ -200,7 +200,7 @@ pub fn is_valid_tid(tid: &str) -> bool { /// /// # Example /// ``` -/// use parakeet_db::tid_util::decode_tid; +/// use parakeet_db::utils::tid::decode_tid; /// /// let tid = "3m4fsspghex2n"; /// match decode_tid(tid) { @@ -266,7 +266,7 @@ pub fn decode_tid(tid: &str) -> Result { /// /// # Example /// ``` -/// use parakeet_db::tid_util::encode_tid; +/// use parakeet_db::utils::tid::encode_tid; /// /// let value = 1234567890i64; /// let tid = encode_tid(value); @@ -301,7 +301,7 @@ pub fn encode_tid(mut value: i64) -> String { /// /// # Example /// ``` -/// use parakeet_db::tid_util::{decode_tid, tid_to_timestamp_micros}; +/// use parakeet_db::utils::tid::{decode_tid, tid_to_timestamp_micros}; /// /// let tid = decode_tid("3m4fsspghex2n").unwrap(); /// let micros = tid_to_timestamp_micros(tid); @@ -325,7 +325,7 @@ pub fn tid_to_timestamp_micros(tid: i64) -> i64 { /// /// # Example /// ``` -/// use parakeet_db::tid_util::{decode_tid, tid_to_datetime}; +/// use parakeet_db::utils::tid::{decode_tid, tid_to_datetime}; /// /// let tid = decode_tid("3m4fsspghex2n").unwrap(); /// let dt = tid_to_datetime(tid); @@ -353,7 +353,7 @@ pub fn tid_to_datetime(tid: i64) -> chrono::DateTime { /// /// # Example /// ``` -/// use parakeet_db::tid_util::tid_string_to_datetime; +/// use parakeet_db::utils::tid::tid_string_to_datetime; /// /// match tid_string_to_datetime("3m4fsspghex2n") { /// Ok(dt) => println!("TID created at: {}", dt), @@ -378,7 +378,7 @@ pub fn tid_string_to_datetime(tid_str: &str) -> Result) -> String { /// /// # Example /// ``` -/// use parakeet_db::tid_util::is_tid_older_than; +/// use parakeet_db::utils::tid::is_tid_older_than; /// use chrono::{Utc, Duration}; /// /// let one_week_ago = Utc::now() - Duration::days(7); diff --git a/parakeet-db/src/domain/value_objects/tid_traits.rs b/parakeet-db/src/domain/value_objects/tid_traits.rs new file mode 100644 index 00000000..04d397e1 --- /dev/null +++ b/parakeet-db/src/domain/value_objects/tid_traits.rs @@ -0,0 +1,60 @@ +//! TID-related traits and helpers for models with TID-based rkeys +//! +//! This module provides traits and macros for models that use TID-based rkeys, +//! allowing them to convert between i64 and TID string representations. + +use super::tid::{decode_tid, encode_tid, tid_to_datetime, TidError}; +use chrono::{DateTime, Utc}; + +/// Trait for models that have a TID-based rkey (stored as i64) +pub trait HasTidRkey { + /// Get the rkey as a TID string + fn rkey_str(&self) -> String; + + /// Get the rkey as i64 + fn rkey_i64(&self) -> i64; +} + +/// Helper to decode a TID string to i64 for use in queries/inserts +pub fn tid_to_i64(tid: &str) -> Result { + decode_tid(tid) +} + +/// Helper to encode an i64 rkey to TID string for use in AT URIs +pub fn i64_to_tid(value: i64) -> String { + encode_tid(value) +} + +/// Macro to implement HasTidRkey trait for models +#[macro_export] +macro_rules! impl_tid_rkey { + ($model:ty) => { + impl $crate::domain::value_objects::tid_traits::HasTidRkey for $model { + fn rkey_str(&self) -> String { + $crate::domain::value_objects::tid::encode_tid(self.rkey) + } + + fn rkey_i64(&self) -> i64 { + self.rkey + } + } + }; +} + +/// Macro to implement created_at() method for TID-based models +/// This derives the timestamp from the TID rkey, eliminating need for separate created_at column +#[macro_export] +macro_rules! impl_tid_created_at { + ($model:ty) => { + impl $model { + /// Get created_at timestamp derived from TID rkey + /// + /// TIDs encode a 53-bit microsecond timestamp in their upper bits. + /// This method extracts that timestamp, providing the original creation time + /// from the AT Protocol record without needing a separate database column. + pub fn created_at(&self) -> ::chrono::DateTime<::chrono::Utc> { + $crate::domain::value_objects::tid::tid_to_datetime(self.rkey) + } + } + }; +} \ No newline at end of file diff --git a/parakeet-db/src/domain/value_objects/uri.rs b/parakeet-db/src/domain/value_objects/uri.rs new file mode 100644 index 00000000..0d16952b --- /dev/null +++ b/parakeet-db/src/domain/value_objects/uri.rs @@ -0,0 +1,38 @@ +//! URI Value Object - Deduplicated URIs +//! +//! URIs are stored deduplicated to save space when the same URI +//! appears multiple times across different records. + +use chrono::{DateTime, Utc}; +use diesel::prelude::*; + +/// Deduplicated URI +#[derive(Debug, Clone, Queryable, Selectable, Identifiable)] +#[diesel(table_name = crate::infrastructure::schema::uris)] +#[diesel(primary_key(id))] +#[diesel(check_for_backend(diesel::pg::Pg))] +pub struct Uri { + pub id: i32, + pub uri: String, + pub created_at: DateTime, +} + +impl Uri { + /// Parse an AT URI to extract components + pub fn parse_at_uri(&self) -> Option<(String, String, String)> { + if !self.uri.starts_with("at://") { + return None; + } + + let parts: Vec<&str> = self.uri.splitn(4, '/').collect(); + if parts.len() < 4 { + return None; + } + + Some(( + parts[2].to_string(), // DID + parts[3].to_string(), // Collection + parts[4].to_string(), // RKey + )) + } +} \ No newline at end of file diff --git a/parakeet-db/src/utils/compression.rs b/parakeet-db/src/infrastructure/compression/codec.rs similarity index 99% rename from parakeet-db/src/utils/compression.rs rename to parakeet-db/src/infrastructure/compression/codec.rs index bc2a8866..4cb4564b 100644 --- a/parakeet-db/src/utils/compression.rs +++ b/parakeet-db/src/infrastructure/compression/codec.rs @@ -2,9 +2,9 @@ use eyre::{Context, Result}; use std::sync::Arc; use zstd::bulk::{Compressor, Decompressor}; -const DICT_BYTES: &[u8] = include_bytes!("./dicts/post_content_v1.dict"); +const DICT_BYTES: &[u8] = include_bytes!("dicts/post_content_v1.dict"); const DICT_VERSION: u8 = 1; // Changed to u8 (1 byte instead of i16) -const COMPRESSION_LEVEL: i32 = 3; +pub const COMPRESSION_LEVEL: i32 = 3; const MAX_DECOMPRESSED_SIZE: usize = 300_000; // 300KB max (Bluesky's current limit) // Zstd frame constants (for reconstruction) diff --git a/parakeet-db/src/dicts/post_content_v1.dict b/parakeet-db/src/infrastructure/compression/dicts/post_content_v1.dict similarity index 100% rename from parakeet-db/src/dicts/post_content_v1.dict rename to parakeet-db/src/infrastructure/compression/dicts/post_content_v1.dict diff --git a/parakeet-db/src/infrastructure/persistence/arrays.rs b/parakeet-db/src/infrastructure/persistence/arrays.rs new file mode 100644 index 00000000..c5b0046d --- /dev/null +++ b/parakeet-db/src/infrastructure/persistence/arrays.rs @@ -0,0 +1,116 @@ +//! Array helper types for PostgreSQL arrays +//! +//! This module provides wrapper types for working with PostgreSQL arrays +//! in Diesel, handling both nullable arrays and arrays with nullable elements. + +use diesel::deserialize::FromSql; +use diesel::pg::Pg; +use diesel::sql_types::{Array, Nullable, Text}; +use diesel::{deserialize, FromSqlRow}; +use serde::{Deserialize, Serialize}; +use std::ops::{Deref, DerefMut}; + +/// Wrapper for TEXT[] arrays that filters out NULLs +#[derive(Clone, Debug, Default, Serialize, Deserialize, FromSqlRow)] +#[diesel(sql_type = Array>)] +pub struct TextArray(pub Vec); + +impl FromSql>, Pg> for TextArray { + fn from_sql(bytes: diesel::pg::PgValue<'_>) -> deserialize::Result { + let vec_with_nulls = + > as FromSql>, Pg>>::from_sql(bytes)?; + Ok(TextArray(vec_with_nulls.into_iter().flatten().collect())) + } +} + +impl Deref for TextArray { + type Target = Vec; + + fn deref(&self) -> &Self::Target { + &self.0 + } +} + +impl DerefMut for TextArray { + fn deref_mut(&mut self) -> &mut Self::Target { + &mut self.0 + } +} + +impl From for Vec { + fn from(v: TextArray) -> Vec { + v.0 + } +} + +// Macro to generate array helper types for ENUMs +macro_rules! enum_array { + ($name:ident, $enum_type:ty, $sql_type:ty) => { + #[derive(Clone, Debug, Default, Serialize, Deserialize, FromSqlRow)] + #[diesel(sql_type = Array>)] + pub struct $name(pub Vec<$enum_type>); + + impl FromSql>, Pg> for $name { + fn from_sql(bytes: diesel::pg::PgValue<'_>) -> deserialize::Result { + let vec_with_nulls = > as FromSql< + Array>, + Pg, + >>::from_sql(bytes)?; + Ok($name(vec_with_nulls.into_iter().flatten().collect())) + } + } + + impl Deref for $name { + type Target = Vec<$enum_type>; + + fn deref(&self) -> &Self::Target { + &self.0 + } + } + + impl DerefMut for $name { + fn deref_mut(&mut self) -> &mut Self::Target { + &mut self.0 + } + } + + impl From<$name> for Vec<$enum_type> { + fn from(v: $name) -> Vec<$enum_type> { + v.0 + } + } + }; +} + +// === ENUM Array Types === +// These wrap PostgreSQL ENUM[] arrays with automatic NULL filtering + +enum_array!( + PostgateRuleArray, + crate::infrastructure::types::PostgateRule, + crate::infrastructure::schema::sql_types::PostgateRule +); + +enum_array!( + ThreadgateRuleArray, + crate::infrastructure::types::ThreadgateRule, + crate::infrastructure::schema::sql_types::ThreadgateRule +); + +enum_array!( + ReasonTypeArray, + crate::infrastructure::types::ReasonType, + crate::infrastructure::schema::sql_types::ReasonType +); + +enum_array!( + SubjectTypeArray, + crate::infrastructure::types::SubjectType, + crate::infrastructure::schema::sql_types::SubjectType +); + +enum_array!( + LanguageCodeArray, + crate::infrastructure::types::LanguageCode, + crate::infrastructure::schema::sql_types::LanguageCode +); \ No newline at end of file diff --git a/parakeet-db/src/infrastructure/persistence/composite/reader.rs b/parakeet-db/src/infrastructure/persistence/composite/reader.rs new file mode 100644 index 00000000..8cd8f513 --- /dev/null +++ b/parakeet-db/src/infrastructure/persistence/composite/reader.rs @@ -0,0 +1,240 @@ +//! PostgreSQL composite type reader for deserializing wire format + +use crate::infrastructure::types::{CaptionMimeType, LanguageCode}; +use chrono::{DateTime, Utc}; +use diesel::deserialize::{self, FromSql}; +use diesel::pg::Pg; +use diesel::sql_types::SqlType; + +use super::types::VideoCaption; + +/// Helper for reading PostgreSQL composite types from wire format +pub struct CompositeReader<'a> { + bytes: &'a [u8], + offset: usize, +} + +impl<'a> CompositeReader<'a> { + pub fn new(bytes: &'a [u8]) -> Self { + Self { bytes, offset: 0 } + } + + pub fn read_i32(&mut self) -> deserialize::Result { + if self.offset + 4 > self.bytes.len() { + return Err("unexpected end of input".into()); + } + let val = i32::from_be_bytes([ + self.bytes[self.offset], + self.bytes[self.offset + 1], + self.bytes[self.offset + 2], + self.bytes[self.offset + 3], + ]); + self.offset += 4; + Ok(val) + } + + pub fn read_field_count(&mut self) -> deserialize::Result { + self.read_i32() + } + + pub fn read_field_header(&mut self) -> deserialize::Result<(i32, i32)> { + let oid = self.read_i32()?; + let len = self.read_i32()?; + Ok((oid, len)) + } + + pub fn read_text_field(&mut self) -> deserialize::Result> { + let (_oid, len) = self.read_field_header()?; + if len == -1 { + return Ok(None); + } + let len = len as usize; + if self.offset + len > self.bytes.len() { + return Err("unexpected end of input".into()); + } + let s = std::str::from_utf8(&self.bytes[self.offset..self.offset + len]) + .map_err(|e| Box::new(e) as Box)?; + self.offset += len; + Ok(Some(s.to_string())) + } + + pub fn read_bytea_field(&mut self) -> deserialize::Result>> { + let (_oid, len) = self.read_field_header()?; + if len == -1 { + return Ok(None); + } + let len = len as usize; + if self.offset + len > self.bytes.len() { + return Err("unexpected end of input".into()); + } + let bytes = self.bytes[self.offset..self.offset + len].to_vec(); + self.offset += len; + Ok(Some(bytes)) + } + + pub fn read_int_field(&mut self) -> deserialize::Result> { + let (_oid, len) = self.read_field_header()?; + if len == -1 { + return Ok(None); + } + if len != 4 { + return Err(format!("expected 4 bytes for i32, got {}", len).into()); + } + let val = self.read_i32()?; + Ok(Some(val)) + } + + pub fn read_enum_field(&mut self) -> deserialize::Result> + where + T: std::str::FromStr, + ::Err: std::fmt::Display, + { + match self.read_text_field()? { + Some(s) => { + let val = s + .parse::() + .map_err(|e| format!("Failed to parse enum: {}", e))?; + Ok(Some(val)) + } + None => Ok(None), + } + } + + pub fn read_composite_field(&mut self) -> deserialize::Result> + where + T: FromSql, + ST: SqlType, + { + let (_oid, len) = self.read_field_header()?; + if len == -1 { + return Ok(None); + } + let len = len as usize; + if self.offset + len > self.bytes.len() { + return Err("unexpected end of input".into()); + } + let composite_bytes = &self.bytes[self.offset..self.offset + len]; + self.offset += len; + + // For VideoCaption specifically + let mut nested_reader = CompositeReader::new(composite_bytes); + let field_count = nested_reader.read_field_count()?; + if field_count != 3 { + return Err(format!("nested composite: expected 3 fields, got {}", field_count).into()); + } + + let lang = nested_reader + .read_enum_field::()? + .ok_or("caption.lang cannot be NULL")?; + let mime_type = nested_reader + .read_enum_field::()? + .ok_or("caption.mime_type cannot be NULL")?; + let cid = nested_reader + .read_bytea_field()? + .ok_or("caption.cid cannot be NULL")?; + + let caption = Box::new(VideoCaption { + lang, + mime_type, + cid, + }); + let result = unsafe { Box::from_raw(Box::into_raw(caption) as *mut T) }; + Ok(Some(*result)) + } + + pub fn read_bigint_field(&mut self) -> deserialize::Result> { + let (_oid, len) = self.read_field_header()?; + if len == -1 { + return Ok(None); + } + if len != 8 { + return Err(format!("expected 8 bytes for i64, got {}", len).into()); + } + if self.offset + 8 > self.bytes.len() { + return Err("unexpected end of input".into()); + } + let val = i64::from_be_bytes([ + self.bytes[self.offset], + self.bytes[self.offset + 1], + self.bytes[self.offset + 2], + self.bytes[self.offset + 3], + self.bytes[self.offset + 4], + self.bytes[self.offset + 5], + self.bytes[self.offset + 6], + self.bytes[self.offset + 7], + ]); + self.offset += 8; + Ok(Some(val)) + } + + pub fn read_bool_field(&mut self) -> deserialize::Result> { + let (_oid, len) = self.read_field_header()?; + if len == -1 { + return Ok(None); + } + if len != 1 { + return Err(format!("expected 1 byte for bool, got {}", len).into()); + } + if self.offset + 1 > self.bytes.len() { + return Err("unexpected end of input".into()); + } + let val = self.bytes[self.offset] != 0; + self.offset += 1; + Ok(Some(val)) + } + + pub fn read_timestamptz_field(&mut self) -> deserialize::Result>> { + let (_oid, len) = self.read_field_header()?; + if len == -1 { + return Ok(None); + } + if len != 8 { + return Err(format!("expected 8 bytes for timestamptz, got {}", len).into()); + } + if self.offset + 8 > self.bytes.len() { + return Err("unexpected end of input".into()); + } + + let micros = i64::from_be_bytes([ + self.bytes[self.offset], + self.bytes[self.offset + 1], + self.bytes[self.offset + 2], + self.bytes[self.offset + 3], + self.bytes[self.offset + 4], + self.bytes[self.offset + 5], + self.bytes[self.offset + 6], + self.bytes[self.offset + 7], + ]); + self.offset += 8; + + const PG_EPOCH_SECS: i64 = 946684800; + let total_micros = PG_EPOCH_SECS * 1_000_000 + micros; + let secs = total_micros / 1_000_000; + let nsecs = ((total_micros % 1_000_000) * 1000) as u32; + + let timestamp = chrono::DateTime::from_timestamp(secs, nsecs).ok_or("Invalid timestamp")?; + Ok(Some(timestamp)) + } + + pub fn read_jsonb_field(&mut self) -> deserialize::Result> { + let (_oid, len) = self.read_field_header()?; + if len == -1 { + return Ok(None); + } + let len = len as usize; + if self.offset + len > self.bytes.len() { + return Err("unexpected end of input".into()); + } + + if len < 1 { + return Err("JSONB data too short".into()); + } + + let jsonb_bytes = &self.bytes[self.offset + 1..self.offset + len]; + self.offset += len; + + let value: serde_json::Value = serde_json::from_slice(jsonb_bytes) + .map_err(|e| format!("Failed to parse JSONB: {}", e))?; + Ok(Some(value)) + } +} \ No newline at end of file diff --git a/parakeet-db/src/infrastructure/persistence/composite/types.rs b/parakeet-db/src/infrastructure/persistence/composite/types.rs new file mode 100644 index 00000000..ec8e79a8 --- /dev/null +++ b/parakeet-db/src/infrastructure/persistence/composite/types.rs @@ -0,0 +1,323 @@ +//! Composite type definitions for PostgreSQL + +use crate::infrastructure::composite::{CompositeReader, CompositeWriter}; +use crate::infrastructure::schema::sql_types::{ + ActorLabel, BlockRecord, BookmarkRecord, FollowRecord, LabelerDefRecord, ListBlockRecord, + ListMuteRecord, MuteRecord, PostExtEmbed, PostFacetEmbed, PostImageEmbed, PostLabel, + PostVideoCaption, PostVideoEmbed, ThreadMuteRecord, +}; +use crate::infrastructure::types::{ + CaptionMimeType, FacetType, ImageMimeType, LabelBlurs, LabelDefaultSetting, LabelSeverity, + LanguageCode, VideoMimeType, +}; +use bytes::BytesMut; +use chrono::{DateTime, Utc}; +use diesel::deserialize::{self, FromSql}; +use diesel::pg::Pg; +use diesel::serialize::{self, Output, ToSql}; +use diesel::AsExpression; +use postgres_types::{IsNull, ToSql as PgToSql, Type}; + +// ============================================================================ +// EMBED TYPES +// ============================================================================ + +// External Link Embed +#[derive( + Debug, + Clone, + PartialEq, + AsExpression, + serde::Serialize, + serde::Deserialize, + parakeet_db_macros::PostgresComposite, +)] +#[diesel(sql_type = PostExtEmbed)] +#[composite(sql_type = "PostExtEmbed")] +pub struct ExtEmbed { + pub uri: String, + pub title: Option, + pub description: Option, + pub thumb_mime_type: Option, + pub thumb_cid: Option>, +} + +// Video Caption +#[derive( + Debug, + Clone, + PartialEq, + AsExpression, + serde::Serialize, + serde::Deserialize, + parakeet_db_macros::PostgresComposite, +)] +#[diesel(sql_type = PostVideoCaption)] +#[composite(sql_type = "PostVideoCaption")] +pub struct VideoCaption { + pub lang: LanguageCode, + pub mime_type: CaptionMimeType, + pub cid: Vec, +} + +// Video Embed +#[derive( + Debug, + Clone, + PartialEq, + AsExpression, + serde::Serialize, + serde::Deserialize, + parakeet_db_macros::PostgresComposite, +)] +#[diesel(sql_type = PostVideoEmbed)] +#[composite(sql_type = "PostVideoEmbed")] +pub struct VideoEmbed { + pub mime_type: VideoMimeType, + pub cid: Vec, + pub alt: Option, + pub width: Option, + pub height: Option, + pub caption_1: Option, + pub caption_2: Option, + pub caption_3: Option, +} + +// Image Embed +#[derive( + Debug, + Clone, + PartialEq, + AsExpression, + serde::Serialize, + serde::Deserialize, + parakeet_db_macros::PostgresComposite, +)] +#[diesel(sql_type = PostImageEmbed)] +#[composite(sql_type = "PostImageEmbed")] +pub struct ImageEmbed { + pub mime_type: ImageMimeType, + pub cid: Vec, + pub alt: Option, + pub width: Option, + pub height: Option, +} + +// Facet Embed +#[derive( + Debug, + Clone, + PartialEq, + AsExpression, + serde::Serialize, + serde::Deserialize, + parakeet_db_macros::PostgresComposite, +)] +#[diesel(sql_type = PostFacetEmbed)] +#[composite(sql_type = "PostFacetEmbed")] +pub struct FacetEmbed { + pub facet_type: FacetType, + pub index_start: i32, + pub index_end: i32, + pub link_uri: Option, + pub mention_actor_id: Option, + pub tag: Option, +} + +// ============================================================================ +// RECORD TYPES +// ============================================================================ + +// Follow Record +#[derive( + Debug, + Clone, + PartialEq, + Eq, + Hash, + AsExpression, + serde::Serialize, + serde::Deserialize, + parakeet_db_macros::PostgresComposite, +)] +#[diesel(sql_type = FollowRecord)] +#[composite(sql_type = "FollowRecord")] +pub struct Follow { + pub subject_actor_id: i32, + pub rkey: i64, +} + +// Mute Record +#[derive( + Debug, + Clone, + PartialEq, + AsExpression, + serde::Serialize, + serde::Deserialize, + parakeet_db_macros::PostgresComposite, +)] +#[diesel(sql_type = MuteRecord)] +#[composite(sql_type = "MuteRecord")] +pub struct Mute { + pub subject_actor_id: i32, + pub created_at: DateTime, +} + +// Block Record +#[derive( + Debug, + Clone, + PartialEq, + Eq, + Hash, + AsExpression, + serde::Serialize, + serde::Deserialize, + parakeet_db_macros::PostgresComposite, +)] +#[diesel(sql_type = BlockRecord)] +#[composite(sql_type = "BlockRecord")] +pub struct Block { + pub subject_actor_id: i32, + pub rkey: i64, +} + +// Bookmark Record +#[derive( + Debug, + Clone, + PartialEq, + Eq, + Hash, + AsExpression, + serde::Serialize, + serde::Deserialize, + parakeet_db_macros::PostgresComposite, +)] +#[diesel(sql_type = BookmarkRecord)] +#[composite(sql_type = "BookmarkRecord")] +pub struct Bookmark { + pub post_actor_id: i32, + pub post_rkey: i64, + pub rkey: i64, +} + +// Thread Mute Record +#[derive( + Debug, + Clone, + PartialEq, + AsExpression, + serde::Serialize, + serde::Deserialize, + parakeet_db_macros::PostgresComposite, +)] +#[diesel(sql_type = ThreadMuteRecord)] +#[composite(sql_type = "ThreadMuteRecord")] +pub struct ThreadMute { + pub root_post_actor_id: i32, + pub root_post_rkey: i64, + pub created_at: DateTime, +} + +// List Mute Record +#[derive( + Debug, + Clone, + PartialEq, + AsExpression, + serde::Serialize, + serde::Deserialize, + parakeet_db_macros::PostgresComposite, +)] +#[diesel(sql_type = ListMuteRecord)] +#[composite(sql_type = "ListMuteRecord")] +pub struct ListMute { + pub list_actor_id: i32, + pub list_rkey: String, + pub created_at: DateTime, +} + +// List Block Record +#[derive( + Debug, + Clone, + PartialEq, + AsExpression, + serde::Serialize, + serde::Deserialize, + parakeet_db_macros::PostgresComposite, +)] +#[diesel(sql_type = ListBlockRecord)] +#[composite(sql_type = "ListBlockRecord")] +pub struct ListBlock { + pub list_actor_id: i32, + pub list_rkey: String, + pub rkey: i64, + pub cid: Vec, +} + +// ============================================================================ +// LABEL TYPES +// ============================================================================ + +// Labeler Definition Record +#[derive( + Debug, + Clone, + PartialEq, + AsExpression, + serde::Serialize, + serde::Deserialize, + parakeet_db_macros::PostgresComposite, +)] +#[diesel(sql_type = LabelerDefRecord)] +#[composite(sql_type = "LabelerDefRecord")] +pub struct LabelerDef { + pub label_identifier: String, + pub severity: Option, + pub blurs: Option, + pub default_setting: Option, + pub adult_only: bool, + pub locales: Option, + pub created_at: DateTime, +} + +// Post Label Record +#[derive( + Debug, + Clone, + PartialEq, + AsExpression, + serde::Serialize, + serde::Deserialize, + parakeet_db_macros::PostgresComposite, +)] +#[diesel(sql_type = PostLabel)] +#[composite(sql_type = "PostLabel")] +pub struct PostLabelRecord { + pub labeler_actor_id: i32, + pub label: String, + pub created_at: DateTime, + pub negated: bool, +} + +// Actor Label Record +#[derive( + Debug, + Clone, + PartialEq, + AsExpression, + serde::Serialize, + serde::Deserialize, + parakeet_db_macros::PostgresComposite, +)] +#[diesel(sql_type = ActorLabel)] +#[composite(sql_type = "ActorLabel")] +pub struct ActorLabelRecord { + pub labeler_actor_id: i32, + pub label: String, + pub created_at: DateTime, + pub negated: bool, +} \ No newline at end of file diff --git a/parakeet-db/src/infrastructure/persistence/composite/writer.rs b/parakeet-db/src/infrastructure/persistence/composite/writer.rs new file mode 100644 index 00000000..f4a95927 --- /dev/null +++ b/parakeet-db/src/infrastructure/persistence/composite/writer.rs @@ -0,0 +1,203 @@ +//! PostgreSQL composite type writer for serializing to wire format + +use chrono::{DateTime, Utc}; +use diesel::pg::Pg; +use diesel::serialize::{self, Output, ToSql}; +use diesel::sql_types::SqlType; +use std::io::Write; + +use super::types::VideoCaption; + +/// Helper for writing PostgreSQL composite types to wire format +pub struct CompositeWriter<'a, 'b, 'c> { + pub out: &'a mut Output<'b, 'c, Pg>, +} + +impl<'a, 'b, 'c> CompositeWriter<'a, 'b, 'c> { + pub fn write_header(out: &'a mut Output<'b, 'c, Pg>, field_count: i32) -> serialize::Result { + out.write_all(&field_count.to_be_bytes())?; + Ok(serialize::IsNull::No) + } + + pub fn with_output(out: &'a mut Output<'b, 'c, Pg>) -> Self { + Self { out } + } + + pub fn write_field_header(&mut self, oid: i32, len: i32) -> serialize::Result { + self.out.write_all(&oid.to_be_bytes())?; + self.out.write_all(&len.to_be_bytes())?; + Ok(serialize::IsNull::No) + } + + pub fn write_text_field(&mut self, value: Option<&str>) -> serialize::Result { + const TEXT_OID: i32 = 25; + match value { + Some(s) => { + let bytes = s.as_bytes(); + self.write_field_header(TEXT_OID, bytes.len() as i32)?; + self.out.write_all(bytes)?; + } + None => { + self.write_field_header(TEXT_OID, -1)?; + } + } + Ok(serialize::IsNull::No) + } + + pub fn write_bytea_field(&mut self, value: Option<&[u8]>) -> serialize::Result { + const BYTEA_OID: i32 = 17; + match value { + Some(bytes) => { + self.write_field_header(BYTEA_OID, bytes.len() as i32)?; + self.out.write_all(bytes)?; + } + None => { + self.write_field_header(BYTEA_OID, -1)?; + } + } + Ok(serialize::IsNull::No) + } + + pub fn write_int_field(&mut self, value: Option) -> serialize::Result { + const INT4_OID: i32 = 23; + match value { + Some(val) => { + self.write_field_header(INT4_OID, 4)?; + self.out.write_all(&val.to_be_bytes())?; + } + None => { + self.write_field_header(INT4_OID, -1)?; + } + } + Ok(serialize::IsNull::No) + } + + pub fn write_enum_field(&mut self, value: Option<&T>) -> serialize::Result + where + T: std::fmt::Display, + { + const TEXT_OID: i32 = 25; + match value { + Some(val) => { + let s = val.to_string(); + let bytes = s.as_bytes(); + self.write_field_header(TEXT_OID, bytes.len() as i32)?; + self.out.write_all(bytes)?; + } + None => { + self.write_field_header(TEXT_OID, -1)?; + } + } + Ok(serialize::IsNull::No) + } + + pub fn write_composite_field(&mut self, value: Option<&T>) -> serialize::Result + where + T: ToSql, + ST: SqlType, + { + const COMPOSITE_OID: i32 = 0; + + match value { + Some(val) => { + let mut buffer = Vec::new(); + + // Cast T to VideoCaption (we know this is safe from the call site) + let caption = unsafe { &*(val as *const T as *const VideoCaption) }; + + // Write field count + buffer.extend_from_slice(&3i32.to_be_bytes()); + + // Write lang (TEXT/ENUM) + buffer.extend_from_slice(&25i32.to_be_bytes()); // TEXT OID + let lang_str = caption.lang.to_string(); + buffer.extend_from_slice(&(lang_str.len() as i32).to_be_bytes()); + buffer.extend_from_slice(lang_str.as_bytes()); + + // Write mime_type (TEXT/ENUM) + buffer.extend_from_slice(&25i32.to_be_bytes()); // TEXT OID + let mime_str = caption.mime_type.to_string(); + buffer.extend_from_slice(&(mime_str.len() as i32).to_be_bytes()); + buffer.extend_from_slice(mime_str.as_bytes()); + + // Write cid (BYTEA) + buffer.extend_from_slice(&17i32.to_be_bytes()); // BYTEA OID + buffer.extend_from_slice(&(caption.cid.len() as i32).to_be_bytes()); + buffer.extend_from_slice(&caption.cid); + + self.write_field_header(COMPOSITE_OID, buffer.len() as i32)?; + self.out.write_all(&buffer)?; + } + None => { + self.write_field_header(COMPOSITE_OID, -1)?; + } + } + Ok(serialize::IsNull::No) + } + + pub fn write_bigint_field(&mut self, value: Option) -> serialize::Result { + const INT8_OID: i32 = 20; + match value { + Some(val) => { + self.write_field_header(INT8_OID, 8)?; + self.out.write_all(&val.to_be_bytes())?; + } + None => { + self.write_field_header(INT8_OID, -1)?; + } + } + Ok(serialize::IsNull::No) + } + + pub fn write_bool_field(&mut self, value: Option) -> serialize::Result { + const BOOL_OID: i32 = 16; + match value { + Some(val) => { + self.write_field_header(BOOL_OID, 1)?; + self.out.write_all(&[if val { 1 } else { 0 }])?; + } + None => { + self.write_field_header(BOOL_OID, -1)?; + } + } + Ok(serialize::IsNull::No) + } + + pub fn write_timestamptz_field(&mut self, value: Option<&DateTime>) -> serialize::Result { + const TIMESTAMPTZ_OID: i32 = 1184; + match value { + Some(dt) => { + const PG_EPOCH_SECS: i64 = 946684800; + let timestamp_secs = dt.timestamp(); + let timestamp_micros = (timestamp_secs - PG_EPOCH_SECS) * 1_000_000 + + (dt.timestamp_subsec_micros() as i64); + + self.write_field_header(TIMESTAMPTZ_OID, 8)?; + self.out.write_all(×tamp_micros.to_be_bytes())?; + } + None => { + self.write_field_header(TIMESTAMPTZ_OID, -1)?; + } + } + Ok(serialize::IsNull::No) + } + + pub fn write_jsonb_field(&mut self, value: Option<&serde_json::Value>) -> serialize::Result { + const JSONB_OID: i32 = 3802; + match value { + Some(val) => { + let json_bytes = serde_json::to_vec(val) + .map_err(|e| Box::new(e) as Box)?; + + let total_len = 1 + json_bytes.len(); + self.write_field_header(JSONB_OID, total_len as i32)?; + self.out.write_all(&[1u8])?; // JSONB version 1 + self.out.write_all(&json_bytes)?; + } + None => { + self.write_field_header(JSONB_OID, -1)?; + } + } + Ok(serialize::IsNull::No) + } +} \ No newline at end of file diff --git a/parakeet-db/src/infrastructure/schema.rs b/parakeet-db/src/infrastructure/persistence/schema.rs similarity index 100% rename from parakeet-db/src/infrastructure/schema.rs rename to parakeet-db/src/infrastructure/persistence/schema.rs diff --git a/parakeet-db/src/infrastructure/types/mod.rs b/parakeet-db/src/infrastructure/persistence/types.rs similarity index 92% rename from parakeet-db/src/infrastructure/types/mod.rs rename to parakeet-db/src/infrastructure/persistence/types.rs index 0e75c9a7..576c27ca 100644 --- a/parakeet-db/src/infrastructure/types/mod.rs +++ b/parakeet-db/src/infrastructure/persistence/types.rs @@ -1,33 +1,3 @@ -// ============================================================================= -// TYPES.RS - Type-Safe ENUMs for Parakeet -// ============================================================================= -// -// This file contains type definitions that provide compile-time type -// safety for all AT Protocol enums and Parakeet-specific enums. -// -// KEY PRINCIPLES: -// -// 1. **Protocol-Defined ENUMs**: Every enum from AT Protocol lexicons -// - Matches official lexicon values exactly -// - Provides type safety at query time -// - Enables database constraints -// -// 2. **Storage Optimization**: ENUMs stored as PostgreSQL custom types -// - 4 bytes per enum (not variable-length TEXT) -// - Database-level validation -// - Index-friendly (integer-based) -// -// 3. **Rust Integration**: Diesel integration for seamless ORM use -// - FromSql/ToSql implementations -// - FromStr for parsing -// - Display for serialization -// -// 4. **Extensibility**: All enums support unknown values -// - Future lexicon additions don't break existing code -// - Unknown variants parsed but not stored -// -// ============================================================================= - // ============================================================================= // MACRO: diesel_enum! // ============================================================================= @@ -49,7 +19,7 @@ macro_rules! diesel_enum { #[derive(Debug, Clone, Copy, PartialOrd, PartialEq, Eq, Hash)] #[derive(diesel::AsExpression, diesel::FromSqlRow)] #[derive(serde::Serialize, serde::Deserialize)] - #[diesel(sql_type = crate::schema::sql_types::$pg_type)] + #[diesel(sql_type = crate::infrastructure::schema::sql_types::$pg_type)] #[serde(rename_all = "lowercase")] pub enum $rust_name { $($variant,)* @@ -74,7 +44,7 @@ macro_rules! diesel_enum { } } - impl diesel::deserialize::FromSql for $rust_name + impl diesel::deserialize::FromSql for $rust_name where DB: diesel::backend::Backend, String: diesel::deserialize::FromSql, @@ -88,7 +58,7 @@ macro_rules! diesel_enum { } } - impl diesel::serialize::ToSql for $rust_name { + impl diesel::serialize::ToSql for $rust_name { fn to_sql<'b>(&'b self, out: &mut diesel::serialize::Output<'b, '_, diesel::pg::Pg>) -> diesel::serialize::Result { use std::io::Write; let val = self.to_string(); @@ -496,7 +466,6 @@ diesel_enum!(RepostStatus, RepostStatus, { Stub = "stub", }); - // ============================================================================= // NOTES ON ENUM EXTENSIBILITY // ============================================================================= diff --git a/parakeet-db/src/jacquard_views.rs b/parakeet-db/src/jacquard_views.rs deleted file mode 100644 index 2f42b5c6..00000000 --- a/parakeet-db/src/jacquard_views.rs +++ /dev/null @@ -1,204 +0,0 @@ -//! Jacquard view conversions for database models. -//! -//! This module extends database models with methods that return Jacquard AT Protocol types -//! directly, eliminating the need for a separate converter layer in the appview. - -use crate::models::{Actor, Post, List}; -use crate::types::ActorStatus; -use crate::cid_util; -use crate::tid_util; -use crate::compression::PostContentCodec; -use jacquard_api::app_bsky; -use jacquard_common::types::{ - did::Did, - handle::Handle, - string::{Datetime, AtUri, Cid, Uri}, -}; -use jacquard_common::{CowStr, IntoStatic, Data}; -use jacquard_common::url::Url; -use chrono::{DateTime, Utc}; - -// ============================================================================= -// ACTOR → PROFILE VIEWS -// ============================================================================= - -impl Actor { - /// Convert to basic ProfileView - /// - /// Converts the Actor model to a Jacquard ProfileView, including: - /// - Converting avatar/banner CID digests to CDN URLs - /// - Handling missing/invalid handles gracefully - /// - Setting proper indexed_at timestamps - pub fn to_profile_view(&self) -> app_bsky::actor::ProfileView<'static> { - // Convert avatar CID to CDN URL - let avatar_url = self.profile_avatar_cid.as_ref() - .and_then(|avatar_cid| cid_util::digest_to_blob_cid_string(avatar_cid)) - .and_then(|cid_str| { - Url::parse(&format!("https://cdn.bsky.social/img/avatar/plain/{}/{}@jpeg", self.did, cid_str)) - .ok() - .map(Uri::Https) - }); - - // Note: ProfileView doesn't have a banner field in the current API - // Banner would be in ProfileViewDetailed - - app_bsky::actor::ProfileView::new() - .did(Did::new(&self.did).expect("Invalid DID")) - .handle(self.handle.as_ref() - .and_then(|h| Handle::new(h).ok()) - .unwrap_or_else(|| Handle::new("invalid.handle").unwrap())) - .display_name(self.profile_display_name.as_ref().map(|s| CowStr::from(s.as_str()))) - .description(self.profile_description.as_ref().map(|s| CowStr::from(s.as_str()))) - .avatar(avatar_url) - .indexed_at(self.last_indexed.map(to_datetime)) - .labels(vec![]) - .build() - .into_static() - } - - /// Check if actor is active - pub fn is_active(&self) -> bool { - matches!(self.status, ActorStatus::Active) - } -} - -// ============================================================================= -// POST → POST VIEWS -// ============================================================================= - -impl Post { - /// Convert to PostView with author profile - /// - /// This converts the Post model to a Jacquard PostView, including: - /// - Building the AT URI from author DID and post TID - /// - Converting CID digest to string - /// - Decompressing and parsing post content - /// - Setting engagement counts - pub fn to_post_view( - &self, - author: app_bsky::actor::ProfileViewBasic<'static>, - ) -> app_bsky::feed::PostView<'static> { - // Build the AT URI for the post - let uri = AtUri::new_owned(format!( - "at://{}/app.bsky.feed.post/{}", - author.did.as_str(), - tid_util::encode_tid(self.rkey) - )).expect("Valid AT URI"); - - // Convert CID digest to string (using record CID format for posts) - let cid = cid_util::digest_to_record_cid_string(&self.cid) - .map(|s| Cid::from(CowStr::from(s))) - .unwrap_or_else(|| Cid::from(CowStr::from("bafyreiunknown"))); - - // Decompress and parse the post content if available - let record = if let Some(content_bytes) = &self.content { - let codec = PostContentCodec::new(); - codec.decompress(content_bytes) - .ok() - .map(|json_str| Data::from(json_str.as_bytes().to_vec())) - .unwrap_or_else(|| Data::from(b"{}".to_vec())) - } else { - // Stub post with no content - Data::from(b"{}".to_vec()) - }; - - // Get created_at timestamp from TID (using the existing impl from models.rs) - let indexed_at = to_datetime(self.created_at()); - - app_bsky::feed::PostView::new() - .uri(uri) - .cid(cid) - .author(author) - .record(record) - .reply_count(Some(self.reply_count as i64)) - .repost_count(Some(self.repost_count as i64)) - .like_count(Some(self.like_count as i64)) - .quote_count(Some(self.quote_count as i64)) - .indexed_at(indexed_at) - .labels(vec![]) - .build() - .into_static() - } -} - -// ============================================================================= -// LIST → LIST VIEWS -// ============================================================================= - -impl List { - /// Convert to ListView with creator profile - /// - /// This converts the List model to a Jacquard ListView, including: - /// - Building the AT URI from owner DID and list rkey - /// - Converting CID digest to string - /// - Mapping ListType to ListPurpose - /// - Converting avatar CID to CDN URL - pub fn to_list_view( - &self, - creator: app_bsky::actor::ProfileView<'static>, - ) -> app_bsky::graph::ListView<'static> { - // Build the AT URI for the list - let uri = AtUri::new_owned(format!( - "at://{}/app.bsky.graph.list/{}", - creator.did.as_str(), - self.rkey - )).expect("Valid AT URI"); - - // Convert CID digest to string - let cid = cid_util::digest_to_record_cid_string(&self.cid) - .map(|s| Cid::from(CowStr::from(s))) - .unwrap_or_else(|| Cid::from(CowStr::from("bafyreilist"))); - - // Map ListType to ListPurpose - use crate::types::ListType; - let purpose = match self.list_type.as_ref() { - Some(ListType::CurateList) => app_bsky::graph::ListPurpose::AppBskyGraphDefsCuratelist, - Some(ListType::ModList) => app_bsky::graph::ListPurpose::AppBskyGraphDefsModlist, - Some(ListType::ReferenceList) => app_bsky::graph::ListPurpose::AppBskyGraphDefsReferencelist, - None => app_bsky::graph::ListPurpose::AppBskyGraphDefsCuratelist, // Default for stubs - }; - - // Convert avatar CID to CDN URL if present - let avatar = self.avatar_cid.as_ref() - .and_then(|avatar_cid| cid_util::digest_to_blob_cid_string(avatar_cid)) - .and_then(|cid_str| { - Url::parse(&format!("https://cdn.bsky.social/img/avatar/plain/{}/{}@jpeg", - creator.did.as_str(), cid_str)) - .ok() - .map(Uri::Https) - }); - - // Get or estimate created_at timestamp - let indexed_at = to_datetime(self.created_at()); - - let mut builder = app_bsky::graph::ListView::new() - .uri(uri) - .cid(cid) - .name(CowStr::from(self.name.as_ref().cloned().unwrap_or_else(|| "Untitled List".to_string()))) - .creator(creator) - .purpose(purpose) - .list_item_count(Some(0)) // TODO: Get actual count from list_items table - .indexed_at(indexed_at) - .labels(vec![]); - - // Add optional fields - if let Some(desc) = &self.description { - builder = builder.description(Some(CowStr::from(desc.as_str()))); - } - - if let Some(avatar_url) = avatar { - builder = builder.avatar(Some(avatar_url)); - } - - builder.build().into_static() - } -} - -// ============================================================================= -// HELPER FUNCTIONS -// ============================================================================= - -/// Convert chrono DateTime to Jacquard Datetime -fn to_datetime(dt: DateTime) -> Datetime { - Datetime::new(dt.fixed_offset()) -} \ No newline at end of file diff --git a/parakeet-db/src/lib.rs b/parakeet-db/src/lib.rs index 70cf9215..b0c8b496 100644 --- a/parakeet-db/src/lib.rs +++ b/parakeet-db/src/lib.rs @@ -1,10 +1,156 @@ -pub mod at_uri_util; -pub mod cid_util; -pub mod composite_types; -pub mod compression; -pub mod jacquard_views; -pub mod models; -pub mod notifications; -pub mod schema; -pub mod tid_util; -pub mod types; +// === Domain Layer === +pub mod domain { + //! Domain layer - Core business logic and domain models + //! + //! This module contains the domain model following Domain-Driven Design principles: + //! - Entities: Objects with identity (Actor, Post, List, etc.) + //! - Value Objects: Immutable objects without identity (Stats, URIs, etc.) + //! - Aggregates: Consistency boundaries (PostThread, ActorProfile, etc.) + //! - Domain Events: Things that happen in the domain (future) + + pub mod entities { + //! Domain Entities - Objects with identity that can change over time + //! + //! Entities are distinguished by their identity, not their attributes. + //! In the AT Protocol domain: + //! - Actors are identified by their DID + //! - Posts are identified by actor_id + rkey + //! - Lists are identified by actor_id + rkey + + pub mod actor; + pub mod feed_gen; + pub mod label; + pub mod list; + pub mod notification; + pub mod post; + pub mod starter_pack; + pub mod verification; + + pub use actor::Actor; + pub use feed_gen::FeedGen; + pub use label::Label; + pub use list::{List, ListItem}; + pub use notification::Notification; + pub use post::Post; + pub use starter_pack::{StarterPack, StarterPackFeed}; + pub use verification::Verification; + } + pub mod value_objects { + //! Value Objects - Immutable objects without identity + //! + //! Value objects are defined by their attributes, not by identity. + //! They are immutable and can be freely copied. + + // Domain value objects + pub mod repost; + pub mod stats; + pub mod threadgate; + pub mod uri; + + // AT Protocol core concepts (also value objects) + pub mod at_uri; + pub mod cid; + pub mod tid; + pub mod tid_traits; + + // Re-export domain value objects + pub use repost::Repost; + pub use stats::{PostStats, ProfileStats}; + pub use threadgate::Threadgate; + pub use uri::Uri; + + // Re-export AT Protocol concepts + pub use at_uri::*; + pub use cid::*; + pub use tid::*; + pub use tid_traits::*; + } + pub mod aggregates { + //! Domain Aggregates - Consistency boundaries + //! + //! Aggregates define transactional consistency boundaries and + //! encapsulate domain invariants. + + pub mod actor_profile; + pub mod post_thread; + + pub use actor_profile::ActorProfile; + pub use post_thread::PostThread; + } +} + +// === Infrastructure Layer === +pub mod infrastructure { + //! Infrastructure layer - Technical implementation details + //! + //! This layer contains all database-specific code, type mappings, + //! and other technical infrastructure that the domain layer depends on. + + pub mod persistence { + //! Database persistence layer + //! + //! Contains all database-specific code including schema, types, + //! and PostgreSQL composite types. + + pub mod arrays; + pub mod schema; + pub mod types; + + pub mod composite { + //! PostgreSQL composite type definitions for embeds and records. + //! + //! Implements FromSql/ToSql for Diesel using PostgreSQL's composite wire format. + + mod reader; + mod types; + mod writer; + + pub(crate) use reader::CompositeReader; + pub(crate) use writer::CompositeWriter; + + pub use types::{ + ActorLabelRecord, Block, Bookmark, ExtEmbed, FacetEmbed, Follow, ImageEmbed, + LabelerDef, ListBlock, ListMute, Mute, PostLabelRecord, ThreadMute, VideoCaption, + VideoEmbed, + }; + } + } + + pub mod compression { + //! Compression infrastructure + //! + //! Handles content compression using Zstd with dictionaries. + + pub mod codec; + + pub use codec::{PostContentCodec, COMPRESSION_LEVEL}; + } + + // Convenience re-exports for cleaner internal imports + pub use persistence::{arrays, composite, schema, types}; +} + +// === Application Layer === +pub mod application { + //! Application Layer - Orchestrates domain objects and converts to external representations + //! + //! This layer contains application-specific logic that: + //! - Orchestrates domain entities and value objects + //! - Converts domain models to DTOs/ViewModels for external APIs + //! - Handles application-level concerns that don't belong in the domain + + pub mod views { + //! View conversions for domain entities + //! + //! Extension traits that add view conversion methods to domain entities. + + pub mod actor; + pub mod list; + pub mod post; + + // Re-export the extension traits for convenience + pub use actor::ActorViewExt; + pub use list::ListViewExt; + pub use post::PostViewExt; + } +}