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        // Publish only after the commit lands and the writer lock is gone, so
149        // an observer that reads the session back sees the row it was told
150        // about instead of racing the transaction that produced it.
151        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        // Pull the cursor's anchor row up front so the SQL can do
164        // keyset pagination on the selected timestamp and id instead of scanning
165        // every prior row into memory.
166        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        // SQLite stores event_id as INTEGER (signed i64); use i64::MAX as
275        // the unbounded upper sentinel rather than casting EventId::MAX,
276        // which silently wraps to -1.
277        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        // Read the pre-receipt chain root inside the transaction so the
523        // root we sign is exactly the chain the receipt finalises, with
524        // no window for a concurrent append to move the tip.
525        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        // Intentionally replace the receipt's append-time per-event
538        // signature with a receipt-root signature. The receipt's purpose
539        // is to attest the chain root, so `verify()` special-cases it via
540        // `verify_receipt_root` against the pre-receipt root rather than
541        // the receipt event's own canonical bytes.
542        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}