Skip to main content

sqz_engine/
session_store.rs

1use std::path::Path;
2use std::path::PathBuf;
3
4use chrono::{DateTime, Utc};
5use rusqlite::{params, Connection, OpenFlags};
6use serde::{Deserialize, Serialize};
7
8use crate::error::{Result, SqzError};
9use crate::types::{CompressedContent, SessionId, SessionState};
10
11/// A lightweight summary of a session for search results.
12#[derive(Debug, Clone, Serialize, Deserialize)]
13pub struct SessionSummary {
14    pub id: SessionId,
15    pub project_dir: PathBuf,
16    pub compressed_summary: String,
17    pub created_at: DateTime<Utc>,
18    pub updated_at: DateTime<Utc>,
19}
20
21/// SQLite FTS5-backed persistent session and cache store.
22pub struct SessionStore {
23    db: Connection,
24}
25
26// ── Schema ────────────────────────────────────────────────────────────────────
27
28const SCHEMA: &str = r#"
29PRAGMA journal_mode = WAL;
30
31CREATE TABLE IF NOT EXISTS sessions (
32    id               TEXT PRIMARY KEY,
33    project_dir      TEXT NOT NULL,
34    compressed_summary TEXT NOT NULL,
35    created_at       TEXT NOT NULL,
36    updated_at       TEXT NOT NULL,
37    data             BLOB NOT NULL
38);
39
40CREATE VIRTUAL TABLE IF NOT EXISTS sessions_fts USING fts5(
41    id,
42    project_dir,
43    compressed_summary,
44    content='sessions',
45    content_rowid='rowid',
46    tokenize='porter ascii'
47);
48
49CREATE TRIGGER IF NOT EXISTS sessions_ai AFTER INSERT ON sessions BEGIN
50    INSERT INTO sessions_fts(rowid, id, project_dir, compressed_summary)
51    VALUES (new.rowid, new.id, new.project_dir, new.compressed_summary);
52END;
53
54CREATE TRIGGER IF NOT EXISTS sessions_ad AFTER DELETE ON sessions BEGIN
55    INSERT INTO sessions_fts(sessions_fts, rowid, id, project_dir, compressed_summary)
56    VALUES ('delete', old.rowid, old.id, old.project_dir, old.compressed_summary);
57END;
58
59CREATE TRIGGER IF NOT EXISTS sessions_au AFTER UPDATE ON sessions BEGIN
60    INSERT INTO sessions_fts(sessions_fts, rowid, id, project_dir, compressed_summary)
61    VALUES ('delete', old.rowid, old.id, old.project_dir, old.compressed_summary);
62    INSERT INTO sessions_fts(rowid, id, project_dir, compressed_summary)
63    VALUES (new.rowid, new.id, new.project_dir, new.compressed_summary);
64END;
65
66CREATE TABLE IF NOT EXISTS cache_entries (
67    hash        TEXT PRIMARY KEY,
68    data        TEXT NOT NULL,
69    accessed_at TEXT NOT NULL
70);
71"#;
72
73// ── Helpers ───────────────────────────────────────────────────────────────────
74
75pub(crate) fn apply_schema(conn: &Connection) -> rusqlite::Result<()> {
76    conn.execute_batch(SCHEMA)
77}
78
79fn open_connection(path: &Path) -> rusqlite::Result<Connection> {
80    let conn = Connection::open(path)?;
81    apply_schema(&conn)?;
82    Ok(conn)
83}
84
85fn row_to_summary(
86    id: String,
87    project_dir: String,
88    compressed_summary: String,
89    created_at: String,
90    updated_at: String,
91) -> Result<SessionSummary> {
92    let created_at = created_at
93        .parse::<DateTime<Utc>>()
94        .map_err(|e| SqzError::Other(format!("invalid created_at timestamp: {e}")))?;
95    let updated_at = updated_at
96        .parse::<DateTime<Utc>>()
97        .map_err(|e| SqzError::Other(format!("invalid updated_at timestamp: {e}")))?;
98    Ok(SessionSummary {
99        id,
100        project_dir: PathBuf::from(project_dir),
101        compressed_summary,
102        created_at,
103        updated_at,
104    })
105}
106
107// ── SessionStore ──────────────────────────────────────────────────────────────
108
109impl SessionStore {
110    /// Construct a `SessionStore` from an already-open `Connection`.
111    /// Intended for testing (e.g., in-memory databases).
112    #[cfg(test)]
113    pub(crate) fn from_connection(conn: Connection) -> Self {
114        Self { db: conn }
115    }
116
117    /// Open an existing database at `path`. Returns an error if the file does
118    /// not exist or cannot be opened.
119    pub fn open(path: &Path) -> Result<Self> {
120        let conn = Connection::open_with_flags(path, OpenFlags::SQLITE_OPEN_READ_WRITE)?;
121        apply_schema(&conn)?;
122        Ok(Self { db: conn })
123    }
124
125    /// Open the database at `path`, creating it if it does not exist.
126    /// If the database is corrupted, a fresh database is created at the same
127    /// path and a warning is logged to stderr.
128    pub fn open_or_create(path: &Path) -> Result<Self> {
129        match open_connection(path) {
130            Ok(conn) => Ok(Self { db: conn }),
131            Err(e) => {
132                eprintln!(
133                    "sqz warning: session store at '{}' is corrupted or inaccessible ({e}). \
134                     Creating a new database. Prior session data has been lost.",
135                    path.display()
136                );
137                // Remove the corrupted file so we can start fresh.
138                if path.exists() {
139                    let _ = std::fs::remove_file(path);
140                }
141                let conn = open_connection(path)
142                    .map_err(|e2| SqzError::Other(format!("failed to create new session store: {e2}")))?;
143                Ok(Self { db: conn })
144            }
145        }
146    }
147
148    // ── Session CRUD ──────────────────────────────────────────────────────────
149
150    /// Persist a session. Returns the session id.
151    pub fn save_session(&self, session: &SessionState) -> Result<SessionId> {
152        let data = serde_json::to_vec(session)?;
153        let project_dir = session.project_dir.to_string_lossy().to_string();
154        let created_at = session.created_at.to_rfc3339();
155        let updated_at = session.updated_at.to_rfc3339();
156
157        self.db.execute(
158            r#"INSERT INTO sessions (id, project_dir, compressed_summary, created_at, updated_at, data)
159               VALUES (?1, ?2, ?3, ?4, ?5, ?6)
160               ON CONFLICT(id) DO UPDATE SET
161                   project_dir        = excluded.project_dir,
162                   compressed_summary = excluded.compressed_summary,
163                   created_at         = excluded.created_at,
164                   updated_at         = excluded.updated_at,
165                   data               = excluded.data"#,
166            params![
167                session.id,
168                project_dir,
169                session.compressed_summary,
170                created_at,
171                updated_at,
172                data,
173            ],
174        )?;
175
176        Ok(session.id.clone())
177    }
178
179    /// Load a session by id.
180    pub fn load_session(&self, id: SessionId) -> Result<SessionState> {
181        let data: Vec<u8> = self.db.query_row(
182            "SELECT data FROM sessions WHERE id = ?1",
183            params![id],
184            |row| row.get(0),
185        )?;
186        let session: SessionState = serde_json::from_slice(&data)?;
187        Ok(session)
188    }
189
190    // ── Search ────────────────────────────────────────────────────────────────
191
192    /// Full-text search using FTS5 (porter stemmer, ASCII tokenizer).
193    pub fn search(&self, query: &str) -> Result<Vec<SessionSummary>> {
194        let mut stmt = self.db.prepare(
195            r#"SELECT s.id, s.project_dir, s.compressed_summary, s.created_at, s.updated_at
196               FROM sessions s
197               JOIN sessions_fts f ON s.rowid = f.rowid
198               WHERE sessions_fts MATCH ?1
199               ORDER BY rank"#,
200        )?;
201
202        let rows = stmt.query_map(params![query], |row| {
203            Ok((
204                row.get::<_, String>(0)?,
205                row.get::<_, String>(1)?,
206                row.get::<_, String>(2)?,
207                row.get::<_, String>(3)?,
208                row.get::<_, String>(4)?,
209            ))
210        })?;
211
212        let mut results = Vec::new();
213        for row in rows {
214            let (id, project_dir, compressed_summary, created_at, updated_at) = row?;
215            results.push(row_to_summary(id, project_dir, compressed_summary, created_at, updated_at)?);
216        }
217        Ok(results)
218    }
219
220    /// Query sessions whose `updated_at` falls within `[from, to]`.
221    pub fn search_by_date(
222        &self,
223        from: DateTime<Utc>,
224        to: DateTime<Utc>,
225    ) -> Result<Vec<SessionSummary>> {
226        let mut stmt = self.db.prepare(
227            r#"SELECT id, project_dir, compressed_summary, created_at, updated_at
228               FROM sessions
229               WHERE updated_at >= ?1 AND updated_at <= ?2
230               ORDER BY updated_at DESC"#,
231        )?;
232
233        let rows = stmt.query_map(params![from.to_rfc3339(), to.to_rfc3339()], |row| {
234            Ok((
235                row.get::<_, String>(0)?,
236                row.get::<_, String>(1)?,
237                row.get::<_, String>(2)?,
238                row.get::<_, String>(3)?,
239                row.get::<_, String>(4)?,
240            ))
241        })?;
242
243        let mut results = Vec::new();
244        for row in rows {
245            let (id, project_dir, compressed_summary, created_at, updated_at) = row?;
246            results.push(row_to_summary(id, project_dir, compressed_summary, created_at, updated_at)?);
247        }
248        Ok(results)
249    }
250
251    /// Query sessions whose `project_dir` matches `dir` exactly.
252    pub fn search_by_project(&self, dir: &Path) -> Result<Vec<SessionSummary>> {
253        let dir_str = dir.to_string_lossy().to_string();
254        let mut stmt = self.db.prepare(
255            r#"SELECT id, project_dir, compressed_summary, created_at, updated_at
256               FROM sessions
257               WHERE project_dir = ?1
258               ORDER BY updated_at DESC"#,
259        )?;
260
261        let rows = stmt.query_map(params![dir_str], |row| {
262            Ok((
263                row.get::<_, String>(0)?,
264                row.get::<_, String>(1)?,
265                row.get::<_, String>(2)?,
266                row.get::<_, String>(3)?,
267                row.get::<_, String>(4)?,
268            ))
269        })?;
270
271        let mut results = Vec::new();
272        for row in rows {
273            let (id, project_dir, compressed_summary, created_at, updated_at) = row?;
274            results.push(row_to_summary(id, project_dir, compressed_summary, created_at, updated_at)?);
275        }
276        Ok(results)
277    }
278
279    // ── Cache entries ─────────────────────────────────────────────────────────
280
281    /// Persist a cache entry keyed by content hash.
282    pub fn save_cache_entry(&self, hash: &str, compressed: &CompressedContent) -> Result<()> {
283        let data = serde_json::to_string(compressed)?;
284        let now = Utc::now().to_rfc3339();
285        self.db.execute(
286            r#"INSERT INTO cache_entries (hash, data, accessed_at)
287               VALUES (?1, ?2, ?3)
288               ON CONFLICT(hash) DO UPDATE SET data = excluded.data, accessed_at = excluded.accessed_at"#,
289            params![hash, data, now],
290        )?;
291        Ok(())
292    }
293
294    /// Delete a cache entry by content hash.
295    pub fn delete_cache_entry(&self, hash: &str) -> Result<()> {
296        self.db.execute(
297            "DELETE FROM cache_entries WHERE hash = ?1",
298            params![hash],
299        )?;
300        Ok(())
301    }
302
303    /// Return all cache entries ordered by `accessed_at` ASC (oldest first),
304    /// as `(hash, size_bytes)` pairs where `size_bytes` is the byte length of
305    /// the stored JSON data.
306    pub fn list_cache_entries_lru(&self) -> Result<Vec<(String, u64)>> {
307        let mut stmt = self.db.prepare(
308            "SELECT hash, length(data) FROM cache_entries ORDER BY accessed_at ASC",
309        )?;
310        let rows = stmt.query_map([], |row| {
311            Ok((row.get::<_, String>(0)?, row.get::<_, i64>(1)?))
312        })?;
313        let mut entries = Vec::new();
314        for row in rows {
315            let (hash, size) = row?;
316            entries.push((hash, size as u64));
317        }
318        Ok(entries)
319    }
320
321    /// Retrieve a cache entry by content hash, updating `accessed_at`.
322    pub fn get_cache_entry(&self, hash: &str) -> Result<Option<CompressedContent>> {
323        let result: rusqlite::Result<String> = self.db.query_row(
324            "SELECT data FROM cache_entries WHERE hash = ?1",
325            params![hash],
326            |row| row.get(0),
327        );
328
329        match result {
330            Ok(data) => {
331                // Touch accessed_at for LRU tracking.
332                let now = Utc::now().to_rfc3339();
333                let _ = self.db.execute(
334                    "UPDATE cache_entries SET accessed_at = ?1 WHERE hash = ?2",
335                    params![now, hash],
336                );
337                let entry: CompressedContent = serde_json::from_str(&data)?;
338                Ok(Some(entry))
339            }
340            Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
341            Err(e) => Err(SqzError::SessionStore(e)),
342        }
343    }
344}
345
346// ── Tests ─────────────────────────────────────────────────────────────────────
347
348#[cfg(test)]
349mod tests {
350    use super::*;
351    use crate::types::{BudgetState, CorrectionLog, ModelFamily, SessionState};
352    use chrono::Utc;
353    use proptest::prelude::*;
354    use std::path::PathBuf;
355
356    fn make_session(id: &str, project_dir: &str, summary: &str) -> SessionState {
357        let now = Utc::now();
358        SessionState {
359            id: id.to_string(),
360            project_dir: PathBuf::from(project_dir),
361            conversation: vec![],
362            corrections: CorrectionLog::default(),
363            pins: vec![],
364            learnings: vec![],
365            compressed_summary: summary.to_string(),
366            budget: BudgetState {
367                window_size: 200_000,
368                consumed: 0,
369                pinned: 0,
370                model_family: ModelFamily::AnthropicClaude,
371            },
372            tool_usage: vec![],
373            created_at: now,
374            updated_at: now,
375        }
376    }
377
378    fn in_memory_store() -> SessionStore {
379        let conn = Connection::open_in_memory().unwrap();
380        apply_schema(&conn).unwrap();
381        SessionStore { db: conn }
382    }
383
384    #[test]
385    fn test_save_and_load_session() {
386        let store = in_memory_store();
387        let session = make_session("sess-1", "/home/user/project", "REST API refactor");
388
389        let id = store.save_session(&session).unwrap();
390        assert_eq!(id, "sess-1");
391
392        let loaded = store.load_session("sess-1".to_string()).unwrap();
393        assert_eq!(loaded.id, session.id);
394        assert_eq!(loaded.compressed_summary, session.compressed_summary);
395        assert_eq!(loaded.project_dir, session.project_dir);
396    }
397
398    #[test]
399    fn test_save_session_upsert() {
400        let store = in_memory_store();
401        let mut session = make_session("sess-2", "/proj", "initial summary");
402        store.save_session(&session).unwrap();
403
404        session.compressed_summary = "updated summary".to_string();
405        store.save_session(&session).unwrap();
406
407        let loaded = store.load_session("sess-2".to_string()).unwrap();
408        assert_eq!(loaded.compressed_summary, "updated summary");
409    }
410
411    #[test]
412    fn test_load_nonexistent_session_errors() {
413        let store = in_memory_store();
414        let result = store.load_session("does-not-exist".to_string());
415        assert!(result.is_err());
416    }
417
418    #[test]
419    fn test_search_fts() {
420        let store = in_memory_store();
421        store.save_session(&make_session("s1", "/proj", "REST API refactor with authentication")).unwrap();
422        store.save_session(&make_session("s2", "/proj", "database migration postgres")).unwrap();
423
424        let results = store.search("authentication").unwrap();
425        assert_eq!(results.len(), 1);
426        assert_eq!(results[0].id, "s1");
427    }
428
429    #[test]
430    fn test_search_by_date() {
431        let store = in_memory_store();
432        let now = Utc::now();
433        let past = now - chrono::Duration::hours(2);
434        let future = now + chrono::Duration::hours(2);
435
436        store.save_session(&make_session("s1", "/proj", "recent session")).unwrap();
437
438        let results = store.search_by_date(past, future).unwrap();
439        assert!(!results.is_empty());
440        assert!(results.iter().any(|r| r.id == "s1"));
441    }
442
443    #[test]
444    fn test_search_by_project() {
445        let store = in_memory_store();
446        store.save_session(&make_session("s1", "/home/user/alpha", "alpha project")).unwrap();
447        store.save_session(&make_session("s2", "/home/user/beta", "beta project")).unwrap();
448
449        let results = store.search_by_project(Path::new("/home/user/alpha")).unwrap();
450        assert_eq!(results.len(), 1);
451        assert_eq!(results[0].id, "s1");
452    }
453
454    #[test]
455    fn test_cache_entry_round_trip() {
456        let store = in_memory_store();
457        let entry = CompressedContent {
458            data: "compressed data".to_string(),
459            tokens_compressed: 10,
460            tokens_original: 50,
461            stages_applied: vec!["strip_nulls".to_string()],
462            compression_ratio: 0.2,
463            provenance: crate::types::Provenance::default(),
464            verify: None,
465        };
466
467        store.save_cache_entry("abc123", &entry).unwrap();
468
469        let loaded = store.get_cache_entry("abc123").unwrap().unwrap();
470        assert_eq!(loaded.data, entry.data);
471        assert_eq!(loaded.tokens_compressed, entry.tokens_compressed);
472        assert_eq!(loaded.tokens_original, entry.tokens_original);
473    }
474
475    #[test]
476    fn test_get_cache_entry_missing_returns_none() {
477        let store = in_memory_store();
478        let result = store.get_cache_entry("nonexistent").unwrap();
479        assert!(result.is_none());
480    }
481
482    #[test]
483    fn test_open_or_create_corrupted_db() {
484        let dir = tempfile::tempdir().unwrap();
485        let path = dir.path().join("store.db");
486
487        // Write garbage bytes to simulate a corrupted database.
488        std::fs::write(&path, b"this is not a valid sqlite database").unwrap();
489
490        // Should succeed by creating a fresh database.
491        let store = SessionStore::open_or_create(&path).unwrap();
492        let session = make_session("s1", "/proj", "after corruption");
493        store.save_session(&session).unwrap();
494        let loaded = store.load_session("s1".to_string()).unwrap();
495        assert_eq!(loaded.id, "s1");
496    }
497
498    // ── Property-based tests ──────────────────────────────────────────────────
499
500    /// Build a `SessionState` with a specific `updated_at` timestamp.
501    fn make_session_at(id: &str, summary: &str, updated_at: DateTime<Utc>) -> SessionState {
502        let now = Utc::now();
503        SessionState {
504            id: id.to_string(),
505            project_dir: PathBuf::from("/proj"),
506            conversation: vec![],
507            corrections: CorrectionLog::default(),
508            pins: vec![],
509            learnings: vec![],
510            compressed_summary: summary.to_string(),
511            budget: BudgetState {
512                window_size: 200_000,
513                consumed: 0,
514                pinned: 0,
515                model_family: ModelFamily::AnthropicClaude,
516            },
517            tool_usage: vec![],
518            created_at: now,
519            updated_at,
520        }
521    }
522
523    // ── Property 26: Session store search correctness ─────────────────────────
524    // **Validates: Requirements 20.2, 20.3, 20.4**
525    //
526    // For any set of sessions saved to the store, a keyword search SHALL return
527    // all sessions whose compressed_summary contains the keyword, and no
528    // sessions that don't contain it.
529
530    proptest! {
531        /// **Validates: Requirements 20.2, 20.3, 20.4**
532        ///
533        /// For any set of sessions saved to the store, a keyword search SHALL
534        /// return all sessions whose `compressed_summary` contains the keyword,
535        /// and no sessions that don't contain it.
536        #[test]
537        fn prop_search_correctness(
538            // A simple ASCII keyword: 5-8 lowercase letters, no common English
539            // words that the porter stemmer might conflate with other terms.
540            keyword in "[b-df-hj-np-tv-z]{5,8}",
541            // 1-6 summaries that embed the keyword
542            matching_suffixes in proptest::collection::vec("[a-z ]{4,20}", 1..=6usize),
543            // 1-6 summaries that do NOT contain the keyword
544            non_matching in proptest::collection::vec("[a-z ]{8,30}", 1..=6usize),
545        ) {
546            // Ensure the keyword doesn't accidentally appear in non-matching summaries.
547            for s in &non_matching {
548                prop_assume!(!s.contains(keyword.as_str()));
549            }
550
551            let store = in_memory_store();
552
553            // Save matching sessions (summary = "<suffix> <keyword> <suffix>")
554            let mut matching_ids: std::collections::HashSet<String> = std::collections::HashSet::new();
555            for (i, suffix) in matching_suffixes.iter().enumerate() {
556                let id = format!("match-{i}");
557                let summary = format!("{} {} end", suffix, keyword);
558                store.save_session(&make_session(&id, "/proj", &summary)).unwrap();
559                matching_ids.insert(id);
560            }
561
562            // Save non-matching sessions
563            let mut non_matching_ids: std::collections::HashSet<String> = std::collections::HashSet::new();
564            for (i, summary) in non_matching.iter().enumerate() {
565                let id = format!("nomatch-{i}");
566                store.save_session(&make_session(&id, "/proj", summary)).unwrap();
567                non_matching_ids.insert(id);
568            }
569
570            let results = store.search(&keyword).unwrap();
571            let result_ids: std::collections::HashSet<String> =
572                results.iter().map(|r| r.id.clone()).collect();
573
574            // Every matching session must appear in results.
575            for id in &matching_ids {
576                prop_assert!(
577                    result_ids.contains(id),
578                    "matching session '{}' not found in search results for keyword '{}'",
579                    id, keyword
580                );
581            }
582
583            // No non-matching session may appear in results.
584            for id in &non_matching_ids {
585                prop_assert!(
586                    !result_ids.contains(id),
587                    "non-matching session '{}' incorrectly appeared in search results for keyword '{}'",
588                    id, keyword
589                );
590            }
591        }
592    }
593
594    // ── Property: search_by_date correctness ─────────────────────────────────
595    // **Validates: Requirements 20.4**
596    //
597    // For any set of sessions with different timestamps, searching by a date
598    // range SHALL return exactly the sessions whose `updated_at` falls within
599    // [from, to], and no sessions outside that range.
600
601    proptest! {
602        /// **Validates: Requirements 20.4**
603        ///
604        /// For any set of sessions with distinct timestamps, `search_by_date`
605        /// SHALL return exactly the sessions whose `updated_at` is within
606        /// `[from, to]`, and no sessions outside that range.
607        #[test]
608        fn prop_search_by_date_correctness(
609            // Generate 2-8 offsets in seconds from epoch (spread over a wide range)
610            offsets in proptest::collection::vec(0i64..=86400i64 * 365, 2..=8usize),
611            // The search window: start and end offsets (relative to the minimum offset)
612            window_start_delta in 0i64..=3600i64,
613            window_end_delta   in 3600i64..=7200i64,
614        ) {
615            use chrono::TimeZone;
616
617            // Deduplicate offsets so each session has a unique timestamp.
618            let mut unique_offsets: Vec<i64> = offsets.clone();
619            unique_offsets.sort_unstable();
620            unique_offsets.dedup();
621            prop_assume!(unique_offsets.len() >= 2);
622
623            let base_offset = unique_offsets[0];
624            let from_offset = base_offset + window_start_delta;
625            let to_offset   = base_offset + window_end_delta;
626
627            let from = Utc.timestamp_opt(from_offset, 0).unwrap();
628            let to   = Utc.timestamp_opt(to_offset,   0).unwrap();
629
630            let store = in_memory_store();
631
632            let mut in_range_ids:  std::collections::HashSet<String> = std::collections::HashSet::new();
633            let mut out_range_ids: std::collections::HashSet<String> = std::collections::HashSet::new();
634
635            for (i, &offset) in unique_offsets.iter().enumerate() {
636                let ts = Utc.timestamp_opt(offset, 0).unwrap();
637                let id = format!("sess-{i}");
638                let session = make_session_at(&id, "some summary", ts);
639                store.save_session(&session).unwrap();
640
641                if ts >= from && ts <= to {
642                    in_range_ids.insert(id);
643                } else {
644                    out_range_ids.insert(id);
645                }
646            }
647
648            let results = store.search_by_date(from, to).unwrap();
649            let result_ids: std::collections::HashSet<String> =
650                results.iter().map(|r| r.id.clone()).collect();
651
652            // Every in-range session must appear.
653            for id in &in_range_ids {
654                prop_assert!(
655                    result_ids.contains(id),
656                    "in-range session '{}' missing from search_by_date results",
657                    id
658                );
659            }
660
661            // No out-of-range session may appear.
662            for id in &out_range_ids {
663                prop_assert!(
664                    !result_ids.contains(id),
665                    "out-of-range session '{}' incorrectly appeared in search_by_date results",
666                    id
667                );
668            }
669        }
670    }
671}