#![expect(
clippy::let_underscore_must_use,
reason = "the catalog has no other home; see .claude/OPEN-QUESTIONS-6.4.md"
)]
use crate::{KevyError, KevyResult};
use std::io;
use std::sync::RwLock;
use kevy_index::{
Catalog, Cursor, IndexKind, IndexSpec, IndexValue, Segment, SegmentStats, ValType,
};
use crate::store::{Store, lock_write};
pub(crate) use crate::ops_index_sync::{each_written_key_pub, on_commit, sync_segs};
pub type IndexPage = (Vec<(Vec<u8>, IndexValue)>, Option<Cursor>);
#[cfg(feature = "text")]
pub type FieldSpans = (Vec<u8>, Vec<(u32, u32)>);
#[cfg(feature = "text")]
pub type HighlightedHit = (Vec<u8>, f64, Vec<FieldSpans>);
#[cfg(feature = "text")]
#[path = "ops_index_highlight.rs"]
pub(crate) mod highlight;
#[path = "ops_index_claused.rs"]
pub(crate) mod claused;
#[path = "ops_index_advise.rs"]
pub(crate) mod advise;
#[path = "ops_index_admin.rs"]
mod admin;
#[cfg(feature = "text")]
#[path = "ops_index_text.rs"]
mod text;
#[cfg(feature = "text")]
#[path = "ops_index_text_cold.rs"]
pub(crate) mod text_cold;
pub(crate) fn merge_page(mut all: Vec<(IndexValue, Vec<u8>)>, limit: usize) -> IndexPage {
all.sort();
all.truncate(limit);
let next = if all.len() == limit {
all.last().map(|(v, k)| Cursor { value: v.clone(), key: k.clone() })
} else {
None
};
(all.into_iter().map(|(v, k)| (k, v)).collect(), next)
}
#[derive(Debug, Default)]
pub(crate) struct IndexReg {
pub(crate) catalog: RwLock<(u64, Catalog)>,
pub(crate) usage:
RwLock<std::collections::HashMap<Vec<u8>, std::sync::Arc<kevy_index::UsageCell>>>,
}
#[cfg(not(target_arch = "wasm32"))]
pub(crate) type WinRef<'a> = Option<&'a kevy_window::WindowRt>;
#[cfg(target_arch = "wasm32")]
pub(crate) type WinRef<'a> = Option<&'a core::convert::Infallible>;
#[derive(Debug, Default)]
pub(crate) struct ShardSegs {
pub(crate) version: u64,
pub(crate) segs: Vec<(IndexSpec, Segment)>,
#[cfg(feature = "text")]
pub(crate) text: Vec<(IndexSpec, kevy_text::TextSegment)>,
#[cfg(feature = "vector")]
pub(crate) ann: Vec<(IndexSpec, kevy_vector::Hnsw)>,
pub(crate) agg: Vec<(IndexSpec, kevy_index::AggSegment)>,
#[cfg(not(target_arch = "wasm32"))]
pub(crate) windows: Vec<(Vec<u8>, kevy_window::WindowRt)>,
#[cfg(all(feature = "text", not(target_arch = "wasm32")))]
pub(crate) cold_text: Vec<(Vec<u8>, kevy_window::TextColdDir)>,
#[cfg(all(feature = "tier", not(target_arch = "wasm32")))]
pub(crate) stats_dirty: bool,
#[cfg(all(feature = "tier", not(target_arch = "wasm32")))]
pub(crate) reserved_cache: u64,
}
impl ShardSegs {
#[cfg(not(target_arch = "wasm32"))]
pub(crate) fn window_of(&self, name: &[u8]) -> Option<&kevy_window::WindowRt> {
self.windows.iter().find(|(n, _)| n == name).map(|(_, w)| w)
}
#[inline]
pub(crate) fn mark_stats_dirty(&mut self) {
#[cfg(all(feature = "tier", not(target_arch = "wasm32")))]
{
self.stats_dirty = true;
}
}
}
#[cfg(feature = "persist")]
const SIDECAR: &str = "index-catalog.meta";
impl Store {
pub fn idx_create(
&self,
name: &[u8],
prefix: &[u8],
field: &[u8],
ty: ValType,
kind: IndexKind,
) -> KevyResult<()> {
if prefix.is_empty() {
return Err(KevyError::InvalidInput("empty prefix".into()));
}
#[cfg(not(feature = "text"))]
if kind == IndexKind::Text {
return Err(KevyError::Unsupported("text indexes need the `text` feature".into()));
}
#[cfg(not(feature = "vector"))]
if kind == IndexKind::Ann {
return Err(KevyError::Unsupported("vector indexes need the `vector` feature".into()));
}
let spec = IndexSpec {
name: name.to_vec(),
prefix: prefix.to_vec(),
fields: vec![kevy_index::FieldSpec::new(field.to_vec())],
ty,
kind,
max_bytes: 0,
ann: None,
group_by: None,
with_positions: false,
values: Vec::new(),
composite: None,
};
self.register_spec(spec)
}
pub(crate) fn register_spec(&self, spec: IndexSpec) -> KevyResult<()> {
#[cfg(all(feature = "tier", not(target_arch = "wasm32")))]
crate::ops_index_sync::tier_floor_check(&self.shards)?;
{
let mut g =
self.indexes.catalog.write().unwrap_or_else(std::sync::PoisonError::into_inner);
let (ver, cat) = &mut *g;
cat.create(spec).map_err(|e| io::Error::new(io::ErrorKind::InvalidInput, e))?;
*ver += 1;
}
self.persist_index_sidecar();
self.advise_clear();
self.usage_rekey();
for shard in self.shards.iter() {
let mut g = lock_write(shard);
let inner = &mut *g;
sync_segs(&self.indexes, &mut inner.idx_segs, &mut inner.store);
}
Ok(())
}
#[cfg(feature = "vector")]
pub fn idx_create_ann(
&self,
name: &[u8],
prefix: &[u8],
field: &[u8],
params: kevy_index::AnnSpec,
) -> KevyResult<()> {
if params.dim == 0 || params.distance > 2 {
return Err(KevyError::InvalidInput("bad ann parameters".into()));
}
let spec = IndexSpec {
name: name.to_vec(),
prefix: prefix.to_vec(),
fields: vec![kevy_index::FieldSpec::new(field.to_vec())],
ty: ValType::Vector,
kind: IndexKind::Ann,
max_bytes: 0,
ann: Some(kevy_index::AnnSpec {
m: if params.m == 0 { 16 } else { params.m },
ef: if params.ef == 0 { 200 } else { params.ef },
..params
}),
group_by: None,
with_positions: false,
values: Vec::new(),
composite: None,
};
self.register_spec(spec)
}
pub fn idx_drop(&self, name: &[u8]) -> bool {
let hit = {
let mut g =
self.indexes.catalog.write().unwrap_or_else(std::sync::PoisonError::into_inner);
let (ver, cat) = &mut *g;
let hit = cat.drop_index(name);
if hit {
*ver += 1;
}
hit
};
if hit {
self.persist_index_sidecar();
self.advise_clear();
self.usage_rekey();
}
hit
}
#[cfg(feature = "text")]
pub fn idx_match(
&self,
name: &[u8],
query: &[u8],
limit: usize,
) -> KevyResult<Vec<(Vec<u8>, f64)>> {
Ok(self
.idx_match_with(name, query, limit, crate::MatchOpts::default())?
.into_iter()
.map(|(key, score, _)| (key, score))
.collect())
}
pub fn idx_create_agg(
&self,
name: &[u8],
prefix: &[u8],
field: &[u8],
ty: ValType,
group_by: &[u8],
) -> KevyResult<()> {
if !matches!(ty, ValType::I64 | ValType::F64) || group_by.is_empty() {
return Err(KevyError::InvalidInput("agg requires numeric type + group field".into()));
}
let spec = IndexSpec {
name: name.to_vec(),
prefix: prefix.to_vec(),
fields: vec![kevy_index::FieldSpec::new(field.to_vec())],
ty,
kind: IndexKind::Agg,
max_bytes: 0,
ann: None,
group_by: Some(group_by.to_vec()),
with_positions: false,
values: Vec::new(),
composite: None,
};
self.register_spec(spec)
}
pub fn idx_group(&self, name: &[u8], group: &[u8]) -> KevyResult<kevy_index::GroupStats> {
let mut merged = kevy_index::GroupStats { count: 0, sum: 0.0, min: None, max: None };
let mut found = false;
for shard in self.shards.iter() {
let mut g = lock_write(shard);
let inner = &mut *g;
sync_segs(&self.indexes, &mut inner.idx_segs, &mut inner.store);
if let Some((_, a)) = inner.idx_segs.agg.iter().find(|(s, _)| s.name == name) {
found = true;
kevy_index::merge_group(&mut merged, &a.group(group));
}
}
if !found {
return Err(KevyError::NotFound("no such aggregate index".into()));
}
Ok(merged)
}
pub fn idx_groups(
&self,
name: &[u8],
by: kevy_index::AggBy,
limit: usize,
) -> KevyResult<Vec<(Vec<u8>, kevy_index::GroupStats)>> {
let limit = limit.clamp(1, 1000);
let mut merged: std::collections::HashMap<Vec<u8>, kevy_index::GroupStats> =
std::collections::HashMap::new();
let mut found = false;
for shard in self.shards.iter() {
let mut g = lock_write(shard);
let inner = &mut *g;
sync_segs(&self.indexes, &mut inner.idx_segs, &mut inner.store);
if let Some((_, a)) = inner.idx_segs.agg.iter().find(|(s, _)| s.name == name) {
found = true;
for (gk, st) in a.all_groups() {
match merged.get_mut(&gk) {
Some(m) => kevy_index::merge_group(m, &st),
None => {
merged.insert(gk, st);
}
}
}
}
}
if !found {
return Err(KevyError::NotFound("no such aggregate index".into()));
}
let mut ranked: Vec<(Vec<u8>, kevy_index::GroupStats)> = merged.into_iter().collect();
kevy_index::sort_groups(&mut ranked, by);
ranked.truncate(limit);
Ok(ranked)
}
#[cfg(feature = "vector")]
pub fn idx_knn(
&self,
name: &[u8],
query: &[f32],
k: usize,
ef: usize,
) -> KevyResult<Vec<(Vec<u8>, f32)>> {
let k = k.clamp(1, 1000);
let mut all: Vec<(Vec<u8>, f32)> = Vec::new();
let mut found = false;
for shard in self.shards.iter() {
let mut g = lock_write(shard);
let inner = &mut *g;
sync_segs(&self.indexes, &mut inner.idx_segs, &mut inner.store);
if let Some((_, graph)) = inner.idx_segs.ann.iter().find(|(s, _)| s.name == name) {
found = true;
all.extend(graph.knn(query, k, ef));
}
}
if !found {
return Err(KevyError::NotFound("no such vector index".into()));
}
all.sort_by(|a, b| a.1.total_cmp(&b.1).then_with(|| a.0.cmp(&b.0)));
all.truncate(k);
Ok(all)
}
#[cfg(not(feature = "persist"))]
fn persist_index_sidecar(&self) {}
#[cfg(not(feature = "persist"))]
pub(crate) fn idx_boot(&self) {}
fn for_each_segment(&self, name: &[u8], mut f: impl FnMut(&Segment)) -> KevyResult<()> {
let mut found = false;
for shard in self.shards.iter() {
let mut g = lock_write(shard);
let inner = &mut *g;
sync_segs(&self.indexes, &mut inner.idx_segs, &mut inner.store);
if let Some((_, seg)) = inner.idx_segs.segs.iter().find(|(s, _)| s.name == name) {
found = true;
f(seg);
}
}
if found { Ok(()) } else { Err(KevyError::NotFound("no such index".into())) }
}
#[cfg(feature = "persist")]
fn persist_index_sidecar(&self) {
let Some(dir) = &self.config.data_dir else { return };
let g = self.indexes.catalog.read().unwrap_or_else(std::sync::PoisonError::into_inner);
let tmp = dir.join("index-catalog.meta.tmp");
if std::fs::write(&tmp, g.1.to_sidecar()).is_ok() {
let _ = std::fs::rename(&tmp, dir.join(SIDECAR));
}
}
#[cfg(feature = "persist")]
pub(crate) fn idx_boot(&self) {
let Some(dir) = &self.config.data_dir else { return };
if let Ok(text) = std::fs::read_to_string(dir.join(SIDECAR))
&& let Some(cat) = Catalog::from_sidecar(&text)
&& !cat.is_empty()
{
let mut g =
self.indexes.catalog.write().unwrap_or_else(std::sync::PoisonError::into_inner);
*g = (g.0 + 1, cat);
}
}
}