A better Rust ATProto crate
at main 34 kB view raw
1# Jacquard: AT Protocol Library for Rust 2 3> Simple and powerful AT Protocol (Bluesky) client library emphasizing spec compliance, zero-copy deserialization, and minimal boilerplate. 4 5## Core Philosophy 6 7**Zero-copy by default, owned when needed.** All API types support borrowed deserialization via lifetimes, with `IntoStatic` trait for conversion to `'static` when needed. This avoids the performance penalty of `DeserializeOwned` while giving you control over ownership. 8 9**Validated types everywhere.** DIDs, handles, AT-URIs, NSIDs, TIDs, CIDs—all have strongly-typed, validated wrappers. Invalid inputs fail at construction time, not deep in your application logic. 10 11**Batteries included, but replaceable.** High-level `Agent` for convenience, or use stateless `XrpcCall` builder for full control. Mix and match as needed. 12 13--- 14 15## Critical Patterns to Internalize 16 17### String Type Constructors 18 19ALL validated string types (Did, Handle, AtUri, Nsid, etc.) follow this pattern: 20 21```rust 22// ✅ PREFERRED: Zero-allocation for borrowed strings 23let did = Did::new("did:plc:abc123")?; // Borrows from input 24 25// ✅ BEST: Zero-allocation for static strings 26let nsid = Nsid::new_static("com.atproto.repo.getRecord")?; 27 28// ✅ When you need ownership 29let owned = Did::new_owned("did:plc:abc123")?; 30 31// ❌ AVOID: FromStr always allocates 32let did: Did = "did:plc:abc123".parse()?; // Always allocates! 33 34// ❌ NEVER: Roundtripping through String 35let s = did.as_str().to_string(); 36let did2 = Did::new(&s)?; // Pointless allocation 37``` 38 39**Rule**: Use `new()` for borrowed, `new_static()` for `'static` strings, `new_owned()` when you have ownership. Avoid `FromStr::parse()` unless you don't care about allocations. 40 41### Response Parsing: Borrow vs Own 42 43XRPC responses wrap a `Bytes` buffer. You choose when to parse and whether to own the data: 44 45```rust 46let response = agent.send(request).await?; 47 48// Option 1: Borrow from response buffer (zero-copy) 49let output: GetPostOutput<'_> = response.parse()?; 50// ⚠️ `output` borrows from `response`, both must stay in scope 51 52// Option 2: Convert to owned (allocates, but can outlive response) 53let output: GetPostOutput<'static> = response.into_output()?; 54drop(response); // OK, output is now fully owned 55 56// ❌ WRONG: Can't drop response while holding borrowed parse 57let output = response.parse()?; 58drop(response); // ERROR: output borrows from response 59``` 60 61**Rule**: Use `.parse()` when processing immediately in the same scope. Use `.into_output()` when returning from functions or storing long-term. 62 63### Lifetime Pattern: GATs Not HRTBs 64 65Jacquard uses **Generic Associated Types** on `XrpcResp` to avoid Higher-Rank Trait Bounds: 66 67```rust 68// ✅ Jacquard's approach (GAT) 69trait XrpcResp { 70 type Output<'de>: Deserialize<'de> + IntoStatic; 71 type Err<'de>: Error + Deserialize<'de> + IntoStatic; 72} 73 74// ❌ Alternative that forces DeserializeOwned semantics 75trait BadXrpcResp<'de> { 76 type Output: Deserialize<'de>; 77} 78// Would require: where R: for<'any> BadXrpcResp<'any> 79// This forces owned deserialization! 80``` 81 82**Why this matters**: Async methods return `Response<R>` that owns the buffer. Caller controls lifetime by choosing `.parse()` (borrow) or `.into_output()` (owned). No HRTB needed, zero-copy works in async contexts. 83 84**When implementing custom types**: Use method-level lifetime generics (`<'de>`), not trait-level lifetimes. 85 86### CRITICAL: Never Use `for<'de> Deserialize<'de>` Bounds 87 88**The bound `T: for<'de> Deserialize<'de>` is EQUIVALENT to `T: DeserializeOwned`** and will break all Jacquard types. 89 90```rust 91// ❌ CATASTROPHIC - No Jacquard type can satisfy this 92fn bad<T>(data: &[u8]) -> Result<T> 93where 94 T: for<'de> Deserialize<'de> // Forces owned deserialization! 95{ 96 serde_json::from_slice(data) // Can't borrow from data 97} 98 99// ✅ CORRECT - Use method-level lifetime 100fn good<'de, T>(data: &'de [u8]) -> Result<T> 101where 102 T: Deserialize<'de> // Can borrow from data 103{ 104 serde_json::from_slice(data) 105} 106``` 107 108**Why this breaks**: `CowStr<'a>` and all Jacquard types need to borrow from the input buffer. The HRTB `for<'de>` means "must work with ANY lifetime", which forces the deserializer to allocate owned copies instead of borrowing. 109 110**What to do instead**: Always use method-level lifetime parameters (`<'de>`) and pass the lifetime through to the `Deserialize` bound. Jacquard's entire design (GATs, Response wrapper, IntoStatic) exists to make this pattern work in async contexts. 111 112--- 113 114## Crate-by-Crate Guide 115 116### jacquard (Main Crate) 117 118**Primary entry point**: `Agent<A: AgentSession>` 119 120```rust 121use jacquard::client::{Agent, CredentialSession, MemorySessionStore}; 122use jacquard::identity::PublicResolver; 123 124// App password auth 125let (session, _info) = CredentialSession::authenticated( 126 "alice.bsky.social".into(), 127 "app-password".into(), 128 None, // session_id 129).await?; 130let agent = Agent::from(session); 131 132// Make typed XRPC calls 133use jacquard::api::app_bsky::feed::get_timeline::GetTimeline; 134let response = agent.send(&GetTimeline::new().limit(50).build()).await?; 135let timeline = response.into_output()?; 136``` 137 138**Auto-refresh**: Both `CredentialSession` and `OAuthSession` automatically refresh tokens on 401/expired errors. One retry per request. 139 140**Typed record operations** (via `AgentSessionExt` trait): 141 142```rust 143use jacquard::api::app_bsky::feed::post::Post; 144 145// Create 146let post = Post::builder() 147 .text("Hello ATProto!") 148 .created_at(Datetime::now()) 149 .build(); 150agent.create_record(post, None).await?; 151 152// Get (type-safe!) 153let uri = Post::uri("at://did:plc:abc/app.bsky.feed.post/123")?; 154let response = agent.get_record::<Post>(&uri).await?; 155let post_output = response.parse()?; 156 157// Update with fetch-modify-put pattern 158agent.update_record::<Profile>(&uri, |profile| { 159 profile.display_name = Some("New Name".into()); 160}).await?; 161``` 162 163**Key traits**: 164- `AgentSession`: Common interface for both auth types 165- `XrpcClient`: Stateful XRPC (has base URI, auth tokens) 166- `HttpClient`: Low-level HTTP abstraction 167 168**Common mistake**: Not converting to owned when returning from functions. If your function returns `Post<'_>`, caller can't use it after function returns. Return `Post<'static>` and call `.into_static()` on the value. 169 170### jacquard-common (Foundation) 171 172**Core types**: `Did`, `Handle`, `AtUri`, `Nsid`, `Tid`, `Cid`, `CowStr`, `Data`, `RawData` 173 174**String type traits** (ALL validated types implement these): 175- `new(&str)` - Validates, borrows (zero alloc) 176- `new_static(&'static str)` - Validates, zero alloc 177- `new_owned(impl Into<String>)` - Validates, takes ownership 178- `raw(&str)` - Panics on invalid (use when you KNOW it's valid) 179- `unchecked(&str)` - Unsafe, no validation 180- `as_str(&self) -> &str` - Get string reference 181- `Display`, `FromStr`, `Serialize`, `Deserialize`, `IntoStatic` 182 183**CowStr internals**: Uses `SmolStr` for owned variant (inline storage ≤23 bytes), so most AT Protocol strings (handles, DIDs) don't heap allocate when owned, or are O(1) copy (due to the allocated variant using Arc<str>). 184 185**XRPC layer**: 186 187```rust 188// Stateless XRPC (with any HttpClient) 189use jacquard::common::xrpc::XrpcExt; 190let http = reqwest::Client::new(); 191let response = http 192 .xrpc(Url::parse("https://bsky.social")?) 193 .auth(AuthorizationToken::Bearer(token)) 194 .proxy(did) 195 .send(&request) 196 .await?; 197 198// Stateful XRPC (implement XrpcClient trait) 199let response = agent.send(request).await?; 200``` 201 202**Data vs RawData**: 203- `Data<'a>`: Validated, type-inferred atproto values (strings parsed to Did/Handle/etc.) 204- `RawData<'a>`: Minimal validation, suitable for pass-through/relay use cases 205 206```rust 207// Convert typed → untyped → typed 208let post = Post::builder().text("test").build(); 209let data: Data = to_data(&post)?; 210let post2: Post = from_data(&data)?; 211 212// NEVER use serde_json::Value 213// ❌ let value: serde_json::Value = ...; 214// ✅ let data: Data = ...; 215``` 216 217**Streaming** (feature: `streaming`): 218- `ByteStream` / `ByteSink`: Platform-agnostic (works on WASM via `n0-future`) 219- `HttpClientExt::send_http_streaming()`: Stream response 220- `HttpClientExt::send_http_bidirectional()`: Stream both request and response 221 222**WebSocket** (feature: `websocket`): 223- `WebSocketClient` trait, `WebSocketConnection` 224- Native + WASM: tokio-tungstenite-wasm 225 226**Collection trait** (for record types): 227 228```rust 229pub trait Collection { 230 const NSID: &'static str; 231 type Record: XrpcResp; // Marker type for get_record() 232} 233 234// Enables typed record retrieval: 235let response: Response<Post::Record> = agent.get_record(did, rkey).await?; 236``` 237 238**Critical error**: Using types that don't implement `IntoStatic` in response positions. All generated API types do, but custom types need `#[derive(jacquard_derive::IntoStatic)]`. 239 240### jacquard-api (Generated Bindings) 241 242**764 lexicon schemas** across 52+ namespaces. 243 244**Feature organization**: 245- `minimal`: Core atproto only 246- `bluesky`: Bluesky app + chat + ozone 247- `other`: Curated third-party lexicons 248- `lexicon_community`: Community extensions 249- `ufos`: Experimental/niche 250 251**Generated patterns**: 252- All types have `'a` lifetime for zero-copy deserialization 253- All implement `IntoStatic`, `Serialize`, `Deserialize`, `Clone`, `PartialEq`, `Eq` 254- Builders (`bon::Builder`) on types with 2+ fields (some required) 255- Open unions have `Unknown(Data<'a>)` variant via `#[open_union]` macro 256- Objects have `extra_data: BTreeMap<SmolStr, Data<'a>>` via `#[lexicon]` macro 257 258**Union collision detection**: When multiple namespaces define similar types, foreign refs get prefixed: 259```rust 260// If both app.bsky.embed.images and sh.custom.embed.images exist: 261pub enum SomeUnion<'a> { 262 BskyImages(Box<app_bsky::embed::images::View<'a>>), 263 CustomImages(Box<sh_custom::embed::images::View<'a>>), 264} 265``` 266 267**For each collection (record type)**, generated code includes: 2681. Main record struct (e.g., `Post<'a>`) 2692. `GetRecordOutput` wrapper (`PostGetRecordOutput<'a>` with uri, cid, value) 2703. Marker struct (`PostRecord`) implementing `XrpcResp` 2714. `Collection` trait impl 2725. Helper: `Post::uri()` for constructing typed URIs 273 274**For each XRPC endpoint**: 2751. Request struct with builder 2762. Output struct 2773. Error enum (open union, includes `Unknown` variant) 2784. Response marker implementing `XrpcResp` 2795. Request marker implementing `XrpcRequest` 2806. Endpoint marker implementing `XrpcEndpoint` (server-side) 281 282**Common mistakes**: 283- Not handling `Unknown` variant in union matches (non-exhaustive!) 284- Forgetting that when not using the builder pattern, or Default construction, you must supply `extra_data: BTreeMap::new()` in the constructor, in addition to explicitly named fields. 285- Calling `.into_static()` in tight loops (it clones all borrowed data) 286 287### jacquard-derive (Macros) 288 289**`#[lexicon]`**: Adds `extra_data` field to capture unknown fields during deserialization. 290 291```rust 292#[lexicon] 293#[derive(Serialize, Deserialize)] 294struct MyType<'a> { 295 known_field: CowStr<'a>, 296 // Macro adds: pub extra_data: BTreeMap<SmolStr, Data<'a>> 297} 298``` 299 300With `bon::Builder`: Automatically adds `#[builder(default)]` to `extra_data`. 301 302**`#[open_union]`**: Adds `Unknown(Data<'a>)` variant to enums. 303 304```rust 305#[open_union] 306#[serde(tag = "$type")] 307enum MyUnion<'a> { 308 KnownVariant(Foo<'a>), 309 // Macro adds: #[serde(untagged)] Unknown(Data<'a>) 310} 311``` 312 313**`#[derive(IntoStatic)]`**: Generates owned conversion. 314 315```rust 316#[derive(IntoStatic)] 317struct Post<'a> { 318 text: CowStr<'a>, 319 likes: u32, 320} 321 322// Generates: 323impl IntoStatic for Post<'_> { 324 type Output = Post<'static>; 325 fn into_static(self) -> Post<'static> { 326 Post { 327 text: self.text.into_static(), // Converts to owned 328 likes: self.likes.into_static(), // Passthrough (Copy) 329 } 330 } 331} 332``` 333 334**`#[derive(XrpcRequest)]`**: Generates XRPC boilerplate for custom endpoints. 335 336```rust 337#[derive(Serialize, Deserialize, XrpcRequest)] 338#[xrpc( 339 nsid = "com.example.getThing", 340 method = Query, 341 output = GetThingOutput, 342 error = GetThingError, // Optional, defaults to GenericError 343 server // Optional, generates XrpcEndpoint marker 344)] 345struct GetThing<'a> { 346 #[serde(borrow)] 347 pub id: CowStr<'a>, 348} 349``` 350 351**Critical**: All custom types with lifetimes MUST derive `IntoStatic` or manually implement it. Otherwise, you can't use them with `.into_output()`. 352 353### jacquard-oauth (OAuth/DPoP) 354 355**OAuth flow**: 356 357```rust 358use jacquard::oauth::client::OAuthClient; 359use jacquard::client::FileAuthStore; 360 361let oauth = OAuthClient::with_default_config( 362 FileAuthStore::new("./auth.json") 363); 364 365// Loopback flow (feature: loopback) 366let session = oauth.login_with_local_server( 367 "alice.bsky.social", 368 Default::default(), 369 LoopbackConfig::default(), 370).await?; 371 372let agent = Agent::from(session); 373``` 374 375**DPoP proofs**: Automatically generated for every request. Include: 376- `jti`: Unique token ID (random) 377- `htm`: HTTP method 378- `htu`: Target URI 379- `iat`: Issued at timestamp 380- `nonce`: Server-provided (cached and retried on `use_dpop_nonce` error) 381- `ath`: SHA-256 hash of access token (when present) 382 383**Nonce handling**: Automatic retry on `use_dpop_nonce` errors (400 for auth server, 401 for PDS). Max one retry per request. 384 385**Nonce storage**: 386- `dpop_authserver_nonce`: For token endpoint 387- `dpop_host_nonce`: For PDS XRPC requests 388 389**Token refresh**: Automatic on `invalid_token` errors. Uses `SessionRegistry` with per-DID+session_id locks to prevent concurrent refresh races. 390 391**private_key_jwt**: For non-loopback clients. Automatically used if server supports it. 392 393**Issuer verification**: ALWAYS verify issuer has authority over the DID before trusting tokens. This is a **critical security check**. 394 395```rust 396// In callback flow, after exchanging code: 397let token_response = exchange_code(...).await?; 398// ⚠️ MUST verify before using token 399let pds = resolver.verify_issuer(&server_metadata, &token_response.sub).await?; 400``` 401 402**Common mistakes**: 403- Skipping issuer verification (security vulnerability!) 404- Not updating session after refresh (next request uses expired token) 405- Reusing DPoP proofs across requests (each request needs a fresh proof with new jti/iat) 406- Mixing auth server and host nonces (they're tracked separately) 407- Forgetting `ath` claim when including access token in PDS requests 408 409### jacquard-identity (Identity Resolution) 410 411**Resolution chains** (configurable fallback order): 412 413**Handle → DID**: 4141. DNS TXT `_atproto.<handle>` (feature: `dns`, skipped on WASM) 4152. HTTPS `https://<handle>/.well-known/atproto-did` 4163. PDS XRPC `com.atproto.identity.resolveHandle` 4174. Public API fallback `https://public.api.bsky.app` (if enabled) 4185. Slingshot mini-doc (if configured) 419 420**DID → Document**: 4211. `did:web`: HTTPS `.well-known/did.json` 4222. `did:plc`: PLC directory or Slingshot 4233. PDS XRPC `com.atproto.identity.resolveDid` 424 425```rust 426use jacquard::identity::{JacquardResolver, PublicResolver}; 427 428let resolver = PublicResolver::default(); // DNS + public fallbacks enabled 429 430// Handle → DID 431let did: Did<'static> = resolver.resolve_handle(&handle).await?; 432 433// DID → Document 434let response = resolver.resolve_did_doc(&did).await?; 435let doc = response.parse_validated()?; // Validates doc.id matches requested DID 436 437// Combined: Get PDS endpoint 438let pds_url = resolver.pds_for_did(&did).await?; 439``` 440 441**DidDocResponse pattern** (same as XRPC responses): 442 443```rust 444let response = resolver.resolve_did_doc(&did).await?; 445 446// Borrow from buffer 447let doc: DidDocument<'_> = response.parse()?; 448 449// Validate doc ID 450let doc = response.parse_validated()?; // Error if doc.id != requested DID 451 452// Convert to owned 453let doc: DidDocument<'static> = response.into_owned()?; 454``` 455 456**Mini-doc fallback**: If full DID document parsing fails, automatically tries parsing as `MiniDoc` (Slingshot's minimal format) and synthesizes a minimal `DidDocument`. This is transparent to caller. 457 458**OAuthResolver trait**: Auto-implemented for `JacquardResolver`. Adds OAuth metadata resolution: 459 460```rust 461// High-level: accepts handle, DID, or HTTPS URL 462let (server_metadata, doc_opt) = resolver.resolve_oauth("alice.bsky.social").await?; 463 464// From identity (handle or DID) 465let (server_metadata, doc) = resolver.resolve_from_identity("alice.bsky.social").await?; 466 467// From service URL (PDS or entryway) 468let server_metadata = resolver.resolve_from_service(&pds_url).await?; 469 470// Verify issuer authority over DID 471let pds = resolver.verify_issuer(&server_metadata, &sub_did).await?; 472``` 473 474**Common mistakes**: 475- Not validating doc ID (use `.parse_validated()`, not just `.parse()`) 476- Assuming DNS resolution works on WASM (it doesn't) 477- Comparing issuer URLs with string equality (use `issuer_equivalent()` for trailing slash tolerance) 478- Trusting DID documents without checking `alsoKnownAs` for handle aliases 479- Not caching resolver (it's `Clone`, cheap to share) 480 481### jacquard-axum (Server-Side) 482 483**ExtractXrpc**: Type-safe XRPC request extraction. 484 485```rust 486use jacquard_axum::{ExtractXrpc, IntoRouter}; 487use jacquard::api::com_atproto::identity::resolve_handle::ResolveHandleRequest; 488 489async fn handle_resolve( 490 ExtractXrpc(req): ExtractXrpc<ResolveHandleRequest> 491) -> Json<ResolveHandleOutput<'static>> { 492 let did = resolve_handle_logic(&req.handle).await; 493 Json(ResolveHandleOutput { did, extra_data: Default::default() }) 494} 495 496// Automatic routing 497let app = Router::new() 498 .merge(ResolveHandleRequest::into_router(handle_resolve)); 499``` 500 501**Query vs Procedure**: 502- Query (GET): Extracts from query string via `serde_html_form` 503- Procedure (POST): Calls `Request::decode_body()` (default: JSON, override for CBOR) 504 505**Zero-copy → owned conversion**: Extractor borrows during deserialization, then converts to `'static` via `IntoStatic`. This is why all request types must implement `IntoStatic`. 506 507**Custom encodings**: 508 509```rust 510impl XrpcRequest for MyRequest<'_> { 511 fn decode_body<'de>(body: &'de [u8]) -> Result<Box<Self>> { 512 let req = serde_ipld_dagcbor::from_slice(body)?; 513 Ok(Box::new(req)) 514 } 515} 516``` 517 518**Service auth** (feature: `service-auth`): 519 520```rust 521use jacquard_axum::service_auth::{ServiceAuthConfig, ExtractServiceAuth}; 522 523let config = ServiceAuthConfig::new( 524 Did::new_static("did:web:feedgen.example.com")?, 525 resolver, 526); 527 528async fn handler( 529 ExtractServiceAuth(auth): ExtractServiceAuth, 530) -> String { 531 format!("Authenticated as {}", auth.did()) 532} 533 534let app = Router::new() 535 .route("/xrpc/app.bsky.feed.getFeedSkeleton", get(handler)) 536 .with_state(config); 537``` 538 539**Method binding (`lxm` claim)**: Default enabled. Binds JWTs to specific XRPC methods to prevent token reuse across endpoints. 540 541**JTI replay protection**: NOT built-in. You must implement: 542 543```rust 544if let Some(jti) = auth.jti() { 545 if state.seen_jtis.contains(jti) { 546 return Err(StatusCode::UNAUTHORIZED); 547 } 548 state.seen_jtis.insert(jti.to_string(), auth.exp); 549} 550``` 551 552**Common mistakes**: 553- Forgetting `IntoStatic` derive on custom request types 554- Using wrong trait (use `XrpcEndpoint` marker, not `XrpcRequest`) 555- Not implementing JTI tracking (allows replay attacks) 556- Disabling method binding without understanding security implications 557 558### jacquard-repo (Repository Primitives) 559 560**MST (Merkle Search Tree)**: Immutable, persistent data structure. 561 562```rust 563use jacquard_repo::mst::Mst; 564use jacquard_repo::storage::MemoryBlockStore; 565 566let storage = MemoryBlockStore::new(); 567let mst = Mst::new(storage.clone()); 568 569// ⚠️ IMMUTABLE: Always reassign 570let mst = mst.add("app.bsky.feed.post/abc", record_cid).await?; 571let mst = mst.add("app.bsky.feed.post/xyz", record_cid2).await?; 572 573// Persist and get root CID 574let root_cid = mst.persist().await?; 575``` 576 577**Key validation**: `[a-zA-Z0-9._:~-]+` with exactly one `/` separator. Max 256 bytes. Format: `collection/rkey`. 578 579**Diff operations**: 580 581```rust 582let diff = old_mst.diff(&new_mst).await?; 583diff.validate_limits()?; // Enforce 200 op limit (protocol) 584 585// Convert to different formats 586let verified_ops = diff.to_verified_ops(); // For batch() 587let repo_ops = diff.to_repo_ops(); // For firehose 588``` 589 590**Commits**: 591 592```rust 593use jacquard_repo::commit::Commit; 594 595// Create and sign 596let commit = Commit::new_unsigned(did, data_cid, rev, prev_cid) 597 .sign(&signing_key)?; 598 599// Verify 600commit.verify(&public_key)?; 601``` 602 603**Supported signature algorithms**: Ed25519, ECDSA P-256, ECDSA secp256k1. 604 605**Firehose validation**: 606 607```rust 608// Sync v1.0 (requires prev MST state) 609let new_root = commit.validate_v1_0(prev_mst_root, prev_storage, pubkey).await?; 610 611// Sync v1.1 (inductive, requires prev_data field + op prev CIDs) 612let new_root = commit.validate_v1_1(pubkey).await?; 613``` 614 615**v1.1 inductive validation**: Inverts operations on claimed result, verifies inverted result matches `prev_data`. Requires: 616- `prev_data` field in commit 617- All operations have `prev` CIDs for updates/deletes 618- All required MST blocks in CAR 619 620**CAR I/O**: 621 622```rust 623// Read 624let parsed = parse_car_bytes(&bytes)?; 625let root_cid = parsed.root; 626let blocks = parsed.blocks; // BTreeMap<CID, Bytes> 627 628// Write 629let bytes = write_car_bytes(root_cid, blocks)?; 630``` 631 632**BlockStore trait**: Pluggable storage backend. 633 634```rust 635#[trait_variant::make(Send)] // Conditionally Send on non-WASM 636pub trait BlockStore: Clone { 637 async fn get(&self, cid: &CID) -> Result<Option<Bytes>>; 638 async fn put(&self, data: &[u8]) -> Result<CID>; 639 async fn apply_commit(&self, commit: CommitData) -> Result<()>; // Atomic 640} 641``` 642 643**Implementations**: 644- `MemoryBlockStore`: In-memory (testing) 645- `FileBlockStore`: File-based (persistent) 646- `LayeredBlockStore`: Read-through cache (e.g., temp over persistent for firehose) 647 648**Repository API** (high-level): 649 650```rust 651use jacquard_repo::repo::Repository; 652 653let repo = Repository::create(storage, did, signing_key, None).await?; 654 655// Single operations (don't auto-commit) 656repo.create_record(collection, rkey, cid).await?; 657let old_cid = repo.update_record(collection, rkey, new_cid).await?; 658let deleted_cid = repo.delete_record(collection, rkey).await?; 659 660// Batch commit 661let ops = vec![ 662 RecordWriteOp::Create { collection, rkey, record }, 663 RecordWriteOp::Update { collection, rkey, record, prev }, 664]; 665let (repo_ops, commit_data) = repo.create_commit(&ops, &did, prev, &key).await?; 666repo.apply_commit(commit_data).await?; 667``` 668 669**Common mistakes**: 670- Forgetting immutability (not reassigning MST operations) 671- Not calling `validate_limits()` before creating commits (protocol violation) 672- Using v1.1 validation without `prev_data` field (will fail) 673- Missing `prev` CIDs on update/delete operations for v1.1 674- Not implementing `Clone` cheaply on custom `BlockStore` (use `Arc` internally) 675- Ignoring CID mismatch errors (indicates data corruption) 676 677### jacquard-lexicon (Code Generation) 678 679**ONLY use `just` commands** for code generation: 680 681```bash 682just lex-gen # Fetch + generate 683just lex-fetch # Fetch only 684just codegen # Generate from existing lexicons 685``` 686 687**Union collision detection**: When multiple namespaces have similar type names in a union, foreign refs get prefixed with second NSID segment: 688 689``` 690app.bsky.embed.images → BskyImages 691sh.custom.embed.images → CustomImages 692``` 693 694**Builder heuristics**: 695- Has builder: 1+ required fields, not all bare `CowStr` 696- Has `Default`: 0 required fields OR all required are bare `CowStr` 697 698**Empty objects**: Generate as empty structs with `#[lexicon]` attribute (adds `extra_data`), not as `Data<'a>`. 699 700**Local refs**: `#fragment` normalized to `{current_nsid}#fragment` during generation. 701 702**Feature generation**: Tracks cross-namespace dependencies: 703 704```toml 705net_anisota = ["app_bsky"] # Uses Bluesky embeds 706``` 707 708**Token types**: Unit structs with `Display` impl: 709 710```rust 711pub struct ClickthroughAuthor; 712impl Display for ClickthroughAuthor { 713 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 714 write!(f, "clickthroughAuthor") 715 } 716} 717``` 718 719**Common mistakes**: Running codegen commands manually without `just` (wrong flags, paths). 720 721--- 722 723## Anti-Patterns to Avoid 724 725### ❌ Roundtripping through String 726 727```rust 728// ❌ BAD 729let did_str = did.as_str().to_string(); 730let did2 = Did::new(&did_str)?; 731 732// ✅ GOOD 733let did2 = did.clone(); 734// or 735let did2 = did.into_static(); // If you need 'static 736``` 737 738### ❌ Using serde_json::Value 739 740```rust 741// ❌ NEVER 742let value: serde_json::Value = serde_json::from_slice(bytes)?; 743let post: Post = serde_json::from_value(value)?; 744 745// ✅ ALWAYS 746let data: Data = serde_json::from_slice(bytes)?; 747let post: Post = from_data(&data)?; 748``` 749 750### ❌ Using FromStr for validated types 751 752```rust 753// ❌ SLOW (always allocates) 754let did: Did = "did:plc:abc".parse()?; 755 756// ✅ FAST (zero allocation) 757let did = Did::new("did:plc:abc")?; 758``` 759 760### ❌ Not deriving IntoStatic on custom types 761 762```rust 763// ❌ WILL NOT COMPILE with XRPC responses 764struct MyOutput<'a> { 765 field: CowStr<'a>, 766} 767 768// ✅ REQUIRED 769#[derive(jacquard_derive::IntoStatic)] 770struct MyOutput<'a> { 771 field: CowStr<'a>, 772} 773``` 774 775### ❌ Dropping response while holding borrowed parse 776 777```rust 778// ❌ WILL NOT COMPILE 779let output = { 780 let response = agent.send(request).await?; 781 response.parse()? // Borrows from response! 782}; 783 784// ✅ Keep response alive OR convert to owned 785let response = agent.send(request).await?; 786let output = response.parse()?; 787// OR 788let output = agent.send(request).await?.into_output()?; 789``` 790 791### ❌ Calling .into_static() in tight loops 792 793```rust 794// ❌ WASTEFUL (clones all borrowed data every iteration) 795for post in timeline.feed { 796 let owned = post.into_static(); 797 process(owned); 798} 799 800// ✅ EFFICIENT (borrow when possible) 801for post in &timeline.feed { 802 process(post); 803} 804 805// Only convert to static if storing long-term: 806let stored: Vec<Post<'static>> = timeline.feed.into_iter() 807 .map(|p| p.into_static()) 808 .collect(); 809``` 810 811### ❌ Non-exhaustive union matches 812 813```rust 814// ❌ WILL NOT COMPILE (missing Unknown variant) 815match embed { 816 PostEmbed::Images(img) => { /* ... */ } 817 PostEmbed::Video(vid) => { /* ... */ } 818} 819 820// ✅ HANDLE ALL VARIANTS 821match embed { 822 PostEmbed::Images(img) => { /* ... */ } 823 PostEmbed::Video(vid) => { /* ... */ } 824 _ => { /* Unknown or other variants */ } 825} 826``` 827 828### ❌ Forgetting MST immutability 829 830```rust 831// ❌ WRONG (loses result) 832mst.add(key, cid).await?; 833 834// ✅ CORRECT (reassign) 835let mst = mst.add(key, cid).await?; 836``` 837 838### ❌ Skipping issuer verification in OAuth 839 840```rust 841// ❌ SECURITY VULNERABILITY 842let token_response = exchange_code(...).await?; 843// Immediately trusting token_response.sub without verification! 844 845// ✅ ALWAYS VERIFY 846let token_response = exchange_code(...).await?; 847let pds = resolver.verify_issuer(&server_metadata, &token_response.sub).await?; 848// Now safe to use token_response 849``` 850 851### ❌ Using `for<'de> Deserialize<'de>` bounds (CATASTROPHIC) 852 853**This is the SINGLE MOST COMMON mistake LLMs make with Jacquard.** The HRTB `for<'de>` forces owned deserialization and breaks ALL Jacquard types. 854 855```rust 856// ❌ CATASTROPHIC - Breaks all Jacquard types 857fn deserialize_anything<T>(data: &[u8]) -> Result<T> 858where 859 T: for<'de> Deserialize<'de> // Equivalent to DeserializeOwned! 860{ 861 serde_json::from_slice(data) 862} 863 864// Attempting to use: 865let post: Post = deserialize_anything(&bytes)?; // ERROR: Post<'_> doesn't satisfy bound 866 867// ❌ Also breaks in generic contexts 868struct MyContainer<T> 869where 870 T: for<'de> Deserialize<'de> // No Jacquard type can be stored here! 871{ 872 value: T, 873} 874 875// ❌ Even if you think you need it for async 876async fn fetch<T>(url: &str) -> Result<T> 877where 878 T: for<'de> Deserialize<'de> // Still wrong! Use Response<R> pattern instead 879{ 880 let bytes = http_get(url).await?; 881 serde_json::from_slice(&bytes) // Can't borrow, bytes about to be dropped 882} 883``` 884 885```rust 886// ✅ CORRECT - Method-level lifetime 887fn deserialize_anything<'de, T>(data: &'de [u8]) -> Result<T> 888where 889 T: Deserialize<'de> 890{ 891 serde_json::from_slice(data) 892} 893 894// ✅ CORRECT - With IntoStatic for async 895fn deserialize_owned<'de, T>(data: &'de [u8]) -> Result<T::Output> 896where 897 T: Deserialize<'de> + IntoStatic, 898 T::Output: 'static, 899{ 900 let borrowed: T = serde_json::from_slice(data)?; 901 Ok(borrowed.into_static()) 902} 903 904// ✅ CORRECT - Use Jacquard's Response pattern for async 905async fn fetch<R: XrpcRequest>(url: &str) -> Result<Response<R::Response>> { 906 let bytes = http_get(url).await?; 907 Ok(Response::new(bytes)) // Caller chooses .parse() or .into_output() 908} 909``` 910 911**Why `for<'de>` breaks**: It means "T must deserialize from ANY lifetime", which is impossible if T contains borrowed data. The deserializer can't satisfy "any lifetime" including lifetimes shorter than the input buffer, so it forces owned allocation. 912 913**Rule**: If you see `for<'de> Deserialize<'de>` anywhere in your code with Jacquard types, it's wrong. Use method-level `<'de>` parameters and propagate lifetimes explicitly. 914 915--- 916 917## WASM Compatibility 918 919Core crates support `wasm32-unknown-unknown` target: 920- jacquard-common 921- jacquard-api 922- jacquard-identity (no DNS resolution) 923- jacquard-oauth 924 925**Pattern**: `#[cfg_attr(not(target_arch = "wasm32"), trait_variant::make(Send))]` 926 927**What's different on WASM**: 928- No `Send` bounds on traits 929- DNS resolution skipped in handle→DID chain 930- Tokio-specific features disabled 931 932**Test WASM compilation**: 933 934```bash 935just check-wasm 936# or 937cargo build --target wasm32-unknown-unknown -p jacquard-common --no-default-features 938``` 939 940--- 941 942## Quick Reference 943 944### String Type Constructors 945 946| Method | Allocates? | Use When | 947|--------|-----------|----------| 948| `new(&str)` | No | Borrowed string | 949| `new_static(&'static str)` | No | Static string literal | 950| `new_owned(String)` | Reuses | Already have owned String | 951| `FromStr::parse()` | Yes | Don't care about performance | 952 953### Response Parsing 954 955| Method | Lifetime | Allocates? | 956|--------|----------|-----------| 957| `.parse()` | `<'_>` | No (zero-copy) | 958| `.into_output()` | `'static` | Yes (converts to owned) | 959 960### XRPC Traits 961 962| Trait | Side | Purpose | 963|-------|------|---------| 964| `XrpcRequest` | Client + Server | Request with NSID, method, encode/decode | 965| `XrpcResp` | Client + Server | Response marker with GAT Output/Err | 966| `XrpcEndpoint` | Server | Routing marker with PATH, METHOD | 967| `XrpcClient` | Client | Stateful XRPC with base_uri, send() | 968| `XrpcExt` | Client | Stateless XRPC builder on any HttpClient | 969 970### Session Types 971 972| Type | Auth Method | Auto-Refresh | Storage | 973|------|------------|--------------|---------| 974| `CredentialSession` | Bearer (app password) | Via refreshSession | SessionStore | 975| `OAuthSession` | DPoP (OAuth) | Via token endpoint | ClientAuthStore | 976 977### BlockStore Implementations 978 979| Type | Persistent? | Use Case | 980|------|------------|----------| 981| `MemoryBlockStore` | No | Testing | 982| `FileBlockStore` | Yes | Production | 983| `LayeredBlockStore` | Depends | Read-through cache | 984 985--- 986 987## Common Operations 988 989### Making an XRPC call 990 991```rust 992use jacquard::api::app_bsky::feed::get_author_feed::GetAuthorFeed; 993 994let request = GetAuthorFeed::new() 995 .actor("alice.bsky.social".into()) 996 .limit(50) 997 .build(); 998 999let response = agent.send(request).await?; 1000let output = response.into_output()?; 1001 1002for post in output.feed { 1003 println!("{}: {}", post.post.author.handle, post.post.uri); 1004} 1005``` 1006 1007### Creating a record 1008 1009```rust 1010use jacquard::api::app_bsky::feed::post::Post; 1011use jacquard::common::types::string::Datetime; 1012 1013let post = Post::builder() 1014 .text("Hello ATProto from Jacquard!") 1015 .created_at(Datetime::now()) 1016 .build(); 1017 1018agent.create_record(post, None).await?; 1019``` 1020 1021### Resolving identity 1022 1023```rust 1024use jacquard::identity::PublicResolver; 1025 1026let resolver = PublicResolver::default(); 1027 1028// Handle → DID 1029let did = resolver.resolve_handle(&handle).await?; 1030 1031// DID → PDS endpoint 1032let pds = resolver.pds_for_did(&did).await?; 1033 1034// Combined 1035let (did, pds) = resolver.pds_for_handle(&handle).await?; 1036``` 1037 1038### OAuth login 1039 1040```rust 1041use jacquard::oauth::client::OAuthClient; 1042use jacquard::client::FileAuthStore; 1043 1044let oauth = OAuthClient::with_default_config( 1045 FileAuthStore::new("./auth.json") 1046); 1047 1048let session = oauth.login_with_local_server( 1049 "alice.bsky.social", 1050 Default::default(), 1051 Default::default(), 1052).await?; 1053 1054let agent = Agent::from(session); 1055``` 1056 1057### Server-side XRPC handler 1058 1059```rust 1060use jacquard_axum::{ExtractXrpc, IntoRouter}; 1061use axum::{Router, Json}; 1062 1063async fn handler( 1064 ExtractXrpc(req): ExtractXrpc<MyRequest> 1065) -> Json<MyOutput<'static>> { 1066 // Process request 1067 Json(output) 1068} 1069 1070let app = Router::new() 1071 .merge(MyRequest::into_router(handler)); 1072``` 1073 1074### MST operations 1075 1076```rust 1077use jacquard_repo::mst::Mst; 1078use jacquard_repo::storage::MemoryBlockStore; 1079 1080let storage = MemoryBlockStore::new(); 1081let mst = Mst::new(storage.clone()); 1082 1083let mst = mst.add("app.bsky.feed.post/abc123", record_cid).await?; 1084let mst = mst.add("app.bsky.feed.post/xyz789", record_cid2).await?; 1085 1086let root_cid = mst.persist().await?; 1087``` 1088 1089--- 1090 1091## Documentation Links 1092 1093- [docs.rs/jacquard](https://docs.rs/jacquard/latest/jacquard/) 1094- [docs.rs/jacquard-common](https://docs.rs/jacquard-common/latest/jacquard_common/) 1095- [docs.rs/jacquard-api](https://docs.rs/jacquard-api/latest/jacquard_api/) 1096- [docs.rs/jacquard-oauth](https://docs.rs/jacquard-oauth/latest/jacquard_oauth/) 1097- [docs.rs/jacquard-identity](https://docs.rs/jacquard-identity/latest/jacquard_identity/) 1098- [docs.rs/jacquard-repo](https://docs.rs/jacquard-repo/latest/jacquard_repo/) 1099- [docs.rs/jacquard-axum](https://docs.rs/jacquard-axum/latest/jacquard_axum/) 1100- [Repository](https://tangled.org/@nonbinary.computer/jacquard) 1101 1102--- 1103 1104## Philosophy Summary 1105 1106Jacquard is designed for **correctness**, **performance**, and **ergonomics** in that order. It favors: 1107 11081. **Validation at construction time** - Invalid inputs fail fast, not deep in your code 11092. **Zero-copy by default** - Borrow from buffers, convert to owned only when needed 11103. **Explicit lifetime control** - You choose when to allocate via `.into_static()` or `.into_output()` 11114. **Type safety without boilerplate** - Generated bindings just work, with strong typing 11125. **Batteries included, but replaceable** - High-level `Agent` for convenience, low-level primitives for control 1113 1114**When in doubt**: Read the documentation, use the validated types, respect the lifetimes, and trust the zero-copy patterns. Jacquard is designed to guide you toward correct, performant code.