···11-//! Common types for the jacquard implementation of atproto
11+//! # Common types for the jacquard implementation of atproto
22+//!
33+//! ## Working with Lifetimes and Zero-Copy Deserialization
44+//!
55+//! Jacquard is designed around zero-copy deserialization: types like `Post<'de>` can borrow
66+//! strings and other data directly from the response buffer instead of allocating owned copies.
77+//! This is great for performance, but it creates some interesting challenges when combined with
88+//! async Rust and trait bounds.
99+//!
1010+//! ### The Problem: Lifetimes + Async + Traits
1111+//!
1212+//! The naive approach would be to put a lifetime parameter on the trait itself:
1313+//!
1414+//! ```ignore
1515+//! trait XrpcRequest<'de> {
1616+//! type Output: Deserialize<'de>;
1717+//! // ...
1818+//! }
1919+//! ```
2020+//!
2121+//! This looks reasonable until you try to use it in a generic context. If you have a function
2222+//! that works with *any* lifetime, you need a Higher-Ranked Trait Bound (HRTB):
2323+//!
2424+//! ```ignore
2525+//! fn foo<R>(response: &[u8])
2626+//! where
2727+//! R: for<'any> XrpcRequest<'any>
2828+//! {
2929+//! // deserialize from response...
3030+//! }
3131+//! ```
3232+//!
3333+//! The `for<'any>` bound says "this type must implement `XrpcRequest` for *every possible lifetime*",
3434+//! which is effectively the same as requiring `DeserializeOwned`. You've just thrown away your
3535+//! zero-copy optimization, and this also won't work on most of the types in jacquard. The vast
3636+//! majority of them have either a custom Deserialize implementation which will borrow if it
3737+//! can, a #[serde(borrow)] attribute on one or more fields, or an equivalent lifetime bound
3838+//! attribute, associated with the Deserialize derive macro.
3939+//!
4040+//! It gets worse with async. If you want to return borrowed data from an async method, where does
4141+//! the lifetime come from? The response buffer needs to outlive the borrow, but the buffer is
4242+//! consumed by the HTTP call. You end up with "cannot infer appropriate lifetime" errors or even
4343+//! more confusing errors because the compiler can't prove the buffer will stay alive. You *could*
4444+//! do some lifetime laundering with `unsafe`, but you don't actually *need* to tell rustc to "trust
4545+//! me, bro", you can, with some cleverness, explain this to the compiler in a way that it can
4646+//! reason about perfectly well.
4747+//!
4848+//! ### Explaining where the buffer goes to `rustc`: GATs + Method-Level Lifetimes
4949+//!
5050+//! The fix is to use Generic Associated Types (GATs) on the trait's associated types, while keeping
5151+//! the trait itself lifetime-free:
5252+//!
5353+//! ```ignore
5454+//! trait XrpcResp {
5555+//! const NSID: &'static str;
5656+//!
5757+//! // GATs: lifetime is on the associated type, not the trait
5858+//! type Output<'de>: Deserialize<'de> + IntoStatic;
5959+//! type Err<'de>: Deserialize<'de> + IntoStatic;
6060+//! }
6161+//! ```
6262+//!
6363+//! Now you can write trait bounds without HRTBs:
6464+//!
6565+//! ```ignore
6666+//! fn foo<R: XrpcResp>(response: &[u8]) {
6767+//! // Compiler can pick a concrete lifetime for R::Output<'_>
6868+//! }
6969+//! ```
7070+//!
7171+//! Methods that need lifetimes use method-level generic parameters:
7272+//!
7373+//! ```ignore
7474+//! // This is part of a trait from jacquard itself, used to genericize updates to the Bluesky
7575+//! // preferences union, so that if you implement a similar lexicon type in your AppView or App
7676+//! // Server API, you don't have to special-case it.
7777+//!
7878+//! trait VecUpdate {
7979+//! type GetRequest<'de>: XrpcRequest<'de>; // GAT
8080+//! type PutRequest<'de>: XrpcRequest<'de>; // GAT
8181+//!
8282+//! // Method-level lifetime, not trait-level
8383+//! fn extract_vec<'s>(
8484+//! output: <Self::GetRequest<'s> as XrpcRequest<'s>>::Output<'s>
8585+//! ) -> Vec<Self::Item>;
8686+//! }
8787+//! ```
8888+//!
8989+//! The compiler can monomorphize for concrete lifetimes instead of trying to prove bounds hold
9090+//! for *all* lifetimes at once.
9191+//!
9292+//! ### Handling Async with `Response<R: XrpcResp>`
9393+//!
9494+//! For the async problem, we use a wrapper type that owns the response buffer:
9595+//!
9696+//! ```ignore
9797+//! pub struct Response<R: XrpcResp> {
9898+//! buffer: Bytes, // Refcounted, cheap to clone
9999+//! status: StatusCode,
100100+//! _marker: PhantomData<R>,
101101+//! }
102102+//! ```
103103+//!
104104+//! This lets async methods return a `Response` that owns its buffer, then the *caller* decides
105105+//! the lifetime strategy:
106106+//!
107107+//! ```ignore
108108+//! // Zero-copy: borrow from the owned buffer
109109+//! let output: R::Output<'_> = response.parse()?;
110110+//!
111111+//! // Owned: convert to 'static via IntoStatic
112112+//! let output: R::Output<'static> = response.into_output()?;
113113+//! ```
114114+//!
115115+//! The async method doesn't need to know or care about lifetimes - it just returns the `Response`.
116116+//! The caller gets full control over whether to use borrowed or owned data. It can even decide
117117+//! after the fact that it doesn't want to parse out the API response type that it asked for. Instead
118118+//! it can call `.parse_data()` or `.parse_raw()` on the response to get loosely typed, validated
119119+//! data or minimally typed maximally accepting data values out.
120120+//!
121121+//! ### Example: XRPC Traits in Practice
122122+//!
123123+//! Here's how the pattern works with the XRPC layer:
124124+//!
125125+//! ```ignore
126126+//! // XrpcResp uses GATs, not trait-level lifetime
127127+//! trait XrpcResp {
128128+//! const NSID: &'static str;
129129+//! type Output<'de>: Deserialize<'de> + IntoStatic;
130130+//! type Err<'de>: Deserialize<'de> + IntoStatic;
131131+//! }
132132+//!
133133+//! // Response owns the buffer (Bytes is refcounted)
134134+//! pub struct Response<R: XrpcResp> {
135135+//! buffer: Bytes,
136136+//! status: StatusCode,
137137+//! _marker: PhantomData<R>,
138138+//! }
139139+//!
140140+//! impl<R: XrpcResp> Response<R> {
141141+//! // Borrow from owned buffer
142142+//! pub fn parse(&self) -> XrpcResult<R::Output<'_>> {
143143+//! serde_json::from_slice(&self.buffer)
144144+//! }
145145+//!
146146+//! // Convert to fully owned
147147+//! pub fn into_output(self) -> XrpcResult<R::Output<'static>> {
148148+//! let borrowed = self.parse()?;
149149+//! Ok(borrowed.into_static())
150150+//! }
151151+//! }
152152+//!
153153+//! // Async method returns Response, caller chooses strategy
154154+//! async fn send_xrpc<Req>(&self, req: Req) -> Result<Response<Req::Response>>
155155+//! where
156156+//! Req: XrpcRequest<'_>
157157+//! {
158158+//! // Do HTTP call, get Bytes buffer
159159+//! // Return Response wrapping that buffer
160160+//! // No lifetime issues - Response owns the buffer
161161+//! }
162162+//!
163163+//! // Usage:
164164+//! let response = send_xrpc(request).await?;
165165+//!
166166+//! // Zero-copy: borrow from response buffer
167167+//! let output = response.parse()?; // Output<'_> borrows from response
168168+//!
169169+//! // Or owned: convert to 'static
170170+//! let output = response.into_output()?; // Output<'static> is fully owned
171171+//! ```
172172+//!
173173+//! When you see types like `Response<R: XrpcResp>` or methods with lifetime parameters,
174174+//! this is the pattern at work. It looks a bit funky, but it's solving a specific problem
175175+//! in a way that doesn't require unsafe code or much actual work from you, if you're using it.
176176+//! It's also not too bad to write, once you're aware of the pattern and why it works. If you run
177177+//! into a lifetime/borrowing inference issue in jacquard, please contact the crate author. She'd
178178+//! be happy to debug, and if it's using a method from one of the jacquard crates and seems like
179179+//! it *should* just work, that is a bug in jacquard, and you should [file an issue](https://tangled.org/@nonbinary.computer/jacquard/).
21803181#![warn(missing_docs)]
182182+pub use bytes;
183183+pub use chrono;
4184pub use cowstr::CowStr;
5185pub use into_static::IntoStatic;
6186pub use smol_str;
···21201pub mod session;
22202/// Baseline fundamental AT Protocol data types.
23203pub mod types;
2424-/// XRPC protocol types and traits
204204+// XRPC protocol types and traits
25205pub mod xrpc;
2620627207/// Authorization token types for XRPC requests.
+38-1
crates/jacquard-common/src/types/collection.rs
···11use core::fmt;
2233-use serde::Serialize;
33+use serde::{Deserialize, Serialize};
4455+use crate::IntoStatic;
66+use crate::types::value::Data;
57use crate::types::{
68 aturi::RepoPath,
79 nsid::Nsid,
810 recordkey::{RecordKey, RecordKeyType, Rkey},
911};
1212+use crate::xrpc::XrpcResp;
10131114/// Trait for a collection of records that can be stored in a repository.
1215///
···1619pub trait Collection: fmt::Debug + Serialize {
1720 /// The NSID for the Lexicon that defines the schema of records in this collection.
1821 const NSID: &'static str;
2222+2323+ /// A marker type implementing [`XrpcResp`] that allows typed deserialization of records
2424+ /// from this collection. Used by [`Agent::get_record`] to return properly typed responses.
2525+ type Record: XrpcResp;
19262027 /// Returns the [`Nsid`] for the Lexicon that defines the schema of records in this
2128 /// collection.
···4956 }
5057 }
5158}
5959+6060+/// Generic error type for record retrieval operations.
6161+///
6262+/// Used by generated collection marker types as their error type.
6363+#[derive(
6464+ Debug, Clone, PartialEq, Eq, Serialize, Deserialize, thiserror::Error, miette::Diagnostic,
6565+)]
6666+#[serde(tag = "error", content = "message")]
6767+pub enum RecordError<'a> {
6868+ /// The requested record was not found
6969+ #[error("RecordNotFound")]
7070+ #[serde(rename = "RecordNotFound")]
7171+ RecordNotFound(Option<String>),
7272+ /// An unknown error occurred
7373+ #[error("Unknown")]
7474+ #[serde(rename = "Unknown")]
7575+ #[serde(borrow)]
7676+ Unknown(Data<'a>),
7777+}
7878+7979+impl IntoStatic for RecordError<'_> {
8080+ type Output = RecordError<'static>;
8181+8282+ fn into_static(self) -> Self::Output {
8383+ match self {
8484+ RecordError::RecordNotFound(msg) => RecordError::RecordNotFound(msg),
8585+ RecordError::Unknown(data) => RecordError::Unknown(data.into_static()),
8686+ }
8787+ }
8888+}
···358358/// similar to `serde_json::from_value()`.
359359///
360360/// # Example
361361-/// ```ignore
362362-/// use jacquard_common::types::value::{Data, from_data};
363363-/// use serde::Deserialize;
364364-///
361361+/// ```
362362+/// # use jacquard_common::types::value::{Data, from_data};
363363+/// # use serde::Deserialize;
364364+/// #
365365/// #[derive(Deserialize)]
366366/// struct Post<'a> {
367367/// #[serde(borrow)]
···370370/// author: &'a str,
371371/// }
372372///
373373-/// let data: Data = /* ... */;
373373+/// # fn example() -> Result<(), Box<dyn std::error::Error>> {
374374+/// # let json = serde_json::json!({"text": "hello", "author": "alice"});
375375+/// # let data = Data::from_json(&json)?;
374376/// let post: Post = from_data(&data)?;
377377+/// # Ok(())
378378+/// # }
375379/// ```
376380pub fn from_data<'de, T>(data: &'de Data<'de>) -> Result<T, DataDeserializerError>
377381where
···385389/// Allows extracting strongly-typed structures from untyped `RawData` values.
386390///
387391/// # Example
388388-/// ```ignore
389389-/// use jacquard_common::types::value::{RawData, from_raw_data};
390390-/// use serde::Deserialize;
391391-///
392392-/// #[derive(Deserialize)]
393393-/// struct Post<'a> {
394394-/// #[serde(borrow)]
395395-/// text: &'a str,
396396-/// #[serde(borrow)]
397397-/// author: &'a str,
392392+/// ```
393393+/// # use jacquard_common::types::value::{RawData, from_raw_data, to_raw_data};
394394+/// # use serde::{Serialize, Deserialize};
395395+/// #
396396+/// #[derive(Serialize, Deserialize)]
397397+/// struct Post {
398398+/// text: String,
399399+/// author: String,
398400/// }
399401///
400400-/// let data: RawData = /* ... */;
402402+/// # fn example() -> Result<(), Box<dyn std::error::Error>> {
403403+/// # let orig = Post { text: "hello".to_string(), author: "alice".to_string() };
404404+/// # let data = to_raw_data(&orig)?;
401405/// let post: Post = from_raw_data(&data)?;
406406+/// # Ok(())
407407+/// # }
402408/// ```
403409pub fn from_raw_data<'de, T>(data: &'de RawData<'de>) -> Result<T, DataDeserializerError>
404410where
···412418/// Allows converting strongly-typed structures into untyped `RawData` values.
413419///
414420/// # Example
415415-/// ```ignore
416416-/// use jacquard_common::types::value::{RawData, to_raw_data};
417417-/// use serde::Serialize;
418418-///
421421+/// ```
422422+/// # use jacquard_common::types::value::{RawData, to_raw_data};
423423+/// # use serde::Serialize;
424424+/// #
419425/// #[derive(Serialize)]
420426/// struct Post {
421427/// text: String,
422428/// likes: i64,
423429/// }
424430///
431431+/// # fn example() -> Result<(), Box<dyn std::error::Error>> {
425432/// let post = Post { text: "hello".to_string(), likes: 42 };
426433/// let data: RawData = to_raw_data(&post)?;
434434+/// # Ok(())
435435+/// # }
427436/// ```
428437pub fn to_raw_data<T>(value: &T) -> Result<RawData<'static>, serde_impl::RawDataSerializerError>
429438where
···437446/// Combines `to_raw_data()` and validation/type inference in one step.
438447///
439448/// # Example
440440-/// ```ignore
441441-/// use jacquard_common::types::value::{Data, to_data};
442442-/// use serde::Serialize;
443443-///
449449+/// ```
450450+/// # use jacquard_common::types::value::{Data, to_data};
451451+/// # use serde::Serialize;
452452+/// #
444453/// #[derive(Serialize)]
445454/// struct Post {
446455/// text: String,
447456/// did: String, // Will be inferred as Did if valid
448457/// }
449458///
459459+/// # fn example() -> Result<(), Box<dyn std::error::Error>> {
450460/// let post = Post {
451461/// text: "hello".to_string(),
452462/// did: "did:plc:abc123".to_string()
453463/// };
454464/// let data: Data = to_data(&post)?;
465465+/// # Ok(())
466466+/// # }
455467/// ```
456468pub fn to_data<T>(value: &T) -> Result<Data<'static>, convert::ConversionError>
457469where
+46-51
crates/jacquard-common/src/xrpc.rs
···11-//! Stateless XRPC utilities and request/response mapping
11+//! # Stateless XRPC utilities and request/response mapping
22//!
33//! Mapping overview:
44//! - Success (2xx): parse body into the endpoint's typed output.
···99//! can inspect `error="invalid_token"` or `error="use_dpop_nonce"` and refresh/retry.
1010//! If the header is absent, parse the body and map auth errors to
1111//! `AuthError::TokenExpired`/`InvalidToken`.
1212-//!
1312use bytes::Bytes;
1413use http::{
1514 HeaderName, HeaderValue, Request, StatusCode,
···135134///
136135/// It is implemented by the code generation on a marker struct, like the client-side [XrpcResp] trait.
137136pub trait XrpcEndpoint {
138138- /// Fully-qualified path ('/xrpc/[nsid]') where this endpoint should live on the server
137137+ /// Fully-qualified path ('/xrpc/\[nsid\]') where this endpoint should live on the server
139138 const PATH: &'static str;
140139 /// XRPC method (query/GET or procedure/POST)
141140 const METHOD: XrpcMethod;
···195194/// Extension for stateless XRPC calls on any `HttpClient`.
196195///
197196/// Example
198198-/// ```ignore
199199-/// use jacquard::client::XrpcExt;
200200-/// use jacquard::api::app_bsky::feed::get_author_feed::GetAuthorFeed;
201201-/// use jacquard::types::ident::AtIdentifier;
202202-/// use miette::IntoDiagnostic;
197197+/// ```no_run
198198+/// # #[tokio::main]
199199+/// # async fn main() -> Result<(), Box<dyn std::error::Error>> {
200200+/// use jacquard_common::xrpc::XrpcExt;
201201+/// use jacquard_common::http_client::HttpClient;
203202///
204204-/// #[tokio::main]
205205-/// async fn main() -> miette::Result<()> {
206206-/// let http = reqwest::Client::new();
207207-/// let base = url::Url::parse("https://public.api.bsky.app")?;
208208-/// let resp = http
209209-/// .xrpc(base)
210210-/// .send(
211211-/// GetAuthorFeed::new()
212212-/// .actor(AtIdentifier::new_static("pattern.atproto.systems").unwrap())
213213-/// .limit(5)
214214-/// .build(),
215215-/// )
216216-/// .await?;
217217-/// let out = resp.into_output()?;
218218-/// println!("author feed:\n{}", serde_json::to_string_pretty(&out).into_diagnostic()?);
219219-/// Ok(())
220220-/// }
203203+/// let http = reqwest::Client::new();
204204+/// let base = url::Url::parse("https://public.api.bsky.app")?;
205205+/// // let resp = http.xrpc(base).send(&request).await?;
206206+/// # Ok(())
207207+/// # }
221208/// ```
222209pub trait XrpcExt: HttpClient {
223210 /// Start building an XRPC call for the given base URL.
···256243/// Stateless XRPC call builder.
257244///
258245/// Example (per-request overrides)
259259-/// ```ignore
260260-/// use jacquard::client::{XrpcExt, AuthorizationToken};
261261-/// use jacquard::api::app_bsky::feed::get_author_feed::GetAuthorFeed;
262262-/// use jacquard::types::ident::AtIdentifier;
263263-/// use jacquard::CowStr;
264264-/// use miette::IntoDiagnostic;
246246+/// ```no_run
247247+/// # #[tokio::main]
248248+/// # async fn main() -> Result<(), Box<dyn std::error::Error>> {
249249+/// use jacquard_common::xrpc::XrpcExt;
250250+/// use jacquard_common::{AuthorizationToken, CowStr};
265251///
266266-/// #[tokio::main]
267267-/// async fn main() -> miette::Result<()> {
268268-/// let http = reqwest::Client::new();
269269-/// let base = url::Url::parse("https://public.api.bsky.app")?;
270270-/// let resp = http
271271-/// .xrpc(base)
272272-/// .auth(AuthorizationToken::Bearer(CowStr::from("ACCESS_JWT")))
273273-/// .accept_labelers(vec![CowStr::from("did:plc:labelerid")])
274274-/// .header(http::header::USER_AGENT, http::HeaderValue::from_static("jacquard-example"))
275275-/// .send(
276276-/// GetAuthorFeed::new()
277277-/// .actor(AtIdentifier::new_static("pattern.atproto.systems").unwrap())
278278-/// .limit(5)
279279-/// .build(),
280280-/// )
281281-/// .await?;
282282-/// let out = resp.into_output()?;
283283-/// println!("{}", serde_json::to_string_pretty(&out).into_diagnostic()?);
284284-/// Ok(())
285285-/// }
252252+/// let http = reqwest::Client::new();
253253+/// let base = url::Url::parse("https://public.api.bsky.app")?;
254254+/// let call = http
255255+/// .xrpc(base)
256256+/// .auth(AuthorizationToken::Bearer(CowStr::from("ACCESS_JWT")))
257257+/// .accept_labelers(vec![CowStr::from("did:plc:labelerid")])
258258+/// .header(http::header::USER_AGENT, http::HeaderValue::from_static("jacquard-example"));
259259+/// // let resp = call.send(&request).await?;
260260+/// # Ok(())
261261+/// # }
286262/// ```
287263pub struct XrpcCall<'a, C: HttpClient> {
288264 pub(crate) client: &'a C,
···684660 }
685661 Err(e) => Err(XrpcError::Decode(e)),
686662 }
663663+ }
664664+ }
665665+666666+ /// Reinterpret this response as a different response type.
667667+ ///
668668+ /// This transmutes the response by keeping the same buffer and status code,
669669+ /// but changing the type-level marker. Useful for converting generic XRPC responses
670670+ /// into collection-specific typed responses.
671671+ ///
672672+ /// # Safety
673673+ ///
674674+ /// This is safe in the sense that no memory unsafety occurs, but logical correctness
675675+ /// depends on ensuring the buffer actually contains data that can deserialize to `NEW`.
676676+ /// Incorrect conversion will cause deserialization errors at runtime.
677677+ pub fn transmute<NEW: XrpcResp>(self) -> Response<NEW> {
678678+ Response {
679679+ buffer: self.buffer,
680680+ status: self.status,
681681+ _marker: PhantomData,
687682 }
688683 }
689684}
+77-25
crates/jacquard-derive/src/lib.rs
···11+//! # Derive macros for jacquard lexicon types
22+//!
33+//! This crate provides attribute macros that the code generator uses to add lexicon-specific
44+//! behavior to generated types. You'll rarely need to use these directly unless you're writing
55+//! custom lexicon types by hand. However, deriving IntoStatic will likely be very useful.
66+//!
77+//! ## Macros
88+//!
99+//! ### `#[lexicon]`
1010+//!
1111+//! Adds an `extra_data` field to structs to capture unknown fields during deserialization.
1212+//! This makes objects "open" - they'll accept and preserve fields not defined in the schema.
1313+//!
1414+//! ```ignore
1515+//! #[lexicon]
1616+//! struct Post<'s> {
1717+//! text: &'s str,
1818+//! }
1919+//! // Expands to add:
2020+//! // #[serde(flatten)]
2121+//! // pub extra_data: BTreeMap<SmolStr, Data<'s>>
2222+//! ```
2323+//!
2424+//! ### `#[open_union]`
2525+//!
2626+//! Adds an `Unknown(Data)` variant to enums to make them extensible unions. This lets
2727+//! enums accept variants not defined in your code, storing them as loosely typed atproto `Data`.
2828+//!
2929+//! ```ignore
3030+//! #[open_union]
3131+//! enum RecordEmbed<'s> {
3232+//! #[serde(rename = "app.bsky.embed.images")]
3333+//! Images(Images),
3434+//! }
3535+//! // Expands to add:
3636+//! // #[serde(untagged)]
3737+//! // Unknown(Data<'s>)
3838+//! ```
3939+//!
4040+//! ### `#[derive(IntoStatic)]`
4141+//!
4242+//! Derives conversion from borrowed (`'a`) to owned (`'static`) types by recursively calling
4343+//! `.into_static()` on all fields. Works with structs and enums.
4444+//!
4545+//! ```ignore
4646+//! #[derive(IntoStatic)]
4747+//! struct Post<'a> {
4848+//! text: CowStr<'a>,
4949+//! }
5050+//! // Generates:
5151+//! // impl IntoStatic for Post<'_> {
5252+//! // type Output = Post<'static>;
5353+//! // fn into_static(self) -> Self::Output { ... }
5454+//! // }
5555+//! ```
5656+157use proc_macro::TokenStream;
258use quote::quote;
359use syn::{Data, DeriveInput, Fields, GenericParam, parse_macro_input};
···160216 let (impl_generics, ty_generics, where_clause) = generics.split_for_impl();
161217162218 // Build the Output type with all lifetimes replaced by 'static
163163- let output_generics = generics.params.iter().map(|param| {
164164- match param {
165165- GenericParam::Lifetime(_) => quote! { 'static },
166166- GenericParam::Type(ty) => {
167167- let ident = &ty.ident;
168168- quote! { #ident }
169169- }
170170- GenericParam::Const(c) => {
171171- let ident = &c.ident;
172172- quote! { #ident }
173173- }
219219+ let output_generics = generics.params.iter().map(|param| match param {
220220+ GenericParam::Lifetime(_) => quote! { 'static },
221221+ GenericParam::Type(ty) => {
222222+ let ident = &ty.ident;
223223+ quote! { #ident }
224224+ }
225225+ GenericParam::Const(c) => {
226226+ let ident = &c.ident;
227227+ quote! { #ident }
174228 }
175229 });
176230···182236183237 // Generate the conversion body based on struct/enum
184238 let conversion = match &input.data {
185185- Data::Struct(data_struct) => {
186186- generate_struct_conversion(name, &data_struct.fields)
187187- }
188188- Data::Enum(data_enum) => {
189189- generate_enum_conversion(name, data_enum)
190190- }
239239+ Data::Struct(data_struct) => generate_struct_conversion(name, &data_struct.fields),
240240+ Data::Enum(data_enum) => generate_enum_conversion(name, data_enum),
191241 Data::Union(_) => {
192192- return syn::Error::new_spanned(
193193- input,
194194- "IntoStatic cannot be derived for unions"
195195- )
196196- .to_compile_error()
197197- .into();
242242+ return syn::Error::new_spanned(input, "IntoStatic cannot be derived for unions")
243243+ .to_compile_error()
244244+ .into();
198245 }
199246 };
200247···239286 }
240287}
241288242242-fn generate_enum_conversion(name: &syn::Ident, data_enum: &syn::DataEnum) -> proc_macro2::TokenStream {
289289+fn generate_enum_conversion(
290290+ name: &syn::Ident,
291291+ data_enum: &syn::DataEnum,
292292+) -> proc_macro2::TokenStream {
243293 let variants = data_enum.variants.iter().map(|variant| {
244294 let variant_name = &variant.ident;
245295 match &variant.fields {
···258308 }
259309 Fields::Unnamed(fields) => {
260310 let field_bindings: Vec<_> = (0..fields.unnamed.len())
261261- .map(|i| syn::Ident::new(&format!("field_{}", i), proc_macro2::Span::call_site()))
311311+ .map(|i| {
312312+ syn::Ident::new(&format!("field_{}", i), proc_macro2::Span::call_site())
313313+ })
262314 .collect();
263315 let field_conversions = field_bindings.iter().map(|binding| {
264316 quote! { #binding.into_static() }
+64-11
crates/jacquard-identity/src/lib.rs
···11-//! Identity resolution utilities: DID and handle resolution, DID document fetch,
22-//! and helpers for PDS endpoint discovery. See `identity::resolver` for details.
33-//! Identity resolution: handle → DID and DID → document, with smart fallbacks.
11+//! Identity resolution for the AT Protocol
22+//!
33+//! Jacquard's handle-to-DID and DID-to-document resolution with configurable
44+//! fallback chains.
55+//!
66+//! ## Quick start
77+//!
88+//! ```no_run
99+//! # async fn example() -> Result<(), Box<dyn std::error::Error>> {
1010+//! use jacquard_identity::{PublicResolver, resolver::IdentityResolver};
1111+//! use jacquard_common::types::string::Handle;
1212+//!
1313+//! let resolver = PublicResolver::default();
1414+//!
1515+//! // Resolve handle to DID
1616+//! let did = resolver.resolve_handle(&Handle::new("alice.bsky.social")?).await?;
1717+//!
1818+//! // Fetch DID document
1919+//! let doc_response = resolver.resolve_did_doc(&did).await?;
2020+//! let doc = doc_response.parse()?; // Borrow from response buffer
2121+//! # Ok(())
2222+//! # }
2323+//! ```
424//!
55-//! Fallback order (default):
66-//! - Handle → DID: DNS TXT (if `dns` feature) → HTTPS well-known → PDS XRPC
77-//! `resolveHandle` (when `pds_fallback` is configured) → public API fallback → Slingshot `resolveHandle` (if configured).
88-//! - DID → Doc: did:web well-known → PLC/Slingshot HTTP → PDS XRPC `resolveDid` (when configured),
99-//! then Slingshot mini‑doc (partial) if configured.
2525+//! ## Resolution fallback order
1026//!
1111-//! Parsing returns a `DidDocResponse` so callers can borrow from the response buffer
1212-//! and optionally validate the document `id` against the requested DID.
2727+//! **Handle → DID** (configurable via [`resolver::HandleStep`]):
2828+//! 1. DNS TXT record at `_atproto.{handle}` (if `dns` feature enabled)
2929+//! 2. HTTPS well-known at `https://{handle}/.well-known/atproto-did`
3030+//! 3. PDS XRPC `com.atproto.identity.resolveHandle` (if PDS configured)
3131+//! 4. Public API fallback (`https://public.api.bsky.app`)
3232+//! 5. Slingshot `resolveHandle` (if configured)
3333+//!
3434+//! **DID → Document** (configurable via [`resolver::DidStep`]):
3535+//! 1. `did:web` HTTPS well-known
3636+//! 2. PLC directory HTTP (for `did:plc`)
3737+//! 3. PDS XRPC `com.atproto.identity.resolveDid` (if PDS configured)
3838+//! 4. Slingshot mini-doc (partial document)
3939+//!
4040+//! ## Customization
4141+//!
4242+//! ```
4343+//! use jacquard_identity::JacquardResolver;
4444+//! use jacquard_identity::resolver::{ResolverOptions, PlcSource};
4545+//!
4646+//! let opts = ResolverOptions {
4747+//! plc_source: PlcSource::slingshot_default(),
4848+//! public_fallback_for_handle: true,
4949+//! validate_doc_id: true,
5050+//! ..Default::default()
5151+//! };
5252+//!
5353+//! let resolver = JacquardResolver::new(reqwest::Client::new(), opts);
5454+//! #[cfg(feature = "dns")]
5555+//! let resolver = resolver.with_system_dns(); // Enable DNS TXT resolution
5656+//! ```
5757+//!
5858+//! ## Response types
5959+//!
6060+//! Resolution methods return wrapper types that own the response buffer, allowing
6161+//! zero-copy parsing:
6262+//!
6363+//! - [`resolver::DidDocResponse`] - Full DID document response
6464+//! - [`MiniDocResponse`] - Slingshot mini-doc response (partial)
6565+//!
6666+//! Both support `.parse()` for borrowing and validation.
13671468// use crate::CowStr; // not currently needed directly here
1569pub mod resolver;
···387441 }
388442 PlcSource::Slingshot { base } => base.join(did.as_str())?,
389443 };
390390- println!("Fetching DID document from {}", url);
391444 if let Ok((buf, status)) = self.get_json_bytes(url).await {
392445 return Ok(DidDocResponse {
393446 buffer: buf,
+100-19
crates/jacquard-lexicon/src/codegen/structs.rs
···66use proc_macro2::TokenStream;
77use quote::quote;
8899-use super::utils::{make_ident, value_to_variant_name};
109use super::CodeGenerator;
1010+use super::utils::{make_ident, value_to_variant_name};
11111212/// Enum variant kind for IntoStatic generation
1313#[derive(Debug, Clone)]
···5252 match field_type {
5353 LexObjectProperty::Union(union) => {
5454 // Skip empty, single-variant unions unless they're self-referential
5555- if !union.refs.is_empty() && (union.refs.len() > 1 || self.is_self_referential_union(nsid, &type_name, union)) {
5656- let union_name = self.generate_field_type_name(nsid, &type_name, field_name, "");
5555+ if !union.refs.is_empty()
5656+ && (union.refs.len() > 1
5757+ || self.is_self_referential_union(nsid, &type_name, union))
5858+ {
5959+ let union_name =
6060+ self.generate_field_type_name(nsid, &type_name, field_name, "");
5761 let refs: Vec<_> = union.refs.iter().cloned().collect();
5858- let union_def =
5959- self.generate_union(nsid, &union_name, &refs, None, union.closed)?;
6262+ let union_def = self.generate_union(
6363+ nsid,
6464+ &union_name,
6565+ &refs,
6666+ None,
6767+ union.closed,
6868+ )?;
6069 unions.push(union_def);
6170 }
6271 }
6372 LexObjectProperty::Object(nested_obj) => {
6464- let object_name = self.generate_field_type_name(nsid, &type_name, field_name, "");
7373+ let object_name =
7474+ self.generate_field_type_name(nsid, &type_name, field_name, "");
6575 let obj_def = self.generate_object(nsid, &object_name, nested_obj)?;
6676 unions.push(obj_def);
6777 }
···6979 if let LexArrayItem::Union(union) = &array.items {
7080 // Skip single-variant array unions
7181 if union.refs.len() > 1 {
7272- let union_name = self.generate_field_type_name(nsid, &type_name, field_name, "Item");
8282+ let union_name = self.generate_field_type_name(
8383+ nsid, &type_name, field_name, "Item",
8484+ );
7385 let refs: Vec<_> = union.refs.iter().cloned().collect();
7474- let union_def = self.generate_union(nsid, &union_name, &refs, None, union.closed)?;
8686+ let union_def = self.generate_union(
8787+ nsid,
8888+ &union_name,
8989+ &refs,
9090+ None,
9191+ union.closed,
9292+ )?;
7593 unions.push(union_def);
7694 }
7795 }
···8098 }
8199 }
82100101101+ // Generate typed GetRecordOutput wrapper
102102+ let output_type_name = format!("{}GetRecordOutput", type_name);
103103+ let output_type_ident =
104104+ syn::Ident::new(&output_type_name, proc_macro2::Span::call_site());
105105+106106+ let output_wrapper = quote! {
107107+ /// Typed wrapper for GetRecord response with this collection's record type.
108108+ #[derive(serde::Serialize, serde::Deserialize, Debug, Clone, PartialEq, Eq, jacquard_derive::IntoStatic)]
109109+ #[serde(rename_all = "camelCase")]
110110+ pub struct #output_type_ident<'a> {
111111+ #[serde(skip_serializing_if = "std::option::Option::is_none")]
112112+ #[serde(borrow)]
113113+ pub cid: std::option::Option<jacquard_common::types::string::Cid<'a>>,
114114+ #[serde(borrow)]
115115+ pub uri: jacquard_common::types::string::AtUri<'a>,
116116+ #[serde(borrow)]
117117+ pub value: #ident<'a>,
118118+ }
119119+ };
120120+121121+ // Generate marker struct for XrpcResp
122122+ let record_marker_name = format!("{}Record", type_name);
123123+ let record_marker_ident =
124124+ syn::Ident::new(&record_marker_name, proc_macro2::Span::call_site());
125125+126126+ let record_marker = quote! {
127127+ /// Marker type for deserializing records from this collection.
128128+ pub struct #record_marker_ident;
129129+130130+ impl jacquard_common::xrpc::XrpcResp for #record_marker_ident {
131131+ const NSID: &'static str = #nsid;
132132+ const ENCODING: &'static str = "application/json";
133133+ type Output<'de> = #output_type_ident<'de>;
134134+ type Err<'de> = jacquard_common::types::collection::RecordError<'de>;
135135+ }
136136+137137+138138+ };
139139+ let from_impl = quote! {
140140+ impl From<#output_type_ident<'_>> for #ident<'static> {
141141+ fn from(output: #output_type_ident<'_>) -> Self {
142142+ use jacquard_common::IntoStatic;
143143+ output.value.into_static()
144144+ }
145145+ }
146146+ };
147147+83148 // Generate Collection trait impl
84149 let collection_impl = quote! {
85150 impl jacquard_common::types::collection::Collection for #ident<'_> {
86151 const NSID: &'static str = #nsid;
152152+ type Record = #record_marker_ident;
87153 }
88154 };
8915590156 Ok(quote! {
91157 #struct_def
92158 #(#unions)*
159159+ #output_wrapper
160160+ #record_marker
93161 #collection_impl
162162+ #from_impl
94163 })
95164 }
96165 }
···127196 match field_type {
128197 LexObjectProperty::Union(union) => {
129198 // Skip empty, single-variant unions unless they're self-referential
130130- if !union.refs.is_empty() && (union.refs.len() > 1 || self.is_self_referential_union(nsid, &type_name, union)) {
131131- let union_name = self.generate_field_type_name(nsid, &type_name, field_name, "");
199199+ if !union.refs.is_empty()
200200+ && (union.refs.len() > 1
201201+ || self.is_self_referential_union(nsid, &type_name, union))
202202+ {
203203+ let union_name =
204204+ self.generate_field_type_name(nsid, &type_name, field_name, "");
132205 let refs: Vec<_> = union.refs.iter().cloned().collect();
133206 let union_def =
134207 self.generate_union(nsid, &union_name, &refs, None, union.closed)?;
···136209 }
137210 }
138211 LexObjectProperty::Object(nested_obj) => {
139139- let object_name = self.generate_field_type_name(nsid, &type_name, field_name, "");
212212+ let object_name =
213213+ self.generate_field_type_name(nsid, &type_name, field_name, "");
140214 let obj_def = self.generate_object(nsid, &object_name, nested_obj)?;
141215 unions.push(obj_def);
142216 }
···144218 if let LexArrayItem::Union(union) = &array.items {
145219 // Skip single-variant array unions
146220 if union.refs.len() > 1 {
147147- let union_name = self.generate_field_type_name(nsid, &type_name, field_name, "Item");
221221+ let union_name =
222222+ self.generate_field_type_name(nsid, &type_name, field_name, "Item");
148223 let refs: Vec<_> = union.refs.iter().cloned().collect();
149149- let union_def = self.generate_union(nsid, &union_name, &refs, None, union.closed)?;
224224+ let union_def =
225225+ self.generate_union(nsid, &union_name, &refs, None, union.closed)?;
150226 unions.push(union_def);
151227 }
152228 }
···296372 };
297373298374 // Parse ref to get NSID and def name
299299- let (ref_nsid_str, ref_def) = if let Some((nsid, fragment)) = normalized_ref.split_once('#') {
300300- (nsid, fragment)
301301- } else {
302302- (normalized_ref.as_str(), "main")
303303- };
375375+ let (ref_nsid_str, ref_def) =
376376+ if let Some((nsid, fragment)) = normalized_ref.split_once('#') {
377377+ (nsid, fragment)
378378+ } else {
379379+ (normalized_ref.as_str(), "main")
380380+ };
304381305382 // Skip unknown refs - they'll be handled by Unknown variant
306383 if !self.corpus.ref_exists(&normalized_ref) {
···329406 // For other fragments, include the last NSID segment to avoid collisions
330407 // e.g. app.bsky.embed.images#view -> ImagesView
331408 // app.bsky.embed.video#view -> VideoView
332332- format!("{}{}", last_segment.to_pascal_case(), ref_def.to_pascal_case())
409409+ format!(
410410+ "{}{}",
411411+ last_segment.to_pascal_case(),
412412+ ref_def.to_pascal_case()
413413+ )
333414 };
334415335416 variant_infos.push(VariantInfo {
+38
crates/jacquard-lexicon/src/lib.rs
···11+//! # Lexicon schema parsing and Rust code generation for the Jacquard atproto ecosystem
22+//!
33+//! This crate also provides lexicon fetching capabilitiees ofr
44+//!
55+//! ## Usage
66+//!
77+//! ### Fetch lexicons
88+//!
99+//! The `lex-fetch` binary downloads lexicons from configured sources and
1010+//! runs the code generation pipeline on them:
1111+//!
1212+//! ```bash
1313+//! cargo run -p jacquard-lexicon --bin lex-fetch
1414+//! ```
1515+//!
1616+//! Configuration lives in `lexicons.kdl` at the workspace root.
1717+//!
1818+//! ### Generate Rust code
1919+//!
2020+//! The `jacquard-codegen` binary can be pointed at a local directory to
2121+//! runs the code generation pipeline:
2222+//!
2323+//! ```bash
2424+//! cargo run -p jacquard-lexicon --bin jacquard-codegen -- \
2525+//! -i ./lexicons \
2626+//! -o ./crates/jacquard-api/src
2727+//! ```
2828+//!
2929+//!
3030+//! ## Modules
3131+//!
3232+//! - [`codegen`] - Rust code generation from parsed schemas
3333+//! - [`corpus`] - Lexicon corpus management and namespace organization
3434+//! - [`lexicon`] - Schema parsing and validation
3535+//! - [`union_registry`] - Tracks union types for collision detection
3636+//! - [`fetch`] - Ingests lexicons from git, atproto, http fetch, and other sources
3737+//! - [`fs`] - Filesystem utilities for lexicon storage
3838+139pub mod codegen;
240pub mod corpus;
341pub mod error;
···389389}
390390391391#[async_trait::async_trait]
392392-impl jacquard_common::session::SessionStore<
392392+impl
393393+ jacquard_common::session::SessionStore<
393394 crate::client::credential_session::SessionKey,
394395 crate::client::AtpSession,
395396 > for FileAuthStore
···452453#[cfg(test)]
453454mod tests {
454455 use super::*;
455455- use crate::client::credential_session::SessionKey;
456456 use crate::client::AtpSession;
457457+ use crate::client::credential_session::SessionKey;
457458 use jacquard_common::types::string::{Did, Handle};
458459 use std::fs;
459460 use std::path::PathBuf;
+67
crates/jacquard/src/client/vec_update.rs
···11+/// Bluesky actor preferences implementation
22+pub mod preferences;
33+44+pub use preferences::PreferencesUpdate;
55+66+use jacquard_common::IntoStatic;
77+use jacquard_common::xrpc::{XrpcRequest, XrpcResp};
88+99+/// Trait for get-modify-put patterns on vec-based data structures.
1010+///
1111+/// This trait enables convenient update operations for endpoints that return arrays
1212+/// that need to be fetched, modified, and put back. Common use cases include
1313+/// preferences, saved feeds, and similar collection-style data.
1414+///
1515+/// # Example
1616+///
1717+/// ```ignore
1818+/// use jacquard::client::vec_update::VecUpdate;
1919+///
2020+/// struct PreferencesUpdate;
2121+///
2222+/// impl VecUpdate for PreferencesUpdate {
2323+/// type GetRequest = GetPreferences;
2424+/// type PutRequest = PutPreferences;
2525+/// type Item = PreferencesItem<'static>;
2626+///
2727+/// fn extract_vec(output: GetPreferencesOutput<'_>) -> Vec<Self::Item> {
2828+/// output.preferences.into_iter().map(|p| p.into_static()).collect()
2929+/// }
3030+///
3131+/// fn build_put(items: Vec<Self::Item>) -> PutPreferences {
3232+/// PutPreferences { preferences: items }
3333+/// }
3434+///
3535+/// fn matches(a: &Self::Item, b: &Self::Item) -> bool {
3636+/// // Match by enum variant discriminant
3737+/// std::mem::discriminant(a) == std::mem::discriminant(b)
3838+/// }
3939+/// }
4040+/// ```
4141+pub trait VecUpdate {
4242+ /// The XRPC request type for fetching the data
4343+ type GetRequest<'de>: XrpcRequest<'de>;
4444+4545+ /// The XRPC request type for putting the data back
4646+ type PutRequest<'de>: XrpcRequest<'de>;
4747+4848+ /// The item type contained in the vec (must be owned/static)
4949+ type Item: IntoStatic;
5050+5151+ /// Build the get request
5252+ fn build_get<'s>() -> Self::GetRequest<'s>;
5353+5454+ /// Extract the vec from the get response output
5555+ fn extract_vec<'s>(
5656+ output: <<Self::GetRequest<'s> as XrpcRequest<'s>>::Response as XrpcResp>::Output<'s>,
5757+ ) -> Vec<Self::Item>;
5858+5959+ /// Build the put request from the modified vec
6060+ fn build_put<'s>(items: Vec<Self::Item>) -> Self::PutRequest<'s>;
6161+6262+ /// Check if two items match (for single-item update operations)
6363+ ///
6464+ /// This is used by `update_vec_item` to find and replace a single item in the vec.
6565+ /// For example, preferences might match by enum variant discriminant.
6666+ fn matches<'s>(a: &'s Self::Item, b: &'s Self::Item) -> bool;
6767+}
···11+use jacquard_api::app_bsky::actor::PreferencesItem;
22+use jacquard_api::app_bsky::actor::get_preferences::{GetPreferences, GetPreferencesOutput};
33+use jacquard_api::app_bsky::actor::put_preferences::PutPreferences;
44+use jacquard_common::IntoStatic;
55+66+/// VecUpdate implementation for Bluesky actor preferences.
77+///
88+/// Provides get-modify-put operations on user preferences, which are stored
99+/// as a vec of preference items (each identified by enum discriminant).
1010+///
1111+/// # Example
1212+///
1313+/// ```ignore
1414+/// use jacquard::client::vec_update::PreferencesUpdate;
1515+/// use jacquard_api::app_bsky::actor::PreferencesItem;
1616+///
1717+/// // Update all preferences
1818+/// agent.update_vec::<PreferencesUpdate>(|prefs| {
1919+/// // Add a new preference
2020+/// prefs.push(PreferencesItem::AdultContentPref(
2121+/// Box::new(AdultContentPref { enabled: true })
2222+/// ));
2323+///
2424+/// // Remove by variant
2525+/// prefs.retain(|p| !matches!(p, PreferencesItem::InterestsPref(_)));
2626+/// }).await?;
2727+///
2828+/// // Update a single preference (replaces by discriminant)
2929+/// let pref = PreferencesItem::AdultContentPref(
3030+/// Box::new(AdultContentPref { enabled: false })
3131+/// );
3232+/// agent.update_vec_item::<PreferencesUpdate>(pref).await?;
3333+/// ```
3434+pub struct PreferencesUpdate;
3535+3636+impl super::VecUpdate for PreferencesUpdate {
3737+ type GetRequest<'de> = GetPreferences;
3838+ type PutRequest<'de> = PutPreferences<'de>;
3939+ type Item = PreferencesItem<'static>;
4040+4141+ fn build_get<'s>() -> Self::GetRequest<'s> {
4242+ GetPreferences::new().build()
4343+ }
4444+4545+ fn extract_vec<'s>(
4646+ output: GetPreferencesOutput<'s>,
4747+ ) -> Vec<<Self::Item as IntoStatic>::Output> {
4848+ output
4949+ .preferences
5050+ .into_iter()
5151+ .map(|p| p.into_static())
5252+ .collect()
5353+ }
5454+5555+ fn build_put<'s>(items: Vec<<Self::Item as IntoStatic>::Output>) -> Self::PutRequest<'s> {
5656+ PutPreferences::new().preferences(items).build()
5757+ }
5858+5959+ fn matches<'s>(a: &'s Self::Item, b: &'s Self::Item) -> bool {
6060+ // Match preferences by enum variant discriminant
6161+ std::mem::discriminant(a) == std::mem::discriminant(b)
6262+ }
6363+}
+17-13
crates/jacquard/src/lib.rs
···4949//! async fn main() -> miette::Result<()> {
5050//! let args = Args::parse();
5151//!
5252-//! // File-backed auth store shared by OAuthClient and session registry
5353-//! let store = FileAuthStore::new(&args.store);
5454-//! let client_data = jacquard_oauth::session::ClientData {
5555-//! keyset: None,
5656-//! // Default sets normal localhost redirect URIs and "atproto transition:generic" scopes.
5757-//! // The localhost helper will ensure you have at least "atproto" and will fix urls
5858-//! config: AtprotoClientMetadata::default_localhost(),
5959-//! };
6060-//!
6161-//! // Build an OAuth client (this is reusable, and can create multiple sessions)
6262-//! let oauth = OAuthClient::new(store, client_data);
5252+//! // Build an OAuth client with file-backed auth store and default localhost config
5353+//! let oauth = OAuthClient::with_default_config(FileAuthStore::new(&args.store));
6354//! // Authenticate with a PDS, using a loopback server to handle the callback flow
6455//! # #[cfg(feature = "loopback")]
6556//! let session = oauth
···155146//! Ok(())
156147//! }
157148//! ```
149149+//!
150150+//! ## Component Crates
151151+//!
152152+//! Jacquard is split into several crates for modularity. The main `jacquard` crate
153153+//! re-exports most of the others, so you typically only need to depend on it directly.
154154+//!
155155+//! - [`jacquard-common`] - AT Protocol types (DIDs, handles, at-URIs, NSIDs, TIDs, CIDs, etc.)
156156+//! - [`jacquard-api`] - Generated API bindings from 646+ lexicon schemas
157157+//! - [`jacquard-axum`] - Server-side XRPC handler extractors for Axum framework (not re-exported, depends on jacquard)
158158+//! - [`jacquard-oauth`] - OAuth/DPoP flow implementation with session management
159159+//! - [`jacquard-identity`] - Identity resolution (handle→DID, DID→Doc, OAuth metadata)
160160+//! - [`jacquard-lexicon`] - Lexicon resolution, fetching, parsing and Rust code generation from schemas
161161+//! - [`jacquard-derive`] - Macros (`#[lexicon]`, `#[open_union]`, `#[derive(IntoStatic)]`)
158162159163#![warn(missing_docs)]
160164161161-/// XRPC client traits and basic implementation
162165pub mod client;
163166167167+pub use common::*;
164168#[cfg(feature = "api")]
165169/// If enabled, re-export the generated api crate
166170pub use jacquard_api as api;
167167-pub use jacquard_common::*;
171171+pub use jacquard_common as common;
168172169173#[cfg(feature = "derive")]
170174/// if enabled, reexport the attribute macros
···11+use clap::Parser;
22+use jacquard::api::app_bsky::feed::post::Post;
33+use jacquard::client::{Agent, FileAuthStore};
44+use jacquard::oauth::atproto::AtprotoClientMetadata;
55+use jacquard::oauth::client::OAuthClient;
66+use jacquard::oauth::loopback::LoopbackConfig;
77+use jacquard::types::string::Datetime;
88+use jacquard::xrpc::XrpcClient;
99+use jacquard::CowStr;
1010+use miette::IntoDiagnostic;
1111+1212+#[derive(Parser, Debug)]
1313+#[command(author, version, about = "Create a simple post")]
1414+struct Args {
1515+ /// Handle (e.g., alice.bsky.social), DID, or PDS URL
1616+ input: CowStr<'static>,
1717+1818+ /// Post text
1919+ #[arg(short, long)]
2020+ text: String,
2121+2222+ /// Path to auth store file (will be created if missing)
2323+ #[arg(long, default_value = "/tmp/jacquard-oauth-session.json")]
2424+ store: String,
2525+}
2626+2727+#[tokio::main]
2828+async fn main() -> miette::Result<()> {
2929+ let args = Args::parse();
3030+3131+ let oauth = OAuthClient::with_default_config(FileAuthStore::new(&args.store));
3232+ let session = oauth
3333+ .login_with_local_server(args.input, Default::default(), LoopbackConfig::default())
3434+ .await?;
3535+3636+ let agent: Agent<_> = Agent::from(session);
3737+3838+ // Create a simple text post using the Agent convenience method
3939+ let post = Post {
4040+ text: CowStr::from(args.text),
4141+ created_at: Datetime::now(),
4242+ embed: None,
4343+ entities: None,
4444+ facets: None,
4545+ labels: None,
4646+ langs: None,
4747+ reply: None,
4848+ tags: None,
4949+ extra_data: Default::default(),
5050+ };
5151+5252+ let output = agent.create_record(post, None).await?;
5353+ println!("✓ Created post: {}", output.uri);
5454+5555+ Ok(())
5656+}
+66
examples/create_whitewind_post.rs
···11+use clap::Parser;
22+use jacquard::api::com_whtwnd::blog::entry::Entry;
33+use jacquard::client::{Agent, FileAuthStore};
44+use jacquard::oauth::atproto::AtprotoClientMetadata;
55+use jacquard::oauth::client::OAuthClient;
66+use jacquard::oauth::loopback::LoopbackConfig;
77+use jacquard::types::string::Datetime;
88+use jacquard::xrpc::XrpcClient;
99+use jacquard::CowStr;
1010+use miette::IntoDiagnostic;
1111+1212+#[derive(Parser, Debug)]
1313+#[command(author, version, about = "Create a WhiteWind blog post")]
1414+struct Args {
1515+ /// Handle (e.g., alice.bsky.social), DID, or PDS URL
1616+ input: CowStr<'static>,
1717+1818+ /// Blog post title
1919+ #[arg(short, long)]
2020+ title: String,
2121+2222+ /// Blog post content (markdown)
2323+ #[arg(short, long)]
2424+ content: String,
2525+2626+ /// Optional subtitle
2727+ #[arg(short, long)]
2828+ subtitle: Option<String>,
2929+3030+ /// Path to auth store file (will be created if missing)
3131+ #[arg(long, default_value = "/tmp/jacquard-oauth-session.json")]
3232+ store: String,
3333+}
3434+3535+#[tokio::main]
3636+async fn main() -> miette::Result<()> {
3737+ let args = Args::parse();
3838+3939+ let oauth = OAuthClient::with_default_config(FileAuthStore::new(&args.store));
4040+ let session = oauth
4141+ .login_with_local_server(args.input, Default::default(), LoopbackConfig::default())
4242+ .await?;
4343+4444+ let agent: Agent<_> = Agent::from(session);
4545+4646+ // Create a WhiteWind blog entry
4747+ // The content field accepts markdown
4848+ let entry = Entry {
4949+ title: Some(CowStr::from(args.title)),
5050+ subtitle: args.subtitle.map(CowStr::from),
5151+ content: CowStr::from(args.content),
5252+ created_at: Some(Datetime::now()),
5353+ visibility: Some(CowStr::from("url")), // "url" = public with link, "author" = public on profile
5454+ theme: None,
5555+ ogp: None,
5656+ blobs: None,
5757+ is_draft: None,
5858+ extra_data: Default::default(),
5959+ };
6060+6161+ let output = agent.create_record(entry, None).await?;
6262+ println!("✓ Created WhiteWind blog post: {}", output.uri);
6363+ println!(" View at: https://whtwnd.com/post/{}", output.uri);
6464+6565+ Ok(())
6666+}
+97
examples/post_with_image.rs
···11+use clap::Parser;
22+use jacquard::api::app_bsky::embed::images::{Image, Images};
33+use jacquard::api::app_bsky::feed::post::{Post, PostEmbed};
44+use jacquard::client::{Agent, FileAuthStore};
55+use jacquard::oauth::atproto::AtprotoClientMetadata;
66+use jacquard::oauth::client::OAuthClient;
77+use jacquard::oauth::loopback::LoopbackConfig;
88+use jacquard::types::blob::MimeType;
99+use jacquard::types::string::Datetime;
1010+use jacquard::xrpc::XrpcClient;
1111+use jacquard::CowStr;
1212+use miette::IntoDiagnostic;
1313+use std::path::PathBuf;
1414+1515+#[derive(Parser, Debug)]
1616+#[command(author, version, about = "Create a post with an image")]
1717+struct Args {
1818+ /// Handle (e.g., alice.bsky.social), DID, or PDS URL
1919+ input: CowStr<'static>,
2020+2121+ /// Post text
2222+ #[arg(short, long)]
2323+ text: String,
2424+2525+ /// Path to image file
2626+ #[arg(short, long)]
2727+ image: PathBuf,
2828+2929+ /// Alt text for image
3030+ #[arg(long)]
3131+ alt: Option<String>,
3232+3333+ /// Path to auth store file (will be created if missing)
3434+ #[arg(long, default_value = "/tmp/jacquard-oauth-session.json")]
3535+ store: String,
3636+}
3737+3838+#[tokio::main]
3939+async fn main() -> miette::Result<()> {
4040+ let args = Args::parse();
4141+4242+ let oauth = OAuthClient::with_default_config(FileAuthStore::new(&args.store));
4343+ let session = oauth
4444+ .login_with_local_server(args.input, Default::default(), LoopbackConfig::default())
4545+ .await?;
4646+4747+ let agent: Agent<_> = Agent::from(session);
4848+4949+ // Read image file
5050+ let image_data = std::fs::read(&args.image).into_diagnostic()?;
5151+5252+ // Infer mime type from extension
5353+ let mime_str = match args.image.extension().and_then(|s| s.to_str()) {
5454+ Some("jpg") | Some("jpeg") => "image/jpeg",
5555+ Some("png") => "image/png",
5656+ Some("gif") => "image/gif",
5757+ Some("webp") => "image/webp",
5858+ _ => "image/jpeg", // default
5959+ };
6060+ let mime_type = MimeType::new_static(mime_str);
6161+6262+ println!("📤 Uploading image...");
6363+ let blob_ref = agent.upload_blob(image_data, mime_type).await?;
6464+6565+ // Extract the Blob from the BlobRef
6666+ let blob = match blob_ref {
6767+ jacquard::types::blob::BlobRef::Blob(b) => b,
6868+ _ => miette::bail!("Expected Blob, got LegacyBlob"),
6969+ };
7070+7171+ // Create post with image embed
7272+ let post = Post {
7373+ text: CowStr::from(args.text),
7474+ created_at: Datetime::now(),
7575+ embed: Some(PostEmbed::Images(Box::new(Images {
7676+ images: vec![Image {
7777+ alt: CowStr::from(args.alt.unwrap_or_default()),
7878+ image: blob,
7979+ aspect_ratio: None,
8080+ extra_data: Default::default(),
8181+ }],
8282+ extra_data: Default::default(),
8383+ }))),
8484+ entities: None,
8585+ facets: None,
8686+ labels: None,
8787+ langs: None,
8888+ reply: None,
8989+ tags: None,
9090+ extra_data: Default::default(),
9191+ };
9292+9393+ let output = agent.create_record(post, None).await?;
9494+ println!("✓ Created post with image: {}", output.uri);
9595+9696+ Ok(())
9797+}
+32
examples/public_atproto_feed.rs
···11+use jacquard::api::app_bsky::feed::get_feed::GetFeed;
22+use jacquard::api::app_bsky::feed::post::Post;
33+use jacquard::types::string::AtUri;
44+use jacquard::types::value::from_data;
55+use jacquard::xrpc::XrpcExt;
66+use miette::IntoDiagnostic;
77+88+#[tokio::main]
99+async fn main() -> miette::Result<()> {
1010+ // Stateless XRPC - no auth required for public feeds
1111+ let http = reqwest::Client::new();
1212+ let base = url::Url::parse("https://public.api.bsky.app").into_diagnostic()?;
1313+1414+ // Feed of posts about the AT Protocol
1515+ let feed_uri =
1616+ AtUri::new_static("at://did:plc:oio4hkxaop4ao4wz2pp3f4cr/app.bsky.feed.generator/atproto")
1717+ .unwrap();
1818+1919+ let request = GetFeed::new().feed(feed_uri).limit(10).build();
2020+2121+ let response = http.xrpc(base).send(&request).await?;
2222+ let output = response.into_output()?;
2323+2424+ println!("📰 Latest posts from the AT Protocol feed:\n");
2525+ for (i, item) in output.feed.iter().enumerate() {
2626+ // Deserialize the post record from the Data type
2727+ let post: Post = from_data(&item.post.record).into_diagnostic()?;
2828+ println!("{}.(@{})\n{} ", i + 1, item.post.author.handle, post.text);
2929+ }
3030+3131+ Ok(())
3232+}
+63
examples/read_tangled_repo.rs
···11+use clap::Parser;
22+use jacquard::api::sh_tangled::repo::Repo;
33+use jacquard::client::BasicClient;
44+use jacquard::types::string::AtUri;
55+66+#[derive(Parser, Debug)]
77+#[command(author, version, about = "Read a Tangled git repository record")]
88+struct Args {
99+ /// at:// URI of the repo record
1010+ /// Example: at://did:plc:xyz/sh.tangled.repo/3lzabc123
1111+ /// The default is the jacquard repository
1212+ #[arg(default_value = "at://did:plc:yfvwmnlztr4dwkb7hwz55r2g/sh.tangled.repo/3lzrya6fcwv22")]
1313+ uri: String,
1414+}
1515+1616+#[tokio::main]
1717+async fn main() -> miette::Result<()> {
1818+ let args = Args::parse();
1919+2020+ // Parse the at:// URI
2121+ let uri = AtUri::new(&args.uri)?;
2222+2323+ // Create an unauthenticated agent for public record access
2424+ let agent = BasicClient::unauthenticated();
2525+2626+ // Use Agent's get_record helper with the at:// URI
2727+ let response = agent.get_record::<Repo>(uri).await?;
2828+ let output = response.into_output()?;
2929+3030+ println!("Tangled Repository\n");
3131+ println!("URI: {}", output.uri);
3232+ println!("Name: {}", output.value.name);
3333+3434+ if let Some(desc) = &output.value.description {
3535+ println!("Description: {}", desc);
3636+ }
3737+3838+ println!("Knot: {}", output.value.knot);
3939+ println!("Created: {}", output.value.created_at);
4040+4141+ if let Some(source) = &output.value.source {
4242+ println!("Source: {}", source.as_str());
4343+ }
4444+4545+ if let Some(spindle) = &output.value.spindle {
4646+ println!("CI Spindle: {}", spindle);
4747+ }
4848+4949+ if let Some(labels) = &output.value.labels {
5050+ if !labels.is_empty() {
5151+ println!(
5252+ "Labels available: {}",
5353+ labels
5454+ .iter()
5555+ .map(|l| l.to_string())
5656+ .collect::<Vec<_>>()
5757+ .join(", ")
5858+ );
5959+ }
6060+ }
6161+6262+ Ok(())
6363+}
+40
examples/read_whitewind_posts.rs
···11+use clap::Parser;
22+use jacquard::api::com_whtwnd::blog::entry::Entry;
33+use jacquard::client::BasicClient;
44+use jacquard::types::string::AtUri;
55+66+#[derive(Parser, Debug)]
77+#[command(author, version, about = "Read a WhiteWind blog post")]
88+struct Args {
99+ /// at:// URI of the blog entry
1010+ /// Example: at://did:plc:xyz/com.whtwnd.blog.entry/3l5abc123
1111+ uri: String,
1212+}
1313+1414+#[tokio::main]
1515+async fn main() -> miette::Result<()> {
1616+ let args = Args::parse();
1717+1818+ // Parse the at:// URI
1919+ let uri = AtUri::new(&args.uri)?;
2020+2121+ // Create an unauthenticated agent for public record access
2222+ let agent = BasicClient::unauthenticated();
2323+2424+ // Use Agent's get_record helper with the at:// URI
2525+ let response = agent.get_record::<Entry>(uri).await?;
2626+ let output = response.into_output()?;
2727+2828+ println!("📚 WhiteWind Blog Entry\n");
2929+ println!("URI: {}", output.uri);
3030+ println!("Title: {}", output.value.title.as_deref().unwrap_or("[Untitled]"));
3131+ if let Some(subtitle) = &output.value.subtitle {
3232+ println!("Subtitle: {}", subtitle);
3333+ }
3434+ if let Some(created) = &output.value.created_at {
3535+ println!("Created: {}", created);
3636+ }
3737+ println!("\n{}", output.value.content);
3838+3939+ Ok(())
4040+}
+100
examples/resolve_did.rs
···11+use clap::Parser;
22+use jacquard::client::BasicClient;
33+use jacquard::types::string::Handle;
44+use jacquard_identity::resolver::IdentityResolver;
55+use miette::IntoDiagnostic;
66+77+#[derive(Parser, Debug)]
88+#[command(author, version, about = "Resolve a handle to its DID document")]
99+struct Args {
1010+ /// Handle to resolve (e.g., alice.bsky.social)
1111+ #[arg(default_value = "pfrazee.com")]
1212+ handle: String,
1313+}
1414+1515+#[tokio::main]
1616+async fn main() -> miette::Result<()> {
1717+ let args = Args::parse();
1818+1919+ // Parse the handle
2020+ let handle = Handle::new(&args.handle)?;
2121+2222+ // Create an unauthenticated client with identity resolver
2323+ let client = BasicClient::unauthenticated();
2424+2525+ // Resolve handle to DID
2626+ println!("Resolving handle: {}", handle);
2727+ let did = client
2828+ .resolve_handle(&handle)
2929+ .await
3030+ .map_err(|e| miette::miette!("Failed to resolve handle: {}", e))?;
3131+3232+ println!("DID: {}\n", did);
3333+3434+ // Resolve DID document
3535+ let doc_response = client
3636+ .resolve_did_doc(&did)
3737+ .await
3838+ .map_err(|e| miette::miette!("Failed to resolve DID document: {}", e))?;
3939+4040+ let doc = doc_response
4141+ .parse()
4242+ .map_err(|e| miette::miette!("Failed to parse DID document: {}", e))?;
4343+4444+ println!("DID Document:");
4545+ println!("ID: {}", doc.id);
4646+4747+ if let Some(aka) = &doc.also_known_as {
4848+ if !aka.is_empty() {
4949+ println!("\nAlso Known As:");
5050+ for handle in aka {
5151+ println!(" - {}", handle);
5252+ }
5353+ }
5454+ }
5555+5656+ if let Some(verification_methods) = &doc.verification_method {
5757+ println!("\nVerification Methods:");
5858+ for method in verification_methods {
5959+ println!(" ID: {}", method.id);
6060+ println!(" Type: {}", method.r#type);
6161+ if let Some(controller) = &method.controller {
6262+ println!(" Controller: {}", controller);
6363+ }
6464+ if let Some(key) = &method.public_key_multibase {
6565+ println!(" Public Key (multibase): {}", key);
6666+ }
6767+ if !method.extra_data.is_empty() {
6868+ println!(" Extra fields: {:?}", method.extra_data);
6969+ }
7070+ println!();
7171+ }
7272+ }
7373+7474+ if let Some(services) = &doc.service {
7575+ println!("Services:");
7676+ for service in services {
7777+ println!(" ID: {}", service.id);
7878+ println!(" Type: {}", service.r#type);
7979+ if let Some(endpoint) = &service.service_endpoint {
8080+ println!(" Endpoint: {:?}", endpoint);
8181+ }
8282+ if !service.extra_data.is_empty() {
8383+ println!(" Extra fields: {:?}", service.extra_data);
8484+ }
8585+ println!();
8686+ }
8787+ }
8888+8989+ if !doc.extra_data.is_empty() {
9090+ for (key, value) in &doc.extra_data {
9191+ println!(
9292+ "{}: {}",
9393+ key,
9494+ serde_json::to_string_pretty(value).into_diagnostic()?
9595+ );
9696+ }
9797+ }
9898+9999+ Ok(())
100100+}
+95
examples/update_preferences.rs
···11+use clap::Parser;
22+use jacquard::api::app_bsky::actor::{AdultContentPref, PreferencesItem};
33+use jacquard::client::vec_update::VecUpdate;
44+use jacquard::client::{Agent, FileAuthStore};
55+use jacquard::oauth::atproto::AtprotoClientMetadata;
66+use jacquard::oauth::client::OAuthClient;
77+use jacquard::oauth::loopback::LoopbackConfig;
88+use jacquard::{CowStr, IntoStatic};
99+1010+#[derive(Parser, Debug)]
1111+#[command(author, version, about = "Update Bluesky preferences")]
1212+struct Args {
1313+ /// Handle (e.g., alice.bsky.social), DID, or PDS URL
1414+ input: CowStr<'static>,
1515+1616+ /// Enable adult content
1717+ #[arg(long)]
1818+ enable_adult_content: bool,
1919+2020+ /// Path to auth store file (will be created if missing)
2121+ #[arg(long, default_value = "/tmp/jacquard-oauth-session.json")]
2222+ store: String,
2323+}
2424+2525+/// Helper struct for the VecUpdate pattern on preferences
2626+pub struct PreferencesUpdate;
2727+2828+impl VecUpdate for PreferencesUpdate {
2929+ type GetRequest<'de> = jacquard::api::app_bsky::actor::get_preferences::GetPreferences;
3030+ type PutRequest<'de> = jacquard::api::app_bsky::actor::put_preferences::PutPreferences<'de>;
3131+ type Item = PreferencesItem<'static>;
3232+3333+ fn build_get<'s>() -> Self::GetRequest<'s> {
3434+ jacquard::api::app_bsky::actor::get_preferences::GetPreferences::new().build()
3535+ }
3636+3737+ fn build_put<'s>(items: Vec<Self::Item>) -> Self::PutRequest<'s> {
3838+ jacquard::api::app_bsky::actor::put_preferences::PutPreferences {
3939+ preferences: items,
4040+ extra_data: Default::default(),
4141+ }
4242+ }
4343+4444+ fn extract_vec(
4545+ output: jacquard::api::app_bsky::actor::get_preferences::GetPreferencesOutput<'_>,
4646+ ) -> Vec<Self::Item> {
4747+ output
4848+ .preferences
4949+ .into_iter()
5050+ .map(|p| p.into_static())
5151+ .collect()
5252+ }
5353+5454+ fn matches(a: &Self::Item, b: &Self::Item) -> bool {
5555+ // Match by enum variant discriminant
5656+ std::mem::discriminant(a) == std::mem::discriminant(b)
5757+ }
5858+}
5959+6060+#[tokio::main]
6161+async fn main() -> miette::Result<()> {
6262+ let args = Args::parse();
6363+6464+ let oauth = OAuthClient::with_default_config(FileAuthStore::new(&args.store));
6565+ let session = oauth
6666+ .login_with_local_server(args.input, Default::default(), LoopbackConfig::default())
6767+ .await?;
6868+6969+ let agent: Agent<_> = Agent::from(session);
7070+7171+ // Create the adult content preference
7272+ let adult_pref = AdultContentPref {
7373+ enabled: args.enable_adult_content,
7474+ extra_data: Default::default(),
7575+ };
7676+7777+ // Update preferences using update_vec_item
7878+ // This will replace existing AdultContentPref or add it if not present
7979+ agent
8080+ .update_vec_item::<PreferencesUpdate>(PreferencesItem::AdultContentPref(Box::new(
8181+ adult_pref,
8282+ )))
8383+ .await?;
8484+8585+ println!(
8686+ "✓ Updated adult content preference: {}",
8787+ if args.enable_adult_content {
8888+ "enabled"
8989+ } else {
9090+ "disabled"
9191+ }
9292+ );
9393+9494+ Ok(())
9595+}
+67
examples/update_profile.rs
···11+use clap::Parser;
22+use jacquard::CowStr;
33+use jacquard::api::app_bsky::actor::profile::Profile;
44+use jacquard::client::{Agent, FileAuthStore};
55+use jacquard::oauth::atproto::AtprotoClientMetadata;
66+use jacquard::oauth::client::OAuthClient;
77+use jacquard::oauth::loopback::LoopbackConfig;
88+use jacquard::types::string::AtUri;
99+use jacquard::xrpc::XrpcClient;
1010+use miette::IntoDiagnostic;
1111+1212+#[derive(Parser, Debug)]
1313+#[command(author, version, about = "Update profile display name and description")]
1414+struct Args {
1515+ /// Handle (e.g., alice.bsky.social), DID, or PDS URL
1616+ input: CowStr<'static>,
1717+1818+ /// New display name
1919+ #[arg(long)]
2020+ display_name: Option<String>,
2121+2222+ /// New bio/description
2323+ #[arg(long)]
2424+ description: Option<String>,
2525+2626+ /// Path to auth store file (will be created if missing)
2727+ #[arg(long, default_value = "/tmp/jacquard-oauth-session.json")]
2828+ store: String,
2929+}
3030+3131+#[tokio::main]
3232+async fn main() -> miette::Result<()> {
3333+ let args = Args::parse();
3434+3535+ let oauth = OAuthClient::with_default_config(FileAuthStore::new(&args.store));
3636+ let session = oauth
3737+ .login_with_local_server(args.input, Default::default(), LoopbackConfig::default())
3838+ .await?;
3939+4040+ let agent: Agent<_> = Agent::from(session);
4141+4242+ // Get session info to build the at:// URI for the profile record
4343+ let (did, _) = agent
4444+ .info()
4545+ .await
4646+ .ok_or_else(|| miette::miette!("No session info available"))?;
4747+4848+ // Profile records use "self" as the rkey
4949+ let uri_string = format!("at://{}/app.bsky.actor.profile/self", did);
5050+ let uri = AtUri::new(&uri_string)?;
5151+5252+ // Update profile in-place using the fetch-modify-put pattern
5353+ agent
5454+ .update_record::<Profile>(uri, |profile| {
5555+ if let Some(name) = &args.display_name {
5656+ profile.display_name = Some(CowStr::from(name.clone()));
5757+ }
5858+ if let Some(desc) = &args.description {
5959+ profile.description = Some(CowStr::from(desc.clone()));
6060+ }
6161+ })
6262+ .await?;
6363+6464+ println!("✓ Profile updated successfully");
6565+6666+ Ok(())
6767+}
+44
justfile
···1212# Run 'bacon' to run the project (auto-recompiles)
1313watch *ARGS:
1414 bacon --job run -- -- {{ ARGS }}
1515+1616+# Run the OAuth timeline example
1717+example-oauth *ARGS:
1818+ cargo run -p jacquard --example oauth_timeline --features fancy -- {{ARGS}}
1919+2020+# Create a simple post
2121+example-create-post *ARGS:
2222+ cargo run -p jacquard --example create_post --features fancy -- {{ARGS}}
2323+2424+# Create a post with an image
2525+example-post-image *ARGS:
2626+ cargo run -p jacquard --example post_with_image --features fancy -- {{ARGS}}
2727+2828+# Update profile display name and description
2929+example-update-profile *ARGS:
3030+ cargo run -p jacquard --example update_profile --features fancy -- {{ARGS}}
3131+3232+# Fetch public AT Protocol feed (no auth)
3333+example-public-feed:
3434+ cargo run -p jacquard --example public_atproto_feed
3535+3636+# Create a WhiteWind blog post
3737+example-whitewind-create *ARGS:
3838+ cargo run -p jacquard --example create_whitewind_post --features fancy -- {{ARGS}}
3939+4040+# Read a WhiteWind blog post
4141+example-whitewind-read *ARGS:
4242+ cargo run -p jacquard --example read_whitewind_posts --features fancy,api_full -- {{ARGS}}
4343+4444+# Read info about a Tangled git repository
4545+example-tangled-repo *ARGS:
4646+ cargo run -p jacquard --example read_tangled_repo --features fancy,api_full -- {{ARGS}}
4747+4848+# Resolve a handle to its DID document
4949+example-resolve-did *ARGS:
5050+ cargo run -p jacquard --example resolve_did -- {{ARGS}}
5151+5252+# Update Bluesky preferences
5353+example-update-preferences *ARGS:
5454+ cargo run -p jacquard --example update_preferences --features fancy -- {{ARGS}}
5555+5656+# Run the Axum server example
5757+example-axum:
5858+ cargo run -p jacquard-axum --example axum_server --features jacquard/fancy