A better Rust ATProto crate
0
fork

Configure Feed

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

docs, version bump for beta, changelog

+473 -465
+70
CHANGELOG.md
··· 1 1 # Changelog 2 2 3 + ## [0.12.0-beta.1] - 2026-03-23 4 + 5 + ### Breaking changes 6 + 7 + **Borrow-or-share (BOS) type system** (all crates) 8 + - All validated string types (`Did`, `Handle`, `Nsid`, `Rkey`, `AtUri`, `AtIdentifier`, `MimeType`, `Cid`, `CidLink`, `Blob`, `BlobRef`, `Data`, `Array`, `Object`, `DidDocument`, `RecordUri`, `UriValue`, `RepoPath`) are now parameterised on `S: BosStr = DefaultStr` instead of lifetimes 9 + - `DefaultStr = SmolStr`: owned, inline ≤23 bytes, `DeserializeOwned` 10 + - `&str`, `CowStr<'a>`, `String` all work as backing types via the `BosStr` trait 11 + - `SmolStr`-backed types satisfy `DeserializeOwned`, enabling use in async contexts, collections, and across thread boundaries without `IntoStatic` conversion 12 + - New `.borrow()` method on `Did`, `Handle`, `Nsid`, `Rkey`, `RecordKey` returns `Type<&str>` for cheap borrowing (analogous to `Uri::borrow()`) 13 + - New `.convert::<B>()` method for cross-backing-type conversion 14 + 15 + **XRPC trait changes** (`jacquard-common`) 16 + - `XrpcResp::Output<S: BosStr>`: GAT parameterised on backing type S, not lifetime 17 + - `XrpcResp::Err`: plain associated type, always `SmolStr`-backed and `DeserializeOwned` 18 + - `XrpcRequest` now requires `Serialize` bound 19 + - `XrpcEndpoint::Request<S: BosStr>`: GAT for server-side extraction 20 + - `SubscriptionResp::Message<S: BosStr>`, `SubscriptionEndpoint::Params<S: BosStr>`: updated GATs 21 + - `XrpcProcedureStream::Frame<S: BosStr>`, `XrpcStreamResp::Frame<S: BosStr>`: streaming frame GATs updated 22 + 23 + **Response parsing** (`jacquard-common`) 24 + - `Response::parse::<S>()`: caller chooses backing type via turbofish 25 + - `Response::into_output()`: returns `SmolStr`-backed owned types via `DeserializeOwned` 26 + - Zero-copy: `response.parse::<CowStr<'_>>()`; owned: `response.into_output()` 27 + 28 + **Generated API types** (`jacquard-api`, `jacquard-lexicon`) 29 + - All generated structs/enums: `Foo<S: BosStr = DefaultStr>` with `#[serde(bound(deserialize = "S: Deserialize<'de> + BosStr"))]` 30 + - `#[serde(borrow)]` removed from all generated code 31 + - String field defaults use `FromStaticStr::from_static()` for zero-alloc construction 32 + - Error enums: `SmolStr` message fields, no lifetime parameters 33 + - Type aliases: `<S = DefaultStr>` (no bounds, per rust type checker limitation) 34 + 35 + **OAuth types** (`jacquard-oauth`) 36 + - All session, metadata, token, scope, and JWT types parameterised on `S: BosStr = DefaultStr` 37 + - `OAuthMetadata<S>` parameterised so callers can borrow from stored metadata 38 + - `DpopDataSource` trait methods return `Option<&str>` (was `Option<CowStr<'_>>`) 39 + - DPoP proof building uses `&str` for zero-copy JWT construction 40 + - `build_dpop_proof` takes `&str` parameters, returns `SmolStr` 41 + 42 + **Identity resolution** (`jacquard-identity`) 43 + - `IdentityResolver::resolve_handle<S: BosStr + Sync>(&self, handle: &Handle<S>)`: generic over handle backing type 44 + - `IdentityResolver::resolve_did_doc<S: BosStr + Sync>(&self, did: &Did<S>)`: generic over DID backing type 45 + 46 + **Repository types** (`jacquard-repo`) 47 + - `Commit<S: BosStr = DefaultStr>`, `UnsignedCommit<S>`, `RecordClaim<S>`, `VerifyProofsOutput<S>` parameterised on S 48 + - `RecordWriteOp` now parameterised on both S (for string fields) and BS (for BlockStore) 49 + - `RawData<'a>` intentionally remains lifetime-based 50 + 51 + **Derive macros** (`jacquard-derive`) 52 + - `#[lexicon]` detects type param S, emits `Data<S>` for `extra_data` 53 + - `#[open_union]` detects type param S, emits `Unknown(Data<S>)` 54 + - `#[derive(IntoStatic)]` handles S-parameterised types 55 + - `#[derive(XrpcRequest)]` generates new `Output<S: BosStr>` and `Err` (not GAT) impls 56 + 57 + **Client types** (`jacquard`) 58 + - `AtpSession` fields `access_jwt`/`refresh_jwt` are now `SmolStr` (was `CowStr<'static>`) 59 + - `SessionKey` uses `Did` and `SmolStr` (was `Did<'static>` and `CowStr<'static>`) 60 + - `AgentSessionExt` record methods (`get_record`, `update_record`, etc.) take `AtUri<S>` / `RecordUri<S, R>` with generic S 61 + - Moderation types (`ModerationPrefs`, `LabelerDefs`, `Labeled` trait, etc.) parameterised on S 62 + 63 + ### Removed 64 + 65 + - `jacquard-axum` temporarily removed from workspace (extractor needs redesign for BOS type params) 66 + 67 + ### Changed 68 + 69 + **Codegen** (`jacquard-lexicon`) 70 + - Generated `XrpcResp` impls emit `Output<S: BosStr>` and `Err` (plain type, not GAT) 71 + - Generated `XrpcEndpoint` impls emit `Request<S: BosStr>` 72 + 3 73 ## [0.11.0] - 2026-03-21 4 74 5 75 ### Breaking changes
+31 -33
CLAUDE.md
··· 36 36 - jacquard-common: Core AT Protocol types (DIDs, handles, at-URIs, NSIDs, TIDs, CIDs, etc.) and the `CowStr` type 37 37 - jacquard-lexicon: Lexicon parsing and Rust code generation from lexicon schemas 38 38 - jacquard-api: Generated API bindings from 646 lexicon schemas (ATProto, Bluesky, community lexicons) 39 - - jacquard-derive: Attribute macros (`#[lexicon]`, `#[open_union]`) and derive macros (`#[derive(IntoStatic)]`) for lexicon structures 39 + - jacquard-derive: Attribute macros (`#[lexicon]`, `#[open_union]`) and derive macros (`#[derive(IntoStatic)]`, `#[derive(XrpcRequest)]`) for lexicon structures 40 40 - jacquard-oauth: OAuth/DPoP flow implementation with session management 41 41 - jacquard-axum: Server-side XRPC handler extractors for Axum framework 42 42 - jacquard-identity: Identity resolution (handle→DID, DID→Doc) ··· 125 125 126 126 ## String Type Pattern 127 127 128 - All validated string types follow a consistent pattern: 129 - - Constructors: `new()`, `new_owned()`, `new_static()`, `raw()`, `unchecked()`, `as_str()` 130 - - Traits: `Serialize`, `Deserialize`, `FromStr`, `Display`, `Debug`, `PartialEq`, `Eq`, `Hash`, `Clone`, conversions to/from `String`/`CowStr`/`SmolStr`, `AsRef<str>`, `Deref<Target=str>` 131 - - Implementation notes: Prefer `#[repr(transparent)]` where possible; use `SmolStr` for short strings, `CowStr` for longer; implement or derive `IntoStatic` for owned conversion 128 + All validated string types (`Did`, `Handle`, `Nsid`, `Rkey`, `AtUri`, etc.) are parameterised on `S: BosStr = DefaultStr` where `DefaultStr = SmolStr`: 129 + - Constructors: `new(s: S)`, `new_owned(impl AsRef<str>)`, `new_static(&'static str)`, `raw()`, `unchecked()` 130 + - Borrowing: `borrow(&self) -> Type<&str>` — cheap borrow analogous to `Uri::borrow()` 131 + - Conversion: `convert<B: BosStr + From<S>>(self) -> Type<B>` — cross-type conversion 132 + - Traits: `Serialize`, `Deserialize`, `FromStr`, `Display`, `Debug`, `PartialEq`, `Eq`, `Hash`, `Clone`, `AsRef<str>`, `Deref<Target=str>` 133 + - Implementation notes: `#[repr(transparent)]` newtypes; `SmolStr` as default backing (inline ≤23 bytes, Arc for longer) 132 134 - When constructing from a static string, use `new_static()` to avoid unnecessary allocations 135 + - `FromStaticStr::from_static()` for zero-alloc construction in generic contexts 133 136 134 - ## Lifetimes and Zero-Copy Deserialization 137 + ## Borrow-or-share type system 135 138 136 - All API types support borrowed deserialization via explicit lifetimes: 137 - - Request/output types: parameterised by `'de` lifetime (e.g., `GetAuthorFeed<'de>`, `GetAuthorFeedOutput<'de>`) 138 - - Fields use `#[serde(borrow)]` where possible (strings, nested objects with lifetimes) 139 - - `CowStr<'a>` enables efficient borrowing from input buffers or owning small strings inline (via `SmolStr`) 140 - - All types implement `IntoStatic` trait to convert borrowed data to owned (`'static`) variants 141 - - Code generator automatically propagates lifetimes through nested structures 139 + All API types are parameterised on `S: BosStr = DefaultStr`: 140 + - `SmolStr` (= `DefaultStr`): owned, `DeserializeOwned`, can cross async boundaries and be stored 141 + - `&str`: zero-copy borrowed access, cheapest possible 142 + - `CowStr<'a>`: borrow-or-own flexibility (still lifetime-based itself) 143 + - `String`: standard owned strings 142 144 143 - Response lifetime handling: 144 - - `Response::parse()` borrows from the response buffer for zero-copy parsing 145 - - `Response::into_output()` converts to owned data using `IntoStatic` 146 - - `Response::transmute()`: reinterpret response as different type (used for typed collection responses) 147 - - Both methods provide typed error handling 145 + Response handling: 146 + - `Response::parse::<S>()` — caller chooses backing type via turbofish (e.g., `parse::<CowStr<'_>>()` for zero-copy) 147 + - `Response::into_output()` — returns `SmolStr`-backed owned types (`DeserializeOwned`) 148 + - `Response::transmute()` — reinterpret response as different type (used for typed collection responses) 149 + - `SmolStr`-backed types satisfy `DeserializeOwned`, so they work in async contexts, collections, and across thread boundaries without `IntoStatic` 148 150 149 151 ## API Coverage (jacquard-api) 150 152 ··· 155 157 ## Value Types (jacquard-common) 156 158 157 159 For working with loosely-typed atproto data: 158 - - `Data<'a>`: Validated, typed representation of atproto values 160 + - `Data<S: BosStr>`: Validated, typed representation of atproto values 159 161 - `RawData<'a>`: Unvalidated raw values from deserialization 160 162 - `from_data`, `from_raw_data`, `to_data`, `to_raw_data`: Convert between typed and untyped 161 163 - Useful for second-stage deserialization of `type "unknown"` fields (e.g., `PostView.record`) 162 164 163 165 Collection types: 164 166 - `Collection` trait: Marker trait for record types with `NSID` constant and `Record` associated type 165 - - `RecordError<'a>`: Generic error type for record retrieval operations (RecordNotFound, Unknown) 166 - 167 - ## Lifetime Design Pattern 167 + - `RecordError`: Generic error type for record retrieval operations (RecordNotFound, Unknown) 168 168 169 - Jacquard uses a specific pattern to enable zero-copy deserialization while avoiding HRTB issues and async lifetime problems: 169 + ## XRPC type design pattern 170 170 171 - **GATs on associated types** instead of trait-level lifetimes: 171 + XRPC traits use GATs parameterised on `S: BosStr`: 172 172 ```rust 173 173 trait XrpcResp { 174 - type Output<'de>: Deserialize<'de> + IntoStatic; // GAT, not trait-level lifetime 174 + type Output<S: BosStr>; // GAT parameterised on backing type, not lifetime 175 + type Err; // Plain associated type, always SmolStr-backed 175 176 } 176 177 ``` 177 178 178 - **Method-level generic lifetimes** for trait methods that need them: 179 - ```rust 180 - fn extract_vec<'s>(output: Self::Output<'s>) -> Vec<Item> 181 - ``` 182 - 183 - **Response wrapper owns buffer** to solve async lifetime issues: 179 + **Response wrapper owns buffer** — caller chooses backing type: 184 180 ```rust 185 181 async fn get_record<R>(&self, rkey: K) -> Result<Response<R>> 186 - // Caller chooses: response.parse() (borrow) or response.into_output() (owned) 182 + // response.parse::<CowStr<'_>>() — zero-copy from buffer 183 + // response.into_output() — SmolStr-backed, DeserializeOwned 187 184 ``` 188 185 189 - This pattern avoids `for<'any> Trait<'any>` bounds (which force `DeserializeOwned` semantics) while giving callers control over borrowing vs owning. See `jacquard-common` crate docs for detailed explanation. 186 + Error types (`Err`) are always `SmolStr`-backed and `DeserializeOwned` — no lifetime gymnastics for error handling. 187 + 188 + Generated error enums use `SmolStr` message fields and `#[serde(untagged)] Other { error, message }` catch-all. 190 189 191 190 ## WASM Compatibility 192 191 ··· 201 200 Test WASM compilation: 202 201 ```bash 203 202 just check-wasm 204 - # or: cargo build --target wasm32-unknown-unknown -p jacquard-common --no-default-features 205 203 ``` 206 204 207 205 ## Client Architecture
+9 -9
Cargo.lock
··· 2137 2137 2138 2138 [[package]] 2139 2139 name = "jacquard" 2140 - version = "0.11.0" 2140 + version = "0.12.0-beta.1" 2141 2141 dependencies = [ 2142 2142 "bytes", 2143 2143 "clap", ··· 2171 2171 2172 2172 [[package]] 2173 2173 name = "jacquard-api" 2174 - version = "0.11.1" 2174 + version = "0.12.0-beta.1" 2175 2175 dependencies = [ 2176 2176 "jacquard-common", 2177 2177 "jacquard-derive", ··· 2183 2183 2184 2184 [[package]] 2185 2185 name = "jacquard-common" 2186 - version = "0.11.0" 2186 + version = "0.12.0-beta.1" 2187 2187 dependencies = [ 2188 2188 "base64 0.22.1", 2189 2189 "bon", ··· 2238 2238 2239 2239 [[package]] 2240 2240 name = "jacquard-derive" 2241 - version = "0.11.0" 2241 + version = "0.12.0-beta.1" 2242 2242 dependencies = [ 2243 2243 "heck 0.5.0", 2244 2244 "inventory", ··· 2254 2254 2255 2255 [[package]] 2256 2256 name = "jacquard-identity" 2257 - version = "0.11.0" 2257 + version = "0.12.0-beta.1" 2258 2258 dependencies = [ 2259 2259 "bon", 2260 2260 "bytes", ··· 2277 2277 2278 2278 [[package]] 2279 2279 name = "jacquard-lexgen" 2280 - version = "0.11.1" 2280 + version = "0.12.0-beta.1" 2281 2281 dependencies = [ 2282 2282 "clap", 2283 2283 "clap_complete", ··· 2303 2303 2304 2304 [[package]] 2305 2305 name = "jacquard-lexicon" 2306 - version = "0.11.1" 2306 + version = "0.12.0-beta.1" 2307 2307 dependencies = [ 2308 2308 "bytes", 2309 2309 "cid", ··· 2332 2332 2333 2333 [[package]] 2334 2334 name = "jacquard-oauth" 2335 - version = "0.11.0" 2335 + version = "0.12.0-beta.1" 2336 2336 dependencies = [ 2337 2337 "base64 0.22.1", 2338 2338 "bytes", ··· 2366 2366 2367 2367 [[package]] 2368 2368 name = "jacquard-repo" 2369 - version = "0.11.0" 2369 + version = "0.12.0-beta.1" 2370 2370 dependencies = [ 2371 2371 "anyhow", 2372 2372 "bytes",
+1 -1
Cargo.toml
··· 5 5 6 6 [workspace.package] 7 7 edition = "2024" 8 - version = "0.11.0" 8 + version = "0.12.0-beta.1" 9 9 authors = ["Orual <orual@nonbinary.computer>"] 10 10 #repository = "https://github.com/rsform/jacquard" 11 11 repository = "https://tangled.org/nonbinary.computer/jacquard"
+4 -4
crates/jacquard-api/Cargo.toml
··· 2 2 name = "jacquard-api" 3 3 description = "Generated AT Protocol API bindings for Jacquard" 4 4 edition.workspace = true 5 - version = "0.11.1" 5 + version = "0.12.0-beta.1" 6 6 authors.workspace = true 7 7 repository.workspace = true 8 8 keywords.workspace = true ··· 15 15 features = [ "bluesky", "other", "streaming" ] 16 16 17 17 [dependencies] 18 - jacquard-common = { version = "0.11", path = "../jacquard-common" } 19 - jacquard-derive = { version = "0.11", path = "../jacquard-derive" } 20 - jacquard-lexicon = { version = "0.11", path = "../jacquard-lexicon", default-features = false } 18 + jacquard-common = { version = "0.12.0-beta.1", path = "../jacquard-common" } 19 + jacquard-derive = { version = "0.12.0-beta.1", path = "../jacquard-derive" } 20 + jacquard-lexicon = { version = "0.12.0-beta.1", path = "../jacquard-lexicon", default-features = false } 21 21 miette.workspace = true 22 22 serde.workspace = true 23 23 thiserror.workspace = true
+5 -5
crates/jacquard-axum/Cargo.toml
··· 1 1 [package] 2 2 name = "jacquard-axum" 3 3 edition.workspace = true 4 - version = "0.11.0" 4 + version = "0.12.0-beta.1" 5 5 authors.workspace = true 6 6 repository.workspace = true 7 7 keywords.workspace = true ··· 22 22 [dependencies] 23 23 axum = "0.8.6" 24 24 bytes.workspace = true 25 - jacquard = { version = "0.11", path = "../jacquard", default-features = false, features = ["api"] } 26 - jacquard-common = { version = "0.11", path = "../jacquard-common", features = ["reqwest-client"] } 27 - jacquard-derive = { version = "0.11", path = "../jacquard-derive" } 28 - jacquard-identity = { version = "0.11", path = "../jacquard-identity", optional = true } 25 + jacquard = { version = "0.12.0-beta.1", path = "../jacquard", default-features = false, features = ["api"] } 26 + jacquard-common = { version = "0.12.0-beta.1", path = "../jacquard-common", features = ["reqwest-client"] } 27 + jacquard-derive = { version = "0.12.0-beta.1", path = "../jacquard-derive" } 28 + jacquard-identity = { version = "0.12.0-beta.1", path = "../jacquard-identity", optional = true } 29 29 miette.workspace = true 30 30 multibase = { version = "0.9.1", optional = true } 31 31 serde.workspace = true
+1 -1
crates/jacquard-common/Cargo.toml
··· 2 2 name = "jacquard-common" 3 3 description = "Core AT Protocol types and utilities for Jacquard" 4 4 edition.workspace = true 5 - version = "0.11.0" 5 + version = "0.12.0-beta.1" 6 6 authors.workspace = true 7 7 repository.workspace = true 8 8 keywords.workspace = true
+3 -3
crates/jacquard-derive/Cargo.toml
··· 16 16 17 17 [dependencies] 18 18 heck.workspace = true 19 - jacquard-lexicon = { version = "0.11", path = "../jacquard-lexicon", features = ["codegen"] } 19 + jacquard-lexicon = { version = "0.12.0-beta.1", path = "../jacquard-lexicon", features = ["codegen"] } 20 20 proc-macro2.workspace = true 21 21 quote.workspace = true 22 22 syn.workspace = true 23 23 24 24 [dev-dependencies] 25 25 inventory = "0.3" 26 - jacquard-common = { version = "0.11", path = "../jacquard-common" } 27 - jacquard-lexicon = { version = "0.11", path = "../jacquard-lexicon" } 26 + jacquard-common = { version = "0.12.0-beta.1", path = "../jacquard-common" } 27 + jacquard-lexicon = { version = "0.12.0-beta.1", path = "../jacquard-lexicon" } 28 28 serde.workspace = true 29 29 serde_json.workspace = true 30 30 unicode-segmentation = "1.12"
+3 -3
crates/jacquard-identity/Cargo.toml
··· 1 1 [package] 2 2 name = "jacquard-identity" 3 3 edition.workspace = true 4 - version = "0.11.0" 4 + version = "0.12.0-beta.1" 5 5 authors.workspace = true 6 6 repository.workspace = true 7 7 keywords.workspace = true ··· 22 22 trait-variant.workspace = true 23 23 bon.workspace = true 24 24 bytes.workspace = true 25 - jacquard-common = { version = "0.11", path = "../jacquard-common", features = ["reqwest-client"] } 26 - jacquard-lexicon = { version = "0.11", path = "../jacquard-lexicon", default-features = false } 25 + jacquard-common = { version = "0.12.0-beta.1", path = "../jacquard-common", features = ["reqwest-client"] } 26 + jacquard-lexicon = { version = "0.12.0-beta.1", path = "../jacquard-lexicon", default-features = false } 27 27 reqwest.workspace = true 28 28 serde.workspace = true 29 29 serde_json.workspace = true
+5 -5
crates/jacquard-lexgen/Cargo.toml
··· 2 2 name = "jacquard-lexgen" 3 3 description = "Lexicon fetching and code generation binaries for Jacquard" 4 4 edition.workspace = true 5 - version = "0.11.1" 5 + version = "0.12.0-beta.1" 6 6 authors.workspace = true 7 7 repository.workspace = true 8 8 keywords.workspace = true ··· 32 32 clap.workspace = true 33 33 glob = "0.3" 34 34 inventory = "0.3" 35 - jacquard-common = { version = "0.11", features = [ "reqwest-client" ], path = "../jacquard-common" } 36 - jacquard-derive = { version = "0.11", path = "../jacquard-derive" } 37 - jacquard-identity = { version = "0.11", path = "../jacquard-identity", features = ["dns"] } 38 - jacquard-lexicon = { version = "0.11", path = "../jacquard-lexicon" } 35 + jacquard-common = { version = "0.12.0-beta.1", features = [ "reqwest-client" ], path = "../jacquard-common" } 36 + jacquard-derive = { version = "0.12.0-beta.1", path = "../jacquard-derive" } 37 + jacquard-identity = { version = "0.12.0-beta.1", path = "../jacquard-identity", features = ["dns"] } 38 + jacquard-lexicon = { version = "0.12.0-beta.1", path = "../jacquard-lexicon" } 39 39 kdl = "6" 40 40 miette = { workspace = true, features = ["fancy"] } 41 41 reqwest = { workspace = true, features = ["json", "http2", "system-proxy", "rustls-tls"] }
+3 -3
crates/jacquard-lexicon/Cargo.toml
··· 2 2 name = "jacquard-lexicon" 3 3 description = "Lexicon schema parsing and code generation for Jacquard" 4 4 edition.workspace = true 5 - version = "0.11.1" 5 + version = "0.12.0-beta.1" 6 6 authors.workspace = true 7 7 repository.workspace = true 8 8 keywords.workspace = true ··· 20 20 dashmap.workspace = true 21 21 heck = { workspace = true, optional = true } 22 22 inventory = "0.3" 23 - jacquard-common = { version = "0.11", path = "../jacquard-common" } 23 + jacquard-common = { version = "0.12.0-beta.1", path = "../jacquard-common" } 24 24 miette = { workspace = true } 25 25 multihash.workspace = true 26 26 prettyplease = { workspace = true, optional = true } ··· 39 39 40 40 [dev-dependencies] 41 41 bytes = { workspace = true } 42 - jacquard-derive = { version = "0.11", path = "../jacquard-derive"} 42 + jacquard-derive = { version = "0.12.0-beta.1", path = "../jacquard-derive"} 43 43 tempfile = { version = "3.23.0" }
+3 -3
crates/jacquard-oauth/Cargo.toml
··· 1 1 [package] 2 2 name = "jacquard-oauth" 3 - version = "0.11.0" 3 + version = "0.12.0-beta.1" 4 4 edition.workspace = true 5 5 description = "AT Protocol OAuth 2.1 core types and helpers for Jacquard" 6 6 authors.workspace = true ··· 21 21 streaming = ["jacquard-common/streaming", "dep:n0-future"] 22 22 23 23 [dependencies] 24 - jacquard-common = { version = "0.11", path = "../jacquard-common", features = ["reqwest-client"] } 25 - jacquard-identity = { version = "0.11", path = "../jacquard-identity" } 24 + jacquard-common = { version = "0.12.0-beta.1", path = "../jacquard-common", features = ["reqwest-client"] } 25 + jacquard-identity = { version = "0.12.0-beta.1", path = "../jacquard-identity" } 26 26 serde = { workspace = true, features = ["derive"] } 27 27 serde_json = { workspace = true } 28 28 smol_str = { workspace = true }
+4 -4
crates/jacquard-repo/Cargo.toml
··· 2 2 name = "jacquard-repo" 3 3 description = "AT Protocol repository primitives: MST, commits, CAR I/O" 4 4 edition.workspace = true 5 - version = "0.11.0" 5 + version = "0.12.0-beta.1" 6 6 authors.workspace = true 7 7 repository.workspace = true 8 8 keywords.workspace = true ··· 16 16 17 17 [dependencies] 18 18 # Internal 19 - jacquard-common = { path = "../jacquard-common", version = "0.11", features = ["crypto-ed25519", "crypto-k256", "crypto-p256"] } 20 - jacquard-derive = { path = "../jacquard-derive", version = "0.11" } 21 - jacquard-api = { path = "../jacquard-api", version = "0.11", features = ["streaming"] } 19 + jacquard-common = { path = "../jacquard-common", version = "0.12.0-beta.1", features = ["crypto-ed25519", "crypto-k256", "crypto-p256"] } 20 + jacquard-derive = { path = "../jacquard-derive", version = "0.12.0-beta.1" } 21 + jacquard-api = { path = "../jacquard-api", version = "0.12.0-beta.1", features = ["streaming"] } 22 22 23 23 # Serialization 24 24 serde.workspace = true
+6 -6
crates/jacquard/Cargo.toml
··· 120 120 required-features = ["api_bluesky", "loopback"] 121 121 122 122 [dependencies] 123 - jacquard-api = { version = "0.11", path = "../jacquard-api" } 124 - jacquard-common = { version = "0.11", path = "../jacquard-common", features = [ 123 + jacquard-api = { version = "0.12.0-beta.1", path = "../jacquard-api" } 124 + jacquard-common = { version = "0.12.0-beta.1", path = "../jacquard-common", features = [ 125 125 "reqwest-client", 126 126 ] } 127 - jacquard-oauth = { version = "0.11", path = "../jacquard-oauth" } 128 - jacquard-derive = { version = "0.11", path = "../jacquard-derive", optional = true } 129 - jacquard-identity = { version = "0.11", path = "../jacquard-identity" } 127 + jacquard-oauth = { version = "0.12.0-beta.1", path = "../jacquard-oauth" } 128 + jacquard-derive = { version = "0.12.0-beta.1", path = "../jacquard-derive", optional = true } 129 + jacquard-identity = { version = "0.12.0-beta.1", path = "../jacquard-identity" } 130 130 131 131 132 132 ··· 149 149 150 150 151 151 [target.'cfg(not(target_family = "wasm"))'.dependencies] 152 - jacquard-identity = { version = "0.11", path = "../jacquard-identity", features = ["cache"] } 152 + jacquard-identity = { version = "0.12.0-beta.1", path = "../jacquard-identity", features = ["cache"] } 153 153 reqwest = { workspace = true, features = [ 154 154 "http2", 155 155 "gzip",
+325 -385
llms.txt
··· 1 1 # Jacquard: AT Protocol Library for Rust 2 2 3 - > Simple and powerful AT Protocol (Bluesky) client library emphasizing spec compliance, zero-copy deserialization, and minimal boilerplate. 3 + > Simple and powerful AT Protocol (Bluesky) client library emphasizing spec compliance, flexible string backing types, and minimal boilerplate. 4 4 5 - ## Core Philosophy 5 + ## Core philosophy 6 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. 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 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. 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 10 11 11 **Batteries included, but replaceable.** High-level `Agent` for convenience, or use stateless `XrpcCall` builder for full control. Mix and match as needed. 12 12 13 13 --- 14 14 15 - ## Critical Patterns to Internalize 15 + ## Critical patterns to internalize 16 + 17 + ### String backing types 18 + 19 + All validated types (Did, Handle, AtUri, Nsid, etc.) and all generated API types are generic over `S: BosStr`: 20 + 21 + ```rust 22 + pub struct Did<S: BosStr = DefaultStr> { /* ... */ } 23 + pub struct Post<S: BosStr = DefaultStr> { /* ... */ } 24 + ``` 25 + 26 + The `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. 16 36 17 - ### String Type Constructors 37 + ### String type constructors 18 38 19 39 ALL validated string types (Did, Handle, AtUri, Nsid, etc.) follow this pattern: 20 40 21 41 ```rust 22 - // ✅ PREFERRED: Zero-allocation for borrowed strings 23 - let did = Did::new("did:plc:abc123")?; // Borrows from input 42 + // Wraps S directly (validates). S defaults to SmolStr. 43 + let did = Did::new(SmolStr::new("did:plc:abc123"))?; 24 44 25 - // ✅ BEST: Zero-allocation for static strings 45 + // Zero-allocation for static strings. 26 46 let nsid = Nsid::new_static("com.atproto.repo.getRecord")?; 27 47 28 - // ✅ When you need ownership 48 + // Validates and produces owned (SmolStr-backed by default). 29 49 let owned = Did::new_owned("did:plc:abc123")?; 30 50 31 - // ❌ AVOID: FromStr always allocates 32 - let did: Did = "did:plc:abc123".parse()?; // Always allocates! 51 + // Cheap borrow: Did<SmolStr> -> Did<&str>. 52 + let borrowed: Did<&str> = owned.borrow(); 53 + 54 + // Cross-type conversion: Did<SmolStr> -> Did<String>. 55 + let string_did: Did<String> = owned.convert(); 33 56 34 - // ❌ NEVER: Roundtripping through String 57 + // FromStr always allocates into SmolStr. 58 + let did: Did = "did:plc:abc123".parse()?; 59 + 60 + // AVOID: Roundtripping through String. 35 61 let s = did.as_str().to_string(); 36 - let did2 = Did::new(&s)?; // Pointless allocation 62 + let did2 = Did::new_owned(&s)?; // Pointless allocation. 37 63 ``` 38 64 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. 65 + **Rule**: Use `new_static()` for string literals, `new_owned()` when validating from `&str`, `borrow()` for cheap temporary access, `convert()` for cross-type conversion. 40 66 41 - ### Response Parsing: Borrow vs Own 67 + ### Response parsing: choose your backing type 42 68 43 - XRPC responses wrap a `Bytes` buffer. You choose when to parse and whether to own the data: 69 + XRPC responses wrap a `Bytes` buffer. You choose how to deserialize: 44 70 45 71 ```rust 46 72 let response = agent.send(request).await?; 47 73 48 - // Option 1: Borrow from response buffer (zero-copy) 49 - let output: GetPostOutput<'_> = response.parse()?; 50 - // ⚠️ `output` borrows from `response`, both must stay in scope 74 + // Option 1: Owned SmolStr-backed (DeserializeOwned -- just works!) 75 + let output: GetPostOutput = response.into_output()?; 76 + // Can return from functions, store anywhere, no lifetime concerns. 51 77 52 - // Option 2: Convert to owned (allocates, but can outlive response) 53 - let output: GetPostOutput<'static> = response.into_output()?; 54 - drop(response); // OK, output is now fully owned 78 + // Option 2: Zero-copy with CowStr (borrows from response buffer) 79 + let output: GetPostOutput<CowStr<'_>> = response.parse()?; 80 + // output borrows from response, both must stay in scope. 55 81 56 - // ❌ WRONG: Can't drop response while holding borrowed parse 57 - let output = response.parse()?; 58 - drop(response); // ERROR: output borrows from response 82 + // Option 3: Zero-copy with &str (borrows from response buffer) 83 + let output: GetPostOutput<&str> = response.parse()?; 84 + // Cheapest, but output borrows from response. 59 85 ``` 60 86 61 - **Rule**: Use `.parse()` when processing immediately in the same scope. Use `.into_output()` when returning from functions or storing long-term. 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. 62 88 63 - ### Lifetime Pattern: GATs Not HRTBs 89 + ### Type system: GATs parameterised on S 64 90 65 - Jacquard uses **Generic Associated Types** on `XrpcResp` to avoid Higher-Rank Trait Bounds: 91 + Jacquard uses **Generic Associated Types** on `XrpcResp` parameterised on backing string type: 66 92 67 93 ```rust 68 - // ✅ Jacquard's approach (GAT) 69 94 trait XrpcResp { 70 - type Output<'de>: Deserialize<'de> + IntoStatic; 71 - type Err<'de>: Error + Deserialize<'de> + IntoStatic; 72 - } 73 - 74 - // ❌ Alternative that forces DeserializeOwned semantics 75 - trait BadXrpcResp<'de> { 76 - type Output: Deserialize<'de>; 95 + type Output<S: BosStr>; // GAT parameterised on S, not lifetime. 96 + type Err: Error + DeserializeOwned; // Always owned, always SmolStr-backed. 77 97 } 78 - // Would require: where R: for<'any> BadXrpcResp<'any> 79 - // This forces owned deserialization! 80 98 ``` 81 99 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. 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 83 105 84 - **When implementing custom types**: Use method-level lifetime generics (`<'de>`), not trait-level lifetimes. 106 + ```rust 107 + // borrow() -- cheap Did<SmolStr> -> Did<&str> 108 + let borrowed: Did<&str> = my_did.borrow(); 85 109 86 - ### CRITICAL: Never Use `for<'de> Deserialize<'de>` Bounds 110 + // convert() -- type-level conversion (e.g., SmolStr -> String) 111 + let string_did: Did<String> = my_did.convert(); 87 112 88 - **The bound `T: for<'de> Deserialize<'de>` is EQUIVALENT to `T: DeserializeOwned`** and will break all Jacquard types. 113 + // IntoStatic -- converts CowStr<'_> -> SmolStr (or other owned) 114 + use jacquard::common::IntoStatic; 115 + let owned: Post = borrowed_post.into_static(); 89 116 90 - ```rust 91 - // ❌ CATASTROPHIC - No Jacquard type can satisfy this 92 - fn bad<T>(data: &[u8]) -> Result<T> 93 - where 94 - T: for<'de> Deserialize<'de> // Forces owned deserialization! 95 - { 96 - serde_json::from_slice(data) // Can't borrow from data 97 - } 117 + // as_str() -- get &str from any concrete S (e.g., SmolStr) 118 + let s: &str = my_did.as_str(); 98 119 99 - // ✅ CORRECT - Use method-level lifetime 100 - fn good<'de, T>(data: &'de [u8]) -> Result<T> 101 - where 102 - T: Deserialize<'de> // Can borrow from data 103 - { 104 - serde_json::from_slice(data) 120 + // as_ref() -- get &str from generic S (via AsRef<str>) 121 + fn generic<S: BosStr>(did: &Did<S>) -> &str { 122 + did.as_ref() 105 123 } 106 124 ``` 107 125 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. 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`. 111 127 112 128 --- 113 129 114 - ## Crate-by-Crate Guide 130 + ## Crate-by-crate guide 115 131 116 - ### jacquard (Main Crate) 132 + ### jacquard (main crate) 117 133 118 134 **Primary entry point**: `Agent<A: AgentSession>` 119 135 ··· 121 137 use jacquard::client::{Agent, CredentialSession, MemorySessionStore}; 122 138 use jacquard::identity::PublicResolver; 123 139 124 - // App password auth 140 + // App password auth. 125 141 let (session, _info) = CredentialSession::authenticated( 126 142 "alice.bsky.social".into(), 127 143 "app-password".into(), ··· 129 145 ).await?; 130 146 let agent = Agent::from(session); 131 147 132 - // Make typed XRPC calls 148 + // Make typed XRPC calls. 133 149 use jacquard::api::app_bsky::feed::get_timeline::GetTimeline; 134 - let response = agent.send(&GetTimeline::new().limit(50).build()).await?; 150 + let response = agent.send(&GetTimeline::<&str>::new().limit(50).build()).await?; 135 151 let timeline = response.into_output()?; 136 152 ``` 137 153 ··· 142 158 ```rust 143 159 use jacquard::api::app_bsky::feed::post::Post; 144 160 145 - // Create 161 + // Create. 146 162 let post = Post::builder() 147 163 .text("Hello ATProto!") 148 164 .created_at(Datetime::now()) 149 165 .build(); 150 - agent.create_record(post, None).await?; 166 + agent.create_record::<Post, _>(post, None).await?; 151 167 152 - // Get (type-safe!) 168 + // Get (type-safe!). 153 169 let uri = Post::uri("at://did:plc:abc/app.bsky.feed.post/123")?; 154 170 let response = agent.get_record::<Post>(&uri).await?; 155 - let post_output = response.parse()?; 171 + let post_output = response.into_output()?; 156 172 157 - // Update with fetch-modify-put pattern 173 + // Update with fetch-modify-put pattern. 158 174 agent.update_record::<Profile>(&uri, |profile| { 159 - profile.display_name = Some("New Name".into()); 175 + profile.display_name = Some(SmolStr::new("New Name")); 160 176 }).await?; 161 177 ``` 162 178 ··· 165 181 - `XrpcClient`: Stateful XRPC (has base URI, auth tokens) 166 182 - `HttpClient`: Low-level HTTP abstraction 167 183 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. 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. 169 185 170 - ### jacquard-common (Foundation) 186 + ### jacquard-common (foundation) 171 187 172 - **Core types**: `Did`, `Handle`, `AtUri`, `Nsid`, `Tid`, `Cid`, `CowStr`, `Data`, `RawData` 188 + **Core types**: `Did<S>`, `Handle<S>`, `AtUri<S>`, `Nsid<S>`, `Tid`, `Cid<S>`, `CowStr<'a>`, `SmolStr`, `Data<S>`, `RawData<'a>` 173 189 174 190 **String type traits** (ALL validated types implement these): 175 - - `new(&str)` - Validates, borrows (zero alloc) 191 + - `new(S)` - Validates, wraps S directly 176 192 - `new_static(&'static str)` - Validates, zero alloc 177 - - `new_owned(impl Into<String>)` - Validates, takes ownership 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 178 196 - `raw(&str)` - Panics on invalid (use when you KNOW it's valid) 179 197 - `unchecked(&str)` - Unsafe, no validation 180 198 - `as_str(&self) -> &str` - Get string reference 181 199 - `Display`, `FromStr`, `Serialize`, `Deserialize`, `IntoStatic` 182 200 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>). 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. 184 204 185 205 **XRPC layer**: 186 206 187 207 ```rust 188 - // Stateless XRPC (with any HttpClient) 208 + // Stateless XRPC (with any HttpClient). 189 209 use jacquard::common::xrpc::XrpcExt; 190 210 let http = reqwest::Client::new(); 191 211 let response = http 192 - .xrpc(Uri::parse("https://bsky.social").map_err(|(e, _)| e)?.to_owned()) 212 + .xrpc(Uri::parse("https://bsky.social").map_err(|(e, _)| e)?) 193 213 .auth(AuthorizationToken::Bearer(token)) 194 214 .proxy(did) 195 215 .send(&request) 196 216 .await?; 197 217 198 - // Stateful XRPC (implement XrpcClient trait) 218 + // Stateful XRPC (implement XrpcClient trait). 199 219 let response = agent.send(request).await?; 200 220 ``` 201 221 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 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 205 225 206 226 ```rust 207 - // Convert typed → untyped → typed 227 + // Convert typed -> untyped -> typed. 208 228 let post = Post::builder().text("test").build(); 209 229 let data: Data = to_data(&post)?; 210 230 let post2: Post = from_data(&data)?; 211 231 212 - // NEVER use serde_json::Value 213 - // ❌ let value: serde_json::Value = ...; 214 - // ✅ let data: Data = ...; 232 + // NEVER use serde_json::Value. 233 + // let value: serde_json::Value = ...; 234 + // let data: Data = ...; 215 235 ``` 216 236 217 237 **Streaming** (feature: `streaming`): ··· 228 248 ```rust 229 249 pub trait Collection { 230 250 const NSID: &'static str; 231 - type Record: XrpcResp; // Marker type for get_record() 251 + type Record: XrpcResp; // Marker type for get_record(). 232 252 } 233 253 234 254 // Enables typed record retrieval: 235 255 let response: Response<Post::Record> = agent.get_record(did, rkey).await?; 236 256 ``` 237 257 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)]`. 258 + **CallOptions<'a>**: Still uses lifetimes (not parameterised on S). This is intentional for the options builder pattern. 239 259 240 - ### jacquard-api (Generated Bindings) 260 + ### jacquard-api (generated bindings) 241 261 242 262 **764 lexicon schemas** across 52+ namespaces. 243 263 ··· 249 269 - `ufos`: Experimental/niche 250 270 251 271 **Generated patterns**: 252 - - All types have `'a` lifetime for zero-copy deserialization 272 + - All types: `Foo<S: BosStr = DefaultStr>` with `#[serde(bound(deserialize = "S: Deserialize<'de> + BosStr"))]` 253 273 - 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 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 257 278 258 279 **Union collision detection**: When multiple namespaces define similar types, foreign refs get prefixed: 259 280 ```rust 260 281 // If both app.bsky.embed.images and sh.custom.embed.images exist: 261 - pub enum SomeUnion<'a> { 262 - BskyImages(Box<app_bsky::embed::images::View<'a>>), 263 - CustomImages(Box<sh_custom::embed::images::View<'a>>), 282 + pub enum SomeUnion<S: BosStr = DefaultStr> { 283 + BskyImages(Box<app_bsky::embed::images::View<S>>), 284 + CustomImages(Box<sh_custom::embed::images::View<S>>), 264 285 } 265 286 ``` 266 287 267 288 **For each collection (record type)**, generated code includes: 268 - 1. Main record struct (e.g., `Post<'a>`) 269 - 2. `GetRecordOutput` wrapper (`PostGetRecordOutput<'a>` with uri, cid, value) 289 + 1. Main record struct (e.g., `Post<S: BosStr = DefaultStr>`) 290 + 2. `GetRecordOutput` wrapper (`PostGetRecordOutput<S>` with uri, cid, value) 270 291 3. Marker struct (`PostRecord`) implementing `XrpcResp` 271 292 4. `Collection` trait impl 272 293 5. Helper: `Post::uri()` for constructing typed URIs ··· 281 302 282 303 **Common mistakes**: 283 304 - 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) 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) 286 307 287 - ### jacquard-derive (Macros) 308 + ### jacquard-derive (macros) 288 309 289 310 **`#[lexicon]`**: Adds `extra_data` field to capture unknown fields during deserialization. 290 311 291 312 ```rust 292 313 #[lexicon] 293 314 #[derive(Serialize, Deserialize)] 294 - struct MyType<'a> { 295 - known_field: CowStr<'a>, 296 - // Macro adds: pub extra_data: BTreeMap<SmolStr, Data<'a>> 315 + struct MyType<S: BosStr = DefaultStr> { 316 + known_field: S, 317 + // Macro adds: pub extra_data: BTreeMap<SmolStr, Data<S>> 297 318 } 298 319 ``` 299 320 300 - With `bon::Builder`: Automatically adds `#[builder(default)]` to `extra_data`. 301 - 302 - **`#[open_union]`**: Adds `Unknown(Data<'a>)` variant to enums. 321 + **`#[open_union]`**: Adds `Unknown(Data<S>)` variant to enums. 303 322 304 323 ```rust 305 324 #[open_union] 306 325 #[serde(tag = "$type")] 307 - enum MyUnion<'a> { 308 - KnownVariant(Foo<'a>), 309 - // Macro adds: #[serde(untagged)] Unknown(Data<'a>) 326 + enum MyUnion<S: BosStr = DefaultStr> { 327 + KnownVariant(Foo<S>), 328 + // Macro adds: #[serde(untagged)] Unknown(Data<S>) 310 329 } 311 330 ``` 312 331 313 - **`#[derive(IntoStatic)]`**: Generates owned conversion. 332 + **`#[derive(IntoStatic)]`**: Generates conversion from any `S` to `SmolStr`-backed types. 314 333 315 334 ```rust 316 335 #[derive(IntoStatic)] 317 - struct Post<'a> { 318 - text: CowStr<'a>, 336 + struct Post<S: BosStr = DefaultStr> { 337 + text: S, 319 338 likes: u32, 320 339 } 321 340 322 - // Generates: 323 - impl 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 - } 341 + // Generates conversion so Post<CowStr<'_>> -> Post<SmolStr>, etc. 332 342 ``` 333 343 334 344 **`#[derive(XrpcRequest)]`**: Generates XRPC boilerplate for custom endpoints. ··· 339 349 nsid = "com.example.getThing", 340 350 method = Query, 341 351 output = GetThingOutput, 342 - error = GetThingError, // Optional, defaults to GenericError 343 - server // Optional, generates XrpcEndpoint marker 352 + error = GetThingError, // Optional, defaults to GenericError. 353 + server // Optional, generates XrpcEndpoint marker. 344 354 )] 345 - struct GetThing<'a> { 346 - #[serde(borrow)] 347 - pub id: CowStr<'a>, 355 + struct GetThing { 356 + pub id: SmolStr, 348 357 } 349 358 ``` 350 359 351 - **Critical**: All custom types with lifetimes MUST derive `IntoStatic` or manually implement it. Otherwise, you can't use them with `.into_output()`. 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). 352 361 353 362 ### jacquard-oauth (OAuth/DPoP) 363 + 364 + **Session types parameterised on S** for flexible backing storage. 354 365 355 366 **OAuth flow**: 356 367 ··· 362 373 FileAuthStore::new("./auth.json") 363 374 ); 364 375 365 - // Loopback flow (feature: loopback) 376 + // Loopback flow (feature: loopback). 366 377 let session = oauth.login_with_local_server( 367 378 "alice.bsky.social", 368 379 Default::default(), ··· 372 383 let agent = Agent::from(session); 373 384 ``` 374 385 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) 386 + 387 + **OAuthMetadata<S>**: Parameterised so callers can borrow from stored metadata. 382 388 383 389 **Nonce handling**: Automatic retry on `use_dpop_nonce` errors (400 for auth server, 401 for PDS). Max one retry per request. 384 390 385 - **Nonce storage**: 386 - - `dpop_authserver_nonce`: For token endpoint 387 - - `dpop_host_nonce`: For PDS XRPC requests 388 - 389 391 **Token refresh**: Automatic on `invalid_token` errors. Uses `SessionRegistry` with per-DID+session_id locks to prevent concurrent refresh races. 390 392 391 393 **private_key_jwt**: For non-loopback clients. Automatically used if server supports it. 392 394 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: 397 - let token_response = exchange_code(...).await?; 398 - // ⚠️ MUST verify before using token 399 - let 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) 395 + ### jacquard-identity (identity resolution) 410 396 411 397 **Resolution chains** (configurable fallback order): 412 398 413 - **Handle → DID**: 399 + **Handle -> DID**: 414 400 1. DNS TXT `_atproto.<handle>` (feature: `dns`, skipped on WASM) 415 401 2. HTTPS `https://<handle>/.well-known/atproto-did` 416 402 3. PDS XRPC `com.atproto.identity.resolveHandle` 417 403 4. Public API fallback `https://public.api.bsky.app` (if enabled) 418 404 5. Slingshot mini-doc (if configured) 419 405 420 - **DID → Document**: 406 + **DID -> Document**: 421 407 1. `did:web`: HTTPS `.well-known/did.json` 422 408 2. `did:plc`: PLC directory or Slingshot 423 409 3. PDS XRPC `com.atproto.identity.resolveDid` 424 410 411 + **Methods are generic over backing type**: 425 412 ```rust 426 - use jacquard::identity::{JacquardResolver, PublicResolver}; 427 - 428 - let resolver = PublicResolver::default(); // DNS + public fallbacks enabled 413 + // resolve_handle accepts any Handle<S> backing type. 414 + let did: Did = resolver.resolve_handle(&handle).await?; 415 + // Returns Did (= Did<SmolStr>) for owned results. 429 416 430 - // Handle → DID 431 - let did: Did<'static> = resolver.resolve_handle(&handle).await?; 432 - 433 - // DID → Document 417 + // resolve_did_doc same pattern. 434 418 let response = resolver.resolve_did_doc(&did).await?; 435 - let doc = response.parse_validated()?; // Validates doc.id matches requested DID 419 + let doc = response.parse_validated()?; // Validates doc.id matches requested DID. 436 420 437 - // Combined: Get PDS endpoint 421 + // Combined: get PDS endpoint. 438 422 let pds_url = resolver.pds_for_did(&did).await?; 439 423 ``` 440 424 ··· 443 427 ```rust 444 428 let response = resolver.resolve_did_doc(&did).await?; 445 429 446 - // Borrow from buffer 447 - let doc: DidDocument<'_> = response.parse()?; 430 + // Borrow from buffer. 431 + let doc: DidDocument<CowStr<'_>> = response.parse()?; 448 432 449 - // Validate doc ID 450 - let doc = response.parse_validated()?; // Error if doc.id != requested DID 433 + // Validate doc ID. 434 + let doc = response.parse_validated()?; // Error if doc.id != requested DID. 451 435 452 - // Convert to owned 453 - let doc: DidDocument<'static> = response.into_owned()?; 436 + // Convert to owned. 437 + let doc: DidDocument = response.into_owned()?; 454 438 ``` 455 439 456 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. ··· 458 442 **OAuthResolver trait**: Auto-implemented for `JacquardResolver`. Adds OAuth metadata resolution: 459 443 460 444 ```rust 461 - // High-level: accepts handle, DID, or HTTPS URL 445 + // High-level: accepts handle, DID, or HTTPS URL. 462 446 let (server_metadata, doc_opt) = resolver.resolve_oauth("alice.bsky.social").await?; 463 447 464 - // From identity (handle or DID) 448 + // From identity (handle or DID). 465 449 let (server_metadata, doc) = resolver.resolve_from_identity("alice.bsky.social").await?; 466 450 467 - // From service URL (PDS or entryway) 451 + // From service URL (PDS or entryway). 468 452 let server_metadata = resolver.resolve_from_service(&pds_url).await?; 469 453 470 - // Verify issuer authority over DID 454 + // Verify issuer authority over DID. 471 455 let pds = resolver.verify_issuer(&server_metadata, &sub_did).await?; 472 456 ``` 473 457 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 URIs with string equality (normalise trailing slashes before comparison) 478 - - Trusting DID documents without checking `alsoKnownAs` for handle aliases 479 - - Not caching resolver (it's `Clone`, cheap to share) 458 + ### jacquard-axum (server-side) 480 459 481 - ### jacquard-axum (Server-Side) 460 + **Note**: jacquard-axum is temporarily out of the workspace while the extractor is redesigned for the BosStr type system. 482 461 483 - **ExtractXrpc**: Type-safe XRPC request extraction. 462 + **ExtractXrpc**: Type-safe XRPC request extraction, parameterised on `S: BosStr`. 484 463 485 464 ```rust 486 465 use jacquard_axum::{ExtractXrpc, IntoRouter}; 487 466 use jacquard::api::com_atproto::identity::resolve_handle::ResolveHandleRequest; 467 + use jacquard::common::DefaultStr; 488 468 489 469 async fn handle_resolve( 490 - ExtractXrpc(req): ExtractXrpc<ResolveHandleRequest> 491 - ) -> Json<ResolveHandleOutput<'static>> { 470 + ExtractXrpc(req): ExtractXrpc<ResolveHandleRequest, DefaultStr> 471 + ) -> Json<ResolveHandleOutput> { 492 472 let did = resolve_handle_logic(&req.handle).await; 493 473 Json(ResolveHandleOutput { did, extra_data: Default::default() }) 494 474 } 495 475 496 - // Automatic routing 476 + // Automatic routing. 497 477 let app = Router::new() 498 478 .merge(ResolveHandleRequest::into_router(handle_resolve)); 499 479 ``` ··· 502 482 - Query (GET): Extracts from query string via `serde_html_form` 503 483 - Procedure (POST): Calls `Request::decode_body()` (default: JSON, override for CBOR) 504 484 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 485 **Custom encodings**: 508 486 509 487 ```rust 510 - impl XrpcRequest for MyRequest<'_> { 511 - fn decode_body<'de>(body: &'de [u8]) -> Result<Box<Self>> { 488 + impl XrpcRequest for MyRequest { 489 + fn decode_body(body: &[u8]) -> Result<Box<Self>> { 512 490 let req = serde_ipld_dagcbor::from_slice(body)?; 513 491 Ok(Box::new(req)) 514 492 } ··· 550 528 ``` 551 529 552 530 **Common mistakes**: 553 - - Forgetting `IntoStatic` derive on custom request types 531 + - Forgetting the second type parameter on `ExtractXrpc<Request, S>` (defaults to `DefaultStr`) 554 532 - Using wrong trait (use `XrpcEndpoint` marker, not `XrpcRequest`) 555 533 - Not implementing JTI tracking (allows replay attacks) 556 534 - Disabling method binding without understanding security implications 557 535 558 - ### jacquard-repo (Repository Primitives) 536 + ### jacquard-repo (repository primitives) 559 537 560 538 **MST (Merkle Search Tree)**: Immutable, persistent data structure. 561 539 ··· 566 544 let storage = MemoryBlockStore::new(); 567 545 let mst = Mst::new(storage.clone()); 568 546 569 - // ⚠️ IMMUTABLE: Always reassign 547 + // IMMUTABLE: Always reassign. 570 548 let mst = mst.add("app.bsky.feed.post/abc", record_cid).await?; 571 549 let mst = mst.add("app.bsky.feed.post/xyz", record_cid2).await?; 572 550 573 - // Persist and get root CID 551 + // Persist and get root CID. 574 552 let root_cid = mst.persist().await?; 575 553 ``` 576 554 ··· 580 558 581 559 ```rust 582 560 let diff = old_mst.diff(&new_mst).await?; 583 - diff.validate_limits()?; // Enforce 200 op limit (protocol) 561 + diff.validate_limits()?; // Enforce 200 op limit (protocol). 584 562 585 - // Convert to different formats 586 - let verified_ops = diff.to_verified_ops(); // For batch() 587 - let repo_ops = diff.to_repo_ops(); // For firehose 563 + // Convert to different formats. 564 + let verified_ops = diff.to_verified_ops(); // For batch(). 565 + let repo_ops = diff.to_repo_ops(); // For firehose. 588 566 ``` 589 567 590 568 **Commits**: ··· 592 570 ```rust 593 571 use jacquard_repo::commit::Commit; 594 572 595 - // Create and sign 573 + // Create and sign. 596 574 let commit = Commit::new_unsigned(did, data_cid, rev, prev_cid) 597 575 .sign(&signing_key)?; 598 576 599 - // Verify 577 + // Verify. 600 578 commit.verify(&public_key)?; 601 579 ``` 602 580 ··· 605 583 **Firehose validation**: 606 584 607 585 ```rust 608 - // Sync v1.0 (requires prev MST state) 586 + // Sync v1.0 (requires prev MST state). 609 587 let new_root = commit.validate_v1_0(prev_mst_root, prev_storage, pubkey).await?; 610 588 611 - // Sync v1.1 (inductive, requires prev_data field + op prev CIDs) 589 + // Sync v1.1 (inductive, requires prev_data field + op prev CIDs). 612 590 let new_root = commit.validate_v1_1(pubkey).await?; 613 591 ``` 614 592 ··· 620 598 **CAR I/O**: 621 599 622 600 ```rust 623 - // Read 601 + // Read. 624 602 let parsed = parse_car_bytes(&bytes)?; 625 603 let root_cid = parsed.root; 626 604 let blocks = parsed.blocks; // BTreeMap<CID, Bytes> 627 605 628 - // Write 606 + // Write. 629 607 let bytes = write_car_bytes(root_cid, blocks)?; 630 608 ``` 631 609 632 610 **BlockStore trait**: Pluggable storage backend. 633 611 634 612 ```rust 635 - #[trait_variant::make(Send)] // Conditionally Send on non-WASM 613 + #[trait_variant::make(Send)] // Conditionally Send on non-WASM. 636 614 pub trait BlockStore: Clone { 637 615 async fn get(&self, cid: &CID) -> Result<Option<Bytes>>; 638 616 async fn put(&self, data: &[u8]) -> Result<CID>; 639 - async fn apply_commit(&self, commit: CommitData) -> Result<()>; // Atomic 617 + async fn apply_commit(&self, commit: CommitData) -> Result<()>; // Atomic. 640 618 } 641 619 ``` 642 620 ··· 652 630 653 631 let repo = Repository::create(storage, did, signing_key, None).await?; 654 632 655 - // Single operations (don't auto-commit) 633 + // Single operations (don't auto-commit). 656 634 repo.create_record(collection, rkey, cid).await?; 657 635 let old_cid = repo.update_record(collection, rkey, new_cid).await?; 658 636 let deleted_cid = repo.delete_record(collection, rkey).await?; 659 637 660 - // Batch commit 638 + // Batch commit. 661 639 let ops = vec![ 662 640 RecordWriteOp::Create { collection, rkey, record }, 663 641 RecordWriteOp::Update { collection, rkey, record, prev }, ··· 674 652 - Not implementing `Clone` cheaply on custom `BlockStore` (use `Arc` internally) 675 653 - Ignoring CID mismatch errors (indicates data corruption) 676 654 677 - ### jacquard-lexicon (Code Generation) 655 + ### jacquard-lexicon (code generation) 678 656 679 657 **ONLY use `just` commands** for code generation: 680 658 681 659 ```bash 682 660 just lex-gen # Fetch + generate 683 661 just lex-fetch # Fetch only 684 - just codegen # Generate from existing lexicons 662 + just generate-api # Generate from existing lexicons 685 663 ``` 686 664 687 665 **Union collision detection**: When multiple namespaces have similar type names in a union, foreign refs get prefixed with second NSID segment: 688 666 689 667 ``` 690 - app.bsky.embed.images → BskyImages 691 - sh.custom.embed.images → CustomImages 668 + app.bsky.embed.images -> BskyImages 669 + sh.custom.embed.images -> CustomImages 692 670 ``` 693 671 694 672 **Builder heuristics**: 695 - - Has builder: 1+ required fields, not all bare `CowStr` 696 - - Has `Default`: 0 required fields OR all required are bare `CowStr` 673 + - Has builder: 1+ required fields, not all bare `S` 674 + - Has `Default`: 0 required fields OR all required are bare `S` 697 675 698 - **Empty objects**: Generate as empty structs with `#[lexicon]` attribute (adds `extra_data`), not as `Data<'a>`. 676 + **Empty objects**: Generate as empty structs with `#[lexicon]` attribute (adds `extra_data`), not as `Data<S>`. 699 677 700 678 **Local refs**: `#fragment` normalized to `{current_nsid}#fragment` during generation. 701 679 ··· 720 698 721 699 --- 722 700 723 - ## Anti-Patterns to Avoid 701 + ## Anti-patterns to avoid 724 702 725 - ### ❌ Roundtripping through String 703 + ### Roundtripping through String 726 704 727 705 ```rust 728 - // ❌ BAD 706 + // BAD. 729 707 let did_str = did.as_str().to_string(); 730 - let did2 = Did::new(&did_str)?; 708 + let did2 = Did::new_owned(&did_str)?; 731 709 732 - // ✅ GOOD 710 + // GOOD. 733 711 let did2 = did.clone(); 734 - // or 735 - let did2 = did.into_static(); // If you need 'static 712 + // Or for type conversion: 713 + let did2: Did<String> = did.convert(); 736 714 ``` 737 715 738 - ### ❌ Using serde_json::Value 716 + ### Using serde_json::Value 739 717 740 718 ```rust 741 - // ❌ NEVER 719 + // NEVER. 742 720 let value: serde_json::Value = serde_json::from_slice(bytes)?; 743 721 let post: Post = serde_json::from_value(value)?; 744 722 745 - // ✅ ALWAYS 723 + // ALWAYS. 746 724 let data: Data = serde_json::from_slice(bytes)?; 747 725 let post: Post = from_data(&data)?; 748 726 ``` 749 727 750 - ### ❌ Using FromStr for validated types 728 + ### Using FromStr when new_static() or new_owned() suffices 751 729 752 730 ```rust 753 - // ❌ SLOW (always allocates) 731 + // SLOWER (FromStr always allocates into SmolStr). 754 732 let did: Did = "did:plc:abc".parse()?; 755 733 756 - // ✅ FAST (zero allocation) 757 - let did = Did::new("did:plc:abc")?; 758 - ``` 759 - 760 - ### ❌ Not deriving IntoStatic on custom types 734 + // BETTER (validates and wraps into SmolStr). 735 + let did = Did::new_owned("did:plc:abc")?; 761 736 762 - ```rust 763 - // ❌ WILL NOT COMPILE with XRPC responses 764 - struct MyOutput<'a> { 765 - field: CowStr<'a>, 766 - } 767 - 768 - // ✅ REQUIRED 769 - #[derive(jacquard_derive::IntoStatic)] 770 - struct MyOutput<'a> { 771 - field: CowStr<'a>, 772 - } 737 + // BEST for static strings (zero allocation). 738 + let did = Did::new_static("did:plc:abc")?; 773 739 ``` 774 740 775 - ### ❌ Dropping response while holding borrowed parse 741 + ### Not handling Unknown variant in unions 776 742 777 743 ```rust 778 - // ❌ WILL NOT COMPILE 779 - let output = { 780 - let response = agent.send(request).await?; 781 - response.parse()? // Borrows from response! 782 - }; 783 - 784 - // ✅ Keep response alive OR convert to owned 785 - let response = agent.send(request).await?; 786 - let output = response.parse()?; 787 - // OR 788 - let 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) 795 - for post in timeline.feed { 796 - let owned = post.into_static(); 797 - process(owned); 798 - } 799 - 800 - // ✅ EFFICIENT (borrow when possible) 801 - for post in &timeline.feed { 802 - process(post); 803 - } 804 - 805 - // Only convert to static if storing long-term: 806 - let 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) 744 + // WILL NOT COMPILE (missing Unknown variant). 815 745 match embed { 816 746 PostEmbed::Images(img) => { /* ... */ } 817 747 PostEmbed::Video(vid) => { /* ... */ } 818 748 } 819 749 820 - // ✅ HANDLE ALL VARIANTS 750 + // HANDLE ALL VARIANTS. 821 751 match embed { 822 752 PostEmbed::Images(img) => { /* ... */ } 823 753 PostEmbed::Video(vid) => { /* ... */ } ··· 825 755 } 826 756 ``` 827 757 828 - ### ❌ Forgetting MST immutability 758 + ### Forgetting MST immutability 829 759 830 760 ```rust 831 - // ❌ WRONG (loses result) 761 + // WRONG (loses result). 832 762 mst.add(key, cid).await?; 833 763 834 - // ✅ CORRECT (reassign) 764 + // CORRECT (reassign). 835 765 let mst = mst.add(key, cid).await?; 836 766 ``` 837 767 838 - ### ❌ Skipping issuer verification in OAuth 839 768 840 - ```rust 841 - // ❌ SECURITY VULNERABILITY 842 - let token_response = exchange_code(...).await?; 843 - // Immediately trusting token_response.sub without verification! 769 + ### Using DeserializeOwned with CowStr or &str backing types 844 770 845 - // ✅ ALWAYS VERIFY 846 - let token_response = exchange_code(...).await?; 847 - let pds = resolver.verify_issuer(&server_metadata, &token_response.sub).await?; 848 - // Now safe to use token_response 849 - ``` 771 + `DeserializeOwned` (i.e. `for<'de> Deserialize<'de>`) works perfectly with `SmolStr`-backed types. This is fine: 850 772 851 - ### ❌ Using `for<'de> Deserialize<'de>` bounds (CATASTROPHIC) 773 + ```rust 774 + // FINE -- SmolStr-backed types implement DeserializeOwned. 775 + let post: Post = serde_json::from_slice(&bytes)?; 776 + let post: Post<SmolStr> = serde_json::from_reader(reader)?; 777 + response.into_output()?; // Uses DeserializeOwned internally. 778 + ``` 852 779 853 - **This is the SINGLE MOST COMMON mistake LLMs make with Jacquard.** The HRTB `for<'de>` forces owned deserialization and breaks ALL Jacquard types. 780 + However, it does NOT work with `CowStr<'_>` or `&str` backing types, because those need to borrow from the input buffer: 854 781 855 782 ```rust 856 - // ❌ CATASTROPHIC - Breaks all Jacquard types 857 - fn deserialize_anything<T>(data: &[u8]) -> Result<T> 858 - where 859 - T: for<'de> Deserialize<'de> // Equivalent to DeserializeOwned! 860 - { 861 - serde_json::from_slice(data) 783 + // WRONG -- CowStr needs to borrow from input. 784 + fn bad(data: &[u8]) -> Post<CowStr<'static>> { 785 + serde_json::from_slice(data).unwrap() // Can't borrow from data! 862 786 } 863 787 864 - // Attempting to use: 865 - let post: Post = deserialize_anything(&bytes)?; // ERROR: Post<'_> doesn't satisfy bound 866 - 867 - // ❌ Also breaks in generic contexts 868 - struct MyContainer<T> 869 - where 870 - T: for<'de> Deserialize<'de> // No Jacquard type can be stored here! 871 - { 872 - value: T, 788 + // CORRECT -- use method-level lifetime for zero-copy. 789 + fn good(data: &[u8]) -> Post<CowStr<'_>> { 790 + serde_json::from_slice(data).unwrap() // Borrows from data. 873 791 } 874 792 875 - // ❌ Even if you think you need it for async 876 - async fn fetch<T>(url: &str) -> Result<T> 877 - where 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 793 + // ALSO CORRECT -- use SmolStr when you don't need zero-copy. 794 + fn also_good(data: &[u8]) -> Post { 795 + serde_json::from_slice(data).unwrap() // DeserializeOwned, no borrowing needed. 882 796 } 883 797 ``` 884 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 + 885 803 ```rust 886 - // ✅ CORRECT - Method-level lifetime 887 - fn deserialize_anything<'de, T>(data: &'de [u8]) -> Result<T> 888 - where 889 - T: Deserialize<'de> 890 - { 891 - serde_json::from_slice(data) 892 - } 804 + // WASTEFUL -- SmolStr types are already owned. 805 + let output = response.into_output()?; 806 + let owned = output.into_static(); // No-op! Already SmolStr-backed. 807 + 808 + // USEFUL -- converting from CowStr to SmolStr. 809 + let borrowed: Post<CowStr<'_>> = response.parse()?; 810 + let 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: 893 816 894 - // ✅ CORRECT - With IntoStatic for async 895 - fn deserialize_owned<'de, T>(data: &'de [u8]) -> Result<T::Output> 896 - where 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 - } 817 + ```rust 818 + // AMBIGUOUS -- compiler can't infer which AsRef. 819 + let s = my_smol.as_ref(); 903 820 904 - // ✅ CORRECT - Use Jacquard's Response pattern for async 905 - async 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() 821 + // CLEAR -- use as_str() for concrete SmolStr. 822 + let s: &str = my_smol.as_str(); 823 + 824 + // FINE -- as_ref() works in generic context where S: BosStr. 825 + fn generic<S: BosStr>(val: &Did<S>) -> &str { 826 + val.as_ref() 908 827 } 909 828 ``` 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 829 915 830 --- 916 831 917 - ## WASM Compatibility 832 + ## WASM compatibility 918 833 919 834 Core crates support `wasm32-unknown-unknown` target: 920 835 - jacquard-common ··· 926 841 927 842 **What's different on WASM**: 928 843 - No `Send` bounds on traits 929 - - DNS resolution skipped in handle→DID chain 844 + - DNS resolution skipped in handle->DID chain 930 845 - Tokio-specific features disabled 931 846 932 847 **Test WASM compilation**: ··· 939 854 940 855 --- 941 856 942 - ## Quick Reference 857 + ## Quick reference 943 858 944 - ### String Type Constructors 859 + ### String type constructors 945 860 946 - | Method | Allocates? | Use When | 861 + | Method | Allocates? | Use when | 947 862 |--------|-----------|----------| 948 - | `new(&str)` | No | Borrowed string | 863 + | `new(S)` | Depends on S | Wrapping an already-constructed S | 949 864 | `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 | 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 | 952 869 953 - ### Response Parsing 870 + ### Response parsing 954 871 955 - | Method | Lifetime | Allocates? | 956 - |--------|----------|-----------| 957 - | `.parse()` | `<'_>` | No (zero-copy) | 958 - | `.into_output()` | `'static` | Yes (converts to owned) | 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 | 959 879 960 - ### XRPC Traits 880 + ### XRPC traits 961 881 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() | 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()` | 968 888 | `XrpcExt` | Client | Stateless XRPC builder on any HttpClient | 969 889 970 - ### Session Types 890 + ### Session types 971 891 972 - | Type | Auth Method | Auto-Refresh | Storage | 892 + | Type | Auth method | Auto-refresh | Storage | 973 893 |------|------------|--------------|---------| 974 894 | `CredentialSession` | Bearer (app password) | Via refreshSession | SessionStore | 975 895 | `OAuthSession` | DPoP (OAuth) | Via token endpoint | ClientAuthStore | 976 896 977 - ### BlockStore Implementations 897 + ### BlockStore implementations 978 898 979 - | Type | Persistent? | Use Case | 899 + | Type | Persistent? | Use case | 980 900 |------|------------|----------| 981 901 | `MemoryBlockStore` | No | Testing | 982 902 | `FileBlockStore` | Yes | Production | ··· 984 904 985 905 --- 986 906 987 - ## Common Operations 907 + ## Common operations 988 908 989 909 ### Making an XRPC call 990 910 ··· 1025 945 1026 946 let resolver = PublicResolver::default(); 1027 947 1028 - // Handle → DID 948 + // Handle -> DID. 1029 949 let did = resolver.resolve_handle(&handle).await?; 1030 950 1031 - // DID → PDS endpoint 951 + // DID -> PDS endpoint. 1032 952 let pds = resolver.pds_for_did(&did).await?; 1033 953 1034 - // Combined 954 + // Combined. 1035 955 let (did, pds) = resolver.pds_for_handle(&handle).await?; 1036 956 ``` 1037 957 ··· 1054 974 let agent = Agent::from(session); 1055 975 ``` 1056 976 977 + ### Working with different backing types 978 + 979 + ```rust 980 + // Default SmolStr-backed (most common). 981 + let did = Did::new_owned("did:plc:abc123")?; 982 + 983 + // Cheap borrow for function calls. 984 + let borrowed: Did<&str> = did.borrow(); 985 + some_function(&borrowed); 986 + 987 + // Zero-copy parsing. 988 + let response = agent.send(request).await?; 989 + let output: GetPostOutput<CowStr<'_>> = response.parse()?; 990 + // Process output while response is alive... 991 + 992 + // Convert to owned when needed. 993 + let owned: GetPostOutput = output.into_static(); 994 + ``` 995 + 1057 996 ### Server-side XRPC handler 1058 997 1059 998 ```rust 1060 999 use jacquard_axum::{ExtractXrpc, IntoRouter}; 1000 + use jacquard::common::DefaultStr; 1061 1001 use axum::{Router, Json}; 1062 1002 1063 1003 async fn handler( 1064 - ExtractXrpc(req): ExtractXrpc<MyRequest> 1065 - ) -> Json<MyOutput<'static>> { 1066 - // Process request 1004 + ExtractXrpc(req): ExtractXrpc<MyRequest, DefaultStr> 1005 + ) -> Json<MyOutput> { 1006 + // Process request. 1067 1007 Json(output) 1068 1008 } 1069 1009 ··· 1088 1028 1089 1029 --- 1090 1030 1091 - ## Documentation Links 1031 + ## Documentation links 1092 1032 1093 1033 - [docs.rs/jacquard](https://docs.rs/jacquard/latest/jacquard/) 1094 1034 - [docs.rs/jacquard-common](https://docs.rs/jacquard-common/latest/jacquard_common/) ··· 1101 1041 1102 1042 --- 1103 1043 1104 - ## Philosophy Summary 1044 + ## Philosophy summary 1105 1045 1106 1046 Jacquard is designed for **correctness**, **performance**, and **ergonomics** in that order. It favors: 1107 1047 1108 1048 1. **Validation at construction time** - Invalid inputs fail fast, not deep in your code 1109 - 2. **Zero-copy by default** - Borrow from buffers, convert to owned only when needed 1110 - 3. **Explicit lifetime control** - You choose when to allocate via `.into_static()` or `.into_output()` 1111 - 4. **Type safety without boilerplate** - Generated bindings just work, with strong typing 1049 + 2. **Flexible string backing** - `SmolStr` for owned types with `DeserializeOwned` support, `CowStr`/`&str` for zero-copy when needed 1050 + 3. **Explicit type control** - You choose the backing type via `S: BosStr`, with sensible defaults 1051 + 4. **Type safety without boilerplate** - Generated bindings just work, with strong typing and builders 1112 1052 5. **Batteries included, but replaceable** - High-level `Agent` for convenience, low-level primitives for control 1113 1053 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. 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.