···2324use jacquard::IntoStatic;
25use jacquard::from_json_value;
026use jacquard::types::string::AtUri;
27use weaver_api::com_atproto::repo::strong_ref::StrongRef;
28use weaver_api::sh_weaver::embed::images::Image;
···90 /// None for new entries that haven't been published yet.
91 /// Signal so cloned docs share the same state after publish.
92 pub entry_ref: Signal<Option<StrongRef<'static>>>,
0009394 // --- Edit sync state (for PDS sync) ---
95 /// StrongRef to the sh.weaver.edit.root record for this edit session.
···235 /// Pre-resolved embed content fetched during load.
236 /// Avoids embed pop-in on initial render.
237 pub resolved_content: weaver_common::ResolvedContent,
00238}
239240impl PartialEq for LoadedDocState {
···316 tags,
317 embeds,
318 entry_ref: Signal::new(None),
0319 edit_root: Signal::new(None),
320 last_diff: Signal::new(None),
321 last_synced_version: Signal::new(None),
···490 /// Set the StrongRef when editing an existing entry.
491 pub fn set_entry_ref(&mut self, entry: Option<StrongRef<'static>>) {
492 self.entry_ref.set(entry);
0000000000493 }
494495 // --- Tags accessors ---
···1090 tags,
1091 embeds,
1092 entry_ref: Signal::new(None),
01093 edit_root: Signal::new(None),
1094 last_diff: Signal::new(None),
1095 last_synced_version: Signal::new(None),
···1148 tags,
1149 embeds,
1150 entry_ref: Signal::new(state.entry_ref),
01151 edit_root: Signal::new(state.edit_root),
1152 last_diff: Signal::new(state.last_diff),
1153 // Use the synced version from state (tracks the PDS version vector)
···2324use jacquard::IntoStatic;
25use jacquard::from_json_value;
26+use jacquard::smol_str::SmolStr;
27use jacquard::types::string::AtUri;
28use weaver_api::com_atproto::repo::strong_ref::StrongRef;
29use weaver_api::sh_weaver::embed::images::Image;
···91 /// None for new entries that haven't been published yet.
92 /// Signal so cloned docs share the same state after publish.
93 pub entry_ref: Signal<Option<StrongRef<'static>>>,
94+95+ /// AT-URI of the notebook this draft belongs to (for re-publishing)
96+ pub notebook_uri: Signal<Option<SmolStr>>,
9798 // --- Edit sync state (for PDS sync) ---
99 /// StrongRef to the sh.weaver.edit.root record for this edit session.
···239 /// Pre-resolved embed content fetched during load.
240 /// Avoids embed pop-in on initial render.
241 pub resolved_content: weaver_common::ResolvedContent,
242+ /// Notebook URI for re-publishing to the same notebook.
243+ pub notebook_uri: Option<SmolStr>,
244}
245246impl PartialEq for LoadedDocState {
···322 tags,
323 embeds,
324 entry_ref: Signal::new(None),
325+ notebook_uri: Signal::new(None),
326 edit_root: Signal::new(None),
327 last_diff: Signal::new(None),
328 last_synced_version: Signal::new(None),
···497 /// Set the StrongRef when editing an existing entry.
498 pub fn set_entry_ref(&mut self, entry: Option<StrongRef<'static>>) {
499 self.entry_ref.set(entry);
500+ }
501+502+ /// Get the notebook URI if this draft belongs to a notebook.
503+ pub fn notebook_uri(&self) -> Option<SmolStr> {
504+ self.notebook_uri.read().clone()
505+ }
506+507+ /// Set the notebook URI for re-publishing to the same notebook.
508+ pub fn set_notebook_uri(&mut self, uri: Option<SmolStr>) {
509+ self.notebook_uri.set(uri);
510 }
511512 // --- Tags accessors ---
···1107 tags,
1108 embeds,
1109 entry_ref: Signal::new(None),
1110+ notebook_uri: Signal::new(None),
1111 edit_root: Signal::new(None),
1112 last_diff: Signal::new(None),
1113 last_synced_version: Signal::new(None),
···1166 tags,
1167 embeds,
1168 entry_ref: Signal::new(state.entry_ref),
1169+ notebook_uri: Signal::new(state.notebook_uri),
1170 edit_root: Signal::new(state.edit_root),
1171 last_diff: Signal::new(state.last_diff),
1172 // Use the synced version from state (tracks the PDS version vector)
···388 // Use WeaverExt to upsert entry (handles notebook + entry creation/updates)
389 use jacquard::http_client::HttpClient;
390 use weaver_common::WeaverExt;
391- let (entry_ref, was_created) = agent
392 .upsert_entry(&title, entry_title.as_ref(), entry, None)
393 .await?;
394
···388 // Use WeaverExt to upsert entry (handles notebook + entry creation/updates)
389 use jacquard::http_client::HttpClient;
390 use weaver_common::WeaverExt;
391+ let (entry_ref, _, was_created) = agent
392 .upsert_entry(&title, entry_title.as_ref(), entry, None)
393 .await?;
394
+97-27
crates/weaver-common/src/agent.rs
···123 }
124 }
1250000000000000000000000000000000000000000126 /// Find or create a notebook by title, returning its URI and entry list
127 ///
128 /// If the notebook doesn't exist, creates it with the given DID as author.
···208 }
209 }
210211- /// Find or create an entry within a notebook
212 ///
213- /// Multi-step workflow:
214- /// 1. Find the notebook by title
215- /// 2. If existing_rkey is provided, match by rkey; otherwise match by title
216- /// 3. If found: update the entry with new content
217- /// 4. If not found: create new entry and append to notebook's entry_list
218- ///
219- /// The `existing_rkey` parameter allows updating an entry even if its title changed,
220- /// and enables pre-generating rkeys for path rewriting before publish.
221 ///
222- /// Returns (entry_ref, was_created)
223- fn upsert_entry(
224 &self,
225- notebook_title: &str,
0226 entry_title: &str,
227 entry: entry::Entry<'_>,
228 existing_rkey: Option<&str>,
229- ) -> impl Future<Output = Result<(StrongRef<'static>, bool), WeaverError>>
230 where
231 Self: Sized,
232 {
233 async move {
234- // Get our own DID
235- let (did, _) = self.session_info().await.ok_or_else(|| {
236- AgentError::from(ClientError::invalid_request("No session info available"))
237- })?;
238-239- // Find or create notebook
240- let (notebook_uri, entry_refs) = self.upsert_notebook(notebook_title, &did).await?;
241-242 // If we have an existing rkey, try to find and update that specific entry
243 if let Some(rkey) = existing_rkey {
244 // Check if this entry exists in the notebook by comparing rkeys
···259 .uri(output.uri.into_static())
260 .cid(output.cid.into_static())
261 .build();
262- return Ok((updated_ref, false));
263 }
264 }
265···283 })
284 .await?;
285286- return Ok((new_ref, true));
287 }
288289- // No existing rkey - use title-based matching (original behavior)
290291 // Fast path: if notebook is empty, skip search and create directly
292 if entry_refs.is_empty() {
···307 })
308 .await?;
309310- return Ok((new_ref, true));
311 }
312313 // Check if entry with this title exists in the notebook
···331 .uri(output.uri.into_static())
332 .cid(output.cid.into_static())
333 .build();
334- return Ok((updated_ref, false));
335 }
336 }
337 }
···355 })
356 .await?;
357358- Ok((new_ref, true))
0000000000000000000000000000000000000000000359 }
360 }
361
···123 }
124 }
125126+ /// Fetch a notebook by URI and return its entry list
127+ ///
128+ /// Returns Ok(Some((uri, entry_list))) if the notebook exists and can be parsed,
129+ /// Ok(None) if the notebook doesn't exist,
130+ /// Err if there's a network or parsing error.
131+ fn get_notebook_by_uri(
132+ &self,
133+ uri: &str,
134+ ) -> impl Future<Output = Result<Option<(AtUri<'static>, Vec<StrongRef<'static>>)>, WeaverError>>
135+ where
136+ Self: Sized,
137+ {
138+ async move {
139+ use weaver_api::sh_weaver::notebook::book::Book;
140+141+ let at_uri = AtUri::new(uri)
142+ .map_err(|e| WeaverError::InvalidNotebook(format!("Invalid notebook URI: {}", e)))?;
143+144+ let response = match self.get_record::<Book>(&at_uri).await {
145+ Ok(r) => r,
146+ Err(_) => return Ok(None), // Notebook doesn't exist
147+ };
148+149+ let output = match response.into_output() {
150+ Ok(o) => o,
151+ Err(_) => return Ok(None), // Failed to parse
152+ };
153+154+ let entries = output
155+ .value
156+ .entry_list
157+ .iter()
158+ .cloned()
159+ .map(IntoStatic::into_static)
160+ .collect();
161+162+ Ok(Some((at_uri.into_static(), entries)))
163+ }
164+ }
165+166 /// Find or create a notebook by title, returning its URI and entry list
167 ///
168 /// If the notebook doesn't exist, creates it with the given DID as author.
···248 }
249 }
250251+ /// Find or create an entry within a notebook (with pre-fetched notebook data)
252 ///
253+ /// This variant accepts notebook URI and entry_refs directly to avoid redundant
254+ /// notebook lookups when the caller has already fetched this data.
000000255 ///
256+ /// Returns (entry_ref, notebook_uri, was_created)
257+ fn upsert_entry_with_notebook(
258 &self,
259+ notebook_uri: AtUri<'static>,
260+ entry_refs: Vec<StrongRef<'static>>,
261 entry_title: &str,
262 entry: entry::Entry<'_>,
263 existing_rkey: Option<&str>,
264+ ) -> impl Future<Output = Result<(StrongRef<'static>, AtUri<'static>, bool), WeaverError>>
265 where
266 Self: Sized,
267 {
268 async move {
00000000269 // If we have an existing rkey, try to find and update that specific entry
270 if let Some(rkey) = existing_rkey {
271 // Check if this entry exists in the notebook by comparing rkeys
···286 .uri(output.uri.into_static())
287 .cid(output.cid.into_static())
288 .build();
289+ return Ok((updated_ref, notebook_uri, false));
290 }
291 }
292···310 })
311 .await?;
312313+ return Ok((new_ref, notebook_uri, true));
314 }
315316+ // No existing rkey - use title-based matching
317318 // Fast path: if notebook is empty, skip search and create directly
319 if entry_refs.is_empty() {
···334 })
335 .await?;
336337+ return Ok((new_ref, notebook_uri, true));
338 }
339340 // Check if entry with this title exists in the notebook
···358 .uri(output.uri.into_static())
359 .cid(output.cid.into_static())
360 .build();
361+ return Ok((updated_ref, notebook_uri, false));
362 }
363 }
364 }
···382 })
383 .await?;
384385+ Ok((new_ref, notebook_uri, true))
386+ }
387+ }
388+389+ /// Find or create an entry within a notebook
390+ ///
391+ /// Multi-step workflow:
392+ /// 1. Find the notebook by title
393+ /// 2. If existing_rkey is provided, match by rkey; otherwise match by title
394+ /// 3. If found: update the entry with new content
395+ /// 4. If not found: create new entry and append to notebook's entry_list
396+ ///
397+ /// The `existing_rkey` parameter allows updating an entry even if its title changed,
398+ /// and enables pre-generating rkeys for path rewriting before publish.
399+ ///
400+ /// Returns (entry_ref, notebook_uri, was_created)
401+ fn upsert_entry(
402+ &self,
403+ notebook_title: &str,
404+ entry_title: &str,
405+ entry: entry::Entry<'_>,
406+ existing_rkey: Option<&str>,
407+ ) -> impl Future<Output = Result<(StrongRef<'static>, AtUri<'static>, bool), WeaverError>>
408+ where
409+ Self: Sized,
410+ {
411+ async move {
412+ // Get our own DID
413+ let (did, _) = self.session_info().await.ok_or_else(|| {
414+ AgentError::from(ClientError::invalid_request("No session info available"))
415+ })?;
416+417+ // Find or create notebook
418+ let (notebook_uri, entry_refs) = self.upsert_notebook(notebook_title, &did).await?;
419+420+ // Delegate to the variant with pre-fetched notebook data
421+ self.upsert_entry_with_notebook(
422+ notebook_uri,
423+ entry_refs,
424+ entry_title,
425+ entry,
426+ existing_rkey,
427+ )
428+ .await
429 }
430 }
431
+4
crates/weaver-index/Cargo.toml
···64chrono = { version = "0.4", features = ["serde"] }
65smol_str = "0.3"
6600067# CID handling (for CAR block lookups)
68cid = "0.11"
69···73dashmap = "6"
74include_dir = "0.7.4"
75regex = "1"
07677# WebSocket (for tap consumer)
78tokio-tungstenite = { version = "0.26", features = ["native-tls"] }
···1+-- Draft titles extracted from Loro snapshots
2+-- Updated by background task when edit_heads changes
3+4+CREATE TABLE IF NOT EXISTS draft_titles (
5+ -- Draft identity (matches drafts table)
6+ did String,
7+ rkey String,
8+9+ -- Extracted title from Loro doc
10+ title String DEFAULT '',
11+12+ -- Head used for extraction (stale if doesn't match edit_heads)
13+ head_did String DEFAULT '',
14+ head_rkey String DEFAULT '',
15+ head_cid String DEFAULT '',
16+17+ -- Timestamps
18+ updated_at DateTime64(3) DEFAULT now64(3),
19+ indexed_at DateTime64(3) DEFAULT now64(3)
20+)
21+ENGINE = ReplacingMergeTree(indexed_at)
22+ORDER BY (did, rkey)
+15-2
crates/weaver-index/src/bin/weaver_indexer.rs
···23use clap::{Parser, Subcommand};
4use tracing::{error, info, warn};
05use weaver_index::clickhouse::InserterConfig;
6use weaver_index::clickhouse::{Client, Migrator};
7use weaver_index::config::{
···9};
10use weaver_index::firehose::FirehoseConsumer;
11use weaver_index::server::{AppState, ServerConfig, TelemetryConfig, telemetry};
12-use weaver_index::{FirehoseIndexer, ServiceIdentity, TapIndexer, load_cursor};
0001314#[derive(Parser)]
15#[command(name = "indexer")]
···165 });
166 let did_doc = identity.did_document_with_service(&server_config.service_did, &service_endpoint);
167168- // Create separate clients for indexer and server
169 let indexer_client = Client::new(&ch_config)?;
170 let server_client = Client::new(&ch_config)?;
0171172 // Build AppState for server
173 let state = AppState::new(
···208 tokio::spawn(async move { indexer.run().await })
209 }
210 };
00000000211212 // Run server, monitoring indexer health
213 tokio::select! {
···23use clap::{Parser, Subcommand};
4use tracing::{error, info, warn};
5+use jacquard::client::UnauthenticatedSession;
6use weaver_index::clickhouse::InserterConfig;
7use weaver_index::clickhouse::{Client, Migrator};
8use weaver_index::config::{
···10};
11use weaver_index::firehose::FirehoseConsumer;
12use weaver_index::server::{AppState, ServerConfig, TelemetryConfig, telemetry};
13+use weaver_index::{
14+ DraftTitleTaskConfig, FirehoseIndexer, ServiceIdentity, TapIndexer, load_cursor,
15+ run_draft_title_task,
16+};
1718#[derive(Parser)]
19#[command(name = "indexer")]
···169 });
170 let did_doc = identity.did_document_with_service(&server_config.service_did, &service_endpoint);
171172+ // Create separate clients for indexer, server, and background tasks
173 let indexer_client = Client::new(&ch_config)?;
174 let server_client = Client::new(&ch_config)?;
175+ let task_client = std::sync::Arc::new(Client::new(&ch_config)?);
176177 // Build AppState for server
178 let state = AppState::new(
···213 tokio::spawn(async move { indexer.run().await })
214 }
215 };
216+217+ // Spawn background tasks
218+ let resolver = UnauthenticatedSession::new_public();
219+ tokio::spawn(run_draft_title_task(
220+ task_client,
221+ resolver,
222+ DraftTitleTaskConfig::default(),
223+ ));
224225 // Run server, monitoring indexer health
226 tokio::select! {
+2-2
crates/weaver-index/src/clickhouse.rs
···7pub use client::{Client, TableSize};
8pub use migrations::{DbObject, MigrationResult, Migrator, ObjectType};
9pub use queries::{
10- CollaboratorRow, EditHeadRow, EditNodeRow, EntryRow, HandleMappingRow, NotebookRow,
11- ProfileCountsRow, ProfileRow, ProfileWithCounts,
12};
13pub use resilient_inserter::{InserterConfig, ResilientRecordInserter};
14pub use schema::{
···7pub use client::{Client, TableSize};
8pub use migrations::{DbObject, MigrationResult, Migrator, ObjectType};
9pub use queries::{
10+ CollaboratorRow, EditChainNode, EditHeadRow, EditNodeRow, EntryRow, HandleMappingRow,
11+ NotebookRow, ProfileCountsRow, ProfileRow, ProfileWithCounts, StaleDraftRow,
12};
13pub use resilient_inserter::{InserterConfig, ResilientRecordInserter};
14pub use schema::{
+1-1
crates/weaver-index/src/clickhouse/queries.rs
···1213pub use collab::PermissionRow;
14pub use collab_state::{CollaboratorRow, EditHeadRow};
15-pub use edit::EditNodeRow;
16pub use identity::HandleMappingRow;
17pub use notebooks::{EntryRow, NotebookRow};
18pub use profiles::{ProfileCountsRow, ProfileRow, ProfileWithCounts};
···1213pub use collab::PermissionRow;
14pub use collab_state::{CollaboratorRow, EditHeadRow};
15+pub use edit::{EditChainNode, EditNodeRow, StaleDraftRow};
16pub use identity::HandleMappingRow;
17pub use notebooks::{EntryRow, NotebookRow};
18pub use profiles::{ProfileCountsRow, ProfileRow, ProfileWithCounts};
···163 e.indexed_at AS indexed_at,
164 e.record AS record
165 FROM notebook_entries ne FINAL
166- INNER JOIN entries FINAL AS e ON
167 e.did = ne.entry_did
168 AND e.rkey = ne.entry_rkey
169 AND e.deleted_at = toDateTime64(0, 3)
···731 e.indexed_at AS indexed_at,
732 e.record AS record
733 FROM notebook_entries ne FINAL
734- INNER JOIN entries FINAL AS e ON
735 e.did = ne.entry_did
736 AND e.rkey = ne.entry_rkey
737 AND e.deleted_at = toDateTime64(0, 3)
···163 e.indexed_at AS indexed_at,
164 e.record AS record
165 FROM notebook_entries ne FINAL
166+ INNER JOIN entries e FINAL ON
167 e.did = ne.entry_did
168 AND e.rkey = ne.entry_rkey
169 AND e.deleted_at = toDateTime64(0, 3)
···731 e.indexed_at AS indexed_at,
732 e.record AS record
733 FROM notebook_entries ne FINAL
734+ INNER JOIN entries e FINAL ON
735 e.did = ne.entry_did
736 AND e.rkey = ne.entry_rkey
737 AND e.deleted_at = toDateTime64(0, 3)
···9pub mod service_identity;
10pub mod sqlite;
11pub mod tap;
01213pub use config::Config;
14pub use error::{IndexError, Result};
···17pub use server::{AppState, ServerConfig};
18pub use service_identity::ServiceIdentity;
19pub use sqlite::{ShardKey, ShardRouter, SqliteShard};
0
···9pub mod service_identity;
10pub mod sqlite;
11pub mod tap;
12+pub mod tasks;
1314pub use config::Config;
15pub use error::{IndexError, Result};
···18pub use server::{AppState, ServerConfig};
19pub use service_identity::ServiceIdentity;
20pub use sqlite::{ShardKey, ShardRouter, SqliteShard};
21+pub use tasks::{run_draft_title_task, DraftTitleTaskConfig};
···1+I recently used Jacquard to write an ~AppView~ Index for Weaver. I alluded in my posts about my devlog about that experience how easy I had made the actual web server side of that. Lexicon as a specification language provides a lot of ways to specify data types and a few to specify API endpoints. XRPC is the canonical way to do that, and it's an opinionated subset of HTTP, which narrows down to a specific endpoint format and set of "verbs". Your path is `/xrpc/your.lexicon.nsidEndpoint?argument=value`, your bodies are mostly JSON.
2+3+I'm going to lead off by tooting someone else's horn. Chad Miller's https://quickslice.slices.network/ provides an excellent example of the kind of thing you can do with atproto lexicons, and it doesn't use XRPC at all, but instead generates GraphQL's equivalents. This is more freeform, requires less of you upfront, and is in a lot of ways more granular than XRPC could possibly allow. Jacquard is for the moment built around the expectations of XRPC. If someone want's Jacquard support for GraphQL on atproto lexicons, I'm all ears, though.
4+5+Here's to me one of the benefits of XRPC, and one of the challenges. XRPC only specifies your inputs and your output. everything else between you need to figure out. This means more work, but it also means you have internal flexibility. And Jacquard's server-side XRPC helpers follow that. Jacquard XRPC code generation itself provides the output type and the errors. For the server side it generates one additional marker type, generally labeled `YourXrpcQueryRequest`, and a trait implementation for `XrpcEndpoint`. You can also get these with `derive(XrpcRequest)` on existing Rust structs without writing out lexicon JSON.
6+7+```rust
8+pub trait XrpcEndpoint {
9+ /// Fully-qualified path ('/xrpc/\[nsid\]') where this endpoint should live on the server
10+ const PATH: &'static str;
11+ /// XRPC method (query/GET or procedure/POST)
12+ const METHOD: XrpcMethod;
13+ /// XRPC Request data type
14+ type Request<'de>: XrpcRequest + Deserialize<'de> + IntoStatic;
15+ /// XRPC Response data type
16+ type Response: XrpcResp;
17+}
18+19+/// Endpoint type for
20+///sh.weaver.actor.getActorNotebooks
21+pub struct GetActorNotebooksRequest;
22+impl XrpcEndpoint for GetActorNotebooksRequest {
23+ const PATH: &'static str = "/xrpc/sh.weaver.actor.getActorNotebooks";
24+ const METHOD: XrpcMethod = XrpcMethod::Query;
25+ type Request<'de> = GetActorNotebooks<'de>;
26+ type Response = GetActorNotebooksResponse;
27+}
28+```
29+30+As with many Jacquard traits you see the associated types carrying the lifetime. You may ask, why a second struct and trait? This is very similar to the `XrpcRequest` trait, which is implemented on the request struct itself, after all.
31+32+```rust
33+impl<'a> XrpcRequest for GetActorNotebooks<'a> {
34+ const NSID: &'static str = "sh.weaver.actor.getActorNotebooks";
35+ const METHOD: XrpcMethod = XrpcMethod::Query;
36+ type Response = GetActorNotebooksResponse;
37+}
38+```
39+40+## Time for magic
41+The reason is that lifetime when combined with the constraints Axum puts on extractors. Because the request type includes a lifetime, if we were to attempt to implement `FromRequest` directly for `XrpcRequest`, the trait would require that `XrpcRequest` be implemented for all lifetimes, and also apply an effective `DeserializeOwned` bound, even if we were to specify the `'static` lifetime as we do. And of course `XrpcRequest` is implemented for one specific lifetime, `'a`, the lifetime of whatever it's borrowed from. Meanwhile `XrpcEndpoint` has no lifetime itself, but instead carries the lifetime on the `Request` associated type. This allows us to do the following implementation, where `ExtractXrpc<E>` has no lifetime itself and contains an owned version of the deserialized request. And we can then implement `FromRequest` for `ExtractXrpc<R>`, and put the `for<'any>` bound on the `IntoStatic` trait requirement in a where clause, where it works perfectly. In combination with the code generation in `jacquard-lexicon`, this is the full implementation of Jacquard's Axum XRPC request extractor. Not so bad.
42+43+```rust
44+pub struct ExtractXrpc<E: XrpcEndpoint>(pub E::Request<'static>);
45+46+impl<S, R> FromRequest<S> for ExtractXrpc<R>
47+where
48+ S: Send + Sync,
49+ R: XrpcEndpoint,
50+ for<'a> R::Request<'a>: IntoStatic<Output = R::Request<'static>>,
51+{
52+ type Rejection = Response;
53+54+ fn from_request(
55+ req: Request,
56+ state: &S,
57+ ) -> impl Future<Output = Result<Self, Self::Rejection>> + Send {
58+ async {
59+ match R::METHOD {
60+ XrpcMethod::Procedure(_) => {
61+ let body = Bytes::from_request(req, state)
62+ .await
63+ .map_err(IntoResponse::into_response)?;
64+ let decoded = R::Request::decode_body(&body);
65+ match decoded {
66+ Ok(value) => Ok(ExtractXrpc(*value.into_static())),
67+ Err(err) => Err((
68+ StatusCode::BAD_REQUEST,
69+ Json(json!({
70+ "error": "InvalidRequest",
71+ "message": format!("failed to decode request: {}", err)
72+ })),
73+ ).into_response()),
74+ }
75+ }
76+ XrpcMethod::Query => {
77+ if let Some(path_query) = req.uri().path_and_query() {
78+ let query = path_query.query().unwrap_or("");
79+ let value: R::Request<'_> =
80+ serde_html_form::from_str::<R::Request<'_>>(query).map_err(|e| {
81+ (
82+ StatusCode::BAD_REQUEST,
83+ Json(json!({
84+ "error": "InvalidRequest",
85+ "message": format!("failed to decode request: {}", e)
86+ })),
87+ ).into_response()
88+ })?;
89+ Ok(ExtractXrpc(value.into_static()))
90+ } else {
91+ Err((
92+ StatusCode::BAD_REQUEST,
93+ Json(json!({
94+ "error": "InvalidRequest",
95+ "message": "wrong path"
96+ })),
97+ ).into_response())
98+ }
99+ }
100+ }
101+ }
102+ }
103+```
104+105+Jacquard then also provides an additional utility to round things out, using the associated `PATH` constant to put the handler for your XRPC request at the right spot in your router.
106+```rust
107+/// Conversion trait to turn an XrpcEndpoint and a handler into an axum Router
108+pub trait IntoRouter {
109+ fn into_router<T, S, U>(handler: U) -> Router<S>
110+ where
111+ T: 'static,
112+ S: Clone + Send + Sync + 'static,
113+ U: axum::handler::Handler<T, S>;
114+}
115+116+impl<X> IntoRouter for X
117+where
118+ X: XrpcEndpoint,
119+{
120+ /// Creates an axum router that will invoke `handler` in response to xrpc
121+ /// request `X`.
122+ fn into_router<T, S, U>(handler: U) -> Router<S>
123+ where
124+ T: 'static,
125+ S: Clone + Send + Sync + 'static,
126+ U: axum::handler::Handler<T, S>,
127+ {
128+ Router::new().route(
129+ X::PATH,
130+ (match X::METHOD {
131+ XrpcMethod::Query => axum::routing::get,
132+ XrpcMethod::Procedure(_) => axum::routing::post,
133+ })(handler),
134+ )
135+ }
136+}
137+```
138+139+Which then lets the Axum router for Weaver's Index look like this (truncated for length):
140+141+```rust
142+pub fn router(state: AppState, did_doc: DidDocument<'static>) -> Router {
143+ Router::new()
144+ .route("/", get(landing))
145+ .route(
146+ "/assets/IoskeleyMono-Regular.woff2",
147+ get(font_ioskeley_regular),
148+ )
149+ .route("/assets/IoskeleyMono-Bold.woff2", get(font_ioskeley_bold))
150+ .route(
151+ "/assets/IoskeleyMono-Italic.woff2",
152+ get(font_ioskeley_italic),
153+ )
154+ .route("/xrpc/_health", get(health))
155+ .route("/metrics", get(metrics))
156+ // com.atproto.identity.* endpoints
157+ .merge(ResolveHandleRequest::into_router(identity::resolve_handle))
158+ // com.atproto.repo.* endpoints (record cache)
159+ .merge(GetRecordRequest::into_router(repo::get_record))
160+ .merge(ListRecordsRequest::into_router(repo::list_records))
161+ // app.bsky.* passthrough endpoints
162+ .merge(BskyGetProfileRequest::into_router(bsky::get_profile))
163+ .merge(BskyGetPostsRequest::into_router(bsky::get_posts))
164+ // sh.weaver.actor.* endpoints
165+ .merge(GetProfileRequest::into_router(actor::get_profile))
166+ .merge(GetActorNotebooksRequest::into_router(
167+ actor::get_actor_notebooks,
168+ ))
169+ .merge(GetActorEntriesRequest::into_router(
170+ actor::get_actor_entries,
171+ ))
172+ // sh.weaver.notebook.* endpoints
173+ ...
174+ // sh.weaver.collab.* endpoints
175+ ...
176+ // sh.weaver.edit.* endpoints
177+ ...
178+ .layer(TraceLayer::new_for_http())
179+ .layer(CorsLayer::permissive()
180+ .max_age(std::time::Duration::from_secs(86400))
181+ ).with_state(state)
182+ .merge(did_web_router(did_doc))
183+}
184+```
185+186+Each of the handlers is a fairly straightforward async function that takes `AppState`, the XrpcExtractor, and an extractor and validator for service auth, which allows it to be accessed through via your PDS via the `atproto-proxy` header, and return user-specific data, or gate specific endpoints as requiring authentication.
187+188+> And so yeah, the actual HTTP server part of the index was dead-easy to write. The handlers themselves are some of them fairly *long* functions, as they need to pull together the required data from the database over a couple of queries and then do some conversion, but they're straightforward. At some point I may end up either adding additional specialized view tables to the database or rewriting my queries to do more in SQL or both, but for now it made sense to keep the final decision-making and assembly in Rust, where it's easier to iterate on.
189+### Service Auth
190+Service Auth is, for those not familiar, the non-OAuth way to talk to an XRPC server other than your PDS with an authenticated identity. It's the method the Bluesky AppView uses. There are some downsides to proxying through the PDS, like delay in being able to read your own writes without some PDS-side or app-level handling, but it is conceptually very simple. The PDS, when it pipes through an XRPC request to another service, validates authentication, then generates a short-lived JWT, signs it with the user's private key, and puts it in a header. The service then extracts that, decodes it, and validates it using the public key in the user's DID document. Jacquard provides a middleware that can be used to gate routes based on service auth validation and it also provides an extractor. Initially I provided just one where authentication is required, but as part of building the index I added an additional one for optional authentication, where the endpoint is public, but returns user-specific information when there is an authenticated user. It returns this structure.
191+192+```rust
193+#[derive(Debug, Clone, jacquard_derive::IntoStatic)]
194+pub struct VerifiedServiceAuth<'a> {
195+ /// The authenticated user's DID (from `iss` claim)
196+ did: Did<'a>,
197+ /// The audience (should match your service DID)
198+ aud: Did<'a>,
199+ /// The lexicon method NSID, if present
200+ lxm: Option<Nsid<'a>>,
201+ /// JWT ID (nonce), if present
202+ jti: Option<CowStr<'a>>,
203+}
204+```
205+206+Ultimately I want to provide a similar set of OAuth extractors as well, but those need to be built, still. If I move away from service proxying for the Weaver index, they will definitely get written at that point.
207+208+> I mentioned some bug-fixing in Jacquard was required to make this work. There were a couple of oversights in the `DidDocument` struct and a spot I had incorrectly held a tracing span across an await point. Also, while using the `slingshot_resolver` set of options for `JacquardResolver` is great under normal circumstances (and normally I default to it), the mini-doc does NOT in fact include the signing keys, and cannot be used to validate service auth.
209+>
210+> I am not always a smart woman.
211+212+## Why not go full magic?
213+One thing the Jacquard service auth validation extractor does **not** provide is validation of that jti nonce. That is left as an exercise for the server developer, to maintain a cache of recent nonces and compare against them. I leave a number of things this way, and this is deliberate. I think this is the correct approach. As powerful as "magic" all-in-one frameworks like Dioxus (or the various full-stack JS frameworks) are, the magic usually ends up constraining you in a number of ways. There are a number of awkward things in the front-end app implementation which are downstream of constraints Dioxus applies to your types and functions in order to work its magic.
214+215+There are a lot of possible things you might want to do as an XRPC server. You might be a PDS, you might be an AppView or index, you might be some other sort of service that doesn't really fit into the boxes (like a Tangled knot server or Streamplace node) you might authenticate via service auth or OAuth, communicate via the PDS or directly with the client app. And as such, while my approach to everything in Jacquard is to provide a comprehensive box of tools rather than a complete end-to-end solution, this is especially true on the server side of things, because of that diversity in requirements, and my desire to not constrain developers using the library to work a certain way, so that they can build anything they want on atproto.
216+217+> If you haven't read the Not An AppView entry, here it is. I might recommend reading it, and some other previous entries in that notebook, as it will help put the following in context.
218+219+![[at://did:plc:yfvwmnlztr4dwkb7hwz55r2g/sh.weaver.notebook.entry/3m7ysqf2z5s22]]
220+## Dogfooding again
221+That being said, my experience writing the Weaver front-end and now the index server does leave me wanting a few things. One is a "BFF" session type, which forwards requests through a server to the PDS (or index), acting somewhat like [oatproxy](https://github.com/streamplace/oatproxy) (prototype jacquard version of that [here](https://github.com/espeon/istat/tree/main/jacquard-oatproxy) courtesy of Nat and Claude). This allows easier reading of your own writes via server-side caching, some caching and deduplication of common requests to reduce load on the PDS and roundtrip time. If the seession lives server-side it allows longer-lived confidential sessions for OAuth, and avoids putting OAuth tokens on the client device.
222+223+Once implemented, I will likely refactor the Weaver app to use this session type in fullstack-server mode, which will then help dramatically simplify a bunch of client-side code. The refactored app will likely include an internal XRPC "server" of sorts that will elide differences between the index's XRPC APIs and the index-less flow. With the "fullstack-server" and "use-index" features, the client app running in the browser will forward authenticated requests through the app server to the index or PDS. With "fullstack-server" only, the app server itself acts like a discount version of the index, implemented via generic services like Constellation. Performance will be significantly improved over the original index-less implementation due to better caching, and unifying the cache. In client-only mode there are a couple of options, and I am not sure which is ultimately correct. The straightforward way as far as separation of concerns goes would be to essentially use a web worker as intermediary and local cache. That worker would be compiled to either use the index or to make Constellation and direct PDS requests, depending on the "use-index" feature. However that brings with it the obvious overhead of copying data from the worker to the app in the default mode, and I haven't yet investigated how feasible the available options which might allow zero-copy transfer via SharedArrayBuffer are. That being said, the real-time collaboration feature already works this way (sans SharedArrayBuffer) and lag is comparable to when the iroh connection was handled in the UI thread.
224+225+A fair bit of this is somewhat new territory for me, when it comes to the browser, and I would be ***very*** interested in hearing from people with more domain experience on the likely correct approach.
226+227+On that note, one of my main frustrations with Jacquard as a library is how heavy it is in terms of compiled binary size due to monomorphization. I made that choice, to do everything via static dispatch, but when you want to ship as small a binary as possible over the network, it works against you. On WASM I haven't gotten a great number of exactly the granular damage, but on x86_64 (albeit with less aggressive optimisation for size) we're talking kilobytes of pure duplicated functions for every jacquard type used in the application, plus whatever else.
228+```rust
229+0.0% 0.0% 9.3KiB weaver_app weaver_app::components::editor::sync::create_diff::{closure#0}
230+0.0% 0.0% 9.2KiB loro_internal <loro_internal::txn::Transaction>::_commit
231+0.0% 0.0% 9.2KiB weaver_app <weaver_app::fetch::Fetcher as jacquard::client::AgentSessionExt>::get_record::<weaver_api::sh_weaver::collab::invite::Invite>::{closure#0}
232+0.0% 0.0% 9.2KiB weaver_app <weaver_app::fetch::Fetcher as jacquard::client::AgentSessionExt>::get_record::<weaver_api::sh_weaver::actor::profile::ProfileRecord>::{closure#0}
233+0.0% 0.0% 9.2KiB weaver_app <weaver_app::fetch::Fetcher as jacquard::client::AgentSessionExt>::get_record::<weaver_api::app_bsky::actor::profile::ProfileRecord>::{closure#0}
234+0.0% 0.0% 9.2KiB weaver_renderer <jacquard_identity::JacquardResolver as jacquard_identity::resolver::IdentityResolver>::resolve_did_doc::{closure#0}::{closure#0}
235+0.0% 0.0% 9.2KiB weaver_app <weaver_app::fetch::Client as jacquard::client::AgentSessionExt>::get_record::<weaver_api::sh_weaver::notebook::theme::Theme>::{closure#0}
236+0.0% 0.0% 9.2KiB weaver_app <weaver_app::fetch::Client as jacquard::client::AgentSessionExt>::get_record::<weaver_api::sh_weaver::notebook::entry::Entry>::{closure#0}
237+0.0% 0.0% 9.2KiB weaver_app <weaver_app::fetch::Client as jacquard::client::AgentSessionExt>::get_record::<weaver_api::sh_weaver::notebook::book::Book>::{closure#0}
238+0.0% 0.0% 9.2KiB weaver_app <weaver_app::fetch::Client as jacquard::client::AgentSessionExt>::get_record::<weaver_api::sh_weaver::notebook::colour_scheme::ColourScheme>::{closure#0}
239+0.0% 0.0% 9.2KiB weaver_app <weaver_app::fetch::Client as jacquard::client::AgentSessionExt>::get_record::<weaver_api::sh_weaver::actor::profile::ProfileRecord>::{closure#0}
240+0.0% 0.0% 9.2KiB weaver_app <weaver_app::fetch::Client as jacquard::client::AgentSessionExt>::get_record::<weaver_api::sh_weaver::edit::draft::Draft>::{closure#0}
241+0.0% 0.0% 9.2KiB weaver_app <weaver_app::fetch::Client as jacquard::client::AgentSessionExt>::get_record::<weaver_api::sh_weaver::edit::root::Root>::{closure#0}
242+0.0% 0.0% 9.2KiB weaver_app <weaver_app::fetch::Client as jacquard::client::AgentSessionExt>::get_record::<weaver_api::sh_weaver::edit::diff::Diff>::{closure#0}
243+0.0% 0.0% 9.2KiB weaver_app <weaver_app::fetch::Client as jacquard::client::AgentSessionExt>::get_record::<weaver_api::app_bsky::actor::profile::ProfileRecord>::{closure#0}
244+0.0% 0.0% 9.2KiB resvg <image_webp::vp8::Vp8Decoder<std::io::Take<&mut std::io::cursor::Cursor<&[u8]>>>>::loop_filter
245+0.0% 0.0% 9.2KiB miette <miette::handlers::graphical::GraphicalReportHandler>::render_context::<alloc::string::String>
246+0.0% 0.0% 9.1KiB miette <miette::handlers::graphical::GraphicalReportHandler>::render_context::<core::fmt::Formatter>
247+0.0% 0.0% 9.1KiB weaver_app weaver_app::components::record_editor::EditableRecordContent::{closure#7}::{closure#0}
248+```
249+250+I've taken a couple stabs at refactors to help with this, but haven't found a solution that satisfies me, in part because one of the problems in practice is of course overhead from `serde_json` monomorphization. Unfortunately, the alternatives trade off in frustrating ways. [`facet`](https://github.com/facet-rs/facet) has its own binary size impacts and `facet-json` is missing a couple of critical features to work with atproto JSON data (internally-tagged enums, most notably). Something like [`simd-json`](https://github.com/simd-lite/simd-json) or [`serde_json_borrow`](https://github.com/PSeitz/serde_json_borrow) is fast and can borrow from the buffer in a way that is very useful to us (and honestly I intend to swap to them for some uses at some point), but `serde_json_borrow` only provides a value type, and I would then be uncertain at the monomorphization overhead of transforming that type into jacquard types. The `serde` implementation for `simd-json` is heavily based on `serde_json` and thus likely has much the same overhead problem. And [`miniserde`](https://github.com/dtolnay/miniserde) similarly lacks support for parts of JSON that atproto data requires (enums again). And writing my own custom JSON parser that deserializes into Jacquard's `Data` or `RawData` types (from where it can then be deserialized more simply into concrete types, ideally with much less code duplication) is not a project I have time for, and is on the tedious side of the kind of thing I enjoy, particularly the process of ensuring it is sufficiently robust for real-world use, and doesn't perform terribly.
251+252+`dyn` compatibility for some of the Jacquard traits is possible but comes with its own challenges, as currently `Serialize` is a supertrait of `XrpcRequest`, and rewriting around removing that bound that is both a nontrivial refactor (and a breaking API change, and it's not the only barrier to dyn compatibility) and may not actually reduce the number of copies of `get_record()` in the binary as much as one would hope. Now, if most of the code could be taken out of that and put into a function that could be totally shared between all implementations or at least most, that would be ideal but the solution I found prevented the compiler from inferring the output type from the request type, it decoupled those two things too much. Obviously if I were to do a bunch of cursed internal unsafe rust I could probably make this work, but while I'm comfortable writing unsafe Rust I'm also conscious that I'm writing Jacquard not just for myself. My code will run in situations I cannot anticipate, and it needs to be as reliable as possible and as usable as possible. Additional use of unsafe could help with the latter (laundering lifetimes would make a number of things in Jacquard's main code paths much easier, both for me and for users of the library) but at potential cost to the former if I'm not smart enough or comprehensive enough in my testing.
253+254+So I leave you, dear reader, with some questions this time.
255+256+What choices make sense here? For Jacquard as a library, for writing web applications in Rust, and so on. I'm pretty damn good at this (if I do say so myself, and enough other people agree that I must accept it), but I'm also one person, with a necessarily incomplete understanding of the totality of the field.