1use super::*;
2
3#[async_trait]
4impl SessionImporter for SqliteSessionStore {
5 async fn import(&self, request: ImportSession) -> StoreResult<ImportResult> {
6 request.validate()?;
7 let mut conn = self.lock();
8 let tx = write_transaction(&mut conn)?;
9 if let Some(existing) = read_import(&tx, &request.source_id)? {
10 if existing.source_digest != request.source_digest {
11 return Err(StoreError::Conflict(format!(
12 "import source '{}' changed digest",
13 request.source_id
14 )));
15 }
16 return Ok(existing);
17 }
18
19 let meta = crate::memory_helpers::meta_for_create(request.session);
20 if tx
21 .query_row(
22 "SELECT 1 FROM sessions WHERE id = ?1",
23 params![meta.id],
24 |_| Ok(()),
25 )
26 .optional()
27 .map_err(map_sql)?
28 .is_some()
29 {
30 return Err(StoreError::AlreadyExists(meta.id));
31 }
32 insert_session(&tx, &meta, 1)?;
33 let event_count = request.events.len();
34 for event in request.events {
35 append_in_tx(&tx, &self.hooks, &meta.id, event)?;
36 }
37 tx.execute(
38 "INSERT INTO session_imports (source_id, source_digest, session_id, event_count)
39 VALUES (?1, ?2, ?3, ?4)",
40 params![
41 request.source_id,
42 request.source_digest,
43 meta.id,
44 event_count as i64
45 ],
46 )
47 .map_err(map_sql)?;
48 tx.commit().map_err(map_sql)?;
49 Ok(ImportResult {
50 source_id: request.source_id,
51 source_digest: request.source_digest,
52 session_id: meta.id,
53 event_count,
54 imported: true,
55 })
56 }
57}
58
59#[async_trait]
60impl SessionStore for SqliteSessionStore {
61 fn hooks(&self) -> &StoreHooks {
62 &self.hooks
63 }
64
65 async fn create(&self, request: CreateSession) -> StoreResult<SessionMeta> {
66 let meta = crate::memory_helpers::meta_for_create(request);
67 let mut conn = self.lock();
68 let tx = write_transaction(&mut conn)?;
69 insert_session(&tx, &meta, 1)?;
70 tx.commit().map_err(map_sql)?;
71 Ok(meta)
72 }
73
74 async fn describe(&self, session_id: &str) -> StoreResult<SessionMeta> {
75 let conn = self.lock();
76 let (meta, _) = read_session_meta(&conn, session_id)?;
77 Ok(meta)
78 }
79
80 async fn update(&self, session_id: &str, request: UpdateSession) -> StoreResult<SessionMeta> {
81 let mut conn = self.lock();
82 let tx = write_transaction(&mut conn)?;
83 let (updated_at_ms, updated_at) = now_ms_and_rfc3339();
84 let (current, _) = read_session_meta(&tx, session_id)?;
89 let (title, title_pinned) = crate::memory_helpers::resolve_title_update(
90 current.title,
91 current.title_pinned,
92 request.title,
93 request.title_pinned,
94 );
95 let changed = tx
96 .execute(
97 "UPDATE sessions SET
98 title = ?1,
99 title_pinned = ?13,
100 cwd = COALESCE(?2, cwd),
101 model = COALESCE(?3, model),
102 parent_session_id = COALESCE(?4, parent_session_id),
103 session_type = COALESCE(?5, session_type),
104 project_scope = COALESCE(?6, project_scope),
105 usage_input = COALESCE(?7, usage_input),
106 usage_output = COALESCE(?8, usage_output),
107 usage_cost_usd_micros = COALESCE(?9, usage_cost_usd_micros),
108 updated_at_ms = ?10,
109 updated_at = ?11
110 WHERE id = ?12",
111 params![
112 title,
113 request.cwd,
114 request.model,
115 request.parent_session_id,
116 request.session_type.map(session_type_to_sql),
117 request.project_scope,
118 request.usage_input.map(|value| value as i64),
119 request.usage_output.map(|value| value as i64),
120 request.usage_cost_usd_micros.map(|value| value as i64),
121 updated_at_ms,
122 updated_at,
123 session_id,
124 title_pinned,
125 ],
126 )
127 .map_err(map_sql)?;
128 if changed == 0 {
129 return Err(StoreError::NotFound(session_id.to_string()));
130 }
131 let (meta, _) = read_session_meta(&tx, session_id)?;
132 let mut events = load_all_events(&tx, session_id)?;
133 redact_stored_events(&self.hooks, &mut events)?;
134 tx.execute(
135 "DELETE FROM session_events_fts WHERE session_id = ?1",
136 params![session_id],
137 )
138 .map_err(map_sql)?;
139 tx.execute(
140 "DELETE FROM session_event_vectors WHERE session_id = ?1",
141 params![session_id],
142 )
143 .map_err(map_sql)?;
144 for event in &events {
145 insert_search_rows(&tx, &self.hooks, &meta, event)?;
146 }
147 tx.commit().map_err(map_sql)?;
148 drop(conn);
152 self.hooks.notify_session_changed(&meta);
153 Ok(meta)
154 }
155
156 async fn list(&self, filter: ListFilter) -> StoreResult<Vec<SessionMeta>> {
157 let conn = self.lock();
158 let limit = filter.limit.unwrap_or(MAX_READ_BATCH).min(MAX_READ_BATCH) as i64;
159 let sort_column = match filter.sort_by {
160 ListSortKey::CreatedAt => "created_at_ms",
161 ListSortKey::UpdatedAt => "updated_at_ms",
162 };
163 let cursor_anchor: Option<(i64, String)> = filter
167 .cursor
168 .as_ref()
169 .map(|id| {
170 conn.query_row(
171 &format!("SELECT {sort_column}, id FROM sessions WHERE id = ?1"),
172 params![id],
173 |row| Ok((row.get::<_, i64>(0)?, row.get::<_, String>(1)?)),
174 )
175 .optional()
176 .map_err(map_sql)
177 })
178 .transpose()?
179 .flatten();
180
181 let mut sql = String::from("SELECT s.id FROM sessions s");
182 if filter.tag.is_some() {
183 sql.push_str(" INNER JOIN session_tags t ON t.session_id = s.id AND t.tag = :tag");
184 }
185 sql.push_str(" WHERE 1=1");
186 let mut args: Vec<(&'static str, rusqlite::types::Value)> = Vec::new();
187 if let Some(tag) = filter.tag {
188 args.push((":tag", tag.into()));
189 }
190 if let Some(tenant) = filter.tenant_id {
191 sql.push_str(" AND s.tenant_id = :tenant");
192 args.push((":tenant", tenant.into()));
193 }
194 if let Some(persona) = filter.persona {
195 sql.push_str(" AND s.persona = :persona");
196 args.push((":persona", persona.into()));
197 }
198 if let Some(status) = filter.status {
199 sql.push_str(" AND s.status = :status");
200 args.push((":status", status_to_sql(status).to_string().into()));
201 }
202 if let Some(parent_session_id) = filter.parent_session_id {
203 sql.push_str(" AND s.parent_session_id = :parent_session_id");
204 args.push((":parent_session_id", parent_session_id.into()));
205 }
206 if let Some(session_type) = filter.session_type {
207 sql.push_str(" AND s.session_type = :session_type");
208 args.push((
209 ":session_type",
210 session_type_to_sql(session_type).to_string().into(),
211 ));
212 }
213 if let Some(project_scope) = filter.project_scope {
214 sql.push_str(" AND s.project_scope = :project_scope");
215 args.push((":project_scope", project_scope.into()));
216 }
217 if let Some(after) = filter.created_after_ms {
218 sql.push_str(" AND s.created_at_ms >= :after");
219 args.push((":after", after.into()));
220 }
221 if let Some(before) = filter.created_before_ms {
222 sql.push_str(" AND s.created_at_ms <= :before");
223 args.push((":before", before.into()));
224 }
225 if let Some((anchor_ms, anchor_id)) = cursor_anchor {
226 let comparison = match filter.order {
227 ListOrder::Ascending => ">",
228 ListOrder::Descending => "<",
229 };
230 sql.push_str(&format!(
231 " AND (s.{sort_column} {comparison} :cursor_ms OR (s.{sort_column} = :cursor_ms AND s.id > :cursor_id))"
232 ));
233 args.push((":cursor_ms", anchor_ms.into()));
234 args.push((":cursor_id", anchor_id.into()));
235 }
236 let direction = match filter.order {
237 ListOrder::Ascending => "ASC",
238 ListOrder::Descending => "DESC",
239 };
240 sql.push_str(&format!(
241 " ORDER BY s.{sort_column} {direction}, s.id ASC LIMIT :limit"
242 ));
243 args.push((":limit", limit.into()));
244
245 let named_args: Vec<(&str, &dyn rusqlite::ToSql)> = args
246 .iter()
247 .map(|(name, value)| (*name, value as &dyn rusqlite::ToSql))
248 .collect();
249 let mut stmt = conn.prepare(&sql).map_err(map_sql)?;
250 let ids: Vec<String> = stmt
251 .query_map(named_args.as_slice(), |row| row.get(0))
252 .map_err(map_sql)?
253 .collect::<Result<_, _>>()
254 .map_err(map_sql)?;
255 let mut metas = Vec::with_capacity(ids.len());
256 for id in ids {
257 let (meta, _) = read_session_meta(&conn, &id)?;
258 metas.push(meta);
259 }
260 Ok(metas)
261 }
262
263 async fn append(&self, session_id: &str, event: AppendEvent) -> StoreResult<StoredEvent> {
264 let mut conn = self.lock();
265 let tx = write_transaction(&mut conn)?;
266 let stored = append_in_tx(&tx, &self.hooks, session_id, event)?;
267 tx.commit().map_err(map_sql)?;
268 Ok(stored)
269 }
270
271 async fn read(&self, session_id: &str, range: ReadRange) -> StoreResult<EventPage> {
272 let conn = self.lock();
273 let from = range.from_event_id.unwrap_or(1) as i64;
274 let to = range
278 .to_event_id
279 .map(|value| value as i64)
280 .unwrap_or(i64::MAX);
281 let limit = range.limit.unwrap_or(MAX_READ_BATCH).min(MAX_READ_BATCH) as i64;
282 let mut stmt = conn
283 .prepare(
284 "SELECT session_id, event_id, tenant_id, parent_event_id, actor, kind,
285 custom_kind, payload_json, tags_json, headers_json, ts_ms, ts,
286 record_hash, prev_hash, signature_json
287 FROM session_events
288 WHERE session_id = ?1 AND event_id >= ?2 AND event_id <= ?3
289 ORDER BY event_id ASC LIMIT ?4",
290 )
291 .map_err(map_sql)?;
292 let rows = stmt
293 .query_map(params![session_id, from, to, limit], read_event)
294 .map_err(map_sql)?;
295 let mut events = Vec::new();
296 for row in rows {
297 events.push(row.map_err(map_sql)?);
298 }
299 redact_stored_events(&self.hooks, &mut events)?;
300 let next_cursor = if events.len() as i64 == limit {
301 events.last().map(|tail| tail.event_id + 1)
302 } else {
303 None
304 };
305 Ok(EventPage {
306 events,
307 next_cursor,
308 })
309 }
310
311 async fn fork(
312 &self,
313 session_id: &str,
314 at_event_id: EventId,
315 child_id: Option<SessionId>,
316 ) -> StoreResult<ForkResult> {
317 let mut conn = self.lock();
318 let tx = write_transaction(&mut conn)?;
319 let (parent_meta, _) = read_session_meta(&tx, session_id)?;
320 let parent_events = load_all_events(&tx, session_id)?;
321 if !parent_events
322 .iter()
323 .any(|event| event.event_id == at_event_id)
324 {
325 return Err(StoreError::InvalidInput(format!(
326 "event {at_event_id} not found in session '{session_id}'"
327 )));
328 }
329 let new_id = child_id.unwrap_or_else(|| Uuid::now_v7().to_string());
330 let exists: bool = tx
331 .query_row(
332 "SELECT 1 FROM sessions WHERE id = ?1",
333 params![new_id],
334 |_| Ok(true),
335 )
336 .optional()
337 .map_err(map_sql)?
338 .unwrap_or(false);
339 if exists {
340 return Err(StoreError::AlreadyExists(new_id));
341 }
342 let (ms, text) = now_ms_and_rfc3339();
343 let mut child_meta = parent_meta.clone();
344 child_meta.id = new_id.clone();
345 child_meta.parent_session_id = Some(parent_meta.id);
346 child_meta.created_at_ms = ms;
347 child_meta.created_at = text.clone();
348 child_meta.updated_at_ms = ms;
349 child_meta.updated_at = text;
350 child_meta.status = SessionStatus::Open;
351 child_meta.closed_at_ms = None;
352 child_meta.closed_at = None;
353 child_meta.soft_deleted_at_ms = None;
354 let mut inherited: Vec<StoredEvent> = parent_events
355 .into_iter()
356 .filter(|event| event.event_id <= at_event_id)
357 .collect();
358 prepare_stored_events_for_persistence(&self.hooks, &mut inherited)?;
359 let copied = re_anchor_events(&inherited, &new_id);
360 child_meta.event_count = copied.len();
361 child_meta.last_event_id = copied.last().map(|tail| tail.event_id);
362 child_meta.chain_root_hash = Some(chain_root_hash(&copied));
363 let next_event_id = copied.last().map(|tail| tail.event_id + 1).unwrap_or(1);
364 insert_session(&tx, &child_meta, next_event_id)?;
365 for event in &copied {
366 insert_event(&tx, event)?;
367 insert_search_rows(&tx, &self.hooks, &child_meta, event)?;
368 }
369 tx.commit().map_err(map_sql)?;
370 Ok(ForkResult {
371 child_session_id: new_id,
372 forked_from_event_id: at_event_id,
373 copied_event_count: copied.len(),
374 })
375 }
376
377 async fn truncate(
378 &self,
379 session_id: &str,
380 at_event_id: EventId,
381 ) -> StoreResult<TruncateResult> {
382 let mut conn = self.lock();
383 let tx = write_transaction(&mut conn)?;
384 let (mut meta, _) = read_session_meta(&tx, session_id)?;
385 let exists: bool = tx
386 .query_row(
387 "SELECT 1 FROM session_events WHERE session_id = ?1 AND event_id = ?2",
388 params![session_id, at_event_id as i64],
389 |_| Ok(true),
390 )
391 .optional()
392 .map_err(map_sql)?
393 .unwrap_or(false);
394 if !exists {
395 return Err(StoreError::InvalidInput(format!(
396 "event {at_event_id} not found in session '{session_id}'"
397 )));
398 }
399 let removed: i64 = tx
400 .query_row(
401 "SELECT COUNT(*) FROM session_events
402 WHERE session_id = ?1 AND event_id > ?2",
403 params![session_id, at_event_id as i64],
404 |row| row.get(0),
405 )
406 .map_err(map_sql)?;
407 tx.execute(
408 "DELETE FROM session_events WHERE session_id = ?1 AND event_id > ?2",
409 params![session_id, at_event_id as i64],
410 )
411 .map_err(map_sql)?;
412 tx.execute(
413 "DELETE FROM session_events_fts
414 WHERE session_id = ?1 AND CAST(event_id AS INTEGER) > ?2",
415 params![session_id, at_event_id as i64],
416 )
417 .map_err(map_sql)?;
418 tx.execute(
419 "DELETE FROM session_event_vectors
420 WHERE session_id = ?1 AND event_id > ?2",
421 params![session_id, at_event_id as i64],
422 )
423 .map_err(map_sql)?;
424 let remaining_hashes: Vec<String> = {
425 let mut stmt = tx
426 .prepare(
427 "SELECT record_hash FROM session_events
428 WHERE session_id = ?1 ORDER BY event_id ASC",
429 )
430 .map_err(map_sql)?;
431 let rows = stmt
432 .query_map(params![session_id], |row| row.get::<_, String>(0))
433 .map_err(map_sql)?;
434 let mut out = Vec::new();
435 for row in rows {
436 out.push(row.map_err(map_sql)?);
437 }
438 out
439 };
440 let new_root = remaining_hashes
441 .iter()
442 .fold(chain_root_init(), |root, hash| chain_root_fold(&root, hash));
443 let (ms, text) = now_ms_and_rfc3339();
444 meta.event_count = remaining_hashes.len();
445 meta.last_event_id = Some(at_event_id);
446 meta.chain_root_hash = Some(new_root);
447 meta.updated_at_ms = ms;
448 meta.updated_at = text;
449 tx.execute(
450 "UPDATE sessions SET event_count = ?1, last_event_id = ?2,
451 chain_root_hash = ?3, updated_at_ms = ?4,
452 updated_at = ?5, next_event_id = ?6 WHERE id = ?7",
453 params![
454 meta.event_count as i64,
455 meta.last_event_id.map(|value| value as i64),
456 meta.chain_root_hash,
457 meta.updated_at_ms,
458 meta.updated_at,
459 (at_event_id + 1) as i64,
460 session_id,
461 ],
462 )
463 .map_err(map_sql)?;
464 tx.commit().map_err(map_sql)?;
465 Ok(TruncateResult {
466 kept_event_count: meta.event_count,
467 removed_event_count: removed as usize,
468 new_tip_event_id: meta.last_event_id,
469 })
470 }
471
472 async fn snapshot(&self, session_id: &str) -> StoreResult<Snapshot> {
473 let conn = self.lock();
474 let (meta, _) = read_session_meta(&conn, session_id)?;
475 let mut events = load_all_events(&conn, session_id)?;
476 redact_stored_events(&self.hooks, &mut events)?;
477 let (ms, text) = now_ms_and_rfc3339();
478 let snapshot = Snapshot {
479 id: SnapshotId(format!("snap-{}", Uuid::now_v7())),
480 session: meta,
481 events,
482 captured_at_ms: ms,
483 captured_at: text,
484 };
485 let body = serde_json::to_string(&snapshot)
486 .map_err(|error| StoreError::Backend(error.to_string()))?;
487 conn.execute(
488 "INSERT INTO session_snapshots (id, session_id, captured_at_ms, captured_at, body_json)
489 VALUES (?1, ?2, ?3, ?4, ?5)",
490 params![
491 snapshot.id.0,
492 snapshot.session.id,
493 snapshot.captured_at_ms,
494 snapshot.captured_at,
495 body,
496 ],
497 )
498 .map_err(map_sql)?;
499 Ok(snapshot)
500 }
501
502 async fn replay(&self, snapshot_id: &SnapshotId) -> StoreResult<Snapshot> {
503 let conn = self.lock();
504 let body: Option<String> = conn
505 .query_row(
506 "SELECT body_json FROM session_snapshots WHERE id = ?1",
507 params![snapshot_id.0],
508 |row| row.get(0),
509 )
510 .optional()
511 .map_err(map_sql)?;
512 let body = body.ok_or_else(|| StoreError::NotFound(snapshot_id.0.clone()))?;
513 let mut snapshot: Snapshot =
514 serde_json::from_str(&body).map_err(|error| StoreError::Backend(error.to_string()))?;
515 redact_stored_events(&self.hooks, &mut snapshot.events)?;
516 Ok(snapshot)
517 }
518
519 async fn close(&self, session_id: &str) -> StoreResult<StoredEvent> {
520 let mut conn = self.lock();
521 let tx = write_transaction(&mut conn)?;
522 let (meta, _) = read_session_meta(&tx, session_id)?;
526 crate::memory_helpers::validate_open(&meta)?;
527 let record_root = match meta.chain_root_hash.clone() {
528 Some(root) => root,
529 None => chain_root_hash(&load_all_events(&tx, session_id)?),
530 };
531 let last_event_id = meta.last_event_id.unwrap_or(0);
532 let payload =
533 crate::signing::canonical_receipt_payload(session_id, last_event_id, &record_root);
534 let mut append = AppendEvent::new(SessionEventKind::Receipt, payload);
535 append.actor = Some("session_store".into());
536 let mut stored = append_in_tx(&tx, &self.hooks, session_id, append)?;
537 if let Some(signer) = self
543 .hooks
544 .receipt_signer
545 .as_ref()
546 .or(self.hooks.event_signer.as_ref())
547 {
548 let signature = signer.sign_receipt(&record_root);
549 let signature_json =
550 serde_json::to_string(&signature).unwrap_or_else(|_| "null".into());
551 tx.execute(
552 "UPDATE session_events SET signature_json = ?1
553 WHERE session_id = ?2 AND event_id = ?3",
554 params![signature_json, session_id, stored.event_id as i64],
555 )
556 .map_err(map_sql)?;
557 stored.signed_by = Some(signature);
558 }
559 let (ms, text) = now_ms_and_rfc3339();
560 tx.execute(
561 "UPDATE sessions SET status = ?1, closed_at_ms = ?2, closed_at = ?3,
562 updated_at_ms = ?2, updated_at = ?3 WHERE id = ?4",
563 params![status_to_sql(SessionStatus::Closed), ms, text, session_id,],
564 )
565 .map_err(map_sql)?;
566 tx.commit().map_err(map_sql)?;
567 Ok(stored)
568 }
569
570 async fn soft_delete(&self, session_id: &str) -> StoreResult<SessionMeta> {
571 let conn = self.lock();
572 let (mut meta, _) = read_session_meta(&conn, session_id)?;
573 match meta.status {
574 SessionStatus::HardDeleted => return Err(StoreError::NotFound(session_id.to_string())),
575 SessionStatus::SoftDeleted => return Ok(meta),
576 _ => {}
577 }
578 let (ms, text) = now_ms_and_rfc3339();
579 conn.execute(
580 "UPDATE sessions SET status = ?1, soft_deleted_at_ms = ?2,
581 updated_at_ms = ?2, updated_at = ?3 WHERE id = ?4",
582 params![
583 status_to_sql(SessionStatus::SoftDeleted),
584 ms,
585 text,
586 session_id,
587 ],
588 )
589 .map_err(map_sql)?;
590 meta.status = SessionStatus::SoftDeleted;
591 meta.soft_deleted_at_ms = Some(ms);
592 meta.updated_at_ms = ms;
593 meta.updated_at = text;
594 Ok(meta)
595 }
596
597 async fn hard_delete(&self, session_id: &str) -> StoreResult<()> {
598 let mut conn = self.lock();
599 let tx = write_transaction(&mut conn)?;
600 tx.execute(
601 "DELETE FROM session_events_fts WHERE session_id = ?1",
602 params![session_id],
603 )
604 .map_err(map_sql)?;
605 let removed = tx
606 .execute("DELETE FROM sessions WHERE id = ?1", params![session_id])
607 .map_err(map_sql)?;
608 if removed == 0 {
609 return Err(StoreError::NotFound(session_id.to_string()));
610 }
611 tx.commit().map_err(map_sql)?;
612 Ok(())
613 }
614
615 async fn verify(&self, session_id: &str) -> StoreResult<VerifyReport> {
616 let conn = self.lock();
617 let (meta, _) = read_session_meta(&conn, session_id)?;
618 let events = load_all_events(&conn, session_id)?;
619 let event_verifier = self
620 .hooks
621 .event_signer
622 .as_ref()
623 .map(|signer| signer.verifying_key());
624 let receipt_verifier = self
625 .hooks
626 .receipt_signer
627 .as_ref()
628 .or(self.hooks.event_signer.as_ref())
629 .map(|signer| signer.verifying_key());
630 Ok(verify_session_chain(
631 &meta,
632 &events,
633 event_verifier.as_ref(),
634 receipt_verifier.as_ref(),
635 ))
636 }
637
638 async fn search(&self, query: SearchQuery) -> StoreResult<SearchResponse> {
639 query.validate().map_err(StoreError::InvalidInput)?;
640 let conn = self.lock();
641 let embedder = self.hooks.embedder.clone();
642 let semantic_available = embedder.is_semantic();
643 let effective_mode = if semantic_available {
644 query.mode
645 } else {
646 SearchMode::Fts
647 };
648
649 let literal_query = fts_literal_query(&query.query);
650 if effective_mode == SearchMode::Fts && literal_query.is_empty() {
651 let semantic_floor = !semantic_available;
652 return Ok(SearchResponse {
653 requested_mode: query.mode,
654 effective_mode,
655 embedding_backend: embedder.name().to_string(),
656 semantic_floor,
657 fallback_reason: (semantic_floor && query.mode != SearchMode::Fts)
658 .then(|| "semantic model unavailable; FTS-only fallback active".into()),
659 hits: Vec::new(),
660 });
661 }
662 let mut fts_scores = BTreeMap::new();
663 if effective_mode == SearchMode::Hybrid && !literal_query.is_empty() {
664 let mut sql = String::from(
665 "SELECT f.session_id, CAST(f.event_id AS INTEGER),
666 -bm25(session_events_fts)
667 FROM session_events_fts f
668 INNER JOIN sessions s ON s.id = f.session_id
669 WHERE session_events_fts MATCH :match
670 AND s.status NOT IN ('soft_deleted', 'hard_deleted')",
671 );
672 let mut args: Vec<(&'static str, rusqlite::types::Value)> =
673 vec![(":match", literal_query.clone().into())];
674 append_search_scope(&mut sql, &mut args, &query);
675 let named_args: Vec<(&str, &dyn rusqlite::ToSql)> = args
676 .iter()
677 .map(|(name, value)| (*name, value as &dyn rusqlite::ToSql))
678 .collect();
679 let mut stmt = conn.prepare(&sql).map_err(map_sql)?;
680 let rows = stmt
681 .query_map(named_args.as_slice(), |row| {
682 Ok((
683 row.get::<_, String>(0)?,
684 row.get::<_, i64>(1)? as EventId,
685 row.get::<_, f64>(2)? as f32,
686 ))
687 })
688 .map_err(map_sql)?;
689 for row in rows {
690 let (session_id, event_id, score) = row.map_err(map_sql)?;
691 fts_scores.insert((session_id, event_id), score.max(f32::MIN_POSITIVE));
692 }
693 }
694
695 let fts_only = effective_mode == SearchMode::Fts;
696 let mut sql = if fts_only {
697 String::from(
698 "SELECT e.session_id, e.event_id, e.tenant_id, e.parent_event_id,
699 e.actor, e.kind, e.custom_kind, e.payload_json, e.tags_json,
700 e.headers_json, e.ts_ms, e.ts, e.record_hash, e.prev_hash,
701 e.signature_json, s.title, s.cwd, s.model, s.project_scope,
702 NULL, NULL, NULL, -bm25(session_events_fts)
703 FROM session_events_fts
704 INNER JOIN session_events e
705 ON e.session_id = session_events_fts.session_id
706 AND e.event_id = CAST(session_events_fts.event_id AS INTEGER)
707 INNER JOIN sessions s ON s.id = e.session_id
708 WHERE session_events_fts MATCH :candidate_match
709 AND s.status NOT IN ('soft_deleted', 'hard_deleted')",
710 )
711 } else {
712 String::from(
713 "SELECT e.session_id, e.event_id, e.tenant_id, e.parent_event_id,
714 e.actor, e.kind, e.custom_kind, e.payload_json, e.tags_json,
715 e.headers_json, e.ts_ms, e.ts, e.record_hash, e.prev_hash,
716 e.signature_json, s.title, s.cwd, s.model, s.project_scope,
717 v.backend, v.dim, v.embedding, NULL
718 FROM session_events e
719 INNER JOIN sessions s ON s.id = e.session_id
720 LEFT JOIN session_event_vectors v
721 ON v.session_id = e.session_id AND v.event_id = e.event_id
722 WHERE s.status NOT IN ('soft_deleted', 'hard_deleted')",
723 )
724 };
725 let mut args: Vec<(&'static str, rusqlite::types::Value)> = if fts_only {
726 vec![(":candidate_match", literal_query.into())]
727 } else {
728 Vec::new()
729 };
730 append_search_scope(&mut sql, &mut args, &query);
731 if fts_only {
732 sql.push_str(
733 " ORDER BY bm25(session_events_fts) ASC,
734 e.session_id ASC, e.event_id ASC
735 LIMIT :candidate_limit",
736 );
737 args.push((
738 ":candidate_limit",
739 i64::try_from(query.limit()).unwrap_or(i64::MAX).into(),
740 ));
741 } else {
742 sql.push_str(" ORDER BY e.session_id ASC, e.event_id ASC");
743 }
744 let named_args: Vec<(&str, &dyn rusqlite::ToSql)> = args
745 .iter()
746 .map(|(name, value)| (*name, value as &dyn rusqlite::ToSql))
747 .collect();
748 let mut stmt = conn.prepare(&sql).map_err(map_sql)?;
749 let rows = stmt
750 .query_map(named_args.as_slice(), |row| {
751 Ok((
752 read_event(row)?,
753 row.get::<_, Option<String>>(15)?,
754 row.get::<_, Option<String>>(16)?,
755 row.get::<_, Option<String>>(17)?,
756 row.get::<_, Option<String>>(18)?,
757 row.get::<_, Option<String>>(19)?,
758 row.get::<_, Option<i64>>(20)?,
759 row.get::<_, Option<Vec<u8>>>(21)?,
760 row.get::<_, Option<f64>>(22)?.map(|score| score as f32),
761 ))
762 })
763 .map_err(map_sql)?;
764 let mut candidates = Vec::new();
765 for row in rows {
766 candidates.push(row.map_err(map_sql)?);
767 }
768 drop(stmt);
769 drop(conn);
770
771 let mut redacted = candidates
772 .iter()
773 .map(|(event, ..)| event.clone())
774 .collect::<Vec<_>>();
775 redact_stored_events(&self.hooks, &mut redacted)?;
776 for ((event, ..), redacted_event) in candidates.iter_mut().zip(redacted) {
777 *event = redacted_event;
778 }
779 let documents = candidates
780 .iter()
781 .map(|(event, title, cwd, model, project_scope, ..)| {
782 redacted_search_document_parts(
783 self.hooks.redaction.as_ref(),
784 title.as_deref(),
785 cwd.as_deref(),
786 model.as_deref(),
787 project_scope.as_deref(),
788 event,
789 )
790 })
791 .collect::<Vec<_>>();
792 let aligned_fts_scores = candidates
793 .iter()
794 .map(|(event, _, _, _, _, _, _, _, direct_fts_score)| {
795 direct_fts_score
796 .map(|score| score.max(f32::MIN_POSITIVE))
797 .unwrap_or_else(|| {
798 fts_scores
799 .get(&(event.session_id.clone(), event.event_id))
800 .copied()
801 .unwrap_or_default()
802 })
803 })
804 .collect::<Vec<_>>();
805 let semantic_scores = if semantic_available {
806 let query_vector = embedder.embed(&query.query);
807 candidates
808 .iter()
809 .enumerate()
810 .map(|(index, (_, _, _, _, _, backend, dim, blob, _))| {
811 let stored = backend
812 .as_deref()
813 .filter(|backend| *backend == embedder.name())
814 .zip(dim.and_then(|dim| usize::try_from(dim).ok()))
815 .filter(|(_, dim)| *dim == embedder.dim())
816 .zip(blob.as_deref())
817 .and_then(|((_, dim), blob)| vector_from_blob(blob, dim));
818 let vector =
819 stored.unwrap_or_else(|| embedder.embed(documents[index].as_str()));
820 crate::search::cosine(&query_vector, &vector).max(0.0)
821 })
822 .collect::<Vec<_>>()
823 } else {
824 vec![0.0; candidates.len()]
825 };
826
827 let fts_ranks = ranks(&aligned_fts_scores);
828 let semantic_ranks = ranks(&semantic_scores);
829 let mut hits = candidates
830 .into_iter()
831 .enumerate()
832 .filter_map(|(index, (event, ..))| {
833 let fts_rank = fts_ranks.get(&index).copied();
834 let semantic_rank = semantic_ranks.get(&index).copied();
835 let fts_score =
836 (aligned_fts_scores[index] > 0.0).then_some(aligned_fts_scores[index]);
837 let semantic_score =
838 (semantic_scores[index] > 0.0).then_some(semantic_scores[index]);
839 let included = match effective_mode {
840 SearchMode::Fts => fts_rank.is_some(),
841 SearchMode::Semantic => semantic_rank.is_some(),
842 SearchMode::Hybrid => fts_rank.is_some() || semantic_rank.is_some(),
843 };
844 included.then(|| SearchHit {
845 session_id: event.session_id.clone(),
846 event_id: event.event_id,
847 kind: event.kind.clone(),
848 score: combined_score(
849 effective_mode,
850 fts_rank,
851 semantic_rank,
852 fts_score,
853 semantic_score,
854 ),
855 fts_score,
856 semantic_score,
857 snippet: snippet(&documents[index], &query.query, 240),
858 event,
859 })
860 })
861 .collect::<Vec<_>>();
862 hits.sort_by(|left, right| {
863 right
864 .score
865 .total_cmp(&left.score)
866 .then_with(|| left.session_id.cmp(&right.session_id))
867 .then_with(|| left.event_id.cmp(&right.event_id))
868 });
869 hits.truncate(query.limit());
870 let semantic_floor = !semantic_available;
871 Ok(SearchResponse {
872 requested_mode: query.mode,
873 effective_mode,
874 embedding_backend: embedder.name().to_string(),
875 semantic_floor,
876 fallback_reason: (semantic_floor && query.mode != SearchMode::Fts)
877 .then(|| "semantic model unavailable; FTS-only fallback active".into()),
878 hits,
879 })
880 }
881}
882
883fn append_search_scope(
884 sql: &mut String,
885 args: &mut Vec<(&'static str, rusqlite::types::Value)>,
886 query: &SearchQuery,
887) {
888 if let Some(tenant_id) = query.filter.tenant_id.as_ref() {
889 sql.push_str(" AND s.tenant_id = :search_tenant");
890 args.push((":search_tenant", tenant_id.clone().into()));
891 }
892 if let Some(project_scope) = query.filter.project_scope.as_ref() {
893 sql.push_str(" AND s.project_scope = :search_project");
894 args.push((":search_project", project_scope.clone().into()));
895 }
896 if let Some(session_id) = query.filter.session_id.as_ref() {
897 sql.push_str(" AND s.id = :search_session");
898 args.push((":search_session", session_id.clone().into()));
899 }
900}