A better Rust ATProto crate
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.