···19 pub tags: Vec<SmolStr>,
20 pub author_dids: Vec<SmolStr>,
21 #[serde(with = "clickhouse::serde::chrono::datetime64::millis")]
0022 pub indexed_at: chrono::DateTime<chrono::Utc>,
23 pub record: SmolStr,
24}
···34 pub path: SmolStr,
35 pub tags: Vec<SmolStr>,
36 pub author_dids: Vec<SmolStr>,
0037 #[serde(with = "clickhouse::serde::chrono::datetime64::millis")]
38 pub indexed_at: chrono::DateTime<chrono::Utc>,
39 pub record: SmolStr,
···58 path,
59 tags,
60 author_dids,
061 indexed_at,
62 record
63 FROM notebooks
64 WHERE did = ?
65 AND (path = ? OR title = ?)
66 AND deleted_at = toDateTime64(0, 3)
67- ORDER BY event_time DESC
68 LIMIT 1
69 "#;
70···102 path,
103 tags,
104 author_dids,
0105 indexed_at,
106 record
107 FROM notebooks
108 WHERE did = ?
109 AND rkey = ?
110 AND deleted_at = toDateTime64(0, 3)
111- ORDER BY event_time DESC
112 LIMIT 1
113 "#;
114···131 ///
132 /// Note: This is a simplified version. The full implementation would
133 /// need to join with notebook's entryList to get proper ordering.
134- /// For now, we just list entries by the same author.
135 pub async fn list_notebook_entries(
136 &self,
137 did: &str,
138 limit: u32,
139 cursor: Option<&str>,
140 ) -> Result<Vec<EntryRow>, IndexError> {
0141 let query = if cursor.is_some() {
142 r#"
143 SELECT
···149 path,
150 tags,
151 author_dids,
0152 indexed_at,
153 record
154 FROM entries
···169 path,
170 tags,
171 author_dids,
0172 indexed_at,
173 record
174 FROM entries
···200 /// Get an entry by rkey, picking the most recent version across collaborators.
201 ///
202 /// For collaborative entries, the same rkey may exist in multiple repos.
203- /// This returns the most recently updated version, with indexed_at as tiebreaker.
204 ///
205 /// `candidate_dids` should include the notebook owner + all collaborator DIDs.
206 pub async fn get_entry(
···225 path,
226 tags,
227 author_dids,
0228 indexed_at,
229 record
230 FROM entries
231 WHERE rkey = ?
232 AND did IN ({})
233 AND deleted_at = toDateTime64(0, 3)
234- ORDER BY updated_at DESC, indexed_at DESC
235 LIMIT 1
236 "#,
237 placeholders.join(", ")
···271 path,
272 tags,
273 author_dids,
0274 indexed_at,
275 record
276 FROM entries
277 WHERE did = ?
278 AND rkey = ?
279 AND deleted_at = toDateTime64(0, 3)
280- ORDER BY updated_at DESC, indexed_at DESC
281 LIMIT 1
282 "#;
283···312 path,
313 tags,
314 author_dids,
0315 indexed_at,
316 record
317 FROM entries
318 WHERE did = ?
319 AND (path = ? OR title = ?)
320 AND deleted_at = toDateTime64(0, 3)
321- ORDER BY event_time DESC
322 LIMIT 1
323 "#;
324···336 })?;
337338 Ok(row)
0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000339 }
340}
···19 pub tags: Vec<SmolStr>,
20 pub author_dids: Vec<SmolStr>,
21 #[serde(with = "clickhouse::serde::chrono::datetime64::millis")]
22+ pub created_at: chrono::DateTime<chrono::Utc>,
23+ #[serde(with = "clickhouse::serde::chrono::datetime64::millis")]
24 pub indexed_at: chrono::DateTime<chrono::Utc>,
25 pub record: SmolStr,
26}
···36 pub path: SmolStr,
37 pub tags: Vec<SmolStr>,
38 pub author_dids: Vec<SmolStr>,
39+ #[serde(with = "clickhouse::serde::chrono::datetime64::millis")]
40+ pub created_at: chrono::DateTime<chrono::Utc>,
41 #[serde(with = "clickhouse::serde::chrono::datetime64::millis")]
42 pub indexed_at: chrono::DateTime<chrono::Utc>,
43 pub record: SmolStr,
···62 path,
63 tags,
64 author_dids,
65+ created_at,
66 indexed_at,
67 record
68 FROM notebooks
69 WHERE did = ?
70 AND (path = ? OR title = ?)
71 AND deleted_at = toDateTime64(0, 3)
72+ ORDER BY toStartOfFiveMinutes(event_time) DESC, created_at DESC
73 LIMIT 1
74 "#;
75···107 path,
108 tags,
109 author_dids,
110+ created_at,
111 indexed_at,
112 record
113 FROM notebooks
114 WHERE did = ?
115 AND rkey = ?
116 AND deleted_at = toDateTime64(0, 3)
117+ ORDER BY toStartOfFiveMinutes(event_time) DESC, created_at DESC
118 LIMIT 1
119 "#;
120···137 ///
138 /// Note: This is a simplified version. The full implementation would
139 /// need to join with notebook's entryList to get proper ordering.
140+ /// For now, we just list entries by the same author, ordered by rkey (notebook order).
141 pub async fn list_notebook_entries(
142 &self,
143 did: &str,
144 limit: u32,
145 cursor: Option<&str>,
146 ) -> Result<Vec<EntryRow>, IndexError> {
147+ // Note: rkey ordering is intentional here - it's the notebook's entry order
148 let query = if cursor.is_some() {
149 r#"
150 SELECT
···156 path,
157 tags,
158 author_dids,
159+ created_at,
160 indexed_at,
161 record
162 FROM entries
···177 path,
178 tags,
179 author_dids,
180+ created_at,
181 indexed_at,
182 record
183 FROM entries
···209 /// Get an entry by rkey, picking the most recent version across collaborators.
210 ///
211 /// For collaborative entries, the same rkey may exist in multiple repos.
212+ /// This returns the most recently updated version.
213 ///
214 /// `candidate_dids` should include the notebook owner + all collaborator DIDs.
215 pub async fn get_entry(
···234 path,
235 tags,
236 author_dids,
237+ created_at,
238 indexed_at,
239 record
240 FROM entries
241 WHERE rkey = ?
242 AND did IN ({})
243 AND deleted_at = toDateTime64(0, 3)
244+ ORDER BY toStartOfFiveMinutes(event_time) DESC, created_at DESC
245 LIMIT 1
246 "#,
247 placeholders.join(", ")
···281 path,
282 tags,
283 author_dids,
284+ created_at,
285 indexed_at,
286 record
287 FROM entries
288 WHERE did = ?
289 AND rkey = ?
290 AND deleted_at = toDateTime64(0, 3)
291+ ORDER BY toStartOfFiveMinutes(event_time) DESC, created_at DESC
292 LIMIT 1
293 "#;
294···323 path,
324 tags,
325 author_dids,
326+ created_at,
327 indexed_at,
328 record
329 FROM entries
330 WHERE did = ?
331 AND (path = ? OR title = ?)
332 AND deleted_at = toDateTime64(0, 3)
333+ ORDER BY toStartOfFiveMinutes(event_time) DESC, created_at DESC
334 LIMIT 1
335 "#;
336···348 })?;
349350 Ok(row)
351+ }
352+353+ /// List notebooks for an actor.
354+ ///
355+ /// Returns notebooks owned by the given DID, ordered by created_at DESC.
356+ /// Cursor is created_at timestamp in milliseconds.
357+ pub async fn list_actor_notebooks(
358+ &self,
359+ did: &str,
360+ limit: u32,
361+ cursor: Option<i64>,
362+ ) -> Result<Vec<NotebookRow>, IndexError> {
363+ let query = if cursor.is_some() {
364+ r#"
365+ SELECT
366+ did,
367+ rkey,
368+ cid,
369+ uri,
370+ title,
371+ path,
372+ tags,
373+ author_dids,
374+ created_at,
375+ indexed_at,
376+ record
377+ FROM notebooks
378+ WHERE did = ?
379+ AND deleted_at = toDateTime64(0, 3)
380+ AND created_at < fromUnixTimestamp64Milli(?)
381+ ORDER BY toStartOfFiveMinutes(event_time) DESC, created_at DESC
382+ LIMIT ?
383+ "#
384+ } else {
385+ r#"
386+ SELECT
387+ did,
388+ rkey,
389+ cid,
390+ uri,
391+ title,
392+ path,
393+ tags,
394+ author_dids,
395+ created_at,
396+ indexed_at,
397+ record
398+ FROM notebooks
399+ WHERE did = ?
400+ AND deleted_at = toDateTime64(0, 3)
401+ ORDER BY toStartOfFiveMinutes(event_time) DESC, created_at DESC
402+ LIMIT ?
403+ "#
404+ };
405+406+ let mut q = self.inner().query(query).bind(did);
407+408+ if let Some(c) = cursor {
409+ q = q.bind(c);
410+ }
411+412+ let rows = q
413+ .bind(limit)
414+ .fetch_all::<NotebookRow>()
415+ .await
416+ .map_err(|e| ClickHouseError::Query {
417+ message: "failed to list actor notebooks".into(),
418+ source: e,
419+ })?;
420+421+ Ok(rows)
422+ }
423+424+ /// List entries for an actor.
425+ ///
426+ /// Returns entries owned by the given DID, ordered by created_at DESC.
427+ /// Cursor is created_at timestamp in milliseconds.
428+ pub async fn list_actor_entries(
429+ &self,
430+ did: &str,
431+ limit: u32,
432+ cursor: Option<i64>,
433+ ) -> Result<Vec<EntryRow>, IndexError> {
434+ let query = if cursor.is_some() {
435+ r#"
436+ SELECT
437+ did,
438+ rkey,
439+ cid,
440+ uri,
441+ title,
442+ path,
443+ tags,
444+ author_dids,
445+ created_at,
446+ indexed_at,
447+ record
448+ FROM entries
449+ WHERE did = ?
450+ AND deleted_at = toDateTime64(0, 3)
451+ AND created_at < fromUnixTimestamp64Milli(?)
452+ ORDER BY toStartOfFiveMinutes(event_time) DESC, created_at DESC
453+ LIMIT ?
454+ "#
455+ } else {
456+ r#"
457+ SELECT
458+ did,
459+ rkey,
460+ cid,
461+ uri,
462+ title,
463+ path,
464+ tags,
465+ author_dids,
466+ created_at,
467+ indexed_at,
468+ record
469+ FROM entries
470+ WHERE did = ?
471+ AND deleted_at = toDateTime64(0, 3)
472+ ORDER BY toStartOfFiveMinutes(event_time) DESC, created_at DESC
473+ LIMIT ?
474+ "#
475+ };
476+477+ let mut q = self.inner().query(query).bind(did);
478+479+ if let Some(c) = cursor {
480+ q = q.bind(c);
481+ }
482+483+ let rows =
484+ q.bind(limit)
485+ .fetch_all::<EntryRow>()
486+ .await
487+ .map_err(|e| ClickHouseError::Query {
488+ message: "failed to list actor entries".into(),
489+ source: e,
490+ })?;
491+492+ Ok(rows)
493+ }
494+495+ /// Get a global feed of notebooks.
496+ ///
497+ /// Returns notebooks ordered by created_at DESC (chronological) or by
498+ /// popularity metrics if algorithm is "popular".
499+ /// Cursor is created_at timestamp in milliseconds.
500+ pub async fn get_notebook_feed(
501+ &self,
502+ algorithm: &str,
503+ tags: Option<&[&str]>,
504+ limit: u32,
505+ cursor: Option<i64>,
506+ ) -> Result<Vec<NotebookRow>, IndexError> {
507+ // For now, just chronological. Popular would need join with counts.
508+ let base_query = if tags.is_some() && cursor.is_some() {
509+ r#"
510+ SELECT
511+ did,
512+ rkey,
513+ cid,
514+ uri,
515+ title,
516+ path,
517+ tags,
518+ author_dids,
519+ created_at,
520+ indexed_at,
521+ record
522+ FROM notebooks
523+ WHERE deleted_at = toDateTime64(0, 3)
524+ AND hasAny(tags, ?)
525+ AND created_at < fromUnixTimestamp64Milli(?)
526+ ORDER BY toStartOfFiveMinutes(event_time) DESC, created_at DESC
527+ LIMIT ?
528+ "#
529+ } else if tags.is_some() {
530+ r#"
531+ SELECT
532+ did,
533+ rkey,
534+ cid,
535+ uri,
536+ title,
537+ path,
538+ tags,
539+ author_dids,
540+ created_at,
541+ indexed_at,
542+ record
543+ FROM notebooks
544+ WHERE deleted_at = toDateTime64(0, 3)
545+ AND hasAny(tags, ?)
546+ ORDER BY toStartOfFiveMinutes(event_time) DESC, created_at DESC
547+ LIMIT ?
548+ "#
549+ } else if cursor.is_some() {
550+ r#"
551+ SELECT
552+ did,
553+ rkey,
554+ cid,
555+ uri,
556+ title,
557+ path,
558+ tags,
559+ author_dids,
560+ created_at,
561+ indexed_at,
562+ record
563+ FROM notebooks
564+ WHERE deleted_at = toDateTime64(0, 3)
565+ AND created_at < fromUnixTimestamp64Milli(?)
566+ ORDER BY toStartOfFiveMinutes(event_time) DESC, created_at DESC
567+ LIMIT ?
568+ "#
569+ } else {
570+ r#"
571+ SELECT
572+ did,
573+ rkey,
574+ cid,
575+ uri,
576+ title,
577+ path,
578+ tags,
579+ author_dids,
580+ created_at,
581+ indexed_at,
582+ record
583+ FROM notebooks
584+ WHERE deleted_at = toDateTime64(0, 3)
585+ ORDER BY toStartOfFiveMinutes(event_time) DESC, created_at DESC
586+ LIMIT ?
587+ "#
588+ };
589+590+ let _ = algorithm; // TODO: implement popular sorting
591+592+ let mut q = self.inner().query(base_query);
593+594+ if let Some(t) = tags {
595+ q = q.bind(t);
596+ }
597+ if let Some(c) = cursor {
598+ q = q.bind(c);
599+ }
600+601+ let rows = q
602+ .bind(limit)
603+ .fetch_all::<NotebookRow>()
604+ .await
605+ .map_err(|e| ClickHouseError::Query {
606+ message: "failed to get notebook feed".into(),
607+ source: e,
608+ })?;
609+610+ Ok(rows)
611+ }
612+613+ /// Get a global feed of entries.
614+ ///
615+ /// Returns entries ordered by created_at DESC (chronological).
616+ /// Cursor is created_at timestamp in milliseconds.
617+ pub async fn get_entry_feed(
618+ &self,
619+ algorithm: &str,
620+ tags: Option<&[&str]>,
621+ limit: u32,
622+ cursor: Option<i64>,
623+ ) -> Result<Vec<EntryRow>, IndexError> {
624+ let base_query = if tags.is_some() && cursor.is_some() {
625+ r#"
626+ SELECT
627+ did,
628+ rkey,
629+ cid,
630+ uri,
631+ title,
632+ path,
633+ tags,
634+ author_dids,
635+ created_at,
636+ indexed_at,
637+ record
638+ FROM entries
639+ WHERE deleted_at = toDateTime64(0, 3)
640+ AND hasAny(tags, ?)
641+ AND created_at < fromUnixTimestamp64Milli(?)
642+ ORDER BY toStartOfFiveMinutes(event_time) DESC, created_at DESC
643+ LIMIT ?
644+ "#
645+ } else if tags.is_some() {
646+ r#"
647+ SELECT
648+ did,
649+ rkey,
650+ cid,
651+ uri,
652+ title,
653+ path,
654+ tags,
655+ author_dids,
656+ created_at,
657+ indexed_at,
658+ record
659+ FROM entries
660+ WHERE deleted_at = toDateTime64(0, 3)
661+ AND hasAny(tags, ?)
662+ ORDER BY toStartOfFiveMinutes(event_time) DESC, created_at DESC
663+ LIMIT ?
664+ "#
665+ } else if cursor.is_some() {
666+ r#"
667+ SELECT
668+ did,
669+ rkey,
670+ cid,
671+ uri,
672+ title,
673+ path,
674+ tags,
675+ author_dids,
676+ created_at,
677+ indexed_at,
678+ record
679+ FROM entries
680+ WHERE deleted_at = toDateTime64(0, 3)
681+ AND created_at < fromUnixTimestamp64Milli(?)
682+ ORDER BY toStartOfFiveMinutes(event_time) DESC, created_at DESC
683+ LIMIT ?
684+ "#
685+ } else {
686+ r#"
687+ SELECT
688+ did,
689+ rkey,
690+ cid,
691+ uri,
692+ title,
693+ path,
694+ tags,
695+ author_dids,
696+ created_at,
697+ indexed_at,
698+ record
699+ FROM entries
700+ WHERE deleted_at = toDateTime64(0, 3)
701+ ORDER BY toStartOfFiveMinutes(event_time) DESC, created_at DESC
702+ LIMIT ?
703+ "#
704+ };
705+706+ let _ = algorithm; // TODO: implement popular sorting
707+708+ let mut q = self.inner().query(base_query);
709+710+ if let Some(t) = tags {
711+ q = q.bind(t);
712+ }
713+ if let Some(c) = cursor {
714+ q = q.bind(c);
715+ }
716+717+ let rows =
718+ q.bind(limit)
719+ .fetch_all::<EntryRow>()
720+ .await
721+ .map_err(|e| ClickHouseError::Query {
722+ message: "failed to get entry feed".into(),
723+ source: e,
724+ })?;
725+726+ Ok(rows)
727+ }
728+729+ /// Get an entry at a specific index within a notebook.
730+ ///
731+ /// Returns the entry at the given 0-based index, plus adjacent entries for prev/next.
732+ pub async fn get_book_entry_at_index(
733+ &self,
734+ notebook_did: &str,
735+ notebook_rkey: &str,
736+ index: u32,
737+ ) -> Result<Option<(EntryRow, Option<EntryRow>, Option<EntryRow>)>, IndexError> {
738+ // Fetch entries for this notebook with index context
739+ // We need 3 entries: prev (index-1), current (index), next (index+1)
740+ let offset = if index > 0 { index - 1 } else { 0 };
741+ let fetch_count = if index > 0 { 3u32 } else { 2u32 };
742+743+ let query = r#"
744+ SELECT
745+ e.did,
746+ e.rkey,
747+ e.cid,
748+ e.uri,
749+ e.title,
750+ e.path,
751+ e.tags,
752+ e.author_dids,
753+ e.created_at,
754+ e.indexed_at,
755+ e.record
756+ FROM notebook_entries ne FINAL
757+ INNER JOIN entries e ON
758+ e.did = ne.entry_did
759+ AND e.rkey = ne.entry_rkey
760+ AND e.deleted_at = toDateTime64(0, 3)
761+ WHERE ne.notebook_did = ?
762+ AND ne.notebook_rkey = ?
763+ ORDER BY ne.position ASC
764+ LIMIT ? OFFSET ?
765+ "#;
766+767+ let rows: Vec<EntryRow> = self
768+ .inner()
769+ .query(query)
770+ .bind(notebook_did)
771+ .bind(notebook_rkey)
772+ .bind(fetch_count)
773+ .bind(offset)
774+ .fetch_all()
775+ .await
776+ .map_err(|e| ClickHouseError::Query {
777+ message: "failed to get book entry at index".into(),
778+ source: e,
779+ })?;
780+781+ if rows.is_empty() {
782+ return Ok(None);
783+ }
784+785+ // Determine which row is which based on the offset
786+ let mut iter = rows.into_iter();
787+ if index == 0 {
788+ // No prev, rows[0] is current, rows[1] is next (if exists)
789+ let current = iter.next();
790+ let next = iter.next();
791+ Ok(current.map(|c| (c, None, next)))
792+ } else {
793+ // rows[0] is prev, rows[1] is current, rows[2] is next
794+ let prev = iter.next();
795+ let current = iter.next();
796+ let next = iter.next();
797+ Ok(current.map(|c| (c, prev, next)))
798+ }
799 }
800}
+340-1
crates/weaver-index/src/endpoints/actor.rs
···1//! sh.weaver.actor.* endpoint handlers
2003use axum::{Json, extract::State};
4use jacquard::IntoStatic;
5use jacquard::cowstr::ToCowStr;
6use jacquard::identity::resolver::IdentityResolver;
7use jacquard::types::ident::AtIdentifier;
8-use jacquard::types::string::{Did, Handle};
9use jacquard_axum::ExtractXrpc;
0010use weaver_api::sh_weaver::actor::{
11 ProfileDataView, ProfileDataViewInner, ProfileView,
0012 get_profile::{GetProfileOutput, GetProfileRequest},
13};
014015use crate::endpoints::repo::XrpcErrorResponse;
16use crate::server::AppState;
0001718/// Handle sh.weaver.actor.getProfile
19///
20/// Returns a profile view with counts for the requested actor.
21pub async fn get_profile(
22 State(state): State<AppState>,
023 ExtractXrpc(args): ExtractXrpc<GetProfileRequest>,
24) -> Result<Json<GetProfileOutput<'static>>, XrpcErrorResponse> {
00025 // Resolve identifier to DID
26 let did = resolve_actor(&state, &args.actor).await?;
27 let did_str = did.as_str();
···156 Some(s.to_cowstr().into_static())
157 }
158}
000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
···1//! sh.weaver.actor.* endpoint handlers
23+use std::collections::{HashMap, HashSet};
4+5use axum::{Json, extract::State};
6use jacquard::IntoStatic;
7use jacquard::cowstr::ToCowStr;
8use jacquard::identity::resolver::IdentityResolver;
9use jacquard::types::ident::AtIdentifier;
10+use jacquard::types::string::{AtUri, Cid, Did, Handle};
11use jacquard_axum::ExtractXrpc;
12+use jacquard_axum::service_auth::{ExtractOptionalServiceAuth, VerifiedServiceAuth};
13+use smol_str::SmolStr;
14use weaver_api::sh_weaver::actor::{
15 ProfileDataView, ProfileDataViewInner, ProfileView,
16+ get_actor_entries::{GetActorEntriesOutput, GetActorEntriesRequest},
17+ get_actor_notebooks::{GetActorNotebooksOutput, GetActorNotebooksRequest},
18 get_profile::{GetProfileOutput, GetProfileRequest},
19};
20+use weaver_api::sh_weaver::notebook::{AuthorListView, EntryView, NotebookView};
2122+use crate::clickhouse::ProfileRow;
23use crate::endpoints::repo::XrpcErrorResponse;
24use crate::server::AppState;
25+26+/// Authenticated viewer context (if present)
27+pub type Viewer = Option<VerifiedServiceAuth<'static>>;
2829/// Handle sh.weaver.actor.getProfile
30///
31/// Returns a profile view with counts for the requested actor.
32pub async fn get_profile(
33 State(state): State<AppState>,
34+ ExtractOptionalServiceAuth(viewer): ExtractOptionalServiceAuth,
35 ExtractXrpc(args): ExtractXrpc<GetProfileRequest>,
36) -> Result<Json<GetProfileOutput<'static>>, XrpcErrorResponse> {
37+ // viewer contains Some(auth) if the request has valid service auth
38+ // can be used later for viewer-specific state (e.g., "you follow this person")
39+ let _viewer = viewer;
40 // Resolve identifier to DID
41 let did = resolve_actor(&state, &args.actor).await?;
42 let did_str = did.as_str();
···171 Some(s.to_cowstr().into_static())
172 }
173}
174+175+/// Parse cursor string to i64 timestamp millis
176+fn parse_cursor(cursor: Option<&str>) -> Result<Option<i64>, XrpcErrorResponse> {
177+ cursor
178+ .map(|c| {
179+ c.parse::<i64>()
180+ .map_err(|_| XrpcErrorResponse::invalid_request("Invalid cursor format"))
181+ })
182+ .transpose()
183+}
184+185+/// Handle sh.weaver.actor.getActorNotebooks
186+///
187+/// Returns notebooks owned by the given actor.
188+pub async fn get_actor_notebooks(
189+ State(state): State<AppState>,
190+ ExtractOptionalServiceAuth(viewer): ExtractOptionalServiceAuth,
191+ ExtractXrpc(args): ExtractXrpc<GetActorNotebooksRequest>,
192+) -> Result<Json<GetActorNotebooksOutput<'static>>, XrpcErrorResponse> {
193+ let _viewer: Viewer = viewer;
194+195+ // Resolve actor to DID
196+ let did = resolve_actor(&state, &args.actor).await?;
197+ let did_str = did.as_str();
198+199+ // Fetch notebooks for this actor
200+ let limit = args.limit.unwrap_or(50).clamp(1, 100) as u32;
201+ let cursor = parse_cursor(args.cursor.as_deref())?;
202+203+ let notebook_rows = state
204+ .clickhouse
205+ .list_actor_notebooks(did_str, limit + 1, cursor)
206+ .await
207+ .map_err(|e| {
208+ tracing::error!("Failed to list actor notebooks: {}", e);
209+ XrpcErrorResponse::internal_error("Database query failed")
210+ })?;
211+212+ // Check if there are more
213+ let has_more = notebook_rows.len() > limit as usize;
214+ let notebook_rows: Vec<_> = notebook_rows.into_iter().take(limit as usize).collect();
215+216+ // Collect author DIDs for hydration
217+ let mut all_author_dids: HashSet<&str> = HashSet::new();
218+ for nb in ¬ebook_rows {
219+ for did in &nb.author_dids {
220+ all_author_dids.insert(did.as_str());
221+ }
222+ }
223+224+ // Batch fetch profiles
225+ let author_dids_vec: Vec<&str> = all_author_dids.into_iter().collect();
226+ let profiles = state
227+ .clickhouse
228+ .get_profiles_batch(&author_dids_vec)
229+ .await
230+ .map_err(|e| {
231+ tracing::error!("Failed to batch fetch profiles: {}", e);
232+ XrpcErrorResponse::internal_error("Database query failed")
233+ })?;
234+235+ let profile_map: HashMap<&str, &ProfileRow> =
236+ profiles.iter().map(|p| (p.did.as_str(), p)).collect();
237+238+ // Build NotebookViews
239+ let mut notebooks: Vec<NotebookView<'static>> = Vec::with_capacity(notebook_rows.len());
240+ for nb_row in ¬ebook_rows {
241+ let notebook_uri = AtUri::new(&nb_row.uri).map_err(|e| {
242+ tracing::error!("Invalid notebook URI in db: {}", e);
243+ XrpcErrorResponse::internal_error("Invalid URI stored")
244+ })?;
245+246+ let notebook_cid = Cid::new(nb_row.cid.as_bytes()).map_err(|e| {
247+ tracing::error!("Invalid notebook CID in db: {}", e);
248+ XrpcErrorResponse::internal_error("Invalid CID stored")
249+ })?;
250+251+ let authors = hydrate_authors(&nb_row.author_dids, &profile_map)?;
252+ let record = parse_record_json(&nb_row.record)?;
253+254+ let notebook = NotebookView::new()
255+ .uri(notebook_uri.into_static())
256+ .cid(notebook_cid.into_static())
257+ .authors(authors)
258+ .record(record)
259+ .indexed_at(nb_row.indexed_at.fixed_offset())
260+ .maybe_title(non_empty_str(&nb_row.title))
261+ .maybe_path(non_empty_str(&nb_row.path))
262+ .build();
263+264+ notebooks.push(notebook);
265+ }
266+267+ // Build cursor for pagination (created_at millis)
268+ let next_cursor = if has_more {
269+ notebook_rows
270+ .last()
271+ .map(|nb| nb.created_at.timestamp_millis().to_cowstr().into_static())
272+ } else {
273+ None
274+ };
275+276+ Ok(Json(
277+ GetActorNotebooksOutput {
278+ notebooks,
279+ cursor: next_cursor,
280+ extra_data: None
281+ }
282+ .into_static(),
283+ ))
284+}
285+286+/// Handle sh.weaver.actor.getActorEntries
287+///
288+/// Returns entries owned by the given actor.
289+pub async fn get_actor_entries(
290+ State(state): State<AppState>,
291+ ExtractOptionalServiceAuth(viewer): ExtractOptionalServiceAuth,
292+ ExtractXrpc(args): ExtractXrpc<GetActorEntriesRequest>,
293+) -> Result<Json<GetActorEntriesOutput<'static>>, XrpcErrorResponse> {
294+ let _viewer: Viewer = viewer;
295+296+ // Resolve actor to DID
297+ let did = resolve_actor(&state, &args.actor).await?;
298+ let did_str = did.as_str();
299+300+ // Fetch entries for this actor
301+ let limit = args.limit.unwrap_or(50).clamp(1, 100) as u32;
302+ let cursor = parse_cursor(args.cursor.as_deref())?;
303+304+ let entry_rows = state
305+ .clickhouse
306+ .list_actor_entries(did_str, limit + 1, cursor)
307+ .await
308+ .map_err(|e| {
309+ tracing::error!("Failed to list actor entries: {}", e);
310+ XrpcErrorResponse::internal_error("Database query failed")
311+ })?;
312+313+ // Check if there are more
314+ let has_more = entry_rows.len() > limit as usize;
315+ let entry_rows: Vec<_> = entry_rows.into_iter().take(limit as usize).collect();
316+317+ // Collect author DIDs for hydration
318+ let mut all_author_dids: HashSet<&str> = HashSet::new();
319+ for entry in &entry_rows {
320+ for did in &entry.author_dids {
321+ all_author_dids.insert(did.as_str());
322+ }
323+ }
324+325+ // Batch fetch profiles
326+ let author_dids_vec: Vec<&str> = all_author_dids.into_iter().collect();
327+ let profiles = state
328+ .clickhouse
329+ .get_profiles_batch(&author_dids_vec)
330+ .await
331+ .map_err(|e| {
332+ tracing::error!("Failed to batch fetch profiles: {}", e);
333+ XrpcErrorResponse::internal_error("Database query failed")
334+ })?;
335+336+ let profile_map: HashMap<&str, &ProfileRow> =
337+ profiles.iter().map(|p| (p.did.as_str(), p)).collect();
338+339+ // Build EntryViews
340+ let mut entries: Vec<EntryView<'static>> = Vec::with_capacity(entry_rows.len());
341+ for entry_row in &entry_rows {
342+ let entry_uri = AtUri::new(&entry_row.uri).map_err(|e| {
343+ tracing::error!("Invalid entry URI in db: {}", e);
344+ XrpcErrorResponse::internal_error("Invalid URI stored")
345+ })?;
346+347+ let entry_cid = Cid::new(entry_row.cid.as_bytes()).map_err(|e| {
348+ tracing::error!("Invalid entry CID in db: {}", e);
349+ XrpcErrorResponse::internal_error("Invalid CID stored")
350+ })?;
351+352+ let authors = hydrate_authors(&entry_row.author_dids, &profile_map)?;
353+ let record = parse_record_json(&entry_row.record)?;
354+355+ let entry = EntryView::new()
356+ .uri(entry_uri.into_static())
357+ .cid(entry_cid.into_static())
358+ .authors(authors)
359+ .record(record)
360+ .indexed_at(entry_row.indexed_at.fixed_offset())
361+ .maybe_title(non_empty_str(&entry_row.title))
362+ .maybe_path(non_empty_str(&entry_row.path))
363+ .build();
364+365+ entries.push(entry);
366+ }
367+368+ // Build cursor for pagination (created_at millis)
369+ let next_cursor = if has_more {
370+ entry_rows
371+ .last()
372+ .map(|e| e.created_at.timestamp_millis().to_cowstr().into_static())
373+ } else {
374+ None
375+ };
376+377+ Ok(Json(
378+ GetActorEntriesOutput {
379+ entries,
380+ cursor: next_cursor,
381+ extra_data: None,
382+ }
383+ .into_static(),
384+ ))
385+}
386+387+/// Hydrate author list from DIDs using profile map
388+fn hydrate_authors(
389+ author_dids: &[SmolStr],
390+ profile_map: &HashMap<&str, &ProfileRow>,
391+) -> Result<Vec<AuthorListView<'static>>, XrpcErrorResponse> {
392+ let mut authors = Vec::with_capacity(author_dids.len());
393+394+ for (idx, did_str) in author_dids.iter().enumerate() {
395+ let profile_data = if let Some(profile) = profile_map.get(did_str.as_str()) {
396+ profile_to_data_view(profile)?
397+ } else {
398+ // No profile found - create minimal view with just the DID
399+ let did = Did::new(did_str).map_err(|e| {
400+ tracing::error!("Invalid DID in author_dids: {}", e);
401+ XrpcErrorResponse::internal_error("Invalid DID stored")
402+ })?;
403+404+ let inner_profile = ProfileView::new()
405+ .did(did.into_static())
406+ .handle(
407+ Handle::new(did_str)
408+ .unwrap_or_else(|_| Handle::new("unknown.invalid").unwrap()),
409+ )
410+ .build();
411+412+ ProfileDataView::new()
413+ .inner(ProfileDataViewInner::ProfileView(Box::new(inner_profile)))
414+ .build()
415+ };
416+417+ let author_view = AuthorListView::new()
418+ .index(idx as i64)
419+ .record(profile_data.into_static())
420+ .build();
421+422+ authors.push(author_view);
423+ }
424+425+ Ok(authors)
426+}
427+428+/// Convert ProfileRow to ProfileDataView
429+fn profile_to_data_view(
430+ profile: &ProfileRow,
431+) -> Result<ProfileDataView<'static>, XrpcErrorResponse> {
432+ use jacquard::types::string::Uri;
433+434+ let did = Did::new(&profile.did).map_err(|e| {
435+ tracing::error!("Invalid DID in profile: {}", e);
436+ XrpcErrorResponse::internal_error("Invalid DID stored")
437+ })?;
438+439+ let handle = if profile.handle.is_empty() {
440+ Handle::new(&profile.did).unwrap_or_else(|_| Handle::new("unknown.invalid").unwrap())
441+ } else {
442+ Handle::new(&profile.handle).map_err(|e| {
443+ tracing::error!("Invalid handle in profile: {}", e);
444+ XrpcErrorResponse::internal_error("Invalid handle stored")
445+ })?
446+ };
447+448+ // Build avatar URL from CID if present
449+ let avatar = if !profile.avatar_cid.is_empty() {
450+ let url = format!(
451+ "https://cdn.bsky.app/img/avatar/plain/{}/{}@jpeg",
452+ profile.did, profile.avatar_cid
453+ );
454+ Uri::new_owned(url).ok()
455+ } else {
456+ None
457+ };
458+459+ // Build banner URL from CID if present
460+ let banner = if !profile.banner_cid.is_empty() {
461+ let url = format!(
462+ "https://cdn.bsky.app/img/banner/plain/{}/{}@jpeg",
463+ profile.did, profile.banner_cid
464+ );
465+ Uri::new_owned(url).ok()
466+ } else {
467+ None
468+ };
469+470+ let inner_profile = ProfileView::new()
471+ .did(did.into_static())
472+ .handle(handle.into_static())
473+ .maybe_display_name(non_empty_str(&profile.display_name))
474+ .maybe_description(non_empty_str(&profile.description))
475+ .maybe_avatar(avatar)
476+ .maybe_banner(banner)
477+ .build();
478+479+ let profile_data = ProfileDataView::new()
480+ .inner(ProfileDataViewInner::ProfileView(Box::new(inner_profile)))
481+ .build();
482+483+ Ok(profile_data)
484+}
485+486+/// Parse record JSON string into owned Data
487+fn parse_record_json(
488+ json: &str,
489+) -> Result<jacquard::types::value::Data<'static>, XrpcErrorResponse> {
490+ use jacquard::types::value::Data;
491+492+ let data: Data<'_> = serde_json::from_str(json).map_err(|e| {
493+ tracing::error!("Failed to parse record JSON: {}", e);
494+ XrpcErrorResponse::internal_error("Invalid record JSON stored")
495+ })?;
496+ Ok(data.into_static())
497+}
+359-3
crates/weaver-index/src/endpoints/notebook.rs
···8use jacquard::types::string::{AtUri, Cid, Did, Handle, Uri};
9use jacquard::types::value::Data;
10use jacquard_axum::ExtractXrpc;
011use smol_str::SmolStr;
12use weaver_api::sh_weaver::actor::{ProfileDataView, ProfileDataViewInner, ProfileView};
13use weaver_api::sh_weaver::notebook::{
14- AuthorListView, BookEntryView, EntryView, NotebookView,
015 get_entry::{GetEntryOutput, GetEntryRequest},
0016 resolve_entry::{ResolveEntryOutput, ResolveEntryRequest},
17 resolve_notebook::{ResolveNotebookOutput, ResolveNotebookRequest},
18};
1920-use crate::clickhouse::ProfileRow;
21-use crate::endpoints::actor::resolve_actor;
22use crate::endpoints::repo::XrpcErrorResponse;
23use crate::server::AppState;
24···27/// Resolves a notebook by actor + path/title, returns notebook with entries.
28pub async fn resolve_notebook(
29 State(state): State<AppState>,
030 ExtractXrpc(args): ExtractXrpc<ResolveNotebookRequest>,
31) -> Result<Json<ResolveNotebookOutput<'static>>, XrpcErrorResponse> {
00032 // Resolve actor to DID
33 let did = resolve_actor(&state, &args.actor).await?;
34 let did_str = did.as_str();
···195/// Gets an entry by AT URI.
196pub async fn get_entry(
197 State(state): State<AppState>,
0198 ExtractXrpc(args): ExtractXrpc<GetEntryRequest>,
199) -> Result<Json<GetEntryOutput<'static>>, XrpcErrorResponse> {
00200 // Parse the AT URI to extract authority and rkey
201 let uri = &args.uri;
202 let authority = uri.authority();
···273/// Resolves an entry by actor + notebook name + entry name.
274pub async fn resolve_entry(
275 State(state): State<AppState>,
0276 ExtractXrpc(args): ExtractXrpc<ResolveEntryRequest>,
277) -> Result<Json<ResolveEntryOutput<'static>>, XrpcErrorResponse> {
00278 // Resolve actor to DID
279 let did = resolve_actor(&state, &args.actor).await?;
280 let did_str = did.as_str();
···294 XrpcErrorResponse::internal_error("Database query failed")
295 })
296 },
0297 async {
298 state
299 .clickhouse
···522523 Ok(profile_data)
524}
00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000
···8use jacquard::types::string::{AtUri, Cid, Did, Handle, Uri};
9use jacquard::types::value::Data;
10use jacquard_axum::ExtractXrpc;
11+use jacquard_axum::service_auth::ExtractOptionalServiceAuth;
12use smol_str::SmolStr;
13use weaver_api::sh_weaver::actor::{ProfileDataView, ProfileDataViewInner, ProfileView};
14use weaver_api::sh_weaver::notebook::{
15+ AuthorListView, BookEntryRef, BookEntryView, EntryView, FeedEntryView, NotebookView,
16+ get_book_entry::{GetBookEntryOutput, GetBookEntryRequest},
17 get_entry::{GetEntryOutput, GetEntryRequest},
18+ get_entry_feed::{GetEntryFeedOutput, GetEntryFeedRequest},
19+ get_notebook_feed::{GetNotebookFeedOutput, GetNotebookFeedRequest},
20 resolve_entry::{ResolveEntryOutput, ResolveEntryRequest},
21 resolve_notebook::{ResolveNotebookOutput, ResolveNotebookRequest},
22};
2324+use crate::clickhouse::{EntryRow, ProfileRow};
25+use crate::endpoints::actor::{Viewer, resolve_actor};
26use crate::endpoints::repo::XrpcErrorResponse;
27use crate::server::AppState;
28···31/// Resolves a notebook by actor + path/title, returns notebook with entries.
32pub async fn resolve_notebook(
33 State(state): State<AppState>,
34+ ExtractOptionalServiceAuth(viewer): ExtractOptionalServiceAuth,
35 ExtractXrpc(args): ExtractXrpc<ResolveNotebookRequest>,
36) -> Result<Json<ResolveNotebookOutput<'static>>, XrpcErrorResponse> {
37+ // viewer can be used later for viewer state (bookmarks, read status, etc.)
38+ let _viewer: Viewer = viewer;
39+40 // Resolve actor to DID
41 let did = resolve_actor(&state, &args.actor).await?;
42 let did_str = did.as_str();
···203/// Gets an entry by AT URI.
204pub async fn get_entry(
205 State(state): State<AppState>,
206+ ExtractOptionalServiceAuth(viewer): ExtractOptionalServiceAuth,
207 ExtractXrpc(args): ExtractXrpc<GetEntryRequest>,
208) -> Result<Json<GetEntryOutput<'static>>, XrpcErrorResponse> {
209+ let _viewer: Viewer = viewer;
210+211 // Parse the AT URI to extract authority and rkey
212 let uri = &args.uri;
213 let authority = uri.authority();
···284/// Resolves an entry by actor + notebook name + entry name.
285pub async fn resolve_entry(
286 State(state): State<AppState>,
287+ ExtractOptionalServiceAuth(viewer): ExtractOptionalServiceAuth,
288 ExtractXrpc(args): ExtractXrpc<ResolveEntryRequest>,
289) -> Result<Json<ResolveEntryOutput<'static>>, XrpcErrorResponse> {
290+ let _viewer: Viewer = viewer;
291+292 // Resolve actor to DID
293 let did = resolve_actor(&state, &args.actor).await?;
294 let did_str = did.as_str();
···308 XrpcErrorResponse::internal_error("Database query failed")
309 })
310 },
311+ // TODO: fix this, as we do need the entries to know for sure which, in case of collisions
312 async {
313 state
314 .clickhouse
···537538 Ok(profile_data)
539}
540+541+/// Parse cursor string to i64 timestamp millis
542+fn parse_cursor(cursor: Option<&str>) -> Result<Option<i64>, XrpcErrorResponse> {
543+ cursor
544+ .map(|c| {
545+ c.parse::<i64>()
546+ .map_err(|_| XrpcErrorResponse::invalid_request("Invalid cursor format"))
547+ })
548+ .transpose()
549+}
550+551+/// Handle sh.weaver.notebook.getNotebookFeed
552+///
553+/// Returns a global feed of notebooks.
554+pub async fn get_notebook_feed(
555+ State(state): State<AppState>,
556+ ExtractOptionalServiceAuth(viewer): ExtractOptionalServiceAuth,
557+ ExtractXrpc(args): ExtractXrpc<GetNotebookFeedRequest>,
558+) -> Result<Json<GetNotebookFeedOutput<'static>>, XrpcErrorResponse> {
559+ let _viewer: Viewer = viewer;
560+561+ let limit = args.limit.unwrap_or(50).clamp(1, 100) as u32;
562+ let cursor = parse_cursor(args.cursor.as_deref())?;
563+ let algorithm = args.algorithm.as_deref().unwrap_or("chronological");
564+565+ // Convert tags to &[&str] if present
566+ let tags_vec: Vec<&str> = args
567+ .tags
568+ .as_ref()
569+ .map(|t| t.iter().map(|s| s.as_ref()).collect())
570+ .unwrap_or_default();
571+ let tags = if tags_vec.is_empty() {
572+ None
573+ } else {
574+ Some(tags_vec.as_slice())
575+ };
576+577+ let notebook_rows = state
578+ .clickhouse
579+ .get_notebook_feed(algorithm, tags, limit + 1, cursor)
580+ .await
581+ .map_err(|e| {
582+ tracing::error!("Failed to get notebook feed: {}", e);
583+ XrpcErrorResponse::internal_error("Database query failed")
584+ })?;
585+586+ // Check if there are more
587+ let has_more = notebook_rows.len() > limit as usize;
588+ let notebook_rows: Vec<_> = notebook_rows.into_iter().take(limit as usize).collect();
589+590+ // Collect author DIDs for hydration
591+ let mut all_author_dids: HashSet<&str> = HashSet::new();
592+ for nb in ¬ebook_rows {
593+ for did in &nb.author_dids {
594+ all_author_dids.insert(did.as_str());
595+ }
596+ }
597+598+ // Batch fetch profiles
599+ let author_dids_vec: Vec<&str> = all_author_dids.into_iter().collect();
600+ let profiles = state
601+ .clickhouse
602+ .get_profiles_batch(&author_dids_vec)
603+ .await
604+ .map_err(|e| {
605+ tracing::error!("Failed to batch fetch profiles: {}", e);
606+ XrpcErrorResponse::internal_error("Database query failed")
607+ })?;
608+609+ let profile_map: HashMap<&str, &ProfileRow> =
610+ profiles.iter().map(|p| (p.did.as_str(), p)).collect();
611+612+ // Build NotebookViews
613+ let mut notebooks: Vec<NotebookView<'static>> = Vec::with_capacity(notebook_rows.len());
614+ for nb_row in ¬ebook_rows {
615+ let notebook_uri = AtUri::new(&nb_row.uri).map_err(|e| {
616+ tracing::error!("Invalid notebook URI in db: {}", e);
617+ XrpcErrorResponse::internal_error("Invalid URI stored")
618+ })?;
619+620+ let notebook_cid = Cid::new(nb_row.cid.as_bytes()).map_err(|e| {
621+ tracing::error!("Invalid notebook CID in db: {}", e);
622+ XrpcErrorResponse::internal_error("Invalid CID stored")
623+ })?;
624+625+ let authors = hydrate_authors(&nb_row.author_dids, &profile_map)?;
626+ let record = parse_record_json(&nb_row.record)?;
627+628+ let notebook = NotebookView::new()
629+ .uri(notebook_uri.into_static())
630+ .cid(notebook_cid.into_static())
631+ .authors(authors)
632+ .record(record)
633+ .indexed_at(nb_row.indexed_at.fixed_offset())
634+ .maybe_title(non_empty_cowstr(&nb_row.title))
635+ .maybe_path(non_empty_cowstr(&nb_row.path))
636+ .build();
637+638+ notebooks.push(notebook);
639+ }
640+641+ // Build cursor for pagination (created_at millis)
642+ let next_cursor = if has_more {
643+ notebook_rows
644+ .last()
645+ .map(|nb| nb.created_at.timestamp_millis().to_cowstr().into_static())
646+ } else {
647+ None
648+ };
649+650+ Ok(Json(
651+ GetNotebookFeedOutput {
652+ notebooks,
653+ cursor: next_cursor,
654+ extra_data: None,
655+ }
656+ .into_static(),
657+ ))
658+}
659+660+/// Handle sh.weaver.notebook.getEntryFeed
661+///
662+/// Returns a global feed of entries.
663+pub async fn get_entry_feed(
664+ State(state): State<AppState>,
665+ ExtractOptionalServiceAuth(viewer): ExtractOptionalServiceAuth,
666+ ExtractXrpc(args): ExtractXrpc<GetEntryFeedRequest>,
667+) -> Result<Json<GetEntryFeedOutput<'static>>, XrpcErrorResponse> {
668+ let _viewer: Viewer = viewer;
669+670+ let limit = args.limit.unwrap_or(50).clamp(1, 100) as u32;
671+ let cursor = parse_cursor(args.cursor.as_deref())?;
672+ let algorithm = args.algorithm.as_deref().unwrap_or("chronological");
673+674+ // Convert tags to &[&str] if present
675+ let tags_vec: Vec<&str> = args
676+ .tags
677+ .as_ref()
678+ .map(|t| t.iter().map(|s| s.as_ref()).collect())
679+ .unwrap_or_default();
680+ let tags = if tags_vec.is_empty() {
681+ None
682+ } else {
683+ Some(tags_vec.as_slice())
684+ };
685+686+ let entry_rows = state
687+ .clickhouse
688+ .get_entry_feed(algorithm, tags, limit + 1, cursor)
689+ .await
690+ .map_err(|e| {
691+ tracing::error!("Failed to get entry feed: {}", e);
692+ XrpcErrorResponse::internal_error("Database query failed")
693+ })?;
694+695+ // Check if there are more
696+ let has_more = entry_rows.len() > limit as usize;
697+ let entry_rows: Vec<_> = entry_rows.into_iter().take(limit as usize).collect();
698+699+ // Collect author DIDs for hydration
700+ let mut all_author_dids: HashSet<&str> = HashSet::new();
701+ for entry in &entry_rows {
702+ for did in &entry.author_dids {
703+ all_author_dids.insert(did.as_str());
704+ }
705+ }
706+707+ // Batch fetch profiles
708+ let author_dids_vec: Vec<&str> = all_author_dids.into_iter().collect();
709+ let profiles = state
710+ .clickhouse
711+ .get_profiles_batch(&author_dids_vec)
712+ .await
713+ .map_err(|e| {
714+ tracing::error!("Failed to batch fetch profiles: {}", e);
715+ XrpcErrorResponse::internal_error("Database query failed")
716+ })?;
717+718+ let profile_map: HashMap<&str, &ProfileRow> =
719+ profiles.iter().map(|p| (p.did.as_str(), p)).collect();
720+721+ // Build FeedEntryViews
722+ let mut feed: Vec<FeedEntryView<'static>> = Vec::with_capacity(entry_rows.len());
723+ for entry_row in &entry_rows {
724+ let entry_view = build_entry_view(entry_row, &profile_map)?;
725+726+ let feed_entry = FeedEntryView::new().entry(entry_view).build();
727+728+ feed.push(feed_entry);
729+ }
730+731+ // Build cursor for pagination (created_at millis)
732+ let next_cursor = if has_more {
733+ entry_rows
734+ .last()
735+ .map(|e| e.created_at.timestamp_millis().to_cowstr().into_static())
736+ } else {
737+ None
738+ };
739+740+ Ok(Json(
741+ GetEntryFeedOutput {
742+ feed,
743+ cursor: next_cursor,
744+ extra_data: None,
745+ }
746+ .into_static(),
747+ ))
748+}
749+750+/// Handle sh.weaver.notebook.getBookEntry
751+///
752+/// Returns an entry at a specific index within a notebook, with prev/next navigation.
753+pub async fn get_book_entry(
754+ State(state): State<AppState>,
755+ ExtractOptionalServiceAuth(viewer): ExtractOptionalServiceAuth,
756+ ExtractXrpc(args): ExtractXrpc<GetBookEntryRequest>,
757+) -> Result<Json<GetBookEntryOutput<'static>>, XrpcErrorResponse> {
758+ let _viewer: Viewer = viewer;
759+760+ // Parse the notebook URI
761+ let notebook_uri = &args.notebook;
762+ let authority = notebook_uri.authority();
763+ let notebook_rkey = notebook_uri
764+ .rkey()
765+ .ok_or_else(|| XrpcErrorResponse::invalid_request("Notebook URI must include rkey"))?;
766+767+ // Resolve authority to DID
768+ let notebook_did = resolve_actor(&state, authority).await?;
769+ let notebook_did_str = notebook_did.as_str();
770+ let notebook_rkey_str = notebook_rkey.as_ref();
771+772+ let index = args.index.unwrap_or(0).max(0) as u32;
773+774+ // Fetch entry at index with prev/next
775+ let result = state
776+ .clickhouse
777+ .get_book_entry_at_index(notebook_did_str, notebook_rkey_str, index)
778+ .await
779+ .map_err(|e| {
780+ tracing::error!("Failed to get book entry: {}", e);
781+ XrpcErrorResponse::internal_error("Database query failed")
782+ })?;
783+784+ let (current_row, prev_row, next_row) =
785+ result.ok_or_else(|| XrpcErrorResponse::not_found("Entry not found at index"))?;
786+787+ // Collect all author DIDs for hydration
788+ let mut all_author_dids: HashSet<&str> = HashSet::new();
789+ for did in ¤t_row.author_dids {
790+ all_author_dids.insert(did.as_str());
791+ }
792+ if let Some(ref prev) = prev_row {
793+ for did in &prev.author_dids {
794+ all_author_dids.insert(did.as_str());
795+ }
796+ }
797+ if let Some(ref next) = next_row {
798+ for did in &next.author_dids {
799+ all_author_dids.insert(did.as_str());
800+ }
801+ }
802+803+ // Batch fetch profiles
804+ let author_dids_vec: Vec<&str> = all_author_dids.into_iter().collect();
805+ let profiles = state
806+ .clickhouse
807+ .get_profiles_batch(&author_dids_vec)
808+ .await
809+ .map_err(|e| {
810+ tracing::error!("Failed to fetch profiles: {}", e);
811+ XrpcErrorResponse::internal_error("Database query failed")
812+ })?;
813+814+ let profile_map: HashMap<&str, &ProfileRow> =
815+ profiles.iter().map(|p| (p.did.as_str(), p)).collect();
816+817+ // Build the current entry view
818+ let entry_view = build_entry_view(¤t_row, &profile_map)?;
819+820+ // Build prev/next refs if present
821+ let prev_ref = if let Some(ref prev) = prev_row {
822+ let prev_view = build_entry_view(prev, &profile_map)?;
823+ Some(BookEntryRef::new().entry(prev_view).build())
824+ } else {
825+ None
826+ };
827+828+ let next_ref = if let Some(ref next) = next_row {
829+ let next_view = build_entry_view(next, &profile_map)?;
830+ Some(BookEntryRef::new().entry(next_view).build())
831+ } else {
832+ None
833+ };
834+835+ let book_entry = BookEntryView::new()
836+ .entry(entry_view)
837+ .index(index as i64)
838+ .maybe_prev(prev_ref)
839+ .maybe_next(next_ref)
840+ .build();
841+842+ Ok(Json(
843+ GetBookEntryOutput {
844+ value: book_entry,
845+ extra_data: None,
846+ }
847+ .into_static(),
848+ ))
849+}
850+851+/// Build an EntryView from an EntryRow
852+fn build_entry_view(
853+ entry_row: &EntryRow,
854+ profile_map: &HashMap<&str, &ProfileRow>,
855+) -> Result<EntryView<'static>, XrpcErrorResponse> {
856+ let entry_uri = AtUri::new(&entry_row.uri).map_err(|e| {
857+ tracing::error!("Invalid entry URI in db: {}", e);
858+ XrpcErrorResponse::internal_error("Invalid URI stored")
859+ })?;
860+861+ let entry_cid = Cid::new(entry_row.cid.as_bytes()).map_err(|e| {
862+ tracing::error!("Invalid entry CID in db: {}", e);
863+ XrpcErrorResponse::internal_error("Invalid CID stored")
864+ })?;
865+866+ let authors = hydrate_authors(&entry_row.author_dids, profile_map)?;
867+ let record = parse_record_json(&entry_row.record)?;
868+869+ let entry_view = EntryView::new()
870+ .uri(entry_uri.into_static())
871+ .cid(entry_cid.into_static())
872+ .authors(authors)
873+ .record(record)
874+ .indexed_at(entry_row.indexed_at.fixed_offset())
875+ .maybe_title(non_empty_cowstr(&entry_row.title))
876+ .maybe_path(non_empty_cowstr(&entry_row.path))
877+ .build();
878+879+ Ok(entry_view)
880+}
+2
crates/weaver-index/src/lib.rs
···6pub mod indexer;
7pub mod parallel_tap;
8pub mod server;
09pub mod sqlite;
10pub mod tap;
11···14pub use indexer::{FirehoseIndexer, load_cursor};
15pub use parallel_tap::TapIndexer;
16pub use server::{AppState, ServerConfig};
017pub use sqlite::{ShardKey, ShardRouter, SqliteShard};
···6pub mod indexer;
7pub mod parallel_tap;
8pub mod server;
9+pub mod service_identity;
10pub mod sqlite;
11pub mod tap;
12···15pub use indexer::{FirehoseIndexer, load_cursor};
16pub use parallel_tap::TapIndexer;
17pub use server::{AppState, ServerConfig};
18+pub use service_identity::ServiceIdentity;
19pub use sqlite::{ShardKey, ShardRouter, SqliteShard};
+5-5
crates/weaver-index/src/parallel_tap.rs
···14use crate::error::{ClickHouseError, Result};
15use crate::tap::{TapConfig as TapConsumerConfig, TapConsumer, TapEvent};
1617-/// TAP indexer with multiple parallel websocket connections
18///
19-/// Each worker maintains its own websocket connection to TAP and its own
20-/// ClickHouse inserter. TAP distributes events across connected clients,
21/// and its ack-gating mechanism ensures per-DID ordering is preserved
22/// regardless of which worker handles which events.
23pub struct TapIndexer {
···297/// then runs INSERT queries to populate target tables for incremental MVs.
298async fn run_backfill(client: Arc<Client>) {
299 // Wait for in-flight inserts to settle
300- info!("backfill: waiting 10s for in-flight inserts to settle");
301- tokio::time::sleep(Duration::from_secs(10)).await;
302303 let mvs = Migrator::incremental_mvs();
304 if mvs.is_empty() {
···14use crate::error::{ClickHouseError, Result};
15use crate::tap::{TapConfig as TapConsumerConfig, TapConsumer, TapEvent};
1617+/// Tap indexer with multiple parallel websocket connections
18///
19+/// Each worker maintains its own websocket connection to Tap and its own
20+/// ClickHouse inserter. Tap distributes events across connected clients,
21/// and its ack-gating mechanism ensures per-DID ordering is preserved
22/// regardless of which worker handles which events.
23pub struct TapIndexer {
···297/// then runs INSERT queries to populate target tables for incremental MVs.
298async fn run_backfill(client: Arc<Client>) {
299 // Wait for in-flight inserts to settle
300+ info!("backfill: waiting 100s for in-flight inserts to settle");
301+ tokio::time::sleep(Duration::from_secs(100)).await;
302303 let mvs = Migrator::incremental_mvs();
304 if mvs.is_empty() {