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.