Skip to main content

harn_session_store/sqlite/
operations.rs

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