A better Rust ATProto crate
102
fork

Configure Feed

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

at main 1054 lines 34 kB view raw
1# Jacquard: AT Protocol Library for Rust 2 3> Simple and powerful AT Protocol (Bluesky) client library emphasizing spec compliance, flexible string backing types, and minimal boilerplate. 4 5## Core philosophy 6 7**Flexible string backing, zero-copy when you want it.** All API types are parameterised on `S: BosStr = DefaultStr` (where `DefaultStr = SmolStr`). By default, types use `SmolStr` for inline storage of short strings and `Arc<str>` for longer ones. When zero-copy is needed, use `CowStr<'_>` or `&str` as the backing type. `DeserializeOwned` works out of the box for `SmolStr`-backed types. 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 backing types 18 19All validated types (Did, Handle, AtUri, Nsid, etc.) and all generated API types are generic over `S: BosStr`: 20 21```rust 22pub struct Did<S: BosStr = DefaultStr> { /* ... */ } 23pub struct Post<S: BosStr = DefaultStr> { /* ... */ } 24``` 25 26The `BosStr` trait combines `Bos<str> + AsRef<str> + FromStaticStr`. Types that implement it: 27 28| Type | Allocates? | Use case | 29|------|-----------|----------| 30| `SmolStr` (default) | Inline <=23 bytes, Arc for longer | General-purpose owned strings | 31| `&str` | Never | Zero-copy borrowed access | 32| `CowStr<'a>` | Only when owned variant needed | Borrow-or-own flexibility | 33| `String` | Always on heap | Standard owned strings | 34 35**Rule**: Use default `SmolStr` for most code. Use `CowStr<'_>` or `&str` when zero-copy parsing matters. Use `String` when interfacing with APIs that require it. 36 37### String type constructors 38 39ALL validated string types (Did, Handle, AtUri, Nsid, etc.) follow this pattern: 40 41```rust 42// Wraps S directly (validates). S defaults to SmolStr. 43let did = Did::new(SmolStr::new("did:plc:abc123"))?; 44 45// Zero-allocation for static strings. 46let nsid = Nsid::new_static("com.atproto.repo.getRecord")?; 47 48// Validates and produces owned (SmolStr-backed by default). 49let owned = Did::new_owned("did:plc:abc123")?; 50 51// Cheap borrow: Did<SmolStr> -> Did<&str>. 52let borrowed: Did<&str> = owned.borrow(); 53 54// Cross-type conversion: Did<SmolStr> -> Did<String>. 55let string_did: Did<String> = owned.convert(); 56 57// FromStr always allocates into SmolStr. 58let did: Did = "did:plc:abc123".parse()?; 59 60// AVOID: Roundtripping through String. 61let s = did.as_str().to_string(); 62let did2 = Did::new_owned(&s)?; // Pointless allocation. 63``` 64 65**Rule**: Use `new_static()` for string literals, `new_owned()` when validating from `&str`, `borrow()` for cheap temporary access, `convert()` for cross-type conversion. 66 67### Response parsing: choose your backing type 68 69XRPC responses wrap a `Bytes` buffer. You choose how to deserialize: 70 71```rust 72let response = agent.send(request).await?; 73 74// Option 1: Owned SmolStr-backed (DeserializeOwned -- just works!) 75let output: GetPostOutput = response.into_output()?; 76// Can return from functions, store anywhere, no lifetime concerns. 77 78// Option 2: Zero-copy with CowStr (borrows from response buffer) 79let output: GetPostOutput<CowStr<'_>> = response.parse()?; 80// output borrows from response, both must stay in scope. 81 82// Option 3: Zero-copy with &str (borrows from response buffer) 83let output: GetPostOutput<&str> = response.parse()?; 84// Cheapest, but output borrows from response. 85``` 86 87**Rule**: Use `.into_output()` for most code -- it returns `SmolStr`-backed owned types with no lifetime concerns. Use `.parse::<CowStr<'_>>()` or `.parse::<&str>()` when zero-copy performance is critical and you can keep the response alive. 88 89### Type system: GATs parameterised on S 90 91Jacquard uses **Generic Associated Types** on `XrpcResp` parameterised on backing string type: 92 93```rust 94trait XrpcResp { 95 type Output<S: BosStr>; // GAT parameterised on S, not lifetime. 96 type Err: Error + DeserializeOwned; // Always owned, always SmolStr-backed. 97} 98``` 99 100**Why this matters**: `SmolStr`-backed types implement `DeserializeOwned`, so `.into_output()` just works. For zero-copy, callers pass `CowStr<'_>` or `&str` as `S` via `.parse()`. Error types are always owned since they are uncommon and don't need zero-copy. 101 102**When implementing custom types**: Parameterise on `S: BosStr`, not on lifetimes. Use `#[serde(bound(deserialize = "S: Deserialize<'de> + BosStr"))]`. 103 104### Backing type conversion 105 106```rust 107// borrow() -- cheap Did<SmolStr> -> Did<&str> 108let borrowed: Did<&str> = my_did.borrow(); 109 110// convert() -- type-level conversion (e.g., SmolStr -> String) 111let string_did: Did<String> = my_did.convert(); 112 113// IntoStatic -- converts CowStr<'_> -> SmolStr (or other owned) 114use jacquard::common::IntoStatic; 115let owned: Post = borrowed_post.into_static(); 116 117// as_str() -- get &str from any concrete S (e.g., SmolStr) 118let s: &str = my_did.as_str(); 119 120// as_ref() -- get &str from generic S (via AsRef<str>) 121fn generic<S: BosStr>(did: &Did<S>) -> &str { 122 did.as_ref() 123} 124``` 125 126**Rule**: Use `.borrow()` for cheap `Type<&str>` access. Use `.convert()` for cross-type conversion. Use `.into_static()` when converting from borrowed (`CowStr<'_>`) to owned. Use `.as_str()` for concrete types, `.as_ref()` for generic `S`. 127 128--- 129 130## Crate-by-crate guide 131 132### jacquard (main crate) 133 134**Primary entry point**: `Agent<A: AgentSession>` 135 136```rust 137use jacquard::client::{Agent, CredentialSession, MemorySessionStore}; 138use jacquard::identity::PublicResolver; 139 140// App password auth. 141let (session, _info) = CredentialSession::authenticated( 142 "alice.bsky.social".into(), 143 "app-password".into(), 144 None, // session_id 145).await?; 146let agent = Agent::from(session); 147 148// Make typed XRPC calls. 149use jacquard::api::app_bsky::feed::get_timeline::GetTimeline; 150let response = agent.send(&GetTimeline::<&str>::new().limit(50).build()).await?; 151let timeline = response.into_output()?; 152``` 153 154**Auto-refresh**: Both `CredentialSession` and `OAuthSession` automatically refresh tokens on 401/expired errors. One retry per request. 155 156**Typed record operations** (via `AgentSessionExt` trait): 157 158```rust 159use jacquard::api::app_bsky::feed::post::Post; 160 161// Create. 162let post = Post::builder() 163 .text("Hello ATProto!") 164 .created_at(Datetime::now()) 165 .build(); 166agent.create_record::<Post, _>(post, None).await?; 167 168// Get (type-safe!). 169let uri = Post::uri("at://did:plc:abc/app.bsky.feed.post/123")?; 170let response = agent.get_record::<Post>(&uri).await?; 171let post_output = response.into_output()?; 172 173// Update with fetch-modify-put pattern. 174agent.update_record::<Profile>(&uri, |profile| { 175 profile.display_name = Some(SmolStr::new("New Name")); 176}).await?; 177``` 178 179**Key traits**: 180- `AgentSession`: Common interface for both auth types 181- `XrpcClient`: Stateful XRPC (has base URI, auth tokens) 182- `HttpClient`: Low-level HTTP abstraction 183 184**Common mistake**: Forgetting that `SmolStr`-backed types are already owned. You do not need to call `.into_static()` on types returned from `.into_output()`. The `.into_static()` method is only needed when converting from `CowStr<'_>`-backed or `&str`-backed types to owned. 185 186### jacquard-common (foundation) 187 188**Core types**: `Did<S>`, `Handle<S>`, `AtUri<S>`, `Nsid<S>`, `Tid`, `Cid<S>`, `CowStr<'a>`, `SmolStr`, `Data<S>`, `RawData<'a>` 189 190**String type traits** (ALL validated types implement these): 191- `new(S)` - Validates, wraps S directly 192- `new_static(&'static str)` - Validates, zero alloc 193- `new_owned(impl AsRef<str>)` - Validates, produces owned S 194- `borrow(&self) -> Type<&str>` - Cheap borrowed view 195- `convert<B: BosStr + From<S>>(self) -> Type<B>` - Cross-type conversion 196- `raw(&str)` - Panics on invalid (use when you KNOW it's valid) 197- `unchecked(&str)` - Unsafe, no validation 198- `as_str(&self) -> &str` - Get string reference 199- `Display`, `FromStr`, `Serialize`, `Deserialize`, `IntoStatic` 200 201**SmolStr**: The default backing type. Inline storage for strings <=23 bytes (covers most handles, DIDs, rkeys). Longer strings use `Arc<str>` for O(1) clone. Implements `DeserializeOwned`. 202 203**CowStr<'a>**: Borrow-or-own string. Uses `SmolStr` for owned variant. This type still uses lifetimes -- it is the borrow-or-own primitive. 204 205**XRPC layer**: 206 207```rust 208// Stateless XRPC (with any HttpClient). 209use jacquard::common::xrpc::XrpcExt; 210let http = reqwest::Client::new(); 211let response = http 212 .xrpc(Uri::parse("https://bsky.social").map_err(|(e, _)| e)?) 213 .auth(AuthorizationToken::Bearer(token)) 214 .proxy(did) 215 .send(&request) 216 .await?; 217 218// Stateful XRPC (implement XrpcClient trait). 219let response = agent.send(request).await?; 220``` 221 222**Data<S> vs RawData<'a>**: 223- `Data<S>`: Validated, type-inferred atproto values (strings parsed to Did/Handle/etc.), parameterised on `S: BosStr` 224- `RawData<'a>`: Minimal validation, lifetime-based (intentionally stays lifetime-based), suitable for pass-through/relay use cases 225 226```rust 227// Convert typed -> untyped -> typed. 228let post = Post::builder().text("test").build(); 229let data: Data = to_data(&post)?; 230let post2: Post = from_data(&data)?; 231 232// NEVER use serde_json::Value. 233// let value: serde_json::Value = ...; 234// let data: Data = ...; 235``` 236 237**Streaming** (feature: `streaming`): 238- `ByteStream` / `ByteSink`: Platform-agnostic (works on WASM via `n0-future`) 239- `HttpClientExt::send_http_streaming()`: Stream response 240- `HttpClientExt::send_http_bidirectional()`: Stream both request and response 241 242**WebSocket** (feature: `websocket`): 243- `WebSocketClient` trait, `WebSocketConnection` 244- Native + WASM: tokio-tungstenite-wasm 245 246**Collection trait** (for record types): 247 248```rust 249pub trait Collection { 250 const NSID: &'static str; 251 type Record: XrpcResp; // Marker type for get_record(). 252} 253 254// Enables typed record retrieval: 255let response: Response<Post::Record> = agent.get_record(did, rkey).await?; 256``` 257 258**CallOptions<'a>**: Still uses lifetimes (not parameterised on S). This is intentional for the options builder pattern. 259 260### jacquard-api (generated bindings) 261 262**764 lexicon schemas** across 52+ namespaces. 263 264**Feature organization**: 265- `minimal`: Core atproto only 266- `bluesky`: Bluesky app + chat + ozone 267- `other`: Curated third-party lexicons 268- `lexicon_community`: Community extensions 269- `ufos`: Experimental/niche 270 271**Generated patterns**: 272- All types: `Foo<S: BosStr = DefaultStr>` with `#[serde(bound(deserialize = "S: Deserialize<'de> + BosStr"))]` 273- All implement `IntoStatic`, `Serialize`, `Deserialize`, `Clone`, `PartialEq`, `Eq` 274- Builders on types with 2+ fields (some required) 275- Open unions: `Unknown(Data<S>)` variant via `#[open_union]` macro 276- Objects: `extra_data: BTreeMap<SmolStr, Data<S>>` via `#[lexicon]` macro 277- Known-values enums: `Other(S)` catch-all 278 279**Union collision detection**: When multiple namespaces define similar types, foreign refs get prefixed: 280```rust 281// If both app.bsky.embed.images and sh.custom.embed.images exist: 282pub enum SomeUnion<S: BosStr = DefaultStr> { 283 BskyImages(Box<app_bsky::embed::images::View<S>>), 284 CustomImages(Box<sh_custom::embed::images::View<S>>), 285} 286``` 287 288**For each collection (record type)**, generated code includes: 2891. Main record struct (e.g., `Post<S: BosStr = DefaultStr>`) 2902. `GetRecordOutput` wrapper (`PostGetRecordOutput<S>` with uri, cid, value) 2913. Marker struct (`PostRecord`) implementing `XrpcResp` 2924. `Collection` trait impl 2935. Helper: `Post::uri()` for constructing typed URIs 294 295**For each XRPC endpoint**: 2961. Request struct with builder 2972. Output struct 2983. Error enum (open union, includes `Unknown` variant) 2994. Response marker implementing `XrpcResp` 3005. Request marker implementing `XrpcRequest` 3016. Endpoint marker implementing `XrpcEndpoint` (server-side) 302 303**Common mistakes**: 304- Not handling `Unknown` variant in union matches (non-exhaustive!) 305- 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 306- Calling `.into_static()` in tight loops (it is a no-op for SmolStr, but is unnecessary) 307 308### jacquard-derive (macros) 309 310**`#[lexicon]`**: Adds `extra_data` field to capture unknown fields during deserialization. 311 312```rust 313#[lexicon] 314#[derive(Serialize, Deserialize)] 315struct MyType<S: BosStr = DefaultStr> { 316 known_field: S, 317 // Macro adds: pub extra_data: BTreeMap<SmolStr, Data<S>> 318} 319``` 320 321**`#[open_union]`**: Adds `Unknown(Data<S>)` variant to enums. 322 323```rust 324#[open_union] 325#[serde(tag = "$type")] 326enum MyUnion<S: BosStr = DefaultStr> { 327 KnownVariant(Foo<S>), 328 // Macro adds: #[serde(untagged)] Unknown(Data<S>) 329} 330``` 331 332**`#[derive(IntoStatic)]`**: Generates conversion from any `S` to `SmolStr`-backed types. 333 334```rust 335#[derive(IntoStatic)] 336struct Post<S: BosStr = DefaultStr> { 337 text: S, 338 likes: u32, 339} 340 341// Generates conversion so Post<CowStr<'_>> -> Post<SmolStr>, etc. 342``` 343 344**`#[derive(XrpcRequest)]`**: Generates XRPC boilerplate for custom endpoints. 345 346```rust 347#[derive(Serialize, Deserialize, XrpcRequest)] 348#[xrpc( 349 nsid = "com.example.getThing", 350 method = Query, 351 output = GetThingOutput, 352 error = GetThingError, // Optional, defaults to GenericError. 353 server // Optional, generates XrpcEndpoint marker. 354)] 355struct GetThing { 356 pub id: SmolStr, 357} 358``` 359 360**Critical**: Custom types used as XRPC outputs must derive `IntoStatic` if they will be used with `.parse()` and then converted. For `.into_output()`, they just need `DeserializeOwned` (which `SmolStr`-backed types get for free). 361 362### jacquard-oauth (OAuth/DPoP) 363 364**Session types parameterised on S** for flexible backing storage. 365 366**OAuth flow**: 367 368```rust 369use jacquard::oauth::client::OAuthClient; 370use jacquard::client::FileAuthStore; 371 372let oauth = OAuthClient::with_default_config( 373 FileAuthStore::new("./auth.json") 374); 375 376// Loopback flow (feature: loopback). 377let session = oauth.login_with_local_server( 378 "alice.bsky.social", 379 Default::default(), 380 LoopbackConfig::default(), 381).await?; 382 383let agent = Agent::from(session); 384``` 385 386 387**OAuthMetadata<S>**: Parameterised so callers can borrow from stored metadata. 388 389**Nonce handling**: Automatic retry on `use_dpop_nonce` errors (400 for auth server, 401 for PDS). Max one retry per request. 390 391**Token refresh**: Automatic on `invalid_token` errors. Uses `SessionRegistry` with per-DID+session_id locks to prevent concurrent refresh races. 392 393**private_key_jwt**: For non-loopback clients. Automatically used if server supports it. 394 395### jacquard-identity (identity resolution) 396 397**Resolution chains** (configurable fallback order): 398 399**Handle -> DID**: 4001. DNS TXT `_atproto.<handle>` (feature: `dns`, skipped on WASM) 4012. HTTPS `https://<handle>/.well-known/atproto-did` 4023. PDS XRPC `com.atproto.identity.resolveHandle` 4034. Public API fallback `https://public.api.bsky.app` (if enabled) 4045. Slingshot mini-doc (if configured) 405 406**DID -> Document**: 4071. `did:web`: HTTPS `.well-known/did.json` 4082. `did:plc`: PLC directory or Slingshot 4093. PDS XRPC `com.atproto.identity.resolveDid` 410 411**Methods are generic over backing type**: 412```rust 413// resolve_handle accepts any Handle<S> backing type. 414let did: Did = resolver.resolve_handle(&handle).await?; 415// Returns Did (= Did<SmolStr>) for owned results. 416 417// resolve_did_doc same pattern. 418let response = resolver.resolve_did_doc(&did).await?; 419let doc = response.parse_validated()?; // Validates doc.id matches requested DID. 420 421// Combined: get PDS endpoint. 422let pds_url = resolver.pds_for_did(&did).await?; 423``` 424 425**DidDocResponse pattern** (same as XRPC responses): 426 427```rust 428let response = resolver.resolve_did_doc(&did).await?; 429 430// Borrow from buffer. 431let doc: DidDocument<CowStr<'_>> = response.parse()?; 432 433// Validate doc ID. 434let doc = response.parse_validated()?; // Error if doc.id != requested DID. 435 436// Convert to owned. 437let doc: DidDocument = response.into_owned()?; 438``` 439 440**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. 441 442**OAuthResolver trait**: Auto-implemented for `JacquardResolver`. Adds OAuth metadata resolution: 443 444```rust 445// High-level: accepts handle, DID, or HTTPS URL. 446let (server_metadata, doc_opt) = resolver.resolve_oauth("alice.bsky.social").await?; 447 448// From identity (handle or DID). 449let (server_metadata, doc) = resolver.resolve_from_identity("alice.bsky.social").await?; 450 451// From service URL (PDS or entryway). 452let server_metadata = resolver.resolve_from_service(&pds_url).await?; 453 454// Verify issuer authority over DID. 455let pds = resolver.verify_issuer(&server_metadata, &sub_did).await?; 456``` 457 458### jacquard-axum (server-side) 459 460**Note**: jacquard-axum is temporarily out of the workspace while the extractor is redesigned for the BosStr type system. 461 462**ExtractXrpc**: Type-safe XRPC request extraction, parameterised on `S: BosStr`. 463 464```rust 465use jacquard_axum::{ExtractXrpc, IntoRouter}; 466use jacquard::api::com_atproto::identity::resolve_handle::ResolveHandleRequest; 467use jacquard::common::DefaultStr; 468 469async fn handle_resolve( 470 ExtractXrpc(req): ExtractXrpc<ResolveHandleRequest, DefaultStr> 471) -> Json<ResolveHandleOutput> { 472 let did = resolve_handle_logic(&req.handle).await; 473 Json(ResolveHandleOutput { did, extra_data: Default::default() }) 474} 475 476// Automatic routing. 477let app = Router::new() 478 .merge(ResolveHandleRequest::into_router(handle_resolve)); 479``` 480 481**Query vs Procedure**: 482- Query (GET): Extracts from query string via `serde_html_form` 483- Procedure (POST): Calls `Request::decode_body()` (default: JSON, override for CBOR) 484 485**Custom encodings**: 486 487```rust 488impl XrpcRequest for MyRequest { 489 fn decode_body(body: &[u8]) -> Result<Box<Self>> { 490 let req = serde_ipld_dagcbor::from_slice(body)?; 491 Ok(Box::new(req)) 492 } 493} 494``` 495 496**Service auth** (feature: `service-auth`): 497 498```rust 499use jacquard_axum::service_auth::{ServiceAuthConfig, ExtractServiceAuth}; 500 501let config = ServiceAuthConfig::new( 502 Did::new_static("did:web:feedgen.example.com")?, 503 resolver, 504); 505 506async fn handler( 507 ExtractServiceAuth(auth): ExtractServiceAuth, 508) -> String { 509 format!("Authenticated as {}", auth.did()) 510} 511 512let app = Router::new() 513 .route("/xrpc/app.bsky.feed.getFeedSkeleton", get(handler)) 514 .with_state(config); 515``` 516 517**Method binding (`lxm` claim)**: Default enabled. Binds JWTs to specific XRPC methods to prevent token reuse across endpoints. 518 519**JTI replay protection**: NOT built-in. You must implement: 520 521```rust 522if let Some(jti) = auth.jti() { 523 if state.seen_jtis.contains(jti) { 524 return Err(StatusCode::UNAUTHORIZED); 525 } 526 state.seen_jtis.insert(jti.to_string(), auth.exp); 527} 528``` 529 530**Common mistakes**: 531- Forgetting the second type parameter on `ExtractXrpc<Request, S>` (defaults to `DefaultStr`) 532- Using wrong trait (use `XrpcEndpoint` marker, not `XrpcRequest`) 533- Not implementing JTI tracking (allows replay attacks) 534- Disabling method binding without understanding security implications 535 536### jacquard-repo (repository primitives) 537 538**MST (Merkle Search Tree)**: Immutable, persistent data structure. 539 540```rust 541use jacquard_repo::mst::Mst; 542use jacquard_repo::storage::MemoryBlockStore; 543 544let storage = MemoryBlockStore::new(); 545let mst = Mst::new(storage.clone()); 546 547// IMMUTABLE: Always reassign. 548let mst = mst.add("app.bsky.feed.post/abc", record_cid).await?; 549let mst = mst.add("app.bsky.feed.post/xyz", record_cid2).await?; 550 551// Persist and get root CID. 552let root_cid = mst.persist().await?; 553``` 554 555**Key validation**: `[a-zA-Z0-9._:~-]+` with exactly one `/` separator. Max 256 bytes. Format: `collection/rkey`. 556 557**Diff operations**: 558 559```rust 560let diff = old_mst.diff(&new_mst).await?; 561diff.validate_limits()?; // Enforce 200 op limit (protocol). 562 563// Convert to different formats. 564let verified_ops = diff.to_verified_ops(); // For batch(). 565let repo_ops = diff.to_repo_ops(); // For firehose. 566``` 567 568**Commits**: 569 570```rust 571use jacquard_repo::commit::Commit; 572 573// Create and sign. 574let commit = Commit::new_unsigned(did, data_cid, rev, prev_cid) 575 .sign(&signing_key)?; 576 577// Verify. 578commit.verify(&public_key)?; 579``` 580 581**Supported signature algorithms**: Ed25519, ECDSA P-256, ECDSA secp256k1. 582 583**Firehose validation**: 584 585```rust 586// Sync v1.0 (requires prev MST state). 587let new_root = commit.validate_v1_0(prev_mst_root, prev_storage, pubkey).await?; 588 589// Sync v1.1 (inductive, requires prev_data field + op prev CIDs). 590let new_root = commit.validate_v1_1(pubkey).await?; 591``` 592 593**v1.1 inductive validation**: Inverts operations on claimed result, verifies inverted result matches `prev_data`. Requires: 594- `prev_data` field in commit 595- All operations have `prev` CIDs for updates/deletes 596- All required MST blocks in CAR 597 598**CAR I/O**: 599 600```rust 601// Read. 602let parsed = parse_car_bytes(&bytes)?; 603let root_cid = parsed.root; 604let blocks = parsed.blocks; // BTreeMap<CID, Bytes> 605 606// Write. 607let bytes = write_car_bytes(root_cid, blocks)?; 608``` 609 610**BlockStore trait**: Pluggable storage backend. 611 612```rust 613#[trait_variant::make(Send)] // Conditionally Send on non-WASM. 614pub trait BlockStore: Clone { 615 async fn get(&self, cid: &CID) -> Result<Option<Bytes>>; 616 async fn put(&self, data: &[u8]) -> Result<CID>; 617 async fn apply_commit(&self, commit: CommitData) -> Result<()>; // Atomic. 618} 619``` 620 621**Implementations**: 622- `MemoryBlockStore`: In-memory (testing) 623- `FileBlockStore`: File-based (persistent) 624- `LayeredBlockStore`: Read-through cache (e.g., temp over persistent for firehose) 625 626**Repository API** (high-level): 627 628```rust 629use jacquard_repo::repo::Repository; 630 631let repo = Repository::create(storage, did, signing_key, None).await?; 632 633// Single operations (don't auto-commit). 634repo.create_record(collection, rkey, cid).await?; 635let old_cid = repo.update_record(collection, rkey, new_cid).await?; 636let deleted_cid = repo.delete_record(collection, rkey).await?; 637 638// Batch commit. 639let ops = vec![ 640 RecordWriteOp::Create { collection, rkey, record }, 641 RecordWriteOp::Update { collection, rkey, record, prev }, 642]; 643let (repo_ops, commit_data) = repo.create_commit(&ops, &did, prev, &key).await?; 644repo.apply_commit(commit_data).await?; 645``` 646 647**Common mistakes**: 648- Forgetting immutability (not reassigning MST operations) 649- Not calling `validate_limits()` before creating commits (protocol violation) 650- Using v1.1 validation without `prev_data` field (will fail) 651- Missing `prev` CIDs on update/delete operations for v1.1 652- Not implementing `Clone` cheaply on custom `BlockStore` (use `Arc` internally) 653- Ignoring CID mismatch errors (indicates data corruption) 654 655### jacquard-lexicon (code generation) 656 657**ONLY use `just` commands** for code generation: 658 659```bash 660just lex-gen # Fetch + generate 661just lex-fetch # Fetch only 662just generate-api # Generate from existing lexicons 663``` 664 665**Union collision detection**: When multiple namespaces have similar type names in a union, foreign refs get prefixed with second NSID segment: 666 667``` 668app.bsky.embed.images -> BskyImages 669sh.custom.embed.images -> CustomImages 670``` 671 672**Builder heuristics**: 673- Has builder: 1+ required fields, not all bare `S` 674- Has `Default`: 0 required fields OR all required are bare `S` 675 676**Empty objects**: Generate as empty structs with `#[lexicon]` attribute (adds `extra_data`), not as `Data<S>`. 677 678**Local refs**: `#fragment` normalized to `{current_nsid}#fragment` during generation. 679 680**Feature generation**: Tracks cross-namespace dependencies: 681 682```toml 683net_anisota = ["app_bsky"] # Uses Bluesky embeds 684``` 685 686**Token types**: Unit structs with `Display` impl: 687 688```rust 689pub struct ClickthroughAuthor; 690impl Display for ClickthroughAuthor { 691 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 692 write!(f, "clickthroughAuthor") 693 } 694} 695``` 696 697**Common mistakes**: Running codegen commands manually without `just` (wrong flags, paths). 698 699--- 700 701## Anti-patterns to avoid 702 703### Roundtripping through String 704 705```rust 706// BAD. 707let did_str = did.as_str().to_string(); 708let did2 = Did::new_owned(&did_str)?; 709 710// GOOD. 711let did2 = did.clone(); 712// Or for type conversion: 713let did2: Did<String> = did.convert(); 714``` 715 716### Using serde_json::Value 717 718```rust 719// NEVER. 720let value: serde_json::Value = serde_json::from_slice(bytes)?; 721let post: Post = serde_json::from_value(value)?; 722 723// ALWAYS. 724let data: Data = serde_json::from_slice(bytes)?; 725let post: Post = from_data(&data)?; 726``` 727 728### Using FromStr when new_static() or new_owned() suffices 729 730```rust 731// SLOWER (FromStr always allocates into SmolStr). 732let did: Did = "did:plc:abc".parse()?; 733 734// BETTER (validates and wraps into SmolStr). 735let did = Did::new_owned("did:plc:abc")?; 736 737// BEST for static strings (zero allocation). 738let did = Did::new_static("did:plc:abc")?; 739``` 740 741### Not handling Unknown variant in unions 742 743```rust 744// WILL NOT COMPILE (missing Unknown variant). 745match embed { 746 PostEmbed::Images(img) => { /* ... */ } 747 PostEmbed::Video(vid) => { /* ... */ } 748} 749 750// HANDLE ALL VARIANTS. 751match embed { 752 PostEmbed::Images(img) => { /* ... */ } 753 PostEmbed::Video(vid) => { /* ... */ } 754 _ => { /* Unknown or other variants */ } 755} 756``` 757 758### Forgetting MST immutability 759 760```rust 761// WRONG (loses result). 762mst.add(key, cid).await?; 763 764// CORRECT (reassign). 765let mst = mst.add(key, cid).await?; 766``` 767 768 769### Using DeserializeOwned with CowStr or &str backing types 770 771`DeserializeOwned` (i.e. `for<'de> Deserialize<'de>`) works perfectly with `SmolStr`-backed types. This is fine: 772 773```rust 774// FINE -- SmolStr-backed types implement DeserializeOwned. 775let post: Post = serde_json::from_slice(&bytes)?; 776let post: Post<SmolStr> = serde_json::from_reader(reader)?; 777response.into_output()?; // Uses DeserializeOwned internally. 778``` 779 780However, it does NOT work with `CowStr<'_>` or `&str` backing types, because those need to borrow from the input buffer: 781 782```rust 783// WRONG -- CowStr needs to borrow from input. 784fn bad(data: &[u8]) -> Post<CowStr<'static>> { 785 serde_json::from_slice(data).unwrap() // Can't borrow from data! 786} 787 788// CORRECT -- use method-level lifetime for zero-copy. 789fn good(data: &[u8]) -> Post<CowStr<'_>> { 790 serde_json::from_slice(data).unwrap() // Borrows from data. 791} 792 793// ALSO CORRECT -- use SmolStr when you don't need zero-copy. 794fn also_good(data: &[u8]) -> Post { 795 serde_json::from_slice(data).unwrap() // DeserializeOwned, no borrowing needed. 796} 797``` 798 799**Rule**: `DeserializeOwned` is fine for `SmolStr`-backed types (the default). Only avoid it when you specifically want zero-copy with `CowStr<'_>` or `&str`. Use `.into_output()` for owned results and `.parse::<CowStr<'_>>()` for zero-copy. 800 801### Calling .into_static() unnecessarily 802 803```rust 804// WASTEFUL -- SmolStr types are already owned. 805let output = response.into_output()?; 806let owned = output.into_static(); // No-op! Already SmolStr-backed. 807 808// USEFUL -- converting from CowStr to SmolStr. 809let borrowed: Post<CowStr<'_>> = response.parse()?; 810let owned: Post = borrowed.into_static(); // Converts CowStr -> SmolStr. 811``` 812 813### Using .as_ref() on SmolStr when .as_str() is clearer 814 815`SmolStr` implements `AsRef<str>` but also `AsRef<[u8]>`, which can cause ambiguity: 816 817```rust 818// AMBIGUOUS -- compiler can't infer which AsRef. 819let s = my_smol.as_ref(); 820 821// CLEAR -- use as_str() for concrete SmolStr. 822let s: &str = my_smol.as_str(); 823 824// FINE -- as_ref() works in generic context where S: BosStr. 825fn generic<S: BosStr>(val: &Did<S>) -> &str { 826 val.as_ref() 827} 828``` 829 830--- 831 832## WASM compatibility 833 834Core crates support `wasm32-unknown-unknown` target: 835- jacquard-common 836- jacquard-api 837- jacquard-identity (no DNS resolution) 838- jacquard-oauth 839 840**Pattern**: `#[cfg_attr(not(target_arch = "wasm32"), trait_variant::make(Send))]` 841 842**What's different on WASM**: 843- No `Send` bounds on traits 844- DNS resolution skipped in handle->DID chain 845- Tokio-specific features disabled 846 847**Test WASM compilation**: 848 849```bash 850just check-wasm 851# or 852cargo build --target wasm32-unknown-unknown -p jacquard-common --no-default-features 853``` 854 855--- 856 857## Quick reference 858 859### String type constructors 860 861| Method | Allocates? | Use when | 862|--------|-----------|----------| 863| `new(S)` | Depends on S | Wrapping an already-constructed S | 864| `new_static(&'static str)` | No | Static string literal | 865| `new_owned(impl AsRef<str>)` | SmolStr: inline/Arc | Validating from &str | 866| `borrow(&self) -> Type<&str>` | No | Cheap temporary access | 867| `convert::<B>(self) -> Type<B>` | Depends on B | Cross-type conversion | 868| `FromStr::parse()` | SmolStr: inline/Arc | Don't care about explicitness | 869 870### Response parsing 871 872| Method | Backing type | Allocates? | Use when | 873|--------|-------------|-----------|----------| 874| `.into_output()` | `SmolStr` | Yes (DeserializeOwned) | Most code -- owned, no lifetime concerns | 875| `.parse::<CowStr<'_>>()` | `CowStr<'_>` | No (zero-copy) | Performance-critical, response stays alive | 876| `.parse::<&str>()` | `&str` | No (zero-copy) | Cheapest, response stays alive | 877| `.parse_data()` | `CowStr<'_>` | No | Untyped Data access | 878| `.parse_raw()` | `'_` | No | Untyped RawData access | 879 880### XRPC traits 881 882| Trait | Side | Key types | 883|-------|------|-----------| 884| `XrpcRequest` | Client + Server | `type Response: XrpcResp`, requires `Serialize` | 885| `XrpcResp` | Client + Server | `type Output<S: BosStr>`, `type Err: DeserializeOwned` | 886| `XrpcEndpoint` | Server | `type Request<S: BosStr>`, `type Response: XrpcResp` | 887| `XrpcClient` | Client | `base_uri()`, `send()` | 888| `XrpcExt` | Client | Stateless XRPC builder on any HttpClient | 889 890### Session types 891 892| Type | Auth method | Auto-refresh | Storage | 893|------|------------|--------------|---------| 894| `CredentialSession` | Bearer (app password) | Via refreshSession | SessionStore | 895| `OAuthSession` | DPoP (OAuth) | Via token endpoint | ClientAuthStore | 896 897### BlockStore implementations 898 899| Type | Persistent? | Use case | 900|------|------------|----------| 901| `MemoryBlockStore` | No | Testing | 902| `FileBlockStore` | Yes | Production | 903| `LayeredBlockStore` | Depends | Read-through cache | 904 905--- 906 907## Common operations 908 909### Making an XRPC call 910 911```rust 912use jacquard::api::app_bsky::feed::get_author_feed::GetAuthorFeed; 913 914let request = GetAuthorFeed::new() 915 .actor("alice.bsky.social".into()) 916 .limit(50) 917 .build(); 918 919let response = agent.send(request).await?; 920let output = response.into_output()?; 921 922for post in output.feed { 923 println!("{}: {}", post.post.author.handle, post.post.uri); 924} 925``` 926 927### Creating a record 928 929```rust 930use jacquard::api::app_bsky::feed::post::Post; 931use jacquard::common::types::string::Datetime; 932 933let post = Post::builder() 934 .text("Hello ATProto from Jacquard!") 935 .created_at(Datetime::now()) 936 .build(); 937 938agent.create_record(post, None).await?; 939``` 940 941### Resolving identity 942 943```rust 944use jacquard::identity::PublicResolver; 945 946let resolver = PublicResolver::default(); 947 948// Handle -> DID. 949let did = resolver.resolve_handle(&handle).await?; 950 951// DID -> PDS endpoint. 952let pds = resolver.pds_for_did(&did).await?; 953 954// Combined. 955let (did, pds) = resolver.pds_for_handle(&handle).await?; 956``` 957 958### OAuth login 959 960```rust 961use jacquard::oauth::client::OAuthClient; 962use jacquard::client::FileAuthStore; 963 964let oauth = OAuthClient::with_default_config( 965 FileAuthStore::new("./auth.json") 966); 967 968let session = oauth.login_with_local_server( 969 "alice.bsky.social", 970 Default::default(), 971 Default::default(), 972).await?; 973 974let agent = Agent::from(session); 975``` 976 977### Working with different backing types 978 979```rust 980// Default SmolStr-backed (most common). 981let did = Did::new_owned("did:plc:abc123")?; 982 983// Cheap borrow for function calls. 984let borrowed: Did<&str> = did.borrow(); 985some_function(&borrowed); 986 987// Zero-copy parsing. 988let response = agent.send(request).await?; 989let output: GetPostOutput<CowStr<'_>> = response.parse()?; 990// Process output while response is alive... 991 992// Convert to owned when needed. 993let owned: GetPostOutput = output.into_static(); 994``` 995 996### Server-side XRPC handler 997 998```rust 999use jacquard_axum::{ExtractXrpc, IntoRouter}; 1000use jacquard::common::DefaultStr; 1001use axum::{Router, Json}; 1002 1003async fn handler( 1004 ExtractXrpc(req): ExtractXrpc<MyRequest, DefaultStr> 1005) -> Json<MyOutput> { 1006 // Process request. 1007 Json(output) 1008} 1009 1010let app = Router::new() 1011 .merge(MyRequest::into_router(handler)); 1012``` 1013 1014### MST operations 1015 1016```rust 1017use jacquard_repo::mst::Mst; 1018use jacquard_repo::storage::MemoryBlockStore; 1019 1020let storage = MemoryBlockStore::new(); 1021let mst = Mst::new(storage.clone()); 1022 1023let mst = mst.add("app.bsky.feed.post/abc123", record_cid).await?; 1024let mst = mst.add("app.bsky.feed.post/xyz789", record_cid2).await?; 1025 1026let root_cid = mst.persist().await?; 1027``` 1028 1029--- 1030 1031## Documentation links 1032 1033- [docs.rs/jacquard](https://docs.rs/jacquard/latest/jacquard/) 1034- [docs.rs/jacquard-common](https://docs.rs/jacquard-common/latest/jacquard_common/) 1035- [docs.rs/jacquard-api](https://docs.rs/jacquard-api/latest/jacquard_api/) 1036- [docs.rs/jacquard-oauth](https://docs.rs/jacquard-oauth/latest/jacquard_oauth/) 1037- [docs.rs/jacquard-identity](https://docs.rs/jacquard-identity/latest/jacquard_identity/) 1038- [docs.rs/jacquard-repo](https://docs.rs/jacquard-repo/latest/jacquard_repo/) 1039- [docs.rs/jacquard-axum](https://docs.rs/jacquard-axum/latest/jacquard_axum/) 1040- [Repository](https://tangled.org/@nonbinary.computer/jacquard) 1041 1042--- 1043 1044## Philosophy summary 1045 1046Jacquard is designed for **correctness**, **performance**, and **ergonomics** in that order. It favors: 1047 10481. **Validation at construction time** - Invalid inputs fail fast, not deep in your code 10492. **Flexible string backing** - `SmolStr` for owned types with `DeserializeOwned` support, `CowStr`/`&str` for zero-copy when needed 10503. **Explicit type control** - You choose the backing type via `S: BosStr`, with sensible defaults 10514. **Type safety without boilerplate** - Generated bindings just work, with strong typing and builders 10525. **Batteries included, but replaceable** - High-level `Agent` for convenience, low-level primitives for control 1053 1054**When in doubt**: Use the default `SmolStr`-backed types, call `.into_output()` for owned responses, use `.borrow()` for cheap access, and trust the type system. Jacquard is designed to guide you toward correct, performant code.