Skip to main content

seyk_core/
cache.rs

1//! Embedding caches.
2//!
3//! A cache is an optimisation, not a corpus. It stores per-resource metadata,
4//! per-segment locators, embedding vectors and a keyword index of each
5//! segment's words, never a readable copy of the source content. (The keyword
6//! index retains word frequencies, not source word order, and is still sensitive.)
7//!
8//! Vectors are keyed by `(profile, input_hash)`:
9//! - the profile hashes everything that affects a vector (model, revision,
10//!   precision, dimension, normalisation, prompts, segmenter version);
11//! - the input hash covers the exact text (or bytes) sent to the model.
12//!
13//! So a vector is only ever reused for byte-identical input under an identical
14//! profile. Size and mtime are only used to decide whether a file needs to be
15//! re-read and re-hashed.
16
17use std::collections::{HashMap, HashSet};
18use std::path::{Path, PathBuf};
19
20use rusqlite::{Connection, OptionalExtension, params};
21use serde::{Deserialize, Serialize};
22
23use crate::embedder::ModelIdentity;
24use crate::embedding::{from_bytes, to_bytes};
25use crate::lexical::{FIELDS, LexicalDoc, LexicalQuery, order_free_text};
26use crate::{
27    Error, Modality, ResourceId, ResourceLocator, ResourceMetadata, ResourceVersion, Result,
28};
29
30/// Everything that determines a cached vector, besides the input itself.
31#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
32pub struct CacheProfile {
33    pub model: ModelIdentity,
34    pub segmenter_version: String,
35}
36
37impl CacheProfile {
38    /// Stable identifier derived from every field.
39    pub fn id(&self) -> String {
40        let json = serde_json::to_vec(self).expect("profile serialises");
41        blake3::hash(&json).to_hex()[..24].to_string()
42    }
43}
44
45/// A segment as remembered by the cache.
46#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
47pub struct CachedSegment {
48    pub locator: ResourceLocator,
49    pub heading: Option<String>,
50    /// Hash of the exact model input for this segment.
51    pub input_hash: String,
52}
53
54#[derive(Debug, Clone, PartialEq, Eq)]
55pub struct CachedResource {
56    pub metadata: ResourceMetadata,
57    pub version: ResourceVersion,
58    /// Preparation descriptor hash; absent for migrated legacy entries.
59    pub descriptor_hash: Option<String>,
60    pub segments: Vec<CachedSegment>,
61}
62
63/// Transient keyword input paired with the segment evidence it describes.
64/// The cache consumes the body for indexing and never stores it as source text.
65#[derive(Debug, Clone)]
66pub struct SegmentWrite<'a> {
67    pub segment: &'a CachedSegment,
68    pub lexical_body: &'a str,
69}
70
71/// Explicit ingestion input. Readback evidence alone cannot replace keyword data.
72///
73/// ```compile_fail
74/// use seyk_core::{EmbeddingCache, ResourceId, Result};
75/// use seyk_core::cache::CachedResource;
76/// fn rewrite(cache: &mut dyn EmbeddingCache, id: &ResourceId, read: &CachedResource) -> Result<()> {
77///     cache.put_resource("profile", id, read)
78/// }
79/// ```
80#[derive(Debug, Clone)]
81pub struct ResourceWrite<'a> {
82    pub metadata: &'a ResourceMetadata,
83    pub version: &'a ResourceVersion,
84    pub descriptor_hash: Option<&'a str>,
85    pub segments: Vec<SegmentWrite<'a>>,
86}
87
88/// Resource metadata and the provider token used to index it.
89///
90/// Unstable: may change before 1.0 alongside the provider and cache contracts.
91#[derive(Debug, Clone, PartialEq, Eq)]
92pub struct CachedResourceState {
93    pub metadata: ResourceMetadata,
94    pub version: ResourceVersion,
95    /// Preparation descriptor hash; absent for migrated legacy entries.
96    pub descriptor_hash: Option<String>,
97}
98
99/// A cached segment joined with its vector, ready to search.
100#[derive(Debug, Clone)]
101pub struct StoredVector {
102    pub resource: ResourceId,
103    /// Position of the segment within its resource.
104    pub ordinal: usize,
105    pub segment: CachedSegment,
106    pub vector: Vec<f32>,
107}
108
109/// A segment matching a keyword query.
110#[derive(Debug, Clone, PartialEq)]
111pub struct LexicalHit {
112    pub resource: ResourceId,
113    pub ordinal: usize,
114    /// BM25 relevance, higher is better. Only comparable within one query.
115    pub score: f32,
116}
117
118#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
119pub struct ProfileStats {
120    pub profile_id: String,
121    pub profile: Option<CacheProfile>,
122    pub resources: u64,
123    pub segments: u64,
124    pub embeddings: u64,
125    /// Derived image representations, counted separately from retained f32 embeddings.
126    #[serde(default)]
127    pub image_codes: u64,
128}
129
130#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
131pub struct CacheStats {
132    /// Where the cache lives, when it is persistent.
133    pub location: Option<PathBuf>,
134    pub size_bytes: Option<u64>,
135    #[serde(skip)]
136    pub scrub_pending: bool,
137    pub profiles: Vec<ProfileStats>,
138    /// Directory’s own macOS backup flag captured at open; unknown elsewhere or on query failure.
139    #[serde(default)]
140    pub backup_excluded: Option<bool>,
141    /// Known sync roots detected at open; not proof that other paths are local.
142    #[serde(default)]
143    pub in_synced_folder: bool,
144}
145
146/// Result of an explicit or idle-time privacy scrub.
147#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
148pub struct CacheCleanup {
149    pub profiles_scrubbed: usize,
150    pub pending: bool,
151}
152
153impl CacheCleanup {
154    pub fn is_empty(&self) -> bool {
155        self.profiles_scrubbed == 0 && !self.pending
156    }
157}
158
159/// Storage for resource metadata, segment locators and vectors.
160///
161/// Unstable: exposes ResourceVersion and CachedResourceState, which may change
162/// before 1.0; filesystem is the only supported production provider.
163pub trait EmbeddingCache: Send {
164    /// Erase stale FTS postings and historical WAL frames. Busy readers leave work pending.
165    fn scrub(&mut self) -> Result<CacheCleanup> {
166        Ok(CacheCleanup::default())
167    }
168
169    /// Attempt a background scrub after this long without writes. Busy work is retried
170    /// at the same interval, rather than on every scheduler tick.
171    fn scrub_if_idle(&mut self, _quiet: std::time::Duration) -> Result<Option<CacheCleanup>> {
172        Ok(None)
173    }
174
175    /// Scheduler delay until the next quiet-period/retry deadline. When idle,
176    /// check at this interval to observe activity from other processes.
177    fn scrub_delay(&self, quiet: std::time::Duration) -> std::time::Duration {
178        quiet
179    }
180
181    fn register_profile(&mut self, id: &str, profile: &CacheProfile) -> Result<()>;
182
183    /// Every cached resource for a profile, keyed by identity. Segments are not loaded.
184    fn resource_index(&self, profile: &str) -> Result<HashMap<ResourceId, CachedResourceState>>;
185
186    fn resource(&self, profile: &str, path: &ResourceId) -> Result<Option<CachedResource>>;
187
188    /// Replace a resource and its segments.
189    fn put_resource(
190        &mut self,
191        profile: &str,
192        path: &ResourceId,
193        resource: &ResourceWrite<'_>,
194    ) -> Result<()>;
195
196    /// Update metadata and version when content is unchanged.
197    fn touch_resource(
198        &mut self,
199        profile: &str,
200        path: &ResourceId,
201        metadata: &ResourceMetadata,
202        version: &ResourceVersion,
203    ) -> Result<()>;
204
205    fn remove_resources(&mut self, profile: &str, paths: &[ResourceId]) -> Result<()>;
206
207    /// Which of `hashes` already have a vector.
208    fn known_inputs(&self, profile: &str, hashes: &[String]) -> Result<HashSet<String>>;
209
210    fn put_embeddings(&mut self, profile: &str, items: &[(String, Vec<f32>)]) -> Result<()>;
211
212    /// Whether persistent Photo Anchor representations are supported.
213    fn supports_image_codes(&self) -> bool {
214        false
215    }
216
217    /// Derive missing image codes from retained f32 during an opted-in sync.
218    fn sync_image_codes(&mut self, _profile: &str, _paths: &HashSet<ResourceId>) -> Result<()> {
219        Err(Error::Config(
220            "q4 image scan requires a persistent SQLite cache; --no-cache is unsupported".into(),
221        ))
222    }
223
224    /// Read representations without writing or loading f32 for coded images.
225    fn image_scan_rows(
226        &self,
227        _profile: &str,
228        _paths: &HashSet<ResourceId>,
229    ) -> Result<Vec<ScanVector>> {
230        Err(Error::Config(
231            "q4 image scan is unsupported by this cache".into(),
232        ))
233    }
234
235    /// Fetch only selected f32 vectors, deduplicated by input hash.
236    fn selected_image_vectors(
237        &self,
238        _profile: &str,
239        _hashes: &HashSet<String>,
240    ) -> Result<HashMap<String, Vec<f32>>> {
241        Err(Error::Config(
242            "q4 image scan is unsupported by this cache".into(),
243        ))
244    }
245
246    /// All searchable vectors for the given paths.
247    fn vectors_for(&self, profile: &str, paths: &HashSet<ResourceId>) -> Result<Vec<StoredVector>>;
248
249    /// Segments of `paths` matching `query` by keyword, best first, at most `limit`.
250    /// Equal scores are ordered by path bytes, then segment ordinal.
251    fn lexical_search(
252        &self,
253        profile: &str,
254        query: &LexicalQuery,
255        paths: &HashSet<ResourceId>,
256        limit: usize,
257    ) -> Result<Vec<LexicalHit>>;
258
259    /// Query tokens confirmed by the cached index for the requested segments.
260    /// Called only for final hits ranked by the keyword leg, after fusion and
261    /// eligibility checks. Return one term list per key, in the same order.
262    /// Never recover source text; report only complete matching query groups.
263    fn lexical_matched_terms(
264        &self,
265        profile: &str,
266        query: &LexicalQuery,
267        keys: &[(ResourceId, usize)],
268    ) -> Result<Vec<Vec<String>>>;
269
270    /// Drop vectors no segment refers to any more.
271    fn prune_orphans(&mut self, profile: &str) -> Result<u64>;
272
273    fn stats(&self) -> Result<CacheStats>;
274
275    fn clear(&mut self) -> Result<()>;
276}
277
278// ---------------------------------------------------------------------------
279// In-memory cache (ephemeral searches and tests)
280// ---------------------------------------------------------------------------
281
282/// A cache that lives only as long as the value. Used for `--no-cache` searches.
283#[derive(Debug, Default)]
284pub struct MemoryCache {
285    profiles: HashMap<String, CacheProfile>,
286    // Bucketed by profile id, like the trait. Kept apart from `profiles`
287    // because, unlike SQLite, writes may precede registration.
288    resources: HashMap<String, HashMap<ResourceId, CachedResource>>,
289    vectors: HashMap<String, HashMap<String, Vec<f32>>>,
290    /// Keyword index per profile and path, one document per segment.
291    lexical: HashMap<String, HashMap<ResourceId, Vec<LexicalDoc>>>,
292}
293
294impl MemoryCache {
295    pub fn new() -> Self {
296        Self::default()
297    }
298}
299
300impl EmbeddingCache for MemoryCache {
301    fn register_profile(&mut self, id: &str, profile: &CacheProfile) -> Result<()> {
302        self.profiles.insert(id.to_string(), profile.clone());
303        Ok(())
304    }
305
306    fn resource_index(&self, profile: &str) -> Result<HashMap<ResourceId, CachedResourceState>> {
307        Ok(self
308            .resources
309            .get(profile)
310            .into_iter()
311            .flatten()
312            .map(|(path, r)| {
313                (
314                    path.clone(),
315                    CachedResourceState {
316                        metadata: r.metadata.clone(),
317                        version: r.version.clone(),
318                        descriptor_hash: r.descriptor_hash.clone(),
319                    },
320                )
321            })
322            .collect())
323    }
324
325    fn resource(&self, profile: &str, path: &ResourceId) -> Result<Option<CachedResource>> {
326        Ok(self
327            .resources
328            .get(profile)
329            .and_then(|b| b.get(path))
330            .cloned())
331    }
332
333    fn put_resource(
334        &mut self,
335        profile: &str,
336        path: &ResourceId,
337        resource: &ResourceWrite<'_>,
338    ) -> Result<()> {
339        let docs = lexical_docs(path, resource);
340        // Like SQLite, keep no readable text: it is indexed, then dropped.
341        let stored = CachedResource {
342            metadata: resource.metadata.clone(),
343            version: resource.version.clone(),
344            descriptor_hash: resource.descriptor_hash.map(str::to_owned),
345            segments: resource
346                .segments
347                .iter()
348                .map(|s| s.segment.clone())
349                .collect(),
350        };
351        self.resources
352            .entry(profile.to_string())
353            .or_default()
354            .insert(path.clone(), stored);
355        self.lexical
356            .entry(profile.to_string())
357            .or_default()
358            .insert(path.clone(), docs);
359        Ok(())
360    }
361
362    fn touch_resource(
363        &mut self,
364        profile: &str,
365        path: &ResourceId,
366        metadata: &ResourceMetadata,
367        version: &ResourceVersion,
368    ) -> Result<()> {
369        if let Some(r) = self
370            .resources
371            .get_mut(profile)
372            .and_then(|b| b.get_mut(path))
373        {
374            r.metadata = metadata.clone();
375            r.version = version.clone();
376        }
377        Ok(())
378    }
379
380    fn remove_resources(&mut self, profile: &str, paths: &[ResourceId]) -> Result<()> {
381        if let Some(bucket) = self.resources.get_mut(profile) {
382            for p in paths {
383                bucket.remove(p);
384            }
385        }
386        if let Some(bucket) = self.lexical.get_mut(profile) {
387            for p in paths {
388                bucket.remove(p);
389            }
390        }
391        Ok(())
392    }
393
394    fn known_inputs(&self, profile: &str, hashes: &[String]) -> Result<HashSet<String>> {
395        let Some(vectors) = self.vectors.get(profile) else {
396            return Ok(HashSet::new());
397        };
398        Ok(hashes
399            .iter()
400            .filter(|h| vectors.contains_key(*h))
401            .cloned()
402            .collect())
403    }
404
405    fn put_embeddings(&mut self, profile: &str, items: &[(String, Vec<f32>)]) -> Result<()> {
406        let vectors = self.vectors.entry(profile.to_string()).or_default();
407        for (h, v) in items {
408            vectors.insert(h.clone(), v.clone());
409        }
410        Ok(())
411    }
412
413    fn vectors_for(&self, profile: &str, paths: &HashSet<ResourceId>) -> Result<Vec<StoredVector>> {
414        let (Some(resources), Some(vectors)) =
415            (self.resources.get(profile), self.vectors.get(profile))
416        else {
417            return Ok(Vec::new());
418        };
419        let mut out = Vec::new();
420        for (path, r) in resources {
421            if !paths.contains(path) {
422                continue;
423            }
424            for (ordinal, s) in r.segments.iter().enumerate() {
425                if let Some(v) = vectors.get(&s.input_hash) {
426                    out.push(StoredVector {
427                        resource: path.clone(),
428                        ordinal,
429                        segment: s.clone(),
430                        vector: v.clone(),
431                    });
432                }
433            }
434        }
435        Ok(out)
436    }
437
438    fn lexical_search(
439        &self,
440        profile: &str,
441        query: &LexicalQuery,
442        paths: &HashSet<ResourceId>,
443        limit: usize,
444    ) -> Result<Vec<LexicalHit>> {
445        let Some(bucket) = self.lexical.get(profile) else {
446            return Ok(Vec::new());
447        };
448        // Score against the whole profile, as FTS5 does, so document frequencies
449        // do not depend on which paths were asked for.
450        let docs = bucket
451            .iter()
452            .flat_map(|(path, docs)| docs.iter().enumerate().map(move |(i, d)| ((path, i), d)));
453        let mut scored = crate::lexical::bm25(docs, query);
454        scored.sort_by(|a, b| {
455            b.1.total_cmp(&a.1)
456                .then_with(|| resource_key(a.0.0).cmp(&resource_key(b.0.0)))
457                .then_with(|| a.0.1.cmp(&b.0.1))
458        });
459        Ok(scored
460            .into_iter()
461            .filter(|((path, _), _)| paths.contains(*path))
462            .take(limit)
463            .map(|((path, ordinal), score)| LexicalHit {
464                resource: path.clone(),
465                ordinal,
466                score: score as f32,
467            })
468            .collect())
469    }
470
471    fn lexical_matched_terms(
472        &self,
473        profile: &str,
474        query: &LexicalQuery,
475        keys: &[(ResourceId, usize)],
476    ) -> Result<Vec<Vec<String>>> {
477        Ok(keys
478            .iter()
479            .map(|(path, ordinal)| {
480                self.lexical
481                    .get(profile)
482                    .and_then(|bucket| bucket.get(path))
483                    .and_then(|docs| docs.get(*ordinal))
484                    .map(|doc| doc.matched_terms(query))
485                    .unwrap_or_default()
486            })
487            .collect())
488    }
489
490    fn prune_orphans(&mut self, profile: &str) -> Result<u64> {
491        let Some(vectors) = self.vectors.get_mut(profile) else {
492            return Ok(0);
493        };
494        let live: HashSet<&str> = self
495            .resources
496            .get(profile)
497            .into_iter()
498            .flat_map(|b| b.values())
499            .flat_map(|r| r.segments.iter().map(|s| s.input_hash.as_str()))
500            .collect();
501        let before = vectors.len();
502        vectors.retain(|h, _| live.contains(h.as_str()));
503        Ok((before - vectors.len()) as u64)
504    }
505
506    fn stats(&self) -> Result<CacheStats> {
507        let profiles = self
508            .profiles
509            .iter()
510            .map(|(id, p)| {
511                let resources = self.resources.get(id);
512                ProfileStats {
513                    profile_id: id.clone(),
514                    profile: Some(p.clone()),
515                    resources: resources.map_or(0, |b| b.len() as u64),
516                    segments: resources
517                        .into_iter()
518                        .flat_map(|b| b.values())
519                        .map(|r| r.segments.len() as u64)
520                        .sum(),
521                    embeddings: self.vectors.get(id).map_or(0, |b| b.len() as u64),
522                    image_codes: 0,
523                }
524            })
525            .collect();
526        Ok(CacheStats {
527            location: None,
528            size_bytes: None,
529            backup_excluded: None,
530            in_synced_folder: false,
531            scrub_pending: false,
532            profiles,
533        })
534    }
535
536    fn clear(&mut self) -> Result<()> {
537        *self = Self::default();
538        Ok(())
539    }
540}
541
542/// One keyword-index document per segment: file name, path, heading and text.
543fn lexical_docs(path: &ResourceId, resource: &ResourceWrite<'_>) -> Vec<LexicalDoc> {
544    let (name, full) = path.labels();
545    resource
546        .segments
547        .iter()
548        .map(|s| LexicalDoc::new(&name, &full, s.segment.heading.as_deref(), s.lexical_body))
549        .collect()
550}
551
552// ---------------------------------------------------------------------------
553// SQLite cache (default, persistent)
554// ---------------------------------------------------------------------------
555
556pub(crate) mod image_codes;
557pub use image_codes::{IMAGE_CODEC_ID, ScanData, ScanVector};
558mod import;
559pub use import::{CacheImportMapping, CacheImportReport};
560
561/// Current SQLite cache schema; independent of CLI JSON protocol versions.
562pub const SCHEMA_VERSION: &str = "7";
563
564/// Injective for arbitrary UTF-8 IDs, including empty IDs. Hex contains no `_`,
565/// so another profile's name cannot collide with an FTS shadow-table suffix.
566fn fts_table(profile: &str) -> String {
567    let hex: String = profile
568        .as_bytes()
569        .iter()
570        .map(|b| format!("{b:02x}"))
571        .collect();
572    format!("\"segments_fts_p_{hex}\"")
573}
574
575fn create_profile_fts(tx: &rusqlite::Transaction<'_>, profile: &str) -> Result<()> {
576    tx.execute_batch(&format!(
577        "CREATE VIRTUAL TABLE IF NOT EXISTS {} USING fts5(
578            name, path, heading, body, detail=full, columnsize=1,
579            tokenize='unicode61 remove_diacritics 2');",
580        fts_table(profile)
581    ))?;
582    Ok(())
583}
584
585/// Reconstruct only live, already order-free fields. No provider reads, vector
586/// writes are needed during the upgrade.
587fn migrate_shared_fts(tx: &rusqlite::Transaction<'_>, profiles: &[String]) -> Result<()> {
588    let missing: bool = tx.query_row(
589        "SELECT EXISTS(SELECT 1 FROM segments s JOIN resources r
590         ON r.profile=s.profile AND r.path=s.path
591         WHERE s.rowid NOT IN (SELECT rowid FROM segments_fts)
592         AND NOT (r.content_hash='' AND r.modified_ns IS NULL
593                  AND (r.version IS NULL OR r.version='null') AND r.descriptor_hash IS NULL))",
594        [],
595        |r| r.get(0),
596    )?;
597    if missing {
598        return Err(Error::Config(
599            "cache migration: live segment is missing its keyword row".into(),
600        ));
601    }
602    tx.execute_batch(
603        "CREATE VIRTUAL TABLE temp.migration_vocab USING fts5vocab(main, segments_fts, instance);",
604    )?;
605    let mut docs: HashMap<i64, (String, [Vec<String>; 4])> = tx
606        .prepare("SELECT s.rowid,s.profile FROM segments s JOIN segments_fts f ON f.rowid=s.rowid")?
607        .query_map([], |r| {
608            Ok((r.get(0)?, (r.get(1)?, std::array::from_fn(|_| Vec::new()))))
609        })?
610        .collect::<rusqlite::Result<_>>()?;
611    {
612        let mut stmt = tx.prepare("SELECT term,doc,col FROM temp.migration_vocab")?;
613        let mut rows = stmt.query([])?;
614        while let Some(row) = rows.next()? {
615            let doc = row.get::<_, i64>(1)?;
616            let Some((_, fields)) = docs.get_mut(&doc) else {
617                continue;
618            };
619            let col: String = row.get(2)?;
620            let index = ["name", "path", "heading", "body"]
621                .iter()
622                .position(|f| *f == col)
623                .ok_or_else(|| Error::Config("cache migration: invalid keyword field".into()))?;
624            fields[index].push(row.get(0)?);
625        }
626    }
627    for (rowid, (profile, fields)) in &mut docs {
628        let fields = fields.each_mut().map(|terms| {
629            terms.sort();
630            terms.join(" ")
631        });
632        tx.prepare_cached(&format!(
633            "INSERT INTO {}(rowid,name,path,heading,body) VALUES(?1,?2,?3,?4,?5)",
634            fts_table(profile)
635        ))?
636        .execute(params![rowid, fields[0], fields[1], fields[2], fields[3]])?;
637    }
638    // Verify every occurrence (including duplicate tokens and sorted positions)
639    // before removing the shared index. Any failure rolls back the whole upgrade.
640    for profile in profiles {
641        let table = fts_table(profile);
642        tx.execute_batch(&format!(
643            "CREATE VIRTUAL TABLE temp.migration_new USING fts5vocab(main, {table}, instance);"
644        ))?;
645        let different: bool = tx.query_row(
646            "WITH old AS (SELECT term,doc,col,offset FROM temp.migration_vocab
647                JOIN segments s ON s.rowid=doc WHERE s.profile=?1),
648             missing AS (SELECT * FROM old EXCEPT SELECT * FROM temp.migration_new),
649             added AS (SELECT * FROM temp.migration_new EXCEPT SELECT * FROM old)
650             SELECT EXISTS(SELECT 1 FROM missing UNION ALL SELECT 1 FROM added)",
651            [profile],
652            |r| r.get(0),
653        )?;
654        let count: i64 =
655            tx.query_row(&format!("SELECT count(*) FROM {table}"), [], |r| r.get(0))?;
656        if different || count != docs.values().filter(|(p, _)| p == profile).count() as i64 {
657            return Err(Error::Config(
658                "cache migration: keyword round trip failed".into(),
659            ));
660        }
661        tx.execute_batch("DROP TABLE temp.migration_new;")?;
662    }
663    tx.execute_batch("DROP TABLE temp.migration_vocab; DROP TABLE segments_fts;")?;
664    Ok(())
665}
666
667/// Persistent cache in a single SQLite file.
668// All write transactions use IMMEDIATE: acquire the writer slot before any
669// comparison/read snapshot, so another connection cannot cause BUSY_SNAPSHOT.
670pub struct SqliteCache {
671    conn: Connection,
672    path: PathBuf,
673    guards: super::cache_guards::GuardStatus,
674    last_change: std::time::Instant,
675    last_attempt: Option<std::time::Instant>,
676    observed_generation: Option<String>,
677    #[cfg(test)]
678    after_vector_read: Option<Box<dyn FnMut() + Send>>,
679}
680
681impl SqliteCache {
682    /// The default location: `<platform cache dir>/seyk/cache.sqlite3`.
683    ///
684    /// macOS: `~/Library/Caches/seyk/`, Linux: `$XDG_CACHE_HOME/seyk/`
685    /// (usually `~/.cache/seyk/`).
686    pub fn default_path() -> Result<PathBuf> {
687        let base = dirs::cache_dir()
688            .ok_or_else(|| Error::Config("no cache directory on this platform".into()))?;
689        Ok(base.join("seyk").join("cache.sqlite3"))
690    }
691
692    pub fn open_default() -> Result<Self> {
693        Self::open(Self::default_path()?)
694    }
695
696    /// Open or create a cache file. Embeddings can leak information about
697    /// their source text, so owned directories and the cache file are private to the user.
698    pub fn open(path: impl Into<PathBuf>) -> Result<Self> {
699        Self::open_with_guards(path.into(), super::cache_guards::prepare)
700    }
701
702    fn open_with_guards(
703        path: PathBuf,
704        prepare: impl FnOnce(&Path, bool) -> super::cache_guards::GuardStatus,
705    ) -> Result<Self> {
706        Self::open_internal(path, prepare, true)
707    }
708
709    fn open_internal(
710        path: PathBuf,
711        prepare: impl FnOnce(&Path, bool) -> super::cache_guards::GuardStatus,
712        initialize: bool,
713    ) -> Result<Self> {
714        let dir = super::cache_guards::parent_dir(&path);
715        // Only successful creation of the final directory establishes ownership;
716        // an existence check followed by create_dir_all would race another creator.
717        let created = match std::fs::create_dir(dir) {
718            Ok(()) => true,
719            Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
720                std::fs::create_dir_all(super::cache_guards::parent_dir(dir))
721                    .map_err(|e| Error::io(dir, e))?;
722                match std::fs::create_dir(dir) {
723                    Ok(()) => true,
724                    Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists && dir.is_dir() => {
725                        false
726                    }
727                    Err(e) => return Err(Error::io(dir, e)),
728                }
729            }
730            Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists && dir.is_dir() => false,
731            Err(e) => return Err(Error::io(dir, e)),
732        };
733        let owned = created || super::cache_guards::is_default_dir(dir);
734        if owned {
735            restrict_permissions(dir, 0o700);
736        }
737        // Native verification and sync detection happen once, before any cache mutex.
738        let guards = prepare(dir, owned);
739        let conn = Connection::open(&path)?;
740        restrict_permissions(&path, 0o600);
741        conn.pragma_update(None, "journal_mode", "WAL")?;
742        conn.pragma_update(None, "synchronous", "NORMAL")?;
743        conn.pragma_update(None, "foreign_keys", "ON")?;
744        conn.pragma_update(None, "secure_delete", "ON")?;
745        conn.busy_timeout(std::time::Duration::from_secs(10))?;
746        let mut cache = Self {
747            conn,
748            path,
749            guards,
750            last_change: std::time::Instant::now(),
751            last_attempt: None,
752            observed_generation: None,
753            #[cfg(test)]
754            after_vector_read: None,
755        };
756        if !initialize {
757            return Ok(cache);
758        }
759        cache.migrate()?;
760        cache.observed_generation = cache
761            .conn
762            .query_row(
763                "SELECT value FROM meta WHERE key='wal_scrub_pending'",
764                [],
765                |r| r.get(0),
766            )
767            .optional()?;
768        cache.last_change = std::time::Instant::now();
769        Ok(cache)
770    }
771
772    pub fn path(&self) -> &Path {
773        &self.path
774    }
775
776    fn cleanup_pending(&self) -> Result<bool> {
777        Ok(self.conn.query_row(
778            "SELECT EXISTS(SELECT 1 FROM meta WHERE key IN ('wal_scrub_pending', 'compact_pending') OR key GLOB 'fts_scrub_pending:*')",
779            [], |r| r.get(0),
780        )?)
781    }
782
783    /// Do not spend the normal write busy timeout waiting for readers during a scrub.
784    fn run_scrub(&mut self) -> Result<CacheCleanup> {
785        self.last_attempt = Some(std::time::Instant::now());
786        if !self.cleanup_pending()? {
787            return Ok(CacheCleanup::default());
788        }
789        self.conn.busy_timeout(std::time::Duration::ZERO)?;
790        let mut report = CacheCleanup::default();
791        let result = self.scrub_pending_inner(&mut report);
792        self.conn.busy_timeout(std::time::Duration::from_secs(10))?;
793        match result {
794            Ok(()) => Ok(report),
795            Err(Error::Cache(error)) if sqlite_busy(&error) => {
796                report.pending = true;
797                Ok(report)
798            }
799            Err(error) => Err(error),
800        }
801    }
802
803    fn scrub_pending_inner(&mut self, report: &mut CacheCleanup) -> Result<()> {
804        let tx = self
805            .conn
806            .transaction_with_behavior(rusqlite::TransactionBehavior::Immediate)?;
807        let profiles = tx
808            .prepare("SELECT key,value FROM meta WHERE key GLOB 'fts_scrub_pending:*'")?
809            .query_map([], |r| Ok((r.get::<_, String>(0)?, r.get::<_, String>(1)?)))?
810            .collect::<rusqlite::Result<Vec<_>>>()?;
811        for (key, _) in &profiles {
812            optimize_fts(&tx, key.strip_prefix("fts_scrub_pending:").unwrap())?;
813        }
814        // Capture markers under the same write lock as the FTS scrub.
815        let generation: Option<String> = tx
816            .query_row(
817                "SELECT value FROM meta WHERE key='wal_scrub_pending'",
818                [],
819                |r| r.get(0),
820            )
821            .optional()?;
822        tx.commit()?;
823        report.profiles_scrubbed = profiles.len();
824        let Some(generation) = generation else {
825            report.pending = self.cleanup_pending()?;
826            return Ok(());
827        };
828        let compact: bool = self.conn.query_row(
829            "SELECT EXISTS(SELECT 1 FROM meta WHERE key='compact_pending')",
830            [],
831            |r| r.get(0),
832        )?;
833        if compact {
834            self.conn.flush_prepared_statement_cache();
835            self.conn.execute_batch("VACUUM")?;
836        }
837        // The WAL flag stays durable until checkpoint completion, including across crashes.
838        let busy: i64 = self
839            .conn
840            .query_row("PRAGMA wal_checkpoint(TRUNCATE)", [], |r| r.get(0))?;
841        if busy != 0 {
842            report.pending = true;
843            return Ok(());
844        }
845        // This housekeeping may write new WAL frames, but they contain only live meta
846        // records, never the deleted content removed by the completed checkpoint.
847        let tx = self
848            .conn
849            .transaction_with_behavior(rusqlite::TransactionBehavior::Immediate)?;
850        for (key, value) in &profiles {
851            tx.execute(
852                "DELETE FROM meta WHERE key=?1 AND value=?2",
853                params![key, value],
854            )?;
855        }
856        let finished = tx.execute(
857            "DELETE FROM meta WHERE key='wal_scrub_pending' AND value=?1",
858            [generation],
859        )?;
860        if finished != 0 {
861            tx.execute("DELETE FROM meta WHERE key='compact_pending'", [])?;
862        }
863        tx.commit()?;
864        report.pending = self.cleanup_pending()?;
865        Ok(())
866    }
867
868    fn record_write(&mut self, changed: bool, generation: Option<String>) {
869        if changed {
870            self.last_change = std::time::Instant::now();
871            // Captured under the write lease: our own marker must not look like
872            // a new external write at the next (potentially minute-later) wake.
873            self.observed_generation = generation;
874        }
875    }
876
877    fn migrate(&mut self) -> Result<()> {
878        let tx = self
879            .conn
880            .transaction_with_behavior(rusqlite::TransactionBehavior::Immediate)?;
881        Self::initialize_schema(&tx)?;
882        tx.commit()?;
883        Ok(())
884    }
885
886    fn initialize_schema(tx: &rusqlite::Transaction<'_>) -> Result<()> {
887        // Inspect before writing: an unversioned nonempty database is not a new cache.
888        let has_meta: bool = tx.query_row(
889            "SELECT EXISTS(SELECT 1 FROM sqlite_master WHERE name = 'meta')",
890            [],
891            |r| r.get(0),
892        )?;
893        let version: Option<String> = if has_meta {
894            tx.query_row(
895                "SELECT value FROM meta WHERE key = 'schema_version'",
896                [],
897                |r| r.get(0),
898            )
899            .optional()?
900        } else {
901            None
902        };
903        if version.as_deref() == Some(SCHEMA_VERSION) {
904            return Ok(());
905        }
906        if version.as_deref() == Some("6") {
907            tx.execute_batch(image_codes::CREATE)?;
908            tx.execute(
909                "UPDATE meta SET value=?1 WHERE key='schema_version'",
910                [SCHEMA_VERSION],
911            )?;
912            return Ok(());
913        }
914        if let Some(version) = &version {
915            if !matches!(version.as_str(), "1" | "2" | "3" | "4" | "5") {
916                return Err(Error::Config(format!(
917                    "cache schema {version} is newer than this binary supports; use a newer seyk or a separate --cache-path"
918                )));
919            }
920        } else {
921            let nonempty: bool = tx.query_row(
922                "SELECT EXISTS(SELECT 1 FROM sqlite_master WHERE name NOT LIKE 'sqlite_%')",
923                [],
924                |r| r.get(0),
925            )?;
926            if nonempty {
927                return Err(Error::Config(
928                    "nonempty cache is missing schema_version metadata".into(),
929                ));
930            }
931        }
932        tx.execute_batch(
933            "CREATE TABLE IF NOT EXISTS meta (key TEXT PRIMARY KEY, value TEXT NOT NULL);",
934        )?;
935        tx.execute_batch(
936            "CREATE TABLE IF NOT EXISTS profiles (
937                 id TEXT PRIMARY KEY,
938                 profile TEXT NOT NULL,
939                 dimension INTEGER NOT NULL
940             );
941             CREATE TABLE IF NOT EXISTS resources (
942                 profile TEXT NOT NULL REFERENCES profiles(id) ON DELETE CASCADE,
943                 path BLOB NOT NULL,
944                 modality TEXT NOT NULL,
945                 size_bytes INTEGER NOT NULL,
946                 modified_ns INTEGER,
947                 content_hash TEXT NOT NULL,
948                 version TEXT,
949                 PRIMARY KEY (profile, path)
950             );
951             CREATE TABLE IF NOT EXISTS segments (
952                 id INTEGER PRIMARY KEY,
953                 profile TEXT NOT NULL,
954                 path BLOB NOT NULL,
955                 ordinal INTEGER NOT NULL,
956                 locator TEXT NOT NULL,
957                 heading TEXT,
958                 input_hash TEXT NOT NULL,
959                 UNIQUE (profile, path, ordinal),
960                 FOREIGN KEY (profile, path) REFERENCES resources(profile, path) ON DELETE CASCADE
961             );
962             CREATE INDEX IF NOT EXISTS segments_by_input ON segments(profile, input_hash);
963             CREATE TABLE IF NOT EXISTS embeddings (
964                 profile TEXT NOT NULL REFERENCES profiles(id) ON DELETE CASCADE,
965                 input_hash TEXT NOT NULL,
966                 vector BLOB NOT NULL,
967                 PRIMARY KEY (profile, input_hash)
968             );
969             ",
970        )?;
971        tx.execute_batch(image_codes::CREATE)?;
972        let has_media_type: bool = tx.query_row(
973            "SELECT EXISTS(SELECT 1 FROM pragma_table_info('resources') WHERE name = 'media_type')",
974            [],
975            |r| r.get(0),
976        )?;
977        if !has_media_type {
978            tx.execute_batch("ALTER TABLE resources ADD COLUMN media_type TEXT;")?;
979        }
980        // Add the provider token and preparation fingerprint: keys, segment rowids, FTS and vectors
981        // remain byte-for-byte intact when upgrading an existing v3 cache.
982        let has_version: bool = tx.query_row(
983            "SELECT EXISTS(SELECT 1 FROM pragma_table_info('resources') WHERE name = 'version')",
984            [],
985            |r| r.get(0),
986        )?;
987        if !has_version {
988            tx.execute_batch("ALTER TABLE resources ADD COLUMN version TEXT;")?;
989        }
990        let has_descriptor: bool = tx.query_row(
991            "SELECT EXISTS(SELECT 1 FROM pragma_table_info('resources') WHERE name = 'descriptor_hash')",
992            [], |r| r.get(0),
993        )?;
994        if !has_descriptor {
995            tx.execute_batch("ALTER TABLE resources ADD COLUMN descriptor_hash TEXT;")?;
996        }
997        if matches!(version.as_deref(), Some("1" | "2" | "3")) {
998            tx.execute_batch(
999                r#"UPDATE resources SET version = CASE
1000                WHEN modified_ns IS NOT NULL AND content_hash != ''
1001                THEN '"fs:' || size_bytes || ':' || modified_ns || '"'
1002                ELSE 'null' END;"#,
1003            )?;
1004        }
1005        if version.is_some() {
1006            // Pin the legacy implicit rowid before any future VACUUM can renumber it.
1007            tx.execute_batch(
1008                "CREATE TABLE segments_v6 (
1009                    id INTEGER PRIMARY KEY,
1010                    profile TEXT NOT NULL, path BLOB NOT NULL, ordinal INTEGER NOT NULL,
1011                    locator TEXT NOT NULL, heading TEXT, input_hash TEXT NOT NULL,
1012                    UNIQUE(profile, path, ordinal),
1013                    FOREIGN KEY(profile, path) REFERENCES resources(profile, path) ON DELETE CASCADE
1014                 );
1015                 INSERT INTO segments_v6 SELECT rowid, profile, path, ordinal, locator, heading, input_hash FROM segments;
1016                 DROP TABLE segments;
1017                 ALTER TABLE segments_v6 RENAME TO segments;
1018                 CREATE INDEX segments_by_input ON segments(profile, input_hash);",
1019            )?;
1020        }
1021        let profiles = tx
1022            .prepare("SELECT id FROM profiles")?
1023            .query_map([], |r| r.get::<_, String>(0))?
1024            .collect::<rusqlite::Result<Vec<_>>>()?;
1025        for profile in &profiles {
1026            create_profile_fts(tx, profile)?;
1027        }
1028        if matches!(version.as_deref(), Some("1" | "2")) {
1029            // Remove source-order postings and invalidate both metadata and provider fast paths.
1030            // Keep segment references, including unsynced roots, so vectors cannot be pruned.
1031            tx.execute_batch(
1032                "DROP TABLE IF EXISTS segments_fts;
1033                 UPDATE resources SET modified_ns = NULL, content_hash = '', version = 'null', descriptor_hash = NULL;",
1034            )?;
1035        } else if version.is_some() {
1036            migrate_shared_fts(tx, &profiles)?;
1037        }
1038        tx.execute(
1039            "INSERT OR REPLACE INTO meta (key, value) VALUES ('schema_version', ?1)",
1040            [SCHEMA_VERSION],
1041        )?;
1042        if version.is_some() {
1043            tx.execute_batch("INSERT OR IGNORE INTO meta VALUES('compact_pending','1');")?;
1044            mark_cleanup(tx, None)?;
1045        }
1046        Ok(())
1047    }
1048}
1049
1050// File keys retain the legacy encoding and sort order. NUL cannot occur in
1051// a valid file path, so it separates non-file identities without collisions.
1052pub(crate) fn resource_key(id: &ResourceId) -> Vec<u8> {
1053    match id.to_file_path() {
1054        Some(path) => path_key(&path),
1055        None => std::iter::once(0).chain(id.as_str().bytes()).collect(),
1056    }
1057}
1058
1059fn key_resource(bytes: Vec<u8>) -> rusqlite::Result<ResourceId> {
1060    let result = if let Some(uri) = bytes.strip_prefix(&[0]) {
1061        std::str::from_utf8(uri)
1062            .map_err(|e| Error::Config(e.to_string()))
1063            .and_then(str::parse)
1064    } else {
1065        ResourceId::from_path(key_path(bytes))
1066    };
1067    result.map_err(|e| {
1068        rusqlite::Error::FromSqlConversionFailure(0, rusqlite::types::Type::Blob, Box::new(e))
1069    })
1070}
1071
1072fn decode_version(value: Option<String>) -> rusqlite::Result<ResourceVersion> {
1073    value
1074        .map(|v| {
1075            serde_json::from_str(&v).map_err(|e| {
1076                rusqlite::Error::FromSqlConversionFailure(
1077                    0,
1078                    rusqlite::types::Type::Text,
1079                    Box::new(e),
1080                )
1081            })
1082        })
1083        .transpose()
1084        .map(Option::unwrap_or_default)
1085}
1086
1087#[cfg(unix)]
1088pub(crate) fn path_key(p: &Path) -> Vec<u8> {
1089    use std::os::unix::ffi::OsStrExt;
1090    p.as_os_str().as_bytes().to_vec()
1091}
1092
1093#[cfg(unix)]
1094fn key_path(b: Vec<u8>) -> PathBuf {
1095    use std::os::unix::ffi::OsStringExt;
1096    PathBuf::from(std::ffi::OsString::from_vec(b))
1097}
1098
1099#[cfg(windows)]
1100pub(crate) fn path_key(p: &Path) -> Vec<u8> {
1101    // Discovery has always stored canonical Windows paths, including the
1102    // verbatim drive prefix. URI decoding omits that prefix; restore it so a
1103    // pre-v4 cache continues to use exactly the same keys.
1104    use std::path::{Component, Prefix};
1105    let raw = p.to_string_lossy();
1106    match p.components().next() {
1107        Some(Component::Prefix(prefix)) if matches!(prefix.kind(), Prefix::Disk(_)) => {
1108            format!(r"\\?\{raw}").into_bytes()
1109        }
1110        _ => raw.into_owned().into_bytes(),
1111    }
1112}
1113
1114#[cfg(not(any(unix, windows)))]
1115pub(crate) fn path_key(p: &Path) -> Vec<u8> {
1116    p.to_string_lossy().into_owned().into_bytes()
1117}
1118
1119#[cfg(not(unix))]
1120fn key_path(b: Vec<u8>) -> PathBuf {
1121    PathBuf::from(String::from_utf8_lossy(&b).into_owned())
1122}
1123
1124#[cfg(unix)]
1125fn restrict_permissions(p: &Path, mode: u32) {
1126    use std::os::unix::fs::PermissionsExt;
1127    let _ = std::fs::set_permissions(p, std::fs::Permissions::from_mode(mode));
1128}
1129
1130#[cfg(not(unix))]
1131fn restrict_permissions(_: &Path, _: u32) {}
1132
1133fn sqlite_busy(error: &rusqlite::Error) -> bool {
1134    matches!(error, rusqlite::Error::SqliteFailure(code, _)
1135        if matches!(code.code, rusqlite::ErrorCode::DatabaseBusy | rusqlite::ErrorCode::DatabaseLocked))
1136}
1137
1138fn mark_cleanup(tx: &rusqlite::Transaction<'_>, profile: Option<&str>) -> Result<String> {
1139    let generation: String = tx.query_row("SELECT lower(hex(randomblob(16)))", [], |r| r.get(0))?;
1140    tx.execute(
1141        "INSERT INTO meta VALUES('wal_scrub_pending',?1)
1142         ON CONFLICT(key) DO UPDATE SET value=excluded.value",
1143        [&generation],
1144    )?;
1145    if let Some(profile) = profile {
1146        tx.execute(
1147            "INSERT INTO meta VALUES(?1,?2) ON CONFLICT(key) DO UPDATE SET value=excluded.value",
1148            params![format!("fts_scrub_pending:{profile}"), generation],
1149        )?;
1150    }
1151    Ok(generation)
1152}
1153
1154// A fresh write requests no scrub, but postpones an already pending idle scrub.
1155// Updating the marker also lets other engines/processes observe activity.
1156fn refresh_pending_activity(tx: &rusqlite::Transaction<'_>) -> Result<Option<String>> {
1157    Ok(tx.query_row(
1158        "UPDATE meta SET value=lower(hex(randomblob(16))) WHERE key='wal_scrub_pending' RETURNING value",
1159        [], |r| r.get(0),
1160    ).optional()?)
1161}
1162
1163/// Remove a resource's keyword rows. FTS rows are not covered by
1164/// the foreign-key cascade, so they go first, while their segment rows exist.
1165fn delete_fts_rows(tx: &rusqlite::Transaction, profile: &str, key: &[u8]) -> Result<bool> {
1166    let deleted = tx
1167        .prepare_cached(&format!(
1168            "DELETE FROM {} WHERE rowid IN
1169             (SELECT rowid FROM segments WHERE profile = ?1 AND path = ?2)",
1170            fts_table(profile)
1171        ))?
1172        .execute(params![profile, key])?;
1173    Ok(deleted != 0)
1174}
1175
1176/// Merge all FTS segments to erase stale postings, including compressed suffixes.
1177/// The forensic negative-control test verifies this with the bundled SQLite.
1178fn optimize_fts(tx: &rusqlite::Transaction, profile: &str) -> Result<()> {
1179    let table = fts_table(profile);
1180    tx.execute_batch(&format!("INSERT INTO {table}({table}) VALUES('optimize');"))?;
1181    Ok(())
1182}
1183
1184fn parse_modality(s: &str) -> Modality {
1185    serde_json::from_value(serde_json::Value::String(s.to_string())).unwrap_or(Modality::Text)
1186}
1187
1188fn json_err(e: serde_json::Error) -> Error {
1189    Error::Config(format!("corrupt cache entry: {e}"))
1190}
1191
1192impl EmbeddingCache for SqliteCache {
1193    fn scrub(&mut self) -> Result<CacheCleanup> {
1194        self.run_scrub()
1195    }
1196
1197    fn scrub_if_idle(&mut self, quiet: std::time::Duration) -> Result<Option<CacheCleanup>> {
1198        let now = std::time::Instant::now();
1199        let generation: Option<String> = self
1200            .conn
1201            .query_row(
1202                "SELECT value FROM meta WHERE key='wal_scrub_pending'",
1203                [],
1204                |r| r.get(0),
1205            )
1206            .optional()?;
1207        if generation != self.observed_generation {
1208            self.observed_generation = generation;
1209            self.last_change = now;
1210        }
1211        if now.duration_since(self.last_change) < quiet
1212            || self
1213                .last_attempt
1214                .is_some_and(|attempt| now.duration_since(attempt) < quiet)
1215            || !self.cleanup_pending()?
1216        {
1217            return Ok(None);
1218        }
1219        self.run_scrub().map(Some)
1220    }
1221
1222    fn scrub_delay(&self, quiet: std::time::Duration) -> std::time::Duration {
1223        let remaining = quiet.saturating_sub(self.last_change.elapsed()).max(
1224            self.last_attempt
1225                .map_or(std::time::Duration::ZERO, |attempt| {
1226                    quiet.saturating_sub(attempt.elapsed())
1227                }),
1228        );
1229        if remaining.is_zero() {
1230            quiet
1231        } else {
1232            remaining
1233        }
1234    }
1235
1236    fn register_profile(&mut self, id: &str, profile: &CacheProfile) -> Result<()> {
1237        let tx = self
1238            .conn
1239            .transaction_with_behavior(rusqlite::TransactionBehavior::Immediate)?;
1240        let inserted = tx.execute(
1241            "INSERT OR IGNORE INTO profiles (id, profile, dimension) VALUES (?1, ?2, ?3)",
1242            params![
1243                id,
1244                serde_json::to_string(profile).map_err(json_err)?,
1245                profile.model.dimension.get() as i64
1246            ],
1247        )?;
1248        if inserted == 0 {
1249            let table_exists: bool = tx.query_row(
1250                "SELECT EXISTS(SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = ?1)",
1251                [fts_table(id).trim_matches('"')],
1252                |r| r.get(0),
1253            )?;
1254            if !table_exists {
1255                return Err(Error::Config(
1256                    "corrupt cache: registered profile is missing its keyword table".into(),
1257                ));
1258            }
1259        } else {
1260            create_profile_fts(&tx, id)?;
1261        }
1262        tx.commit()?;
1263        Ok(())
1264    }
1265
1266    fn resource_index(&self, profile: &str) -> Result<HashMap<ResourceId, CachedResourceState>> {
1267        let mut stmt = self.conn.prepare_cached(
1268            "SELECT path, modality, size_bytes, modified_ns, content_hash, version, descriptor_hash, media_type FROM resources WHERE profile = ?1",
1269        )?;
1270        let rows = stmt.query_map([profile], |r| {
1271            let id = key_resource(r.get(0)?)?;
1272            let metadata = ResourceMetadata {
1273                media_type: r.get(7)?,
1274                modality: parse_modality(&r.get::<_, String>(1)?),
1275                size_bytes: r.get::<_, i64>(2)? as u64,
1276                modified_ns: r.get(3)?,
1277                content_hash: r.get(4)?,
1278            };
1279            let version = decode_version(r.get(5)?)?;
1280            Ok((
1281                id,
1282                CachedResourceState {
1283                    metadata,
1284                    version,
1285                    descriptor_hash: r.get(6)?,
1286                },
1287            ))
1288        })?;
1289        Ok(rows.collect::<rusqlite::Result<_>>()?)
1290    }
1291
1292    fn resource(&self, profile: &str, path: &ResourceId) -> Result<Option<CachedResource>> {
1293        let key = resource_key(path);
1294        let metadata = self
1295            .conn
1296            .query_row(
1297                "SELECT modality, size_bytes, modified_ns, content_hash, version, descriptor_hash, media_type FROM resources WHERE profile = ?1 AND path = ?2",
1298                params![profile, key],
1299                |r| {
1300                    Ok((ResourceMetadata {
1301                        media_type: r.get(6)?,
1302                        modality: parse_modality(&r.get::<_, String>(0)?),
1303                        size_bytes: r.get::<_, i64>(1)? as u64,
1304                        modified_ns: r.get(2)?,
1305                        content_hash: r.get(3)?,
1306                    }, decode_version(r.get(4)?)?, r.get::<_, Option<String>>(5)?))
1307                },
1308            )
1309            .optional()?;
1310        let Some((metadata, version, descriptor_hash)) = metadata else {
1311            return Ok(None);
1312        };
1313        let mut stmt = self.conn.prepare_cached(
1314            "SELECT locator, heading, input_hash FROM segments WHERE profile = ?1 AND path = ?2 ORDER BY ordinal",
1315        )?;
1316        let segments = stmt
1317            .query_map(params![profile, key], |r| {
1318                Ok((
1319                    r.get::<_, String>(0)?,
1320                    r.get::<_, Option<String>>(1)?,
1321                    r.get::<_, String>(2)?,
1322                ))
1323            })?
1324            .map(|row| {
1325                let (loc, heading, input_hash) = row?;
1326                Ok(CachedSegment {
1327                    locator: serde_json::from_str(&loc).map_err(json_err)?,
1328                    heading,
1329                    input_hash,
1330                })
1331            })
1332            .collect::<Result<Vec<_>>>()?;
1333        Ok(Some(CachedResource {
1334            metadata,
1335            version,
1336            descriptor_hash,
1337            segments,
1338        }))
1339    }
1340
1341    fn put_resource(
1342        &mut self,
1343        profile: &str,
1344        path: &ResourceId,
1345        resource: &ResourceWrite<'_>,
1346    ) -> Result<()> {
1347        let key = resource_key(path);
1348        let tx = self
1349            .conn
1350            .transaction_with_behavior(rusqlite::TransactionBehavior::Immediate)?;
1351        let deleted = delete_fts_rows(&tx, profile, &key)?;
1352        let replaced = tx.execute(
1353            "DELETE FROM resources WHERE profile = ?1 AND path = ?2",
1354            params![profile, key],
1355        )?;
1356        let m = &resource.metadata;
1357        tx.execute(
1358            "INSERT INTO resources (profile, path, modality, size_bytes, modified_ns, content_hash, version, descriptor_hash, media_type)
1359             VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)",
1360            params![
1361                profile,
1362                key,
1363                m.modality.to_string(),
1364                m.size_bytes as i64,
1365                m.modified_ns,
1366                m.content_hash,
1367                serde_json::to_string(&resource.version).map_err(json_err)?,
1368                resource.descriptor_hash,
1369                m.media_type
1370            ],
1371        )?;
1372        {
1373            let mut stmt = tx.prepare_cached(
1374                "INSERT INTO segments (profile, path, ordinal, locator, heading, input_hash)
1375                 VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
1376            )?;
1377            let mut fts = tx.prepare_cached(&format!(
1378                "INSERT INTO {} (rowid, name, path, heading, body) VALUES (?1, ?2, ?3, ?4, ?5)",
1379                fts_table(profile)
1380            ))?;
1381            let (name, full) = path.labels();
1382            let name = order_free_text(&name);
1383            let full = order_free_text(&full);
1384            for (i, s) in resource.segments.iter().enumerate() {
1385                stmt.execute(params![
1386                    profile,
1387                    key,
1388                    i as i64,
1389                    serde_json::to_string(&s.segment.locator).map_err(json_err)?,
1390                    s.segment.heading,
1391                    s.segment.input_hash
1392                ])?;
1393                fts.execute(params![
1394                    tx.last_insert_rowid(),
1395                    name,
1396                    full,
1397                    order_free_text(s.segment.heading.as_deref().unwrap_or("")),
1398                    order_free_text(s.lexical_body)
1399                ])?;
1400            }
1401        }
1402        let generation = if deleted || replaced != 0 {
1403            Some(mark_cleanup(&tx, deleted.then_some(profile))?)
1404        } else {
1405            refresh_pending_activity(&tx)?
1406        };
1407        tx.commit()?;
1408        self.record_write(true, generation);
1409        Ok(())
1410    }
1411
1412    fn touch_resource(
1413        &mut self,
1414        profile: &str,
1415        path: &ResourceId,
1416        metadata: &ResourceMetadata,
1417        version: &ResourceVersion,
1418    ) -> Result<()> {
1419        let tx = self
1420            .conn
1421            .transaction_with_behavior(rusqlite::TransactionBehavior::Immediate)?;
1422        let changed = tx.execute(
1423            "UPDATE resources SET modified_ns = ?3, size_bytes = ?4, version = ?5, content_hash = ?6, modality = ?7, media_type = ?8 WHERE profile = ?1 AND path = ?2
1424            AND (modified_ns IS NOT ?3 OR size_bytes IS NOT ?4 OR version IS NOT ?5 OR content_hash IS NOT ?6 OR modality IS NOT ?7 OR media_type IS NOT ?8)",
1425            params![profile, resource_key(path), metadata.modified_ns, metadata.size_bytes as i64, serde_json::to_string(version).map_err(json_err)?, metadata.content_hash, metadata.modality.to_string(), metadata.media_type],
1426        )?;
1427        let generation = if changed != 0 {
1428            Some(mark_cleanup(&tx, None)?)
1429        } else {
1430            None
1431        };
1432        tx.commit()?;
1433        self.record_write(changed != 0, generation);
1434        Ok(())
1435    }
1436
1437    fn remove_resources(&mut self, profile: &str, paths: &[ResourceId]) -> Result<()> {
1438        let tx = self
1439            .conn
1440            .transaction_with_behavior(rusqlite::TransactionBehavior::Immediate)?;
1441        if !tx.query_row(
1442            "SELECT EXISTS(SELECT 1 FROM profiles WHERE id = ?1)",
1443            [profile],
1444            |r| r.get::<_, bool>(0),
1445        )? {
1446            return Ok(());
1447        }
1448        let mut deleted = false;
1449        let mut removed = 0;
1450        {
1451            let mut stmt =
1452                tx.prepare_cached("DELETE FROM resources WHERE profile = ?1 AND path = ?2")?;
1453            for p in paths {
1454                let key = resource_key(p);
1455                deleted |= delete_fts_rows(&tx, profile, &key)?;
1456                removed += stmt.execute(params![profile, key])?;
1457            }
1458        }
1459        let generation = if deleted || removed != 0 {
1460            Some(mark_cleanup(&tx, deleted.then_some(profile))?)
1461        } else {
1462            None
1463        };
1464        tx.commit()?;
1465        self.record_write(deleted || removed != 0, generation);
1466        Ok(())
1467    }
1468
1469    fn known_inputs(&self, profile: &str, hashes: &[String]) -> Result<HashSet<String>> {
1470        let mut stmt = self
1471            .conn
1472            .prepare_cached("SELECT 1 FROM embeddings WHERE profile = ?1 AND input_hash = ?2")?;
1473        let mut out = HashSet::new();
1474        for h in hashes {
1475            if stmt.exists(params![profile, h])? {
1476                out.insert(h.clone());
1477            }
1478        }
1479        Ok(out)
1480    }
1481
1482    fn put_embeddings(&mut self, profile: &str, items: &[(String, Vec<f32>)]) -> Result<()> {
1483        let tx = self
1484            .conn
1485            .transaction_with_behavior(rusqlite::TransactionBehavior::Immediate)?;
1486        let mut overwritten = false;
1487        let mut changed = false;
1488        {
1489            let mut read = tx.prepare_cached(
1490                "SELECT vector FROM embeddings WHERE profile=?1 AND input_hash=?2",
1491            )?;
1492            let mut stmt = tx.prepare_cached(
1493                "INSERT OR REPLACE INTO embeddings (profile,input_hash,vector) VALUES(?1,?2,?3)",
1494            )?;
1495            for (hash, vector) in items {
1496                let bytes = to_bytes(vector);
1497                let old: Option<Vec<u8>> = read
1498                    .query_row(params![profile, hash], |r| r.get(0))
1499                    .optional()?;
1500                #[cfg(test)]
1501                if let Some(observer) = &mut self.after_vector_read {
1502                    observer();
1503                }
1504                if old.as_ref() == Some(&bytes) {
1505                    continue;
1506                }
1507                overwritten |= old.is_some();
1508                changed = true;
1509                stmt.execute(params![profile, hash, bytes])?;
1510            }
1511        }
1512        let generation = if overwritten {
1513            Some(mark_cleanup(&tx, None)?)
1514        } else if changed {
1515            refresh_pending_activity(&tx)?
1516        } else {
1517            None
1518        };
1519        tx.commit()?;
1520        self.record_write(changed, generation);
1521        Ok(())
1522    }
1523
1524    fn supports_image_codes(&self) -> bool {
1525        true
1526    }
1527
1528    fn sync_image_codes(&mut self, profile: &str, paths: &HashSet<ResourceId>) -> Result<()> {
1529        self.backfill_codes(profile, paths)
1530    }
1531
1532    fn image_scan_rows(
1533        &self,
1534        profile: &str,
1535        paths: &HashSet<ResourceId>,
1536    ) -> Result<Vec<ScanVector>> {
1537        self.scan_rows(profile, paths)
1538    }
1539
1540    fn selected_image_vectors(
1541        &self,
1542        profile: &str,
1543        hashes: &HashSet<String>,
1544    ) -> Result<HashMap<String, Vec<f32>>> {
1545        self.selected_vectors(profile, hashes)
1546    }
1547
1548    fn vectors_for(&self, profile: &str, paths: &HashSet<ResourceId>) -> Result<Vec<StoredVector>> {
1549        let mut stmt = self.conn.prepare_cached(
1550            "SELECT s.path, s.locator, s.heading, s.input_hash, e.vector, s.ordinal
1551             FROM segments s JOIN embeddings e ON e.profile = s.profile AND e.input_hash = s.input_hash
1552             WHERE s.profile = ?1
1553             ORDER BY s.path, s.ordinal",
1554        )?;
1555        let mut rows = stmt.query([profile])?;
1556        let mut out = Vec::new();
1557        while let Some(r) = rows.next()? {
1558            let path = key_resource(r.get(0)?)?;
1559            if !paths.contains(&path) {
1560                continue;
1561            }
1562            let locator: String = r.get(1)?;
1563            out.push(StoredVector {
1564                resource: path,
1565                ordinal: r.get::<_, i64>(5)? as usize,
1566                segment: CachedSegment {
1567                    locator: serde_json::from_str(&locator).map_err(json_err)?,
1568                    heading: r.get(2)?,
1569                    input_hash: r.get(3)?,
1570                },
1571                vector: from_bytes(&r.get::<_, Vec<u8>>(4)?).map_err(|error| {
1572                    rusqlite::Error::FromSqlConversionFailure(
1573                        4,
1574                        rusqlite::types::Type::Blob,
1575                        Box::new(error),
1576                    )
1577                })?,
1578            });
1579        }
1580        Ok(out)
1581    }
1582
1583    fn lexical_search(
1584        &self,
1585        profile: &str,
1586        query: &LexicalQuery,
1587        paths: &HashSet<ResourceId>,
1588        limit: usize,
1589    ) -> Result<Vec<LexicalHit>> {
1590        let Some(expr) = query.fts5() else {
1591            return Ok(Vec::new());
1592        };
1593        if !self.conn.query_row(
1594            "SELECT EXISTS(SELECT 1 FROM profiles WHERE id = ?1)",
1595            [profile],
1596            |r| r.get::<_, bool>(0),
1597        )? {
1598            return Ok(Vec::new());
1599        }
1600        let table = fts_table(profile);
1601        let weights = FIELDS.map(|(_, w)| w.to_string()).join(", ");
1602        // bm25() is lower-is-better; ties use the stored path bytes and ordinal
1603        // before filtering requested paths and truncating the candidate list.
1604        let sql = format!(
1605            "SELECT s.path, s.ordinal, -bm25({table}, {weights})
1606             FROM {table} JOIN segments s ON s.rowid = {table}.rowid
1607             WHERE {table} MATCH ?1 AND s.profile = ?2
1608             ORDER BY bm25({table}, {weights}), s.path, s.ordinal"
1609        );
1610        let mut stmt = self.conn.prepare_cached(&sql)?;
1611        let mut rows = stmt.query(params![expr, profile])?;
1612        let mut out = Vec::new();
1613        while out.len() < limit
1614            && let Some(r) = rows.next()?
1615        {
1616            let path = key_resource(r.get(0)?)?;
1617            if paths.contains(&path) {
1618                out.push(LexicalHit {
1619                    resource: path,
1620                    ordinal: r.get::<_, i64>(1)? as usize,
1621                    score: r.get::<_, f64>(2)? as f32,
1622                });
1623            }
1624        }
1625        Ok(out)
1626    }
1627
1628    fn lexical_matched_terms(
1629        &self,
1630        profile: &str,
1631        query: &LexicalQuery,
1632        keys: &[(ResourceId, usize)],
1633    ) -> Result<Vec<Vec<String>>> {
1634        let mut terms = vec![Vec::new(); keys.len()];
1635        if keys.is_empty() || query.groups.is_empty() {
1636            return Ok(terms);
1637        }
1638        // Resolve final keys in batches, keeping arbitrary path bytes intact.
1639        // Bound placeholders below SQLite's variable limit even for large limits.
1640        // CROSS JOIN keeps the small requested set outermost: reordering can
1641        // scan the whole profile and join every sibling of a large resource.
1642        let mut positions = HashMap::new();
1643        for (batch, chunk) in keys.chunks(400).enumerate() {
1644            let values = (0..chunk.len())
1645                .map(|i| format!("(?, ?, {})", batch * 400 + i))
1646                .collect::<Vec<_>>()
1647                .join(",");
1648            let sql = format!(
1649                "WITH requested(path, ordinal, position) AS (VALUES {values})
1650                SELECT s.rowid, r.position FROM requested r
1651                CROSS JOIN segments s ON s.path = r.path AND s.ordinal = r.ordinal
1652                WHERE s.profile = ?"
1653            );
1654            let mut params = Vec::with_capacity(chunk.len() * 2 + 1);
1655            for (path, ordinal) in chunk {
1656                params.push(rusqlite::types::Value::Blob(resource_key(path)));
1657                params.push(rusqlite::types::Value::Integer(*ordinal as i64));
1658            }
1659            params.push(rusqlite::types::Value::Text(profile.to_owned()));
1660            let mut stmt = self.conn.prepare(&sql)?;
1661            let mut rows = stmt.query(rusqlite::params_from_iter(params))?;
1662            while let Some(row) = rows.next()? {
1663                positions
1664                    .entry(row.get::<_, i64>(0)?)
1665                    .or_insert_with(Vec::new)
1666                    .push(row.get::<_, i64>(1)? as usize);
1667            }
1668        }
1669        if positions.is_empty() {
1670            return Ok(terms);
1671        }
1672        let rowids = serde_json::to_string(&positions.keys().collect::<Vec<_>>())
1673            .expect("integer rowids serialize to JSON");
1674        let table = fts_table(profile);
1675        // One contentless-index membership statement per query group, over only
1676        // the returned keyword hits. JSON avoids a variable-count limit here.
1677        // Unary + keeps SQLite from restarting FTS MATCH for each rowid.
1678        let mut stmt = self.conn.prepare_cached(&format!(
1679            "SELECT rowid FROM {table} WHERE {table} MATCH ?1
1680             AND +rowid IN (SELECT value FROM json_each(?2))"
1681        ))?;
1682        for group in &query.groups {
1683            let expr = LexicalQuery {
1684                groups: vec![group.clone()],
1685            }
1686            .fts5()
1687            .unwrap();
1688            let mut rows = stmt.query(params![expr, rowids])?;
1689            while let Some(row) = rows.next()? {
1690                for &position in &positions[&row.get::<_, i64>(0)?] {
1691                    let matched = &mut terms[position];
1692                    for term in group {
1693                        if !matched.contains(term) {
1694                            matched.push(term.clone());
1695                        }
1696                    }
1697                }
1698            }
1699        }
1700        Ok(terms)
1701    }
1702
1703    fn prune_orphans(&mut self, profile: &str) -> Result<u64> {
1704        let tx = self
1705            .conn
1706            .transaction_with_behavior(rusqlite::TransactionBehavior::Immediate)?;
1707        let n = tx.execute(
1708            "DELETE FROM embeddings WHERE profile = ?1 AND NOT EXISTS (
1709                 SELECT 1 FROM segments s WHERE s.profile = embeddings.profile AND s.input_hash = embeddings.input_hash)",
1710            [profile],
1711        )?;
1712        let generation = if n != 0 {
1713            Some(mark_cleanup(&tx, None)?)
1714        } else {
1715            None
1716        };
1717        tx.commit()?;
1718        self.record_write(n != 0, generation);
1719        Ok(n as u64)
1720    }
1721
1722    fn stats(&self) -> Result<CacheStats> {
1723        let mut stmt = self.conn.prepare(
1724            "SELECT p.id, p.profile,
1725                 (SELECT COUNT(*) FROM resources r WHERE r.profile = p.id),
1726                 (SELECT COUNT(*) FROM segments s WHERE s.profile = p.id),
1727                 (SELECT COUNT(*) FROM embeddings e WHERE e.profile = p.id),
1728                 (SELECT COUNT(*) FROM image_codes c WHERE c.profile = p.id)
1729             FROM profiles p ORDER BY p.id",
1730        )?;
1731        let profiles = stmt
1732            .query_map([], |r| {
1733                Ok(ProfileStats {
1734                    profile_id: r.get(0)?,
1735                    profile: serde_json::from_str(&r.get::<_, String>(1)?).ok(),
1736                    resources: r.get::<_, i64>(2)? as u64,
1737                    segments: r.get::<_, i64>(3)? as u64,
1738                    embeddings: r.get::<_, i64>(4)? as u64,
1739                    image_codes: r.get::<_, i64>(5)? as u64,
1740                })
1741            })?
1742            .collect::<rusqlite::Result<Vec<_>>>()?;
1743        let size_bytes = ["", "-wal", "-shm"]
1744            .iter()
1745            .filter_map(|s| std::fs::metadata(format!("{}{s}", self.path.display())).ok())
1746            .map(|m| m.len())
1747            .sum();
1748        Ok(CacheStats {
1749            location: Some(self.path.clone()),
1750            size_bytes: Some(size_bytes),
1751            backup_excluded: self.guards.backup_excluded,
1752            in_synced_folder: self.guards.in_synced_folder,
1753            scrub_pending: self.cleanup_pending()?,
1754            profiles,
1755        })
1756    }
1757
1758    fn clear(&mut self) -> Result<()> {
1759        self.conn.flush_prepared_statement_cache();
1760        let tx = self
1761            .conn
1762            .transaction_with_behavior(rusqlite::TransactionBehavior::Immediate)?;
1763        // Include orphan virtual tables, not just those still represented by profiles.
1764        let tables = tx.prepare("SELECT name FROM sqlite_master WHERE name GLOB 'segments_fts_p_*' AND sql LIKE 'CREATE VIRTUAL TABLE%'")?
1765            .query_map([], |r| r.get::<_, String>(0))?
1766            .collect::<rusqlite::Result<Vec<_>>>()?;
1767        for table in tables {
1768            tx.execute_batch(&format!("DROP TABLE \"{}\"", table.replace('"', "\"\"")))?;
1769        }
1770        tx.execute_batch("DELETE FROM embeddings; DELETE FROM segments; DELETE FROM resources; DELETE FROM profiles;")?;
1771        tx.execute("DELETE FROM meta WHERE key GLOB 'fts_scrub_pending:*'", [])?;
1772        tx.execute_batch("INSERT OR IGNORE INTO meta VALUES('compact_pending','1');")?;
1773        mark_cleanup(&tx, None)?;
1774        tx.commit()?;
1775        if self.run_scrub()?.pending {
1776            return Err(Error::CacheCleanupPending);
1777        }
1778        // Clear also removes the harmless cleanup-marker WAL frames.
1779        let busy: i64 = self
1780            .conn
1781            .query_row("PRAGMA wal_checkpoint(TRUNCATE)", [], |r| r.get(0))?;
1782        if busy != 0 {
1783            let tx = self
1784                .conn
1785                .transaction_with_behavior(rusqlite::TransactionBehavior::Immediate)?;
1786            mark_cleanup(&tx, None)?;
1787            tx.commit()?;
1788            return Err(Error::CacheCleanupPending);
1789        }
1790        Ok(())
1791    }
1792}
1793
1794#[cfg(all(test, windows))]
1795mod windows_tests {
1796    use super::*;
1797
1798    #[test]
1799    fn file_keys_keep_the_legacy_canonical_drive_prefix() {
1800        let id = ResourceId::from_path(r"C:\notes\chapter.md").unwrap();
1801        let legacy = br"\\?\C:\notes\chapter.md".to_vec();
1802        assert_eq!(resource_key(&id), legacy);
1803        assert_eq!(key_resource(legacy).unwrap(), id);
1804    }
1805}
1806
1807#[cfg(test)]
1808mod guard_tests {
1809    use super::*;
1810    use std::cell::{Cell, RefCell};
1811
1812    #[test]
1813    fn stats_reuses_one_verification_even_after_directory_changes() {
1814        let temp = tempfile::tempdir().unwrap();
1815        let calls = Cell::new(0);
1816        let cache =
1817            SqliteCache::open_with_guards(temp.path().join("cache.sqlite3"), |dir, owned| {
1818                assert!(!owned);
1819                super::super::cache_guards::prepare_with(
1820                    dir,
1821                    owned,
1822                    |_, _| panic!("foreign directory publication"),
1823                    |_| {
1824                        calls.set(calls.get() + 1);
1825                        Some(false)
1826                    },
1827                    |_| {},
1828                )
1829            })
1830            .unwrap();
1831        std::fs::create_dir(temp.path().join(".stfolder")).unwrap();
1832        for _ in 0..4 {
1833            let stats = cache.stats().unwrap();
1834            assert_eq!(stats.backup_excluded, Some(false));
1835            assert!(!stats.in_synced_folder);
1836        }
1837        assert_eq!(calls.get(), 1);
1838        assert!(!temp.path().join("CACHEDIR.TAG").exists());
1839    }
1840
1841    #[test]
1842    fn unsupported_tag_publication_warns_and_cache_still_opens() {
1843        let temp = tempfile::tempdir().unwrap();
1844        let warnings = RefCell::new(Vec::new());
1845        let path = temp.path().join("dedicated/cache.sqlite3");
1846        let cache = SqliteCache::open_with_guards(path, |dir, owned| {
1847            assert!(owned);
1848            super::super::cache_guards::prepare_with(
1849                dir,
1850                owned,
1851                |_, _| {
1852                    Err(std::io::Error::new(
1853                        std::io::ErrorKind::Unsupported,
1854                        "hard links unsupported",
1855                    ))
1856                },
1857                |_| None,
1858                |warning| warnings.borrow_mut().push(warning),
1859            )
1860        })
1861        .unwrap();
1862        assert!(cache.stats().is_ok());
1863        assert!(
1864            warnings
1865                .borrow()
1866                .iter()
1867                .any(|w| w.contains("hard links unsupported"))
1868        );
1869        assert!(!cache.path().parent().unwrap().join("CACHEDIR.TAG").exists());
1870        assert!(
1871            std::fs::read_dir(cache.path().parent().unwrap())
1872                .unwrap()
1873                .flatten()
1874                .all(|e| !e
1875                    .file_name()
1876                    .to_string_lossy()
1877                    .starts_with(".CACHEDIR.TAG-"))
1878        );
1879    }
1880}
1881
1882#[cfg(test)]
1883mod scrub_tests {
1884    use super::*;
1885    use std::time::{Duration, Instant};
1886
1887    #[test]
1888    fn sync_serializes_external_writer_between_vector_read_and_insert() {
1889        use std::sync::{
1890            Arc, Mutex,
1891            atomic::{AtomicUsize, Ordering},
1892        };
1893
1894        struct SyntheticEmbedder(crate::ModelIdentity);
1895        impl crate::Embedder for SyntheticEmbedder {
1896            fn identity(&self) -> &crate::ModelIdentity {
1897                &self.0
1898            }
1899            fn supports(&self, modality: Modality) -> bool {
1900                modality == Modality::Text
1901            }
1902            fn embed_batch(
1903                &self,
1904                requests: &[seyk_embed::EmbeddingRequest<'_>],
1905            ) -> seyk_embed::Result<Vec<crate::Embedding>> {
1906                requests
1907                    .iter()
1908                    .map(|_| crate::Embedding::from_raw(vec![1.0; 128], crate::Dimension::D128))
1909                    .collect()
1910            }
1911        }
1912        let temp = tempfile::tempdir().unwrap();
1913        let path = temp.path().join("cache.sqlite3");
1914        let file = temp.path().join("synthetic.txt");
1915        std::fs::write(&file, "synthetic alpha garden words").unwrap();
1916        let mut cache = SqliteCache::open(&path).unwrap();
1917        let writer = Arc::new(Mutex::new(Connection::open(&path).unwrap()));
1918        writer.lock().unwrap().busy_timeout(Duration::ZERO).unwrap();
1919
1920        // Negative control: the exact deferred SELECT -> external commit -> INSERT
1921        // ordering produces BUSY_SNAPSHOT, which a busy timeout cannot resolve.
1922        let tx = cache.conn.transaction().unwrap();
1923        tx.query_row("SELECT count(*) FROM embeddings", [], |r| {
1924            r.get::<_, i64>(0)
1925        })
1926        .unwrap();
1927        writer
1928            .lock()
1929            .unwrap()
1930            .execute(
1931                "INSERT INTO meta(key,value) VALUES('interleaving_control','committed')",
1932                [],
1933            )
1934            .unwrap();
1935        let error = tx
1936            .execute(
1937                "INSERT INTO meta(key,value) VALUES('stale_snapshot','write')",
1938                [],
1939            )
1940            .unwrap_err();
1941        match error {
1942            rusqlite::Error::SqliteFailure(code, _) => {
1943                assert_eq!(code.extended_code, rusqlite::ffi::SQLITE_BUSY_SNAPSHOT)
1944            }
1945            other => panic!("expected BUSY_SNAPSHOT, got {other}"),
1946        }
1947        tx.rollback().unwrap();
1948
1949        // Stop the actual sync after its vector SELECT and attempt the same external
1950        // commit before INSERT. IMMEDIATE must serialize it behind the sync writer.
1951        let attempts = Arc::new(AtomicUsize::new(0));
1952        let observed = attempts.clone();
1953        let external = writer.clone();
1954        cache.after_vector_read = Some(Box::new(move || {
1955            let error = external
1956                .lock()
1957                .unwrap()
1958                .execute(
1959                    "INSERT INTO meta(key,value) VALUES('external_writer','committed')",
1960                    [],
1961                )
1962                .unwrap_err();
1963            assert!(sqlite_busy(&error));
1964            observed.fetch_add(1, Ordering::SeqCst);
1965        }));
1966        let engine = crate::SearchEngine::builder()
1967            .embedder(SyntheticEmbedder(crate::ModelIdentity {
1968                model_id: "test/interleaving".into(),
1969                revision: "1".into(),
1970                variant: "synthetic".into(),
1971                dimension: crate::Dimension::D128,
1972                normalization: "l2".into(),
1973                query_prompt: String::new(),
1974                document_prompt: String::new(),
1975                max_tokens: 512,
1976            }))
1977            .cache(cache)
1978            .build()
1979            .unwrap();
1980        let result = engine.index().paths([&file]).execute().unwrap();
1981        assert_eq!(result.sync.resources_updated, 1);
1982        assert!(attempts.load(Ordering::SeqCst) > 0);
1983        // The external commit succeeds after the sync releases its write lease.
1984        writer
1985            .lock()
1986            .unwrap()
1987            .execute(
1988                "INSERT INTO meta(key,value) VALUES('external_writer','committed')",
1989                [],
1990            )
1991            .unwrap();
1992        assert_eq!(
1993            engine.with_cache(|c| c.stats()).unwrap().profiles[0].embeddings,
1994            1
1995        );
1996    }
1997
1998    #[test]
1999    fn scrub_scheduler_uses_quiet_and_retry_deadlines_without_busy_polling() {
2000        let temp = tempfile::tempdir().unwrap();
2001        let mut cache = SqliteCache::open(temp.path().join("cache.sqlite3")).unwrap();
2002        let quiet = Duration::from_secs(60);
2003        cache.last_change = Instant::now() - Duration::from_secs(40);
2004        let delay = cache.scrub_delay(quiet);
2005        assert!(delay > Duration::from_secs(19) && delay <= Duration::from_secs(20));
2006        cache.last_attempt = Some(Instant::now() - Duration::from_secs(5));
2007        let retry = cache.scrub_delay(quiet);
2008        assert!(retry > Duration::from_secs(54) && retry <= Duration::from_secs(55));
2009        cache.last_change = Instant::now() - Duration::from_secs(61);
2010        cache.last_attempt = None;
2011        assert_eq!(
2012            cache.scrub_delay(quiet),
2013            quiet,
2014            "idle worker checks once per minute"
2015        );
2016        assert_eq!(MemoryCache::new().scrub_delay(quiet), quiet);
2017    }
2018
2019    #[test]
2020    fn local_writes_keep_their_deadline_but_external_writes_restart_it() {
2021        let temp = tempfile::tempdir().unwrap();
2022        let path = temp.path().join("cache.sqlite3");
2023        let mut cache = SqliteCache::open(&path).unwrap();
2024        let profile = CacheProfile {
2025            model: ModelIdentity {
2026                model_id: "test/deadline".into(),
2027                revision: "1".into(),
2028                variant: "synthetic".into(),
2029                dimension: crate::Dimension::D128,
2030                normalization: "l2".into(),
2031                query_prompt: String::new(),
2032                document_prompt: String::new(),
2033                max_tokens: 512,
2034            },
2035            segmenter_version: "test".into(),
2036        };
2037        let pid = profile.id();
2038        cache.register_profile(&pid, &profile).unwrap();
2039        cache
2040            .put_embeddings(&pid, &[("input".into(), vec![1.0; 128])])
2041            .unwrap();
2042        cache
2043            .put_embeddings(&pid, &[("input".into(), vec![2.0; 128])])
2044            .unwrap();
2045        assert!(cache.observed_generation.is_some());
2046        let quiet = Duration::from_secs(60);
2047        cache.last_change = Instant::now() - Duration::from_secs(61);
2048        assert!(
2049            !cache.scrub_if_idle(quiet).unwrap().unwrap().pending,
2050            "own marker must not add a second quiet period at the first wake"
2051        );
2052        cache
2053            .put_embeddings(&pid, &[("input".into(), vec![3.0; 128])])
2054            .unwrap();
2055        // A new-only write while pending also captures the refreshed marker.
2056        cache
2057            .put_embeddings(&pid, &[("new-input".into(), vec![4.0; 128])])
2058            .unwrap();
2059        let mut writer = Connection::open(&path).unwrap();
2060        let tx = writer
2061            .transaction_with_behavior(rusqlite::TransactionBehavior::Immediate)
2062            .unwrap();
2063        refresh_pending_activity(&tx).unwrap();
2064        tx.commit().unwrap();
2065        cache.last_change = Instant::now() - Duration::from_secs(61);
2066        cache.last_attempt = None;
2067        assert!(cache.scrub_if_idle(quiet).unwrap().is_none());
2068        assert!(cache.last_change.elapsed() < Duration::from_secs(1));
2069        let reopened = SqliteCache::open(&path).unwrap();
2070        assert_eq!(
2071            reopened.observed_generation, cache.observed_generation,
2072            "open observes pending generation before starting its quiet period"
2073        );
2074    }
2075
2076    #[test]
2077    fn idle_scrub_observes_other_writers_and_throttles_busy_retries() {
2078        let temp = tempfile::tempdir().unwrap();
2079        let path = temp.path().join("cache.sqlite3");
2080        let mut cache = SqliteCache::open(&path).unwrap();
2081        let tx = cache.conn.transaction().unwrap();
2082        mark_cleanup(&tx, None).unwrap();
2083        tx.commit().unwrap();
2084        let quiet = Duration::from_secs(60);
2085        assert!(cache.scrub_if_idle(quiet).unwrap().is_none());
2086        cache.last_change = Instant::now() - Duration::from_secs(61);
2087        // A fresh write in another process postpones pending cleanup too.
2088        let mut writer = Connection::open(&path).unwrap();
2089        let tx = writer.transaction().unwrap();
2090        refresh_pending_activity(&tx).unwrap();
2091        tx.commit().unwrap();
2092        assert!(cache.scrub_if_idle(quiet).unwrap().is_none());
2093        assert!(cache.last_change.elapsed() < quiet);
2094        let reader = Connection::open(&path).unwrap();
2095        reader.execute_batch("BEGIN; SELECT * FROM meta;").unwrap();
2096        cache.last_change = Instant::now() - Duration::from_secs(61);
2097        assert!(cache.scrub_if_idle(quiet).unwrap().unwrap().pending);
2098        assert!(
2099            cache.scrub_if_idle(quiet).unwrap().is_none(),
2100            "do not optimize again on every tick with a busy reader"
2101        );
2102        assert!(cache.cleanup_pending().unwrap());
2103        reader.execute_batch("ROLLBACK").unwrap();
2104        cache.last_attempt = Some(Instant::now() - Duration::from_secs(61));
2105        assert!(!cache.scrub_if_idle(quiet).unwrap().unwrap().pending);
2106        assert!(!cache.cleanup_pending().unwrap());
2107    }
2108}