1fn map_error(error: crate::Error) -> crate::wire::ErrorEnvelope {
5 error.into()
6}
7
8#[derive(Debug, Clone)]
13pub struct NamespaceIdent(pub Vec<String>);
14
15impl NamespaceIdent {
16 pub fn root() -> Self {
17 Self(vec![])
18 }
19 pub fn as_table_id(&self, table_name: &str) -> Vec<String> {
20 let mut id = self.0.clone();
21 id.push(table_name.to_string());
22 id
23 }
24}
25
26pub fn resolve_namespace(
30 namespace: Option<&str>,
31) -> Result<NamespaceIdent, crate::wire::ErrorEnvelope> {
32 match namespace {
33 None | Some(crate::wire::DEFAULT_NAMESPACE) => Ok(NamespaceIdent::root()),
34 Some(other) => Err(map_error(crate::Error::namespace_unknown(other))),
35 }
36}
37
38fn map_storage(error: anyhow::Error) -> crate::wire::ErrorEnvelope {
39 if let Some(conflict) = error.downcast_ref::<crate::substrate::ConflictExhausted>() {
42 return map_error(crate::Error::Conflict {
43 attempts: conflict.attempts,
44 });
45 }
46 map_error(crate::Error::Storage(error))
47}
48
49mod ingest_handler {
50 use anyhow::Result;
51 use tokio_stream::StreamExt;
52
53 use crate::{
54 adapter::{Adapter, AdapterYield, SkipOracle, SkipReason},
55 sessions::{IngestEvent, IngestSummary, IngestValidator, OutcomeStatus, RowOutcome, Store},
56 wire::{
57 ErrorBody, ErrorCode, IngestEnvelope, IngestRequest, IngestResponse, IngestResult,
58 IngestStatus, validate_protocol,
59 },
60 };
61
62 use super::{map_error, map_storage};
63
64 pub const MAX_INGEST_EVENTS: usize = 1000;
66
67 #[derive(Debug, Clone)]
74 pub enum SyncEvent {
75 Discovered { total: Option<usize> },
79 SessionDone(SessionOutcome),
82 SkippedBulk { status: SyncStatus, count: usize },
84 Flushing { pending: usize },
88 }
89
90 #[derive(Debug, Clone)]
92 pub struct SessionOutcome {
93 pub project: Option<String>,
95 pub session_id: Option<String>,
98 pub messages: usize,
101 pub status: SyncStatus,
102 }
103
104 #[derive(Debug, Clone)]
117 pub enum SyncStatus {
118 Ok,
119 Partial {
120 dropped_events: usize,
121 first_drop_reason: Option<String>,
124 },
125 Skipped {
126 reason: String,
127 },
128 Rejected {
129 reason: String,
130 },
131 Fresh,
134 Empty,
138 Superseded,
145 }
146
147 #[derive(Debug, Default)]
148 struct InFlight {
149 project: Option<String>,
150 session_id: String,
151 messages: usize,
152 dropped_events: usize,
157 first_drop_reason: Option<String>,
158 session_index: usize,
162 }
163
164 #[derive(Debug)]
169 struct PendingDone {
170 project: Option<String>,
171 session_id: String,
172 messages: usize,
173 dropped_events: usize,
174 first_drop_reason: Option<String>,
175 session_index: usize,
176 }
177
178 const ADAPTER_FLUSH_BATCH: usize = 100;
185
186 pub async fn ingest_adapter<F>(
197 store: &Store,
198 adapter: &dyn Adapter,
199 oracle: &dyn SkipOracle,
200 mut on_event: F,
201 ) -> Result<IngestSummary>
202 where
203 F: FnMut(SyncEvent),
204 {
205 let mut summary = IngestSummary::default();
206 let truncations_before = crate::adapter::extract::truncated_values_count();
207 let discover_started = std::time::Instant::now();
211 let total = adapter
212 .discover()
213 .await
214 .map_err(|error| tracing::debug!(%error, "adapter discover failed"))
215 .ok();
216 tracing::debug!(target: "pond::perf", stage = "discover", elapsed_ms = discover_started.elapsed().as_millis() as u64, total = total.unwrap_or(0), "sync stage");
217 on_event(SyncEvent::Discovered { total });
218
219 let mut events = adapter.events_with(oracle);
220 let mut validator = IngestValidator::default();
221 let mut index = 0usize;
225 let mut in_flight: Option<InFlight> = None;
226 let mut pending_dones: std::collections::VecDeque<PendingDone> =
230 std::collections::VecDeque::new();
231 let mut decode_total = std::time::Duration::ZERO;
236 let mut decode_count = 0u64;
237 let mut validator_total = std::time::Duration::ZERO;
238 let mut validator_count = 0u64;
239 let run_started = std::time::Instant::now();
240
241 loop {
242 let decode_start = std::time::Instant::now();
243 let next = events.next().await;
244 decode_total += decode_start.elapsed();
245 decode_count += 1;
246 let event = match next {
247 Some(event) => event,
248 None => break,
249 };
250 match event {
251 Ok(AdapterYield::Skipped {
252 session_id,
253 project,
254 reason,
255 }) => {
256 let status = match reason {
257 SkipReason::Fresh => {
258 summary.skipped_fresh += 1;
259 SyncStatus::Fresh
260 }
261 SkipReason::Empty => {
262 summary.skipped_empty += 1;
263 SyncStatus::Empty
264 }
265 SkipReason::Superseded => {
266 summary.skipped_superseded += 1;
267 SyncStatus::Superseded
268 }
269 SkipReason::Unsupported(reason) => {
270 summary.skipped_files += 1;
271 SyncStatus::Skipped { reason }
272 }
273 };
274 on_event(SyncEvent::SessionDone(SessionOutcome {
275 project,
276 session_id,
277 messages: 0,
278 status,
279 }));
280 }
281 Ok(AdapterYield::SkippedBatch { reason, count }) => {
282 let status = match reason {
283 SkipReason::Fresh => {
284 summary.skipped_fresh += count;
285 SyncStatus::Fresh
286 }
287 SkipReason::Empty => {
288 summary.skipped_empty += count;
289 SyncStatus::Empty
290 }
291 SkipReason::Superseded => {
292 summary.skipped_superseded += count;
293 SyncStatus::Superseded
294 }
295 SkipReason::Unsupported(reason) => {
296 summary.skipped_files += count;
297 SyncStatus::Skipped { reason }
298 }
299 };
300 on_event(SyncEvent::SkippedBulk { status, count });
301 }
302 Ok(AdapterYield::Event(event)) => {
303 if matches!(&event, IngestEvent::Session(_))
308 && let Some(prev) = in_flight.take()
309 {
310 pending_dones.push_back(PendingDone {
311 project: prev.project,
312 session_id: prev.session_id,
313 messages: prev.messages,
314 dropped_events: prev.dropped_events,
315 first_drop_reason: prev.first_drop_reason,
316 session_index: prev.session_index,
317 });
318 }
319 let event_index = index;
320 match &event {
321 IngestEvent::Session(session) => {
322 in_flight = Some(InFlight {
323 project: Some((*session.project).clone()),
324 session_id: session.id.clone(),
325 messages: 0,
326 dropped_events: 0,
327 first_drop_reason: None,
328 session_index: event_index,
329 });
330 }
331 IngestEvent::Message(_) => {
332 if let Some(slot) = in_flight.as_mut() {
333 slot.messages += 1;
334 }
335 }
336 IngestEvent::Part(_) => {}
337 }
338
339 let validator_start = std::time::Instant::now();
340 let push_outcomes = validator.push(store, index, event).await?;
341 validator_total += validator_start.elapsed();
342 validator_count += 1;
343 for outcome in &push_outcomes {
350 if matches!(outcome.status, OutcomeStatus::Error)
351 && outcome.kind != "session"
352 && let Some(slot) = in_flight.as_mut()
353 {
354 slot.dropped_events += 1;
355 if slot.first_drop_reason.is_none() {
356 slot.first_drop_reason =
357 outcome.error.as_ref().map(|err| err.message.clone());
358 }
359 }
360 }
361 summary.add_outcomes(&push_outcomes);
362 index += 1;
363
364 if validator.pending_substreams() >= ADAPTER_FLUSH_BATCH {
369 on_event(SyncEvent::Flushing {
370 pending: validator.pending_substreams(),
371 });
372 let flush_start = std::time::Instant::now();
373 let (flush_outcomes, flush_counts) = validator.flush(store).await?;
374 validator_total += flush_start.elapsed();
375 validator_count += 1;
376 summary.add_outcomes_errors_only(&flush_outcomes);
380 summary.add_batch(&flush_counts);
381 drain_pending_dones(&mut pending_dones, &flush_outcomes, &mut on_event);
382 }
383 }
384 Err(error) => {
385 tracing::debug!(
391 %error,
392 "adapter event error (per-line drop by design)"
393 );
394 match in_flight.as_mut() {
395 Some(slot) => {
396 slot.dropped_events += 1;
400 if slot.first_drop_reason.is_none() {
401 slot.first_drop_reason = Some(error.to_string());
402 }
403 summary.dropped_events += 1;
404 }
405 None => {
406 summary.skipped_files += 1;
411 on_event(SyncEvent::SessionDone(SessionOutcome {
412 project: None,
413 session_id: None,
414 messages: 0,
415 status: SyncStatus::Skipped {
416 reason: error.to_string(),
417 },
418 }));
419 }
420 }
421 }
422 }
423 }
424
425 if let Some(prev) = in_flight.take() {
426 pending_dones.push_back(PendingDone {
427 project: prev.project,
428 session_id: prev.session_id,
429 messages: prev.messages,
430 dropped_events: prev.dropped_events,
431 first_drop_reason: prev.first_drop_reason,
432 session_index: prev.session_index,
433 });
434 }
435 if validator.pending_substreams() > 0 {
436 on_event(SyncEvent::Flushing {
437 pending: validator.pending_substreams(),
438 });
439 }
440 let validator_start = std::time::Instant::now();
441 let (final_outcomes, final_counts) = validator.finish(store).await?;
442 validator_total += validator_start.elapsed();
443 validator_count += 1;
444 summary.add_outcomes_errors_only(&final_outcomes);
445 summary.add_batch(&final_counts);
446 drain_pending_dones(&mut pending_dones, &final_outcomes, &mut on_event);
447
448 summary.truncated_values = crate::adapter::extract::truncated_values_count()
449 .saturating_sub(truncations_before) as usize;
450
451 let total = run_started.elapsed();
452 let other = total
453 .saturating_sub(decode_total)
454 .saturating_sub(validator_total);
455 tracing::info!(
456 target: "pond::perf",
457 total_ms = total.as_millis() as u64,
458 decode_ms = decode_total.as_millis() as u64,
459 validator_ms = validator_total.as_millis() as u64,
460 other_ms = other.as_millis() as u64,
461 decode_calls = decode_count,
462 validator_calls = validator_count,
463 rows_inserted = summary.inserted as u64,
464 rows_matched = summary.matched as u64,
465 dropped_events = summary.dropped_events as u64,
466 dropped_sessions = summary.dropped_sessions as u64,
467 skipped_files = summary.skipped_files as u64,
468 skipped_fresh = summary.skipped_fresh as u64,
469 skipped_superseded = summary.skipped_superseded as u64,
470 truncated_values = summary.truncated_values as u64,
471 "ingest_adapter complete"
472 );
473 Ok(summary)
474 }
475
476 fn drain_pending_dones<F>(
483 queue: &mut std::collections::VecDeque<PendingDone>,
484 outcomes: &[RowOutcome],
485 on_event: &mut F,
486 ) where
487 F: FnMut(SyncEvent),
488 {
489 let mut session_outcome_by_index: std::collections::HashMap<usize, &RowOutcome> =
492 std::collections::HashMap::new();
493 for outcome in outcomes {
494 if outcome.kind == "session" {
495 session_outcome_by_index.insert(outcome.index, outcome);
496 }
497 }
498
499 while let Some(done) = queue.pop_front() {
500 let session_outcome = session_outcome_by_index.get(&done.session_index).copied();
501 let rejection_reason = session_outcome.and_then(|outcome| {
502 if matches!(outcome.status, OutcomeStatus::Error) {
503 Some(
504 outcome
505 .error
506 .as_ref()
507 .map(|err| err.message.clone())
508 .unwrap_or_else(|| "session-level rejection".to_owned()),
509 )
510 } else {
511 None
512 }
513 });
514 let status = if let Some(reason) = rejection_reason {
515 SyncStatus::Rejected { reason }
516 } else if done.dropped_events > 0 {
517 SyncStatus::Partial {
518 dropped_events: done.dropped_events,
519 first_drop_reason: done.first_drop_reason,
520 }
521 } else {
522 SyncStatus::Ok
523 };
524 on_event(SyncEvent::SessionDone(SessionOutcome {
525 project: done.project,
526 session_id: Some(done.session_id),
527 messages: done.messages,
528 status,
529 }));
530 }
531 }
532
533 pub async fn pond_ingest(store: &Store, request: IngestRequest) -> IngestEnvelope {
539 if let Err(envelope) = validate_protocol(request.protocol_version) {
540 return IngestEnvelope::Error(envelope);
541 }
542 if let Err(envelope) = super::resolve_namespace(request.namespace.as_deref()) {
543 return IngestEnvelope::Error(envelope);
544 }
545 if request.events.is_empty() {
546 return IngestEnvelope::Error(map_error(crate::Error::validation_field(
547 "events must be a non-empty array",
548 "events",
549 Some(serde_json::json!([])),
550 Some("non-empty array".to_owned()),
551 )));
552 }
553 if request.events.len() > MAX_INGEST_EVENTS {
554 return IngestEnvelope::Error(map_error(crate::Error::validation_field(
555 format!("ingest batch exceeds the event cap: at most {MAX_INGEST_EVENTS} events"),
556 "events",
557 Some(serde_json::json!(request.events.len())),
558 Some(format!("at most {MAX_INGEST_EVENTS} events")),
559 )));
560 }
561
562 match ingest_events(store, request.events).await {
563 Ok(outcomes) => {
564 let mut accepted = 0;
565 let mut rejected = 0;
566 for outcome in &outcomes {
567 match outcome.status {
568 OutcomeStatus::Inserted | OutcomeStatus::Matched => accepted += 1,
569 OutcomeStatus::Error => rejected += 1,
570 }
571 }
572 let results = outcomes
573 .into_iter()
574 .map(outcome_to_result)
575 .collect::<Vec<_>>();
576 IngestEnvelope::Success(IngestResponse {
577 accepted,
578 rejected,
579 results,
580 })
581 }
582 Err(failure) => IngestEnvelope::Error(map_storage(failure)),
583 }
584 }
585
586 pub async fn ingest_events(store: &Store, events: Vec<IngestEvent>) -> Result<Vec<RowOutcome>> {
592 let mut validator = IngestValidator::default();
593 let mut outcomes = Vec::with_capacity(events.len());
594 for (index, event) in events.into_iter().enumerate() {
595 let mut chunk = validator.push(store, index, event).await?;
596 outcomes.append(&mut chunk);
597 }
598 let (mut tail, _counts) = validator.finish(store).await?;
601 outcomes.append(&mut tail);
602 outcomes.sort_by_key(|outcome| outcome.index);
603 Ok(outcomes)
604 }
605
606 fn outcome_to_result(outcome: RowOutcome) -> IngestResult {
607 let (status, error) = match (outcome.status, outcome.error) {
608 (OutcomeStatus::Inserted, _) => (IngestStatus::Inserted, None),
609 (OutcomeStatus::Matched, _) => (IngestStatus::Matched, None),
610 (OutcomeStatus::Error, error) => {
611 let body = error
612 .map(|err| {
613 let mut details = serde_json::Map::new();
614 if let Some(field) = err.field {
615 details.insert("field".to_owned(), serde_json::json!(field));
616 }
617 if let Some(reason) = err.reason {
618 details.insert("reason".to_owned(), serde_json::json!(reason));
619 }
620 ErrorBody {
621 code: ErrorCode::ValidationFailed,
622 message: err.message,
623 details: serde_json::Value::Object(details),
624 }
625 })
626 .unwrap_or_else(|| ErrorBody {
627 code: ErrorCode::ValidationFailed,
628 message: "ingest failed".to_owned(),
629 details: serde_json::json!({}),
630 });
631 (IngestStatus::Error, Some(body))
632 }
633 };
634 IngestResult {
635 index: outcome.index,
636 kind: outcome.kind.to_owned(),
637 pk: outcome.pk,
638 status,
639 error,
640 }
641 }
642}
643
644pub use crate::sessions::{IngestEvent, IngestSummary, IngestValidator, search_text};
645pub use ingest_handler::{
646 MAX_INGEST_EVENTS, SessionOutcome, SyncEvent, SyncStatus, ingest_adapter, ingest_events,
647 pond_ingest,
648};
649
650mod export_handler {
651 use anyhow::{Context, Result};
662 use tokio::io::{AsyncWrite, AsyncWriteExt};
663
664 use crate::sessions::{IngestEvent, Store};
665
666 #[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
667 pub struct ExportSummary {
668 pub sessions: usize,
669 pub messages: usize,
670 pub parts: usize,
671 }
672
673 pub async fn pond_export<W>(
674 store: &Store,
675 session_filter: Option<&str>,
676 writer: &mut W,
677 ) -> Result<ExportSummary>
678 where
679 W: AsyncWrite + Unpin,
680 {
681 let mut session_ids = match session_filter {
682 Some(id) => vec![id.to_owned()],
683 None => store.session_ids().await?,
684 };
685 session_ids.sort();
686
687 let mut summary = ExportSummary::default();
688 for session_id in session_ids {
689 let Some(stored) = store
690 .get_session(&session_id)
691 .await
692 .with_context(|| format!("export: failed to load session {session_id}"))?
693 else {
694 if session_filter.is_some() {
695 anyhow::bail!("export: session not found: {session_id}");
696 }
697 continue;
698 };
699 write_event(writer, &IngestEvent::Session(stored.session)).await?;
700 summary.sessions += 1;
701 for message_with_parts in stored.messages {
702 write_event(writer, &IngestEvent::Message(message_with_parts.message)).await?;
703 summary.messages += 1;
704 for part in message_with_parts.parts {
705 write_event(writer, &IngestEvent::Part(part)).await?;
706 summary.parts += 1;
707 }
708 }
709 }
710 writer.flush().await.context("export: flush failed")?;
711 Ok(summary)
712 }
713
714 async fn write_event<W>(writer: &mut W, event: &IngestEvent) -> Result<()>
715 where
716 W: AsyncWrite + Unpin,
717 {
718 let line = serde_json::to_string(event).context("export: serialize event")?;
719 writer
720 .write_all(line.as_bytes())
721 .await
722 .context("export: write event")?;
723 writer
724 .write_all(b"\n")
725 .await
726 .context("export: write newline")?;
727 Ok(())
728 }
729}
730
731pub use export_handler::{ExportSummary, pond_export};
732
733mod restore_handler {
734 use anyhow::{Context, Result, bail};
741
742 use crate::sessions::{SessionWithMessages, Store};
743
744 pub async fn restore_lineage(
745 store: &Store,
746 session_id: &str,
747 ) -> Result<Vec<SessionWithMessages>> {
748 let Some(parent) = store.get_session(session_id).await? else {
749 bail!("export: session not found: {session_id}");
750 };
751 let mut sessions = vec![parent];
752 for child in store.child_sessions(session_id).await? {
753 if !store.child_sessions(&child.id).await?.is_empty() {
754 bail!(
755 "adapter-lineage-complete-restore supports one subagent level; session {} has child sessions",
756 child.id
757 );
758 }
759 let child_id = child.id;
760 let stored = store
761 .get_session(&child_id)
762 .await?
763 .with_context(|| format!("export: child session disappeared: {child_id}"))?;
764 sessions.push(stored);
765 }
766 Ok(sessions)
767 }
768}
769
770pub use restore_handler::restore_lineage;
771
772mod get_handler {
773 use crate::{
774 sessions::{GetLookup, MessageViewParams, RetrievedMessage, SessionViewParams, Store},
775 wire::{
776 GetEnvelope, GetMessageRequest, GetResponse, GetResult, GetSession, GetSessionRequest,
777 MessageView, PartSummary, ResponsePart, validate_protocol,
778 },
779 };
780
781 use super::{map_error, map_storage};
782
783 fn to_message_view(message: RetrievedMessage) -> MessageView {
788 let parts_summary = message
789 .parts
790 .iter()
791 .filter_map(|part| PartSummary::for_kind(&part.kind))
792 .collect();
793 MessageView {
794 id: message.id,
795 role: message.role,
796 timestamp: message.timestamp,
797 text: message.text,
798 content: message.content,
799 parts_summary,
800 }
801 }
802
803 const BUDGET_BYTES: usize = 200_000;
809
810 pub async fn pond_get_session(store: &Store, request: GetSessionRequest) -> GetEnvelope {
811 if let Err(error) = validate_protocol(request.protocol_version) {
812 return GetEnvelope::Error(error);
813 }
814 if let Err(envelope) = super::resolve_namespace(request.namespace.as_deref()) {
815 return GetEnvelope::Error(envelope);
816 }
817 match session_result(store, &request).await {
818 Ok(response) => GetEnvelope::Success(response),
819 Err(error) => GetEnvelope::Error(error),
820 }
821 }
822
823 pub async fn pond_get_message(store: &Store, request: GetMessageRequest) -> GetEnvelope {
824 if let Err(error) = validate_protocol(request.protocol_version) {
825 return GetEnvelope::Error(error);
826 }
827 if let Err(envelope) = super::resolve_namespace(request.namespace.as_deref()) {
828 return GetEnvelope::Error(envelope);
829 }
830 match message_result(store, &request).await {
831 Ok(response) => GetEnvelope::Success(response),
832 Err(error) => GetEnvelope::Error(error),
833 }
834 }
835
836 fn unknown_anchor(field: &str, value: Option<&str>) -> crate::wire::ErrorEnvelope {
839 map_error(crate::Error::validation_field(
840 format!("{field} not found (stale or mistyped pagination anchor)"),
841 field,
842 value.map(|v| serde_json::Value::String(v.to_owned())),
843 Some("a message id from a prior page of this read".to_owned()),
844 ))
845 }
846
847 async fn session_result(
848 store: &Store,
849 request: &GetSessionRequest,
850 ) -> Result<GetResponse, crate::wire::ErrorEnvelope> {
851 if request.after_message_id.is_some() && request.before_message_id.is_some() {
852 return Err(map_error(crate::Error::validation_field(
853 "after_message_id and before_message_id are mutually exclusive",
854 "before_message_id",
855 request
856 .before_message_id
857 .clone()
858 .map(serde_json::Value::String),
859 Some("set only one pagination anchor".to_owned()),
860 )));
861 }
862 let params = SessionViewParams {
863 at_message_id: None,
864 after_message_id: request.after_message_id.as_deref(),
865 before_message_id: request.before_message_id.as_deref(),
866 limit: request.limit,
867 budget_bytes: BUDGET_BYTES,
868 session_from: request.from,
869 };
870 let (session_id, resolved_from) = match store
871 .session_view(&request.id, params.clone())
872 .await
873 .map_err(map_storage)?
874 {
875 GetLookup::NotFound => {
876 match store
880 .session_id_for_message(&request.id)
881 .await
882 .map_err(map_storage)?
883 {
884 Some(session_id) => (session_id, Some(request.id.clone())),
885 None => {
886 return Err(map_error(crate::Error::not_found(
887 "session",
888 serde_json::json!(request.id),
889 format!(
890 "session not found: {} (not a message id either)",
891 request.id
892 ),
893 )));
894 }
895 }
896 }
897 GetLookup::UnknownAnchor => {
898 let (field, value) = match &request.after_message_id {
899 Some(value) => ("after_message_id", Some(value.as_str())),
900 None => ("before_message_id", request.before_message_id.as_deref()),
901 };
902 return Err(unknown_anchor(field, value));
903 }
904 GetLookup::Found(view) => {
905 return Ok(session_response(view, None));
906 }
907 };
908 let anchored = SessionViewParams {
909 at_message_id: resolved_from.as_deref(),
910 ..params
911 };
912 match store
913 .session_view(&session_id, anchored)
914 .await
915 .map_err(map_storage)?
916 {
917 GetLookup::Found(view) => Ok(session_response(view, resolved_from)),
918 GetLookup::NotFound | GetLookup::UnknownAnchor => Err(map_error(
921 crate::Error::internal("resolved parent session lookup failed"),
922 )),
923 }
924 }
925
926 fn session_response(
927 view: crate::sessions::SessionPage,
928 resolved_from_message_id: Option<String>,
929 ) -> GetResponse {
930 GetResponse {
931 session: GetSession::from_session(&view.session),
932 result: GetResult::Session {
933 messages: view.messages.into_iter().map(to_message_view).collect(),
934 before_remaining: view.before_remaining,
935 after_remaining: view.after_remaining,
936 resolved_from_message_id,
937 },
938 }
939 }
940
941 async fn message_result(
942 store: &Store,
943 request: &GetMessageRequest,
944 ) -> Result<GetResponse, crate::wire::ErrorEnvelope> {
945 let message_id = &request.id;
946 let params = MessageViewParams {
947 context_before: request.context_before,
948 context_after: request.context_after,
949 budget_bytes: BUDGET_BYTES,
950 };
951 let view = match store
952 .message_view(message_id, params)
953 .await
954 .map_err(map_storage)?
955 {
956 GetLookup::NotFound => {
957 if store
960 .find_session(message_id)
961 .await
962 .map_err(map_storage)?
963 .is_some()
964 {
965 return Err(map_error(crate::Error::validation_field(
966 format!(
967 "{message_id} is a session id, not a message id - read it with \
968 pond_get_session (CLI: pond get-session), or pass a message id \
969 from its transcript"
970 ),
971 "id",
972 Some(serde_json::Value::String(message_id.clone())),
973 Some("a message id from a search hit or transcript".to_owned()),
974 )));
975 }
976 return Err(map_error(crate::Error::not_found(
977 "message",
978 serde_json::json!(message_id),
979 format!("message not found: {message_id}"),
980 )));
981 }
982 GetLookup::UnknownAnchor => {
984 return Err(map_error(crate::Error::internal(
985 "message_view returned UnknownAnchor for an anchorless lookup",
986 )));
987 }
988 GetLookup::Found(view) => view,
989 };
990 let target = MessageView {
993 id: view.target.id,
994 role: view.target.role,
995 timestamp: view.target.timestamp,
996 text: None,
997 content: None,
998 parts_summary: Vec::new(),
999 };
1000 Ok(GetResponse {
1001 session: GetSession::from_session(&view.session),
1002 result: GetResult::Message {
1003 target,
1004 target_parts: view
1005 .target_parts
1006 .into_iter()
1007 .map(ResponsePart::from_part)
1008 .collect(),
1009 target_parts_remaining: view.target_parts_remaining,
1010 siblings: view.siblings.into_iter().map(to_message_view).collect(),
1011 context_before: request.context_before,
1012 context_after: request.context_after,
1013 },
1014 })
1015 }
1016}
1017
1018pub use get_handler::{pond_get_message, pond_get_session};
1019
1020mod search_handler {
1021 use crate::{
1026 Clock, SystemClock,
1027 embed::{Embedder, LazyEmbedder, format_query},
1028 sessions::{MessageKey, MessageMeta, SearchHit, Store},
1029 substrate::{Predicate, ScalarValue},
1030 wire::{
1031 ErrorEnvelope, PartSummary, ProjectFilter, Role, SearchEnvelope, SearchFilters,
1032 SearchRequest, SearchResponse, SearchResult, SearchSession, SortBy, validate_protocol,
1033 },
1034 };
1035 use chrono::{DateTime, NaiveDate, Utc};
1036 use std::collections::HashMap;
1037
1038 use super::{map_error, map_storage};
1039
1040 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
1045 pub enum SearchMode {
1046 Fts,
1047 Vector,
1048 }
1049
1050 #[derive(Debug, Clone, PartialEq)]
1051 pub struct SearchPlan {
1052 pub mode: SearchMode,
1053 pub query: String,
1054 pub filter: Predicate,
1060 pub filters: SearchFilters,
1061 pub sort_by: SortBy,
1062 pub pool: usize,
1063 pub vector_pool: usize,
1064 pub limit: usize,
1065 pub min_score: f64,
1066 pub exclude_subagents: bool,
1070 }
1071
1072 const LIMIT_CAP: usize = 200;
1073 const HIT_SNIPPET_CHARS: usize = 600;
1077
1078 const RECENCY_BOOST_MAGNITUDE: f64 = 0.02;
1092 const RECENCY_BOOST_SCALE_DAYS: f64 = 30.0;
1093
1094 pub async fn pond_search(
1103 store: &Store,
1104 embedder: &LazyEmbedder,
1105 request: SearchRequest,
1106 search: &crate::config::SearchConfig,
1107 ) -> SearchEnvelope {
1108 match run_search(store, embedder, request, search, &SystemClock).await {
1109 Ok(response) => SearchEnvelope::Success(response),
1110 Err(envelope) => SearchEnvelope::Error(envelope),
1111 }
1112 }
1113
1114 pub async fn explain_search_plan(
1115 store: &Store,
1116 embedder: &LazyEmbedder,
1117 request: SearchRequest,
1118 search: &crate::config::SearchConfig,
1119 ) -> Result<String, ErrorEnvelope> {
1120 let mut plan = plan_search(request)?;
1121 plan.mode = resolve_effective_mode(store, plan.mode).await?;
1122 let mut out = String::new();
1123 match plan.mode {
1124 SearchMode::Fts => {
1125 let fts = store
1126 .explain_fts_plan(&plan.query, plan.pool, &plan.filter)
1127 .await
1128 .map_err(map_storage)?;
1129 out.push_str("fts:\n");
1130 out.push_str(&fts);
1131 out.push('\n');
1132 }
1133 SearchMode::Vector => {
1134 let backend = load_embedder(embedder).await?;
1135 let vector = embed_query(backend.as_ref(), &plan.query)?;
1136 let vector_plan = store
1137 .explain_vector_plan(&vector, plan.vector_pool, &plan.filter, Some(search))
1138 .await
1139 .map_err(map_storage)?;
1140 out.push_str("vector:\n");
1141 out.push_str(&vector_plan);
1142 out.push('\n');
1143 }
1144 }
1145 Ok(out)
1146 }
1147
1148 async fn run_search(
1149 store: &Store,
1150 embedder: &LazyEmbedder,
1151 request: SearchRequest,
1152 search: &crate::config::SearchConfig,
1153 clock: &dyn Clock,
1154 ) -> Result<SearchResponse, ErrorEnvelope> {
1155 let stage_start = std::time::Instant::now();
1158 macro_rules! stage {
1159 ($label:literal) => {
1160 tracing::debug!(
1161 target: "pond::perf",
1162 stage = $label,
1163 elapsed_ms = stage_start.elapsed().as_millis() as u64,
1164 );
1165 };
1166 }
1167 let mut plan = plan_search(request)?;
1168
1169 plan.mode = resolve_effective_mode(store, plan.mode).await?;
1172 stage!("resolve_mode");
1173
1174 if matches!(plan.mode, SearchMode::Fts) && plan.min_score > 0.0 {
1178 return Err(map_error(crate::Error::validation_field(
1179 "min_score is not supported in fts mode (BM25 scores are unbounded \
1180 and not comparable across queries); use vector mode or drop min_score",
1181 "min_score",
1182 Some(serde_json::json!(plan.min_score)),
1183 Some("0 in fts mode".to_owned()),
1184 )));
1185 }
1186
1187 let candidates_fut = async {
1194 match plan.mode {
1195 SearchMode::Fts => {
1196 let mut hits = store
1197 .fts_search(&plan.query, plan.pool, &plan.filter)
1198 .await
1199 .map_err(map_storage)?;
1200 retain_non_subagents(&mut hits, plan.exclude_subagents);
1201 Ok(normalize_fts(hits))
1202 }
1203 SearchMode::Vector => {
1207 let backend = load_embedder(embedder).await?;
1208 let vector = embed_query(backend.as_ref(), &plan.query)?;
1209 stage!("embed_query(+model)");
1210 let mut vector_raw = store
1211 .vector_search(&vector, plan.vector_pool, &plan.filter, Some(search))
1212 .await
1213 .map_err(map_storage)?;
1214 stage!("vector_search");
1215 retain_non_subagents(&mut vector_raw, plan.exclude_subagents);
1216 Ok(normalize_vector(vector_raw))
1217 }
1218 }
1219 };
1220 let scope_fut = async {
1221 store
1222 .searchable_in_scope(&plan.filter)
1223 .await
1224 .map_err(map_storage)
1225 };
1226 let (candidates, searchable_in_scope) = tokio::try_join!(candidates_fut, scope_fut)?;
1227 stage!("arms+scope joined");
1228
1229 if candidates.is_empty() {
1230 return Ok(empty_response(searchable_in_scope));
1231 }
1232
1233 let (mut selected, mut total_sessions, mut matched_total) =
1239 select_top_hits(candidates, plan.min_score, plan.limit);
1240 if selected.is_empty() {
1241 return Ok(empty_response(searchable_in_scope));
1242 }
1243
1244 let rowids: Option<Vec<u64>> = selected.iter().map(|candidate| candidate.rowid).collect();
1250 let mut metas = match &rowids {
1251 Some(rowids) => store
1252 .message_metas_by_rowids(rowids)
1253 .await
1254 .map_err(map_storage)?,
1255 None => {
1256 let keys = selected
1257 .iter()
1258 .map(|candidate| MessageKey {
1259 session_id: candidate.session_id.clone(),
1260 message_id: candidate.message_id.clone(),
1261 })
1262 .collect::<Vec<_>>();
1263 store
1264 .message_metas_by_keys(&keys)
1265 .await
1266 .map_err(map_storage)?
1267 }
1268 };
1269 stage!("metas_hydrated");
1270
1271 if plan.exclude_subagents {
1285 let excluded: std::collections::HashSet<String> = metas
1286 .iter()
1287 .filter(|meta| meta.source_agent.contains('/'))
1288 .map(|meta| meta.session_id.clone())
1289 .collect();
1290 if !excluded.is_empty() {
1291 matched_total = matched_total.saturating_sub(
1292 selected
1293 .iter()
1294 .filter(|candidate| excluded.contains(&candidate.session_id))
1295 .count(),
1296 );
1297 total_sessions = total_sessions.saturating_sub(
1298 selected
1299 .iter()
1300 .map(|candidate| session_root(&candidate.session_id))
1301 .filter(|root| excluded.contains(*root))
1302 .collect::<std::collections::HashSet<_>>()
1303 .len(),
1304 );
1305 selected.retain(|candidate| !excluded.contains(&candidate.session_id));
1306 metas.retain(|meta| !meta.source_agent.contains('/'));
1307 if selected.is_empty() {
1308 return Ok(empty_response(searchable_in_scope));
1309 }
1310 }
1311 }
1312
1313 let meta_index = metas
1314 .iter()
1315 .map(|meta| ((meta.session_id.as_str(), meta.message_id.as_str()), meta))
1316 .collect::<std::collections::HashMap<_, _>>();
1317
1318 let now = clock.now();
1324 let mut scored = Vec::with_capacity(selected.len());
1325 for candidate in selected {
1326 let Some(meta) =
1327 meta_index.get(&(candidate.session_id.as_str(), candidate.message_id.as_str()))
1328 else {
1329 continue;
1330 };
1331 let order_score = match (plan.sort_by, plan.mode) {
1332 (SortBy::Recency, _) => recency_rank(meta.timestamp),
1333 (SortBy::Relevance, SearchMode::Vector) => {
1334 candidate.base_score + recency_boost(meta.timestamp, now)
1335 }
1336 (SortBy::Relevance, SearchMode::Fts) => candidate.base_score,
1337 };
1338 scored.push(ScoredHit {
1339 meta: (*meta).clone(),
1340 display_score: candidate.base_score,
1341 order_score,
1342 });
1343 }
1344 scored.sort_by(|left, right| {
1345 right
1346 .order_score
1347 .partial_cmp(&left.order_score)
1348 .unwrap_or(std::cmp::Ordering::Equal)
1349 .then_with(|| left.meta.session_id.cmp(&right.meta.session_id))
1350 .then_with(|| left.meta.message_id.cmp(&right.meta.message_id))
1351 });
1352
1353 let sessions = build_sessions(store, &scored, &plan.query).await?;
1354 stage!("build_sessions(parts)");
1355 page_sessions(
1356 sessions,
1357 matched_total,
1358 total_sessions,
1359 searchable_in_scope,
1360 &plan,
1361 )
1362 }
1363
1364 fn recency_boost(ts: DateTime<Utc>, now: DateTime<Utc>) -> f64 {
1369 let age_days = (now - ts).num_seconds().max(0) as f64 / 86_400.0;
1370 RECENCY_BOOST_MAGNITUDE * (-age_days / RECENCY_BOOST_SCALE_DAYS).exp()
1371 }
1372
1373 fn recency_rank(ts: DateTime<Utc>) -> f64 {
1376 ts.timestamp() as f64
1377 }
1378
1379 async fn resolve_effective_mode(
1383 store: &Store,
1384 requested: SearchMode,
1385 ) -> Result<SearchMode, ErrorEnvelope> {
1386 if matches!(requested, SearchMode::Fts) {
1387 return Ok(SearchMode::Fts);
1388 }
1389 let has = store.has_embeddings().await.map_err(map_storage)?;
1390 Ok(if has {
1391 SearchMode::Vector
1392 } else {
1393 SearchMode::Fts
1394 })
1395 }
1396
1397 async fn load_embedder(
1401 embedder: &LazyEmbedder,
1402 ) -> Result<std::sync::Arc<dyn Embedder>, ErrorEnvelope> {
1403 embedder.get().await.map_err(|error| {
1404 map_error(crate::Error::internal(format!(
1405 "embedder load failed: {error}"
1406 )))
1407 })
1408 }
1409
1410 pub fn plan_search(request: SearchRequest) -> Result<SearchPlan, ErrorEnvelope> {
1411 validate_protocol(request.protocol_version)?;
1412
1413 let _ns = super::resolve_namespace(request.namespace.as_deref())?;
1414
1415 let mode = match request.mode {
1416 crate::wire::SearchModeWire::Fts => SearchMode::Fts,
1417 crate::wire::SearchModeWire::Vector => SearchMode::Vector,
1418 };
1419 let sort_by = request.sort_by;
1420 let filters = request.filters;
1421 let query = request.query.trim().to_owned();
1422 if query.is_empty() {
1423 return Err(map_error(crate::Error::validation_field(
1424 "query must be non-empty after trim",
1425 "query",
1426 Some(serde_json::json!(request.query)),
1427 Some("non-empty string after trim".to_owned()),
1428 )));
1429 }
1430 if request.limit == 0 {
1431 return Err(map_error(crate::Error::validation_field(
1432 "limit must be at least 1",
1433 "limit",
1434 Some(serde_json::json!(request.limit)),
1435 Some("integer >= 1".to_owned()),
1436 )));
1437 }
1438 let limit = request.limit.min(LIMIT_CAP);
1439 let min_score = filters.min_score;
1440 let filter = build_scope_filter(&filters)?;
1441 let exclude_subagents = default_excludes_subagents(&filters);
1442 let mut pool = limit.saturating_mul(5).max(50);
1447 let mut vector_pool = pool.saturating_mul(2);
1448 if exclude_subagents {
1449 pool = pool.saturating_mul(3) / 2;
1450 vector_pool = vector_pool.saturating_mul(3) / 2;
1451 }
1452 Ok(SearchPlan {
1453 mode,
1454 query,
1455 filter,
1456 filters,
1457 sort_by,
1458 pool,
1459 vector_pool,
1460 limit,
1461 min_score,
1462 exclude_subagents,
1463 })
1464 }
1465
1466 fn session_root(session_id: &str) -> &str {
1471 match session_id.find('/') {
1472 Some(idx) => &session_id[..idx],
1473 None => session_id,
1474 }
1475 }
1476
1477 fn retain_non_subagents(hits: &mut Vec<SearchHit>, exclude: bool) {
1489 if exclude {
1490 hits.retain(|hit| !hit.key.session_id.contains('/'));
1491 }
1492 }
1493
1494 const ANCHOR_MIN_TERM_CHARS: usize = 4;
1499
1500 pub fn hit_payload(text: &str, query: &str) -> String {
1505 let chars_len = text.chars().count();
1506 if chars_len <= HIT_SNIPPET_CHARS {
1507 return text.to_owned();
1508 }
1509 query_snippet(text, query)
1510 }
1511
1512 fn query_snippet(text: &str, query: &str) -> String {
1528 let lower_text = text.to_lowercase();
1529 let terms: Vec<String> = query
1530 .split_whitespace()
1531 .filter(|term| !term.is_empty())
1532 .map(str::to_lowercase)
1533 .collect();
1534 let any_informative = terms
1535 .iter()
1536 .any(|term| term.chars().count() >= ANCHOR_MIN_TERM_CHARS);
1537 let hit = terms
1538 .iter()
1539 .filter(|term| !any_informative || term.chars().count() >= ANCHOR_MIN_TERM_CHARS)
1540 .filter_map(|term| lower_text.find(term.as_str()))
1541 .min();
1542 let chars: Vec<char> = text.chars().collect();
1543 let center = hit
1547 .map(|byte| lower_text[..byte].chars().count())
1548 .unwrap_or(0);
1549 let half = HIT_SNIPPET_CHARS / 2;
1550 let start = center.saturating_sub(half);
1551 let end = (start + HIT_SNIPPET_CHARS).min(chars.len());
1552 let start = end.saturating_sub(HIT_SNIPPET_CHARS);
1553 let mut snippet = String::new();
1559 if start > 0 {
1560 snippet.push_str(&format!("[{start} chars before] "));
1561 }
1562 snippet.extend(&chars[start..end]);
1563 if end < chars.len() {
1564 snippet.push_str(&format!(" [+{} more chars]", chars.len() - end));
1565 }
1566 snippet
1567 }
1568
1569 struct Candidate {
1570 rowid: Option<u64>,
1571 session_id: String,
1572 message_id: String,
1573 base_score: f64,
1574 }
1575
1576 struct ScoredHit {
1577 meta: MessageMeta,
1578 display_score: f64,
1581 order_score: f64,
1585 }
1586
1587 impl ScoredHit {
1588 fn to_search_result(
1589 &self,
1590 query: &str,
1591 summaries: &HashMap<(String, String), Vec<PartSummary>>,
1592 ) -> Result<SearchResult, ErrorEnvelope> {
1593 let text = hit_payload(&self.meta.search_text, query);
1594 let role = match self.meta.role.as_str() {
1595 "system" => Role::System,
1596 "user" => Role::User,
1597 "assistant" => Role::Assistant,
1598 "tool" => Role::Tool,
1599 other => {
1600 return Err(map_error(crate::Error::internal(format!(
1601 "stored message has unknown role: {other}"
1602 ))));
1603 }
1604 };
1605 let parts_summary = if matches!(role, Role::User) {
1608 summaries
1609 .get(&(self.meta.session_id.clone(), self.meta.message_id.clone()))
1610 .cloned()
1611 .unwrap_or_default()
1612 } else {
1613 Vec::new()
1614 };
1615 Ok(SearchResult {
1616 message_id: self.meta.message_id.clone(),
1617 role,
1618 timestamp: self.meta.timestamp,
1619 text,
1620 score: self.display_score.clamp(0.0, 1.0),
1621 parts_summary,
1622 })
1623 }
1624 }
1625
1626 fn normalize_fts(hits: Vec<SearchHit>) -> Vec<Candidate> {
1627 let max = hits.iter().map(|hit| hit.score).fold(0.0_f32, f32::max);
1628 hits.into_iter()
1629 .map(|hit| Candidate {
1630 rowid: hit.rowid,
1631 session_id: hit.key.session_id,
1632 message_id: hit.key.message_id,
1633 base_score: if max > 0.0 {
1634 f64::from(hit.score / max)
1635 } else {
1636 0.0
1637 },
1638 })
1639 .collect()
1640 }
1641
1642 fn normalize_vector(hits: Vec<SearchHit>) -> Vec<Candidate> {
1646 hits.into_iter()
1647 .map(|hit| Candidate {
1648 rowid: hit.rowid,
1649 session_id: hit.key.session_id,
1650 message_id: hit.key.message_id,
1651 base_score: 1.0 - f64::from(hit.score),
1652 })
1653 .collect()
1654 }
1655
1656 fn embed_query(embedder: &dyn Embedder, query: &str) -> Result<Vec<f32>, ErrorEnvelope> {
1657 let prompt = format_query(query);
1658 let vectors =
1662 tokio::task::block_in_place(|| embedder.embed(&[prompt])).map_err(|error_value| {
1663 map_error(crate::Error::internal(format!(
1664 "failed to embed query: {error_value}"
1665 )))
1666 })?;
1667 vectors.into_iter().next().ok_or_else(|| {
1668 map_error(crate::Error::internal(
1669 "embedder returned no vector for query",
1670 ))
1671 })
1672 }
1673
1674 fn select_top_hits(
1684 mut candidates: Vec<Candidate>,
1685 min_score: f64,
1686 limit: usize,
1687 ) -> (Vec<Candidate>, usize, usize) {
1688 if min_score > 0.0 {
1694 candidates.retain(|candidate| candidate.base_score >= min_score);
1695 }
1696 let matched_total = candidates.len();
1697 candidates.sort_by(|left, right| {
1698 right
1699 .base_score
1700 .partial_cmp(&left.base_score)
1701 .unwrap_or(std::cmp::Ordering::Equal)
1702 .then_with(|| left.session_id.cmp(&right.session_id))
1703 .then_with(|| left.message_id.cmp(&right.message_id))
1704 });
1705 let (total_sessions, keep) = {
1708 let mut order: Vec<&str> = Vec::new();
1709 let mut seen: std::collections::HashSet<&str> = std::collections::HashSet::new();
1710 for candidate in &candidates {
1711 let root = session_root(&candidate.session_id);
1712 if seen.insert(root) {
1713 order.push(root);
1714 }
1715 }
1716 let total = order.len();
1717 let keep: std::collections::HashSet<String> =
1718 order.into_iter().take(limit).map(str::to_owned).collect();
1719 (total, keep)
1720 };
1721 let selected = candidates
1722 .into_iter()
1723 .filter(|candidate| keep.contains(session_root(&candidate.session_id)))
1724 .collect();
1725 (selected, total_sessions, matched_total)
1726 }
1727
1728 async fn build_sessions(
1729 store: &Store,
1730 scored: &[ScoredHit],
1731 query: &str,
1732 ) -> Result<Vec<SearchSession>, ErrorEnvelope> {
1733 use std::collections::BTreeMap;
1734
1735 struct Acc {
1736 project: String,
1737 source_agent: String,
1738 matched_count: usize,
1739 rank: f64,
1742 matches: Vec<(DateTime<Utc>, SearchResult)>,
1743 }
1744 let mut user_ids_by_session: BTreeMap<String, Vec<String>> = BTreeMap::new();
1748 for hit in scored {
1749 if hit.meta.role == "user" {
1750 user_ids_by_session
1751 .entry(hit.meta.session_id.clone())
1752 .or_default()
1753 .push(hit.meta.message_id.clone());
1754 }
1755 }
1756 let summary_futs = user_ids_by_session
1759 .iter()
1760 .map(|(session_id, message_ids)| async move {
1761 store
1762 .summary_parts_for_messages(session_id, message_ids)
1763 .await
1764 .map_err(map_storage)
1765 });
1766 let mut summaries: HashMap<(String, String), Vec<PartSummary>> = HashMap::new();
1767 for parts_by_message in futures::future::try_join_all(summary_futs).await? {
1768 for (key, parts) in parts_by_message {
1769 summaries.insert(
1770 key,
1771 parts
1772 .iter()
1773 .filter_map(|part| PartSummary::for_kind(&part.kind))
1774 .collect(),
1775 );
1776 }
1777 }
1778
1779 let mut groups: BTreeMap<String, Acc> = BTreeMap::new();
1780 for hit in scored {
1781 let root = session_root(&hit.meta.session_id).to_owned();
1782 let entry = groups.entry(root).or_insert_with(|| Acc {
1783 project: hit.meta.project.clone(),
1784 source_agent: hit.meta.source_agent.clone(),
1785 matched_count: 0,
1786 rank: f64::NEG_INFINITY,
1787 matches: Vec::new(),
1788 });
1789 entry.matched_count += 1;
1790 entry.rank = entry.rank.max(hit.order_score);
1791 entry
1792 .matches
1793 .push((hit.meta.timestamp, hit.to_search_result(query, &summaries)?));
1794 }
1795
1796 let session_ids = groups.keys().cloned().collect::<Vec<_>>();
1797 let counts = store
1798 .session_message_counts(&session_ids)
1799 .await
1800 .map_err(map_storage)?;
1801
1802 let mut result = groups
1807 .into_iter()
1808 .map(|(session_id, mut acc)| {
1809 acc.matches.sort_by(|left, right| {
1810 right
1811 .0
1812 .cmp(&left.0)
1813 .then_with(|| left.1.message_id.cmp(&right.1.message_id))
1814 });
1815 let matches = acc.matches.into_iter().map(|(_, result)| result).collect();
1816 (
1817 acc.rank,
1818 SearchSession {
1819 session_messages_count: counts
1820 .get(&session_id)
1821 .copied()
1822 .unwrap_or_default(),
1823 session_id,
1824 project: acc.project,
1825 source_agent: acc.source_agent,
1826 matched_message_count: acc.matched_count,
1827 matches,
1828 },
1829 )
1830 })
1831 .collect::<Vec<_>>();
1832 result.sort_by(|left, right| {
1833 right
1834 .0
1835 .partial_cmp(&left.0)
1836 .unwrap_or(std::cmp::Ordering::Equal)
1837 .then_with(|| left.1.session_id.cmp(&right.1.session_id))
1838 });
1839 Ok(result.into_iter().map(|(_, session)| session).collect())
1840 }
1841
1842 fn page_sessions(
1843 sessions: Vec<SearchSession>,
1844 matched_total: usize,
1845 total_sessions: usize,
1846 searchable_in_scope: usize,
1847 plan: &SearchPlan,
1848 ) -> Result<SearchResponse, ErrorEnvelope> {
1849 let emitted: Vec<SearchSession> = sessions.into_iter().take(plan.limit).collect();
1856 let has_more = total_sessions > emitted.len();
1857
1858 Ok(SearchResponse {
1859 sessions: emitted,
1860 matched_total,
1861 searchable_in_scope,
1862 has_more,
1863 })
1864 }
1865
1866 fn regex_escape_literal(value: &str) -> String {
1870 let mut out = String::with_capacity(value.len());
1871 for ch in value.chars() {
1872 if matches!(
1873 ch,
1874 '.' | '+' | '*' | '?' | '(' | ')' | '[' | ']' | '{' | '}' | '^' | '$' | '|' | '\\'
1875 ) {
1876 out.push('\\');
1877 }
1878 out.push(ch);
1879 }
1880 out
1881 }
1882
1883 fn build_scope_clauses(filters: &SearchFilters) -> Result<Vec<Predicate>, ErrorEnvelope> {
1887 let mut clauses = Vec::new();
1888
1889 match &filters.project {
1890 None => {}
1891 Some(ProjectFilter::Contains(value)) => {
1892 clauses.push(Predicate::LikeContains("project", value.clone()));
1893 }
1894 Some(ProjectFilter::Regex(pattern)) => {
1895 clauses.push(Predicate::Regex("project", pattern.clone()));
1896 }
1897 }
1898
1899 if let Some(session_id) = &filters.session_id {
1900 clauses.push(Predicate::Eq("session_id", session_id.clone().into()));
1901 }
1902 if let Some(source_agent) = &filters.source_agent {
1903 clauses.push(Predicate::Regex(
1910 "source_agent",
1911 format!("^{}(/|$)", regex_escape_literal(source_agent)),
1912 ));
1913 }
1914 if let Some(from_date) = &filters.from_date {
1915 clauses.push(Predicate::Gte(
1916 "timestamp",
1917 ScalarValue::Raw(date_bound(from_date, "filters.from_date", false)?),
1918 ));
1919 }
1920 if let Some(to_date) = &filters.to_date {
1921 clauses.push(Predicate::Lte(
1922 "timestamp",
1923 ScalarValue::Raw(date_bound(to_date, "filters.to_date", true)?),
1924 ));
1925 }
1926
1927 Ok(clauses)
1928 }
1929
1930 pub fn build_scope_filter(filters: &SearchFilters) -> Result<Predicate, ErrorEnvelope> {
1934 Ok(Predicate::And(build_scope_clauses(filters)?))
1935 }
1936
1937 pub fn default_excludes_subagents(filters: &SearchFilters) -> bool {
1948 filters.session_id.is_none()
1949 && !filters
1950 .source_agent
1951 .as_deref()
1952 .is_some_and(|agent| agent.contains('/'))
1953 }
1954
1955 fn date_bound(date: &str, field: &str, end_of_day: bool) -> Result<String, ErrorEnvelope> {
1958 NaiveDate::parse_from_str(date, "%Y-%m-%d").map_err(|_| {
1959 map_error(crate::Error::validation_field(
1960 format!("{field} must be in YYYY-MM-DD format; got {date}"),
1961 field,
1962 Some(serde_json::json!(date)),
1963 Some("YYYY-MM-DD".to_owned()),
1964 ))
1965 })?;
1966 let time = if end_of_day { "23:59:59" } else { "00:00:00" };
1967 Ok(format!("timestamp '{date} {time}'"))
1968 }
1969
1970 fn empty_response(searchable_in_scope: usize) -> SearchResponse {
1971 SearchResponse {
1972 sessions: Vec::new(),
1973 matched_total: 0,
1974 searchable_in_scope,
1975 has_more: false,
1976 }
1977 }
1978
1979 #[cfg(test)]
1980 mod grouping_helpers_tests {
1981 #![allow(clippy::expect_used, clippy::unwrap_used)]
1982
1983 use super::*;
1984
1985 #[test]
1986 fn session_root_strips_agent_suffix_for_claude_code_subagents() {
1987 assert_eq!(
1988 session_root("94a50f23-1234-5678-9abc-def012345678"),
1989 "94a50f23-1234-5678-9abc-def012345678",
1990 );
1991 assert_eq!(
1992 session_root("94a50f23-1234-5678-9abc-def012345678/agent-abc123"),
1993 "94a50f23-1234-5678-9abc-def012345678",
1994 );
1995 assert_eq!(session_root("root/a/b"), "root");
1997 }
1998
1999 #[test]
2000 fn retain_non_subagents_drops_slash_ids_only_when_excluding() {
2001 let hit = |sid: &str| SearchHit {
2002 rowid: None,
2003 key: crate::sessions::MessageKey {
2004 session_id: sid.to_owned(),
2005 message_id: "m1".to_owned(),
2006 },
2007 score: 1.0_f32,
2008 };
2009 let base = vec![hit("root-a"), hit("root-b/agent-x"), hit("root-c")];
2010
2011 let mut excluded = base.clone();
2012 retain_non_subagents(&mut excluded, true);
2013 let ids: Vec<&str> = excluded
2014 .iter()
2015 .map(|hit| hit.key.session_id.as_str())
2016 .collect();
2017 assert_eq!(ids, ["root-a", "root-c"]);
2018
2019 let mut kept = base;
2020 retain_non_subagents(&mut kept, false);
2021 assert_eq!(kept.len(), 3);
2022 }
2023 }
2024}
2025
2026pub use search_handler::{
2027 SearchMode, SearchPlan, build_scope_filter, default_excludes_subagents, explain_search_plan,
2028 hit_payload, plan_search, pond_search,
2029};
2030
2031#[cfg(test)]
2032mod tests {
2033 #![allow(clippy::expect_used, clippy::unwrap_used)]
2034
2035 use super::*;
2036 use crate::wire::{ProjectFilter, SearchFilters, SearchRequest};
2037 use chrono::Utc;
2038
2039 fn search_request(query: &str) -> SearchRequest {
2040 SearchRequest {
2041 protocol_version: crate::PROTOCOL_VERSION,
2042 namespace: Some("local".to_owned()),
2043 query: query.to_owned(),
2044 mode: crate::wire::SearchModeWire::Vector,
2045 sort_by: crate::wire::SortBy::Relevance,
2046 filters: SearchFilters::default(),
2047 limit: 20,
2048 }
2049 }
2050
2051 #[test]
2052 fn hit_payload_returns_short_text_in_full() {
2053 let short = "a short message body";
2054 let text = hit_payload(short, "message");
2055 assert_eq!(text, short, "small text is returned as-is");
2056 }
2057
2058 #[test]
2059 fn hit_payload_windows_long_text_around_the_query_term() {
2060 let body = format!("{}NEEDLE{}", "a".repeat(2000), "b".repeat(394));
2062 let text = hit_payload(&body, "needle");
2063 assert!(
2064 text.contains("NEEDLE"),
2065 "text is the match-windowed snippet: {text}"
2066 );
2067 assert!(
2070 text.chars().count() <= 600 + 64,
2071 "snippet window is bounded by HIT_SNIPPET_CHARS plus markers: {}",
2072 text.chars().count()
2073 );
2074 }
2075
2076 #[test]
2077 fn hit_payload_snippet_survives_case_folding_that_changes_byte_length() {
2078 let body = format!("İÉÉÉ{}", "a".repeat(2100));
2082 let text = hit_payload(&body, "ééé");
2083 assert!(
2084 text.contains("ÉÉÉ"),
2085 "snippet windows on the matched term: {text}"
2086 );
2087 }
2088
2089 #[tokio::test]
2090 async fn restore_lineage_rejects_a_graph_nesting_deeper_than_one_level() {
2091 use crate::adapter::Extracted;
2092 use crate::sessions::Store;
2093 use crate::wire::{ProviderOptions, Session};
2094 use tempfile::TempDir;
2095
2096 let session = |id: &str, parent: Option<&str>| Session {
2097 id: id.to_owned(),
2098 parent_session_id: parent.map(str::to_owned),
2099 parent_message_id: None,
2100 source_agent: "claude-code".to_owned(),
2101 created_at: Utc::now(),
2102 project: Extracted::from_test_value("/tmp/pond".to_owned()),
2103 options: ProviderOptions::new(),
2104 };
2105
2106 let dir = TempDir::new().unwrap();
2107 let store = Store::open_local(dir.path()).await.unwrap();
2108 store
2110 .upsert_sessions(&[
2111 session("a", None),
2112 session("b", Some("a")),
2113 session("c", Some("b")),
2114 ])
2115 .await
2116 .unwrap();
2117
2118 let err = restore_lineage(&store, "a").await.unwrap_err();
2120 assert!(
2121 err.to_string().contains("one subagent level"),
2122 "expected the deeper-graph error, got: {err}"
2123 );
2124
2125 let lineage = restore_lineage(&store, "b").await.unwrap();
2127 let ids: Vec<&str> = lineage.iter().map(|s| s.session.id.as_str()).collect();
2128 assert_eq!(ids, ["b", "c"]);
2129 }
2130
2131 #[test]
2132 fn build_scope_filter_pushes_down_each_predicate_and_handles_empty() {
2133 let filters = SearchFilters {
2134 project: Some(ProjectFilter::Contains("/Users/me/pond".to_owned())),
2135 session_id: Some("01HXY".to_owned()),
2136 source_agent: None,
2137 from_date: Some("2026-01-01".to_owned()),
2138 to_date: Some("2026-05-01".to_owned()),
2139 min_score: 0.0,
2140 };
2141 let sql = build_scope_filter(&filters).unwrap().to_lance();
2142 assert!(sql.contains("project LIKE '%/Users/me/pond%'"));
2143 assert!(sql.contains("session_id = '01HXY'"));
2144 assert!(sql.contains("timestamp >="));
2145 assert!(sql.contains("timestamp <="));
2146 assert!(!sql.contains("source_agent"));
2148
2149 assert_eq!(
2152 build_scope_filter(&SearchFilters::default())
2153 .unwrap()
2154 .to_lance(),
2155 "",
2156 );
2157 }
2158
2159 #[test]
2160 fn build_scope_filter_rejects_bad_date() {
2161 let bad_date = SearchFilters {
2162 from_date: Some("01-01-2026".to_owned()),
2163 ..SearchFilters::default()
2164 };
2165 assert!(build_scope_filter(&bad_date).is_err());
2166 }
2167
2168 #[test]
2169 fn build_scope_filter_escapes_like_wildcards() {
2170 let filters = SearchFilters {
2171 project: Some(ProjectFilter::Contains("/Users/me/my_project".to_owned())),
2172 ..SearchFilters::default()
2173 };
2174 let sql = build_scope_filter(&filters).unwrap().to_lance();
2175 assert!(
2178 sql.contains(r"my\_project"),
2179 "underscore must be escaped: {sql}"
2180 );
2181 assert!(
2182 sql.contains(r"ESCAPE '\'"),
2183 "predicate must declare the escape char: {sql}"
2184 );
2185 }
2186
2187 #[test]
2188 fn source_agent_filter_is_exact_or_subpath_never_a_sibling_prefix() {
2189 let filters = SearchFilters {
2190 source_agent: Some("openclaw".to_owned()),
2191 ..SearchFilters::default()
2192 };
2193 let sql = build_scope_filter(&filters).unwrap().to_lance();
2194 assert_eq!(
2198 sql, "regexp_like(source_agent, '^openclaw(/|$)')",
2199 "anchored exact-or-subpath: {sql}"
2200 );
2201 assert!(!sql.contains("LIKE"), "no LIKE form: {sql}");
2203
2204 let sub = SearchFilters {
2206 source_agent: Some("openclaw/subagent".to_owned()),
2207 ..SearchFilters::default()
2208 };
2209 let sql = build_scope_filter(&sub).unwrap().to_lance();
2210 assert_eq!(
2211 sql, "regexp_like(source_agent, '^openclaw/subagent(/|$)')",
2212 "{sql}"
2213 );
2214
2215 let meta = SearchFilters {
2217 source_agent: Some("a.b".to_owned()),
2218 ..SearchFilters::default()
2219 };
2220 let sql = build_scope_filter(&meta).unwrap().to_lance();
2221 assert_eq!(sql, "regexp_like(source_agent, '^a\\.b(/|$)')", "{sql}");
2222 }
2223
2224 #[test]
2225 fn source_agent_subpath_disables_exclusion_but_root_value_keeps_it() {
2226 assert!(default_excludes_subagents(&SearchFilters::default()));
2228 assert!(!default_excludes_subagents(&SearchFilters {
2231 source_agent: Some("openclaw/subagent".to_owned()),
2232 ..SearchFilters::default()
2233 }));
2234 assert!(default_excludes_subagents(&SearchFilters {
2239 source_agent: Some("openclaw".to_owned()),
2240 ..SearchFilters::default()
2241 }));
2242 }
2243
2244 #[test]
2245 fn plan_search_shapes_request_for_each_planning_input() {
2246 let mut request = search_request(" vector memory ");
2247 request.limit = 500;
2248 request.filters.min_score = 0.42;
2249 let plan = plan_search(request).unwrap();
2251 assert_eq!(plan.mode, SearchMode::Vector);
2252 assert_eq!(plan.query, "vector memory");
2253 assert_eq!(plan.limit, 200);
2254 assert!(plan.exclude_subagents);
2257 assert_eq!(plan.pool, 1500);
2258 assert_eq!(plan.vector_pool, 3000);
2259 assert_eq!(plan.min_score, 0.42);
2260
2261 let mut request = search_request("tiny pool");
2264 request.mode = crate::wire::SearchModeWire::Fts;
2265 request.limit = 1;
2266 let plan = plan_search(request).unwrap();
2267 assert_eq!(plan.mode, SearchMode::Fts);
2268 assert_eq!(plan.limit, 1);
2269 assert_eq!(plan.pool, 75);
2270 assert_eq!(plan.vector_pool, 150);
2271
2272 let mut request = search_request("filtered");
2276 request.filters.project = Some(ProjectFilter::Contains("/Users/me/pond".to_owned()));
2277 request.filters.session_id = Some("01HXY".to_owned());
2278 let plan = plan_search(request).unwrap();
2279 assert!(!plan.exclude_subagents);
2280 assert_eq!(plan.pool, 100);
2281 assert_eq!(plan.vector_pool, 200);
2282 let sql = plan.filter.to_lance();
2283 assert!(sql.contains("project LIKE"));
2284 assert!(sql.contains("session_id = '01HXY'"));
2285 }
2286
2287 #[test]
2288 fn plan_search_rejects_invalid_composition_before_execution() {
2289 let mut blank = search_request(" ");
2290 let error = plan_search(blank.clone()).unwrap_err().error;
2291 assert_eq!(error.code, crate::wire::ErrorCode::ValidationFailed);
2292 assert_eq!(error.details["field"], "query");
2293
2294 blank.query = "valid".to_owned();
2295 blank.limit = 0;
2296 let error = plan_search(blank.clone()).unwrap_err().error;
2297 assert_eq!(error.details["field"], "limit");
2298
2299 blank.limit = 1;
2300 blank.namespace = Some("remote".to_owned());
2301 let error = plan_search(blank).unwrap_err().error;
2302 assert_eq!(error.code, crate::wire::ErrorCode::NamespaceUnknown);
2303 assert_eq!(error.details["namespace"], "remote");
2304 }
2305}
2306
2307#[cfg(test)]
2308mod get_tests {
2309 #![allow(clippy::expect_used, clippy::unwrap_used)]
2310
2311 use crate::sessions::Store;
2312 use crate::wire::{
2313 GetEnvelope, GetResult, GetSessionRequest, IngestEnvelope, IngestRequest, Message, Part,
2314 PartKind, Provenance, ProviderOptions, Session, SessionFrom,
2315 };
2316 use chrono::{TimeZone, Utc};
2317 use tempfile::TempDir;
2318
2319 fn text_part(session_id: &str, message_id: &str, part_id: &str, body: &str) -> Part {
2320 Part {
2321 session_id: session_id.to_owned(),
2322 id: part_id.to_owned(),
2323 message_id: message_id.to_owned(),
2324 ordinal: 0,
2325 provenance: Provenance::Conversational,
2326 options: ProviderOptions::new(),
2327 kind: PartKind::Text {
2328 text: crate::adapter::extract_str(&serde_json::json!({ "x": body }), "x"),
2329 },
2330 }
2331 }
2332
2333 async fn ingest(store: &Store, events: Vec<super::IngestEvent>) {
2334 let envelope = super::pond_ingest(
2335 store,
2336 IngestRequest {
2337 protocol_version: crate::PROTOCOL_VERSION,
2338 namespace: Some("local".to_owned()),
2339 events,
2340 },
2341 )
2342 .await;
2343 assert!(
2344 matches!(envelope, IngestEnvelope::Success(_)),
2345 "ingest should succeed: {envelope:?}"
2346 );
2347 }
2348
2349 fn session(id: &str, project_marker: &str) -> Session {
2350 Session {
2351 id: id.to_owned(),
2352 parent_session_id: None,
2353 parent_message_id: None,
2354 source_agent: "claude-code".to_owned(),
2355 created_at: Utc.with_ymd_and_hms(2026, 1, 1, 0, 0, 0).unwrap(),
2356 project: crate::adapter::extract_str(&serde_json::json!({ "x": project_marker }), "x")
2357 .unwrap(),
2358 options: ProviderOptions::new(),
2359 }
2360 }
2361
2362 #[tokio::test(flavor = "multi_thread")]
2367 async fn pond_get_paginates_session_via_after_message_id() -> anyhow::Result<()> {
2368 let temp = TempDir::new()?;
2369 let store = Store::open_local(temp.path()).await?;
2370 let session_id = "paginate-session";
2371
2372 let huge_text = "abc def ghi jkl ".repeat(5000);
2375 let mut events = vec![super::IngestEvent::Session(session(
2376 session_id,
2377 "pond-paginate",
2378 ))];
2379 for index in 0..3 {
2380 let message_id = format!("paginate-msg-{index}");
2381 events.push(super::IngestEvent::Message(Message::User {
2382 id: message_id.clone(),
2383 session_id: session_id.to_owned(),
2384 timestamp: Utc
2385 .with_ymd_and_hms(2026, 1, 1, 0, index as u32 + 1, 0)
2386 .unwrap(),
2387 options: ProviderOptions::new(),
2388 }));
2389 events.push(super::IngestEvent::Part(text_part(
2390 session_id,
2391 &message_id,
2392 &format!("paginate-part-{index}"),
2393 &huge_text,
2394 )));
2395 }
2396 ingest(&store, events).await;
2397
2398 let page_request = |after: Option<String>| GetSessionRequest {
2399 protocol_version: crate::PROTOCOL_VERSION,
2400 namespace: Some("local".to_owned()),
2401 id: session_id.to_owned(),
2402 limit: 1000,
2403 from: SessionFrom::Start,
2404 after_message_id: after,
2405 before_message_id: None,
2406 };
2407
2408 let GetEnvelope::Success(first) = super::pond_get_session(&store, page_request(None)).await
2409 else {
2410 panic!("first page must succeed");
2411 };
2412 let GetResult::Session {
2413 messages: first_messages,
2414 after_remaining,
2415 ..
2416 } = first.result
2417 else {
2418 panic!("first page is session-scope");
2419 };
2420 assert!(after_remaining > 0, "long corpus must trip the page budget");
2421 let after = first_messages.last().expect("non-empty page").id.clone();
2422
2423 let GetEnvelope::Success(second) =
2424 super::pond_get_session(&store, page_request(Some(after))).await
2425 else {
2426 panic!("continuation page must succeed");
2427 };
2428 let GetResult::Session {
2429 messages: second_messages,
2430 ..
2431 } = second.result
2432 else {
2433 panic!("continuation is session-scope");
2434 };
2435 assert!(
2436 !second_messages.is_empty(),
2437 "continuation surfaces the rest"
2438 );
2439 let first_ids: std::collections::HashSet<&str> =
2440 first_messages.iter().map(|m| m.id.as_str()).collect();
2441 assert!(
2442 second_messages
2443 .iter()
2444 .all(|m| !first_ids.contains(m.id.as_str())),
2445 "after_message_id pages must be disjoint"
2446 );
2447 Ok(())
2448 }
2449
2450 #[tokio::test(flavor = "multi_thread")]
2455 async fn pond_get_session_from_end_returns_the_recent_tail() -> anyhow::Result<()> {
2456 let temp = TempDir::new()?;
2457 let store = Store::open_local(temp.path()).await?;
2458 let session_id = "tail-session";
2459
2460 let mut events = vec![super::IngestEvent::Session(session(
2461 session_id,
2462 "pond-tail",
2463 ))];
2464 for index in 0..5u32 {
2465 let message_id = format!("tail-msg-{index}");
2466 events.push(super::IngestEvent::Message(Message::User {
2467 id: message_id.clone(),
2468 session_id: session_id.to_owned(),
2469 timestamp: Utc.with_ymd_and_hms(2026, 1, 1, 0, index + 1, 0).unwrap(),
2470 options: ProviderOptions::new(),
2471 }));
2472 events.push(super::IngestEvent::Part(text_part(
2473 session_id,
2474 &message_id,
2475 &format!("tail-part-{index}"),
2476 &format!("message {index}"),
2477 )));
2478 }
2479 ingest(&store, events).await;
2480
2481 let request = |from: SessionFrom| GetSessionRequest {
2482 protocol_version: crate::PROTOCOL_VERSION,
2483 namespace: Some("local".to_owned()),
2484 id: session_id.to_owned(),
2485 limit: 2,
2486 from,
2487 after_message_id: None,
2488 before_message_id: None,
2489 };
2490 let page = |envelope: GetEnvelope| -> (Vec<String>, usize, usize) {
2491 let GetEnvelope::Success(response) = envelope else {
2492 panic!("get must succeed");
2493 };
2494 let GetResult::Session {
2495 messages,
2496 before_remaining,
2497 after_remaining,
2498 ..
2499 } = response.result
2500 else {
2501 panic!("session-scope result expected");
2502 };
2503 (
2504 messages.into_iter().map(|m| m.id).collect(),
2505 before_remaining,
2506 after_remaining,
2507 )
2508 };
2509
2510 let (end_ids, end_before, _) =
2511 page(super::pond_get_session(&store, request(SessionFrom::End)).await);
2512 assert_eq!(
2513 end_ids,
2514 ["tail-msg-3", "tail-msg-4"],
2515 "end returns the newest two, chronologically"
2516 );
2517 assert_eq!(end_before, 3, "three older messages precede the tail");
2518
2519 let (start_ids, _, start_after) =
2520 page(super::pond_get_session(&store, request(SessionFrom::Start)).await);
2521 assert_eq!(
2522 start_ids,
2523 ["tail-msg-0", "tail-msg-1"],
2524 "start returns the oldest two"
2525 );
2526 assert_eq!(start_after, 3, "three newer messages follow the head");
2527 Ok(())
2528 }
2529
2530 #[tokio::test(flavor = "multi_thread")]
2535 async fn get_session_resolves_message_id_and_get_message_rejects_session_id()
2536 -> anyhow::Result<()> {
2537 let temp = TempDir::new()?;
2538 let store = Store::open_local(temp.path()).await?;
2539 let session_id = "resolve-session";
2540
2541 let mut events = vec![super::IngestEvent::Session(session(
2542 session_id,
2543 "pond-resolve",
2544 ))];
2545 for index in 0..4u32 {
2546 let message_id = format!("resolve-msg-{index}");
2547 events.push(super::IngestEvent::Message(Message::User {
2548 id: message_id.clone(),
2549 session_id: session_id.to_owned(),
2550 timestamp: Utc.with_ymd_and_hms(2026, 1, 1, 0, index + 1, 0).unwrap(),
2551 options: ProviderOptions::new(),
2552 }));
2553 events.push(super::IngestEvent::Part(text_part(
2554 session_id,
2555 &message_id,
2556 &format!("resolve-part-{index}"),
2557 &format!("message {index}"),
2558 )));
2559 }
2560 ingest(&store, events).await;
2561
2562 let by_message = GetSessionRequest {
2563 protocol_version: crate::PROTOCOL_VERSION,
2564 namespace: Some("local".to_owned()),
2565 id: "resolve-msg-2".to_owned(),
2566 limit: 20,
2567 from: SessionFrom::Start,
2568 after_message_id: None,
2569 before_message_id: None,
2570 };
2571 let GetEnvelope::Success(response) = super::pond_get_session(&store, by_message).await
2572 else {
2573 panic!("message id must resolve to its parent session");
2574 };
2575 assert_eq!(response.session.id, session_id);
2576 let GetResult::Session {
2577 messages,
2578 before_remaining,
2579 resolved_from_message_id,
2580 ..
2581 } = response.result
2582 else {
2583 panic!("session-scope result expected");
2584 };
2585 assert_eq!(resolved_from_message_id.as_deref(), Some("resolve-msg-2"));
2586 assert_eq!(
2587 messages.first().map(|m| m.id.as_str()),
2588 Some("resolve-msg-2"),
2589 "the page is anchored at the resolved message (inclusive)"
2590 );
2591 assert_eq!(before_remaining, 2, "the two earlier messages page up");
2592
2593 let by_session = crate::wire::GetMessageRequest {
2594 protocol_version: crate::PROTOCOL_VERSION,
2595 namespace: Some("local".to_owned()),
2596 id: session_id.to_owned(),
2597 context_before: 3,
2598 context_after: 3,
2599 };
2600 let GetEnvelope::Error(error) = super::pond_get_message(&store, by_session).await else {
2601 panic!("a session id must not resolve to one message");
2602 };
2603 assert!(
2604 error.error.message.contains("pond_get_session"),
2605 "the rejection teaches the session read: {}",
2606 error.error.message
2607 );
2608 Ok(())
2609 }
2610}