Skip to main content

mongreldb_kit/
db.rs

1//! Database handle for `mongreldb-kit`.
2
3use crate::error::{KitError, Result};
4use crate::internal::{ensure_internal_tables, internal_tables_core};
5use crate::schema::to_core_schema;
6use mongreldb_core::epoch::Snapshot;
7use mongreldb_core::memtable::Row as CoreRow;
8use mongreldb_core::memtable::Value as CoreValue;
9use mongreldb_core::schema::Schema as CoreSchema;
10use mongreldb_core::Database as CoreDatabase;
11use mongreldb_core::{AggState, ApproxAgg, NativeAgg, NativeAggResult, RowId};
12use mongreldb_kit_core::schema::IndexKind as KitIndexKind;
13use mongreldb_kit_core::schema::Schema as KitSchema;
14use mongreldb_kit_core::schema::Table as KitTable;
15use serde_json::Value;
16
17use std::collections::HashMap;
18use std::path::{Path, PathBuf};
19
20const SCHEMA_FILE: &str = "kit_schema.json";
21
22/// A named default-value provider registered by the application.
23pub type DefaultProvider = Box<dyn Fn() -> Value + Send + Sync>;
24
25/// The result of [`Database::explain`]: a static description of a predicate's
26/// index push-down, without running the query.
27#[derive(Debug, Clone)]
28pub struct ExplainPlan {
29    /// Whether at least one native index condition pushes down (vs. a full scan).
30    pub index_accelerated: bool,
31    /// Whether the push-down is exact — the whole predicate translated, so no
32    /// Rust-side residual re-filtering is needed.
33    pub exact: bool,
34    /// The kind of each pushed condition (e.g. `BitmapEq`, `RangeInt`, `Ann`).
35    pub pushed_conditions: Vec<String>,
36}
37
38/// A row paired with its Jaccard set-similarity to a query set (`0.0..=1.0`).
39#[derive(Debug, Clone)]
40pub struct SimilarRow {
41    pub row: crate::schema::Row,
42    pub similarity: f64,
43}
44
45/// Collect the string members of a set-valued column cell. Accepts either a
46/// JSON array value or a JSON string holding an array (how the Kit stores
47/// `json`/`text` set columns); anything else yields the empty set.
48fn parse_string_set(value: Option<&Value>) -> std::collections::HashSet<String> {
49    let arr = match value {
50        Some(Value::Array(a)) => Some(a.clone()),
51        Some(Value::String(s)) => serde_json::from_str::<Value>(s)
52            .ok()
53            .and_then(|v| v.as_array().cloned()),
54        _ => None,
55    };
56    arr.into_iter()
57        .flatten()
58        .filter_map(|v| match v {
59            Value::String(s) => Some(s),
60            Value::Number(n) => Some(n.to_string()),
61            Value::Bool(b) => Some(b.to_string()),
62            _ => None,
63        })
64        .collect()
65}
66
67/// Which aggregate to maintain incrementally.
68#[derive(Debug, Clone, Copy, PartialEq, Eq)]
69pub enum IncrementalAggKind {
70    Count,
71    Sum,
72    Min,
73    Max,
74    Avg,
75}
76
77/// The result of [`Database::incremental_aggregate`].
78#[derive(Debug, Clone)]
79pub struct IncrementalAggregate {
80    /// The exact aggregate value at the current epoch: a JSON number, or `null`
81    /// when no rows matched (`COUNT` returns `0`, not null).
82    pub value: Value,
83    /// `true` when produced by merging only newly-committed rows (the fast
84    /// path); `false` when a full recompute was required (cold cache, a delete,
85    /// pending writes, or the same epoch as the cached state).
86    pub incremental: bool,
87    /// Rows processed in the delta pass (`0` for a full recompute).
88    pub delta_rows: u64,
89}
90
91/// Stable per-`(table, column, agg, filter)` cache key for the engine's
92/// incremental-aggregate cache. Deterministic within a process (fixed-seed
93/// hasher); the cache itself is per-`Db`, so cross-process stability is moot.
94fn incremental_cache_key(
95    table_id: u32,
96    column: Option<u16>,
97    agg: IncrementalAggKind,
98    conditions: &[mongreldb_core::query::Condition],
99) -> u64 {
100    use std::hash::{Hash, Hasher};
101    let mut h = std::collections::hash_map::DefaultHasher::new();
102    table_id.hash(&mut h);
103    column.hash(&mut h);
104    (agg as u8).hash(&mut h);
105    // `Condition` has no `Hash`; its `Debug` form is stable and unique enough.
106    format!("{conditions:?}").hash(&mut h);
107    h.finish()
108}
109
110/// Finalize a mergeable [`AggState`] to a JSON scalar, preserving integer-ness
111/// for `COUNT`/`MIN`/`MAX`/int `SUM` and using a float for averages / float
112/// columns. `null` when there were no matching inputs.
113fn agg_state_value(s: &AggState) -> Value {
114    let num_f64 = |x: f64| {
115        serde_json::Number::from_f64(x)
116            .map(Value::Number)
117            .unwrap_or(Value::Null)
118    };
119    match s {
120        AggState::Count(n) => Value::from(*n),
121        AggState::SumI { sum, .. } => i64::try_from(*sum)
122            .map(Value::from)
123            .unwrap_or_else(|_| num_f64(*sum as f64)),
124        AggState::SumF { sum, .. } => num_f64(*sum),
125        AggState::AvgI { sum, count } if *count > 0 => num_f64(*sum as f64 / *count as f64),
126        AggState::AvgF { sum, count } if *count > 0 => num_f64(*sum / *count as f64),
127        AggState::AvgI { .. } | AggState::AvgF { .. } => Value::Null,
128        AggState::MinI(n) | AggState::MaxI(n) => Value::from(*n),
129        AggState::MinF(f) | AggState::MaxF(f) => num_f64(*f),
130        AggState::Empty => Value::Null,
131    }
132}
133
134/// Which approximate aggregate to estimate from the reservoir sample.
135#[derive(Debug, Clone, Copy, PartialEq, Eq)]
136pub enum ApproxAggKind {
137    Count,
138    Sum,
139    Avg,
140}
141
142/// A reservoir-sampled approximate aggregate with a normal-theory confidence
143/// interval. `ci_low`/`ci_high` bracket `point` at the requested z-score; the
144/// interval collapses to zero width when the sample covers the whole table.
145#[derive(Debug, Clone)]
146pub struct ApproxAggregate {
147    pub point: f64,
148    pub ci_low: f64,
149    pub ci_high: f64,
150    pub n_population: u64,
151    pub n_sample_live: usize,
152    pub n_passing: usize,
153}
154
155/// Short kind label for a core `Condition` (the variant name), decoupled from
156/// the enum shape via its `Debug` form.
157fn condition_label(c: &mongreldb_core::query::Condition) -> String {
158    let dbg = format!("{c:?}");
159    dbg.split(['(', '{', ' ']).next().unwrap_or("").to_string()
160}
161
162/// A kit database handle.
163///
164/// Wraps a MongrelDB core database and a kit schema, providing table metadata
165/// and transaction creation.
166pub struct Database {
167    pub(crate) inner: CoreDatabase,
168    pub(crate) schema: KitSchema,
169    pub(crate) root: PathBuf,
170    /// Application-registered named default providers (`DefaultKind::CustomName`).
171    pub(crate) default_providers: HashMap<String, DefaultProvider>,
172}
173
174impl Database {
175    /// Open an existing kit database.
176    pub fn open(path: &Path) -> Result<Self> {
177        let inner = CoreDatabase::open(path)?;
178        let schema = load_schema(path)?;
179        // Ensure reserved tables exist for databases created by older versions.
180        ensure_internal_tables(&inner)?;
181        reap_rotated_wal_segments(&inner);
182        Ok(Self {
183            inner,
184            schema,
185            root: path.to_path_buf(),
186            default_providers: HashMap::new(),
187        })
188    }
189
190    /// Open an existing page-encrypted kit database with its passphrase.
191    pub fn open_encrypted(path: &Path, passphrase: &str) -> Result<Self> {
192        let inner = CoreDatabase::open_encrypted(path, passphrase)?;
193        let schema = load_schema(path)?;
194        ensure_internal_tables(&inner)?;
195        reap_rotated_wal_segments(&inner);
196        Ok(Self {
197            inner,
198            schema,
199            root: path.to_path_buf(),
200            default_providers: HashMap::new(),
201        })
202    }
203
204    /// Create a fresh page-encrypted kit database (AES-256-GCM; the passphrase
205    /// derives the key hierarchy). Columns flagged `encrypted` /
206    /// `encrypted_indexable` are encrypted at rest.
207    pub fn create_encrypted(path: &Path, schema: KitSchema, passphrase: &str) -> Result<Self> {
208        std::fs::create_dir_all(path)?;
209        let inner = CoreDatabase::create_encrypted(path, passphrase)?;
210        ensure_internal_tables(&inner)?;
211        store_schema(path, &schema)?;
212        for table in &schema.tables {
213            create_core_table(&inner, &table.name, to_core_schema(table))?;
214        }
215        Ok(Self {
216            inner,
217            schema,
218            root: path.to_path_buf(),
219            default_providers: HashMap::new(),
220        })
221    }
222
223    /// Create a fresh kit database with the given schema.
224    pub fn create(path: &Path, schema: KitSchema) -> Result<Self> {
225        std::fs::create_dir_all(path)?;
226        let inner = CoreDatabase::create(path)?;
227
228        // Create the reserved kit tables first so we can record migrations,
229        // reserve unique keys, and touch row guards.
230        ensure_internal_tables(&inner)?;
231
232        // Persist the kit schema to a sidecar file (core tables cannot update
233        // a specific row by id, so a file is the pragmatic stable store).
234        store_schema(path, &schema)?;
235
236        // Create core tables for every user table.
237        for table in &schema.tables {
238            create_core_table(&inner, &table.name, to_core_schema(table))?;
239        }
240
241        Ok(Self {
242            inner,
243            schema,
244            root: path.to_path_buf(),
245            default_providers: HashMap::new(),
246        })
247    }
248
249    /// Register a named default provider used by `DefaultKind::CustomName`
250    /// columns. Returns the database for chaining.
251    pub fn register_default(
252        &mut self,
253        name: impl Into<String>,
254        provider: impl Fn() -> Value + Send + Sync + 'static,
255    ) {
256        self.default_providers
257            .insert(name.into(), Box::new(provider));
258    }
259
260    /// The raw, unguarded MongrelDB core database. This is the Rust analogue of
261    /// the TypeScript kit's `nativeDb` escape hatch: writes made directly
262    /// against it bypass all kit constraints.
263    pub fn raw(&self) -> &CoreDatabase {
264        &self.inner
265    }
266
267    /// Application table names, excluding the reserved `__kit_*` tables.
268    pub fn table_names(&self) -> Vec<String> {
269        self.schema
270            .tables
271            .iter()
272            .map(|t| t.name.clone())
273            .filter(|n| !n.starts_with("__kit_"))
274            .collect()
275    }
276
277    /// Allocate `count` values from the named sequence, returning the first
278    /// allocated value. A fresh sequence starts at `1` (SQL AUTO_INCREMENT
279    /// semantics). The allocation
280    /// runs in its own committed transaction and retries on write-write
281    /// conflicts.
282    pub fn allocate_sequence(&self, name: &str, count: i64) -> Result<i64> {
283        use crate::internal::cols;
284        let mut attempt = 0;
285        loop {
286            let mut txn = self.inner.begin();
287            let snapshot = txn.read_snapshot();
288            let existing = self
289                .visible_core_rows_at(crate::internal::SEQUENCES, snapshot)?
290                .into_iter()
291                .find(|r| internal_bytes(r, cols::SEQ_NAME) == Some(name.to_string()));
292
293            let now = crate::internal::iso_now();
294            // Sequences are 1-based, matching SQL AUTO_INCREMENT / SERIAL. A
295            // starting value of 0 is unsafe: applications use 0 as the "unset"
296            // sentinel for nullable foreign keys.
297            let (start, next, old_row_id) = match &existing {
298                Some(row) => {
299                    let current = match row.columns.get(&cols::SEQ_NEXT) {
300                        Some(CoreValue::Int64(i)) => *i,
301                        _ => 1,
302                    };
303                    (current, current + count, Some(row.row_id))
304                }
305                None => (1, 1 + count, None),
306            };
307
308            if let Some(rid) = old_row_id {
309                txn.delete(crate::internal::SEQUENCES, rid)
310                    .map_err(KitError::from)?;
311            }
312            txn.put(
313                crate::internal::SEQUENCES,
314                vec![
315                    (cols::SEQ_NAME, CoreValue::Bytes(name.as_bytes().to_vec())),
316                    (cols::SEQ_NEXT, CoreValue::Int64(next)),
317                    (cols::SEQ_UPDATED, CoreValue::Bytes(now.into_bytes())),
318                ],
319            )
320            .map_err(KitError::from)?;
321            match txn.commit() {
322                Ok(_) => return Ok(start),
323                Err(mongreldb_core::MongrelError::Conflict(_)) if attempt < 10_000 => {
324                    attempt += 1;
325                    std::thread::yield_now();
326                    continue;
327                }
328                Err(e) => return Err(KitError::from(e)),
329            }
330        }
331    }
332
333    /// Run `f` inside a kit transaction, committing on success and retrying on
334    /// retryable write-write conflicts up to `max_retries` times.
335    pub fn transaction<T, F>(&self, max_retries: usize, mut f: F) -> Result<T>
336    where
337        F: FnMut(&mut crate::txn::Transaction<'_>) -> Result<T>,
338    {
339        let mut attempt = 0;
340        loop {
341            let mut txn = self.begin()?;
342            match f(&mut txn) {
343                Ok(value) => match txn.commit() {
344                    Ok(()) => return Ok(value),
345                    Err(KitError::Conflict(_)) if attempt < max_retries => {
346                        attempt += 1;
347                        continue;
348                    }
349                    Err(e) => return Err(e),
350                },
351                Err(KitError::Conflict(_)) if attempt < max_retries => {
352                    txn.rollback();
353                    attempt += 1;
354                    continue;
355                }
356                Err(e) => {
357                    txn.rollback();
358                    return Err(e);
359                }
360            }
361        }
362    }
363
364    /// Look up a table definition by name.
365    pub fn table(&self, name: &str) -> Option<&KitTable> {
366        self.schema.table(name)
367    }
368
369    /// Return the currently loaded schema.
370    pub fn schema(&self) -> &KitSchema {
371        &self.schema
372    }
373
374    /// Begin a new kit transaction.
375    pub fn begin(&self) -> Result<crate::txn::Transaction<'_>> {
376        let core_txn = self.inner.begin();
377        Ok(crate::txn::Transaction::new(self, core_txn))
378    }
379
380    /// Replace the in-memory schema, usually after a migration.
381    pub fn set_schema(&mut self, schema: KitSchema) {
382        self.schema = schema;
383    }
384
385    /// Verify that the sidecar schema file and all reserved `__kit_*` tables
386    /// are present.
387    pub fn check_internal_tables(&self) -> Result<()> {
388        let schema_file = self.root.join(SCHEMA_FILE);
389        if !schema_file.exists() {
390            return Err(KitError::Integrity(format!(
391                "schema file {} is missing",
392                schema_file.display()
393            )));
394        }
395        for (name, _) in internal_tables_core() {
396            if self.inner.table_id(name).is_err() {
397                return Err(KitError::Integrity(format!(
398                    "internal table {name} is missing"
399                )));
400            }
401        }
402        Ok(())
403    }
404
405    /// Reclaim orphaned runs and stale WAL/shadow files; returns the count
406    /// removed. Safe to run on a live database.
407    pub fn gc(&self) -> Result<usize> {
408        self.inner.gc().map_err(KitError::from)
409    }
410
411    /// Verify run footer checksums; returns any integrity issues as JSON objects
412    /// (`table_id`, `table_name`, `severity`, `description`). Empty ⇒ healthy.
413    pub fn check(&self) -> Vec<serde_json::Value> {
414        self.inner
415            .check()
416            .into_iter()
417            .map(|i| {
418                serde_json::json!({
419                    "table_id": i.table_id,
420                    "table_name": i.table_name,
421                    "severity": i.severity,
422                    "description": i.description,
423                })
424            })
425            .collect()
426    }
427
428    /// Drop corrupt runs; returns the ids of the runs that were dropped.
429    pub fn doctor(&self) -> Result<Vec<u64>> {
430        self.inner.doctor().map_err(KitError::from)
431    }
432
433    /// The current visible commit epoch — a monotonically increasing version
434    /// stamp. A committed write bumps it; a snapshot at this epoch sees all
435    /// currently-committed data.
436    pub fn snapshot_epoch(&self) -> u64 {
437        self.inner.snapshot().0.epoch.0
438    }
439
440    /// Export every visible row of `table` as a TSV document (header row of
441    /// column names, tab-separated cells, `NULL` = empty field). See
442    /// [`crate::tsv`] for the escaping rules.
443    pub fn export_tsv(&self, table: &str) -> Result<String> {
444        let t = self
445            .schema
446            .tables
447            .iter()
448            .find(|t| t.name == table)
449            .ok_or_else(|| KitError::Validation(format!("unknown table '{table}'")))?
450            .clone();
451        let tx = self.begin()?;
452        let rows = tx.all_rows(table)?;
453        Ok(crate::tsv::rows_to_tsv(&t, &rows))
454    }
455
456    /// Import a TSV document into `table` (one committed transaction). Each row
457    /// passes through defaults, validation, and constraint checks like a normal
458    /// insert. Returns the number of rows inserted.
459    pub fn import_tsv(&self, table: &str, text: &str) -> Result<usize> {
460        let t = self
461            .schema
462            .tables
463            .iter()
464            .find(|t| t.name == table)
465            .ok_or_else(|| KitError::Validation(format!("unknown table '{table}'")))?
466            .clone();
467        let rows = crate::tsv::tsv_to_rows(&t, text)?;
468        let n = rows.len();
469        self.transaction(1, |tx| {
470            tx.insert_many(table, rows.clone())?;
471            Ok(())
472        })?;
473        Ok(n)
474    }
475
476    /// Describe how `predicate` would be executed against `table`: which native
477    /// index conditions push down, whether the push-down is exact (no residual
478    /// re-filtering), and whether any index acceleration applies at all. A pure
479    /// diagnostic — it plans but does not run the query.
480    pub fn explain(
481        &self,
482        table: &str,
483        predicate: &mongreldb_kit_core::query::Expr,
484    ) -> Result<ExplainPlan> {
485        let t = self
486            .schema
487            .tables
488            .iter()
489            .find(|t| t.name == table)
490            .ok_or_else(|| KitError::Validation(format!("unknown table '{table}'")))?;
491        Ok(match crate::pushdown::translate_predicate(t, predicate) {
492            Some(p) => ExplainPlan {
493                index_accelerated: p.can_push(),
494                exact: p.fully_translated,
495                pushed_conditions: p.conditions.iter().map(condition_label).collect(),
496            },
497            None => ExplainPlan {
498                index_accelerated: false,
499                exact: false,
500                pushed_conditions: Vec::new(),
501            },
502        })
503    }
504
505    /// Read every row of `table` visible at commit `epoch` — a point-in-time
506    /// (MVCC time-travel) read. `epoch` must not exceed the current snapshot
507    /// epoch. Rows reclaimed by GC/compaction for retired snapshots may no
508    /// longer be reconstructable; this reads whatever the engine still retains
509    /// at that epoch.
510    pub fn rows_at_epoch(&self, table: &str, epoch: u64) -> Result<Vec<crate::schema::Row>> {
511        let t = self
512            .schema
513            .tables
514            .iter()
515            .find(|t| t.name == table)
516            .ok_or_else(|| KitError::Validation(format!("unknown table '{table}'")))?;
517        let current = self.snapshot_epoch();
518        if epoch > current {
519            return Err(KitError::Validation(format!(
520                "epoch {epoch} is in the future (current committed epoch is {current})"
521            )));
522        }
523        let snap = Snapshot::at(mongreldb_core::epoch::Epoch(epoch));
524        let rows = self.visible_core_rows_at(table, snap)?;
525        rows.iter()
526            .map(|r| crate::schema::core_row_to_json(r, t))
527            .collect()
528    }
529
530    /// Estimate an aggregate over `table` from the engine's reservoir sample,
531    /// returning a point estimate and a `z`-score confidence interval (e.g.
532    /// `z = 1.96` for ~95%). `column` is required for `Sum`/`Avg` and ignored
533    /// for `Count`. Returns `None` when the reservoir is empty (no sampled rows
534    /// yet). Fast and O(sample) — trades exactness for speed on large tables.
535    pub fn approx_aggregate(
536        &self,
537        table: &str,
538        column: Option<&str>,
539        agg: ApproxAggKind,
540        z: f64,
541    ) -> Result<Option<ApproxAggregate>> {
542        let t = self
543            .schema
544            .tables
545            .iter()
546            .find(|t| t.name == table)
547            .ok_or_else(|| KitError::Validation(format!("unknown table '{table}'")))?;
548        if matches!(agg, ApproxAggKind::Sum | ApproxAggKind::Avg) && column.is_none() {
549            return Err(KitError::Validation(
550                "approx sum/avg requires a column".into(),
551            ));
552        }
553        let cid = match column {
554            Some(name) => Some(
555                t.columns
556                    .iter()
557                    .find(|c| c.name == name)
558                    .ok_or_else(|| KitError::Validation(format!("unknown column '{name}'")))?
559                    .id as u16,
560            ),
561            None => None,
562        };
563        let core_agg = match agg {
564            ApproxAggKind::Count => ApproxAgg::Count,
565            ApproxAggKind::Sum => ApproxAgg::Sum,
566            ApproxAggKind::Avg => ApproxAgg::Avg,
567        };
568        let handle = self.inner.table(table).map_err(KitError::from)?;
569        let mut guard = handle.lock();
570        let res = guard
571            .approx_aggregate(&[], cid, core_agg, z)
572            .map_err(KitError::from)?;
573        Ok(res.map(|r| ApproxAggregate {
574            point: r.point,
575            ci_low: r.ci_low,
576            ci_high: r.ci_high,
577            n_population: r.n_population,
578            n_sample_live: r.n_sample_live,
579            n_passing: r.n_passing,
580        }))
581    }
582
583    /// Stream `table` in row batches without materializing the whole table at
584    /// once. `f` receives successive chunks of at most `batch_size` value-maps,
585    /// all from one snapshot. Backed by the engine's native scan cursor when the
586    /// table has a sorted run; for an overlay-only table (no run yet) it falls
587    /// back to a single in-memory pass, still chunked to `batch_size`.
588    pub fn scan_batched<F>(&self, table: &str, batch_size: usize, mut f: F) -> Result<()>
589    where
590        F: FnMut(&[serde_json::Map<String, Value>]) -> Result<()>,
591    {
592        let kit_t = self
593            .schema
594            .tables
595            .iter()
596            .find(|t| t.name == table)
597            .ok_or_else(|| KitError::Validation(format!("unknown table '{table}'")))?;
598        let batch_size = batch_size.max(1);
599        // Keep the pin guard alive for the whole scan so GC can't reclaim the
600        // snapshot's versions mid-stream.
601        let (snapshot, _pin) = self.inner.snapshot();
602        let handle = self.inner.table(table).map_err(KitError::from)?;
603        let guard = handle.lock();
604
605        // Projection + per-column (name, kit type), index-aligned, in core order.
606        let mut projection: Vec<(u16, mongreldb_core::schema::TypeId)> = Vec::new();
607        let mut meta: Vec<(String, mongreldb_kit_core::schema::ColumnType)> = Vec::new();
608        for c in &guard.schema().columns {
609            if let Some(kc) = kit_t.columns.iter().find(|kc| kc.id as u16 == c.id) {
610                projection.push((c.id, c.ty));
611                meta.push((kc.name.clone(), kc.storage_type));
612            }
613        }
614
615        match guard
616            .scan_cursor(snapshot, projection, &[])
617            .map_err(KitError::from)?
618        {
619            Some(mut cursor) => {
620                let mut buf: Vec<serde_json::Map<String, Value>> = Vec::with_capacity(batch_size);
621                while let Some(batch) = cursor.next_batch().map_err(KitError::from)? {
622                    let nrows = batch.first().map(|c| c.len()).unwrap_or(0);
623                    for j in 0..nrows {
624                        let mut m = serde_json::Map::new();
625                        for (ci, (name, ty)) in meta.iter().enumerate() {
626                            let cv = batch
627                                .get(ci)
628                                .and_then(|col| col.value_at(j))
629                                .unwrap_or(CoreValue::Null);
630                            m.insert(name.clone(), crate::schema::core_to_json(&cv, *ty)?);
631                        }
632                        buf.push(m);
633                        if buf.len() >= batch_size {
634                            f(&buf)?;
635                            buf.clear();
636                        }
637                    }
638                }
639                if !buf.is_empty() {
640                    f(&buf)?;
641                }
642                Ok(())
643            }
644            None => {
645                drop(guard);
646                let rows = self.visible_core_rows_at(table, snapshot)?;
647                let maps: Vec<serde_json::Map<String, Value>> = rows
648                    .iter()
649                    .map(|r| crate::schema::core_row_to_json(r, kit_t).map(|row| row.values))
650                    .collect::<Result<Vec<_>>>()?;
651                for chunk in maps.chunks(batch_size) {
652                    f(chunk)?;
653                }
654                Ok(())
655            }
656        }
657    }
658
659    /// Rank rows of `table` by Jaccard set-similarity between `query` and the
660    /// string set stored (as a JSON array) in `column`, returning the top `k`
661    /// with similarity `> 0`, highest first — the dedup/join primitive.
662    ///
663    /// When `column` has a `MinHash` index, candidate rows come from the engine's
664    /// LSH index (sub-linear) and are then re-verified with exact Jaccard, so the
665    /// top-k is exact for the recalled candidates (LSH recall is high but < 100%).
666    /// Without an index it is an exact linear scan.
667    pub fn set_similarity(
668        &self,
669        table: &str,
670        column: &str,
671        query: &[String],
672        k: usize,
673    ) -> Result<Vec<SimilarRow>> {
674        let t = self
675            .schema
676            .tables
677            .iter()
678            .find(|t| t.name == table)
679            .ok_or_else(|| KitError::Validation(format!("unknown table '{table}'")))?;
680        let col = t.columns.iter().find(|c| c.name == column).ok_or_else(|| {
681            KitError::Validation(format!("unknown column '{column}' on table '{table}'"))
682        })?;
683        let query_set: std::collections::HashSet<String> = query.iter().cloned().collect();
684
685        let has_minhash = t.indexes.iter().any(|idx| {
686            idx.kind == KitIndexKind::MinHash && idx.columns.iter().any(|c| c == column)
687        });
688        let rows = if has_minhash {
689            // Sub-linear candidate generation via the engine MinHash/LSH index.
690            let query_hashes: Vec<u64> = query
691                .iter()
692                .map(|s| mongreldb_core::index::minhash_token_hash(s))
693                .collect();
694            // Generous candidate budget so exact top-k keeps high recall.
695            let cand_k = k.saturating_mul(8).max(k + 64);
696            let cond = mongreldb_core::query::Condition::MinHashSimilar {
697                column_id: col.id as u16,
698                query: query_hashes,
699                k: cand_k,
700            };
701            let (snapshot, _pin) = self.inner.snapshot();
702            let core_rows = self.query_core_rows_at(table, &[cond], snapshot)?;
703            core_rows
704                .iter()
705                .map(|r| crate::schema::core_row_to_json(r, t))
706                .collect::<Result<Vec<_>>>()?
707        } else {
708            let tx = self.begin()?;
709            tx.all_rows(table)?
710        };
711
712        let mut scored: Vec<SimilarRow> = Vec::new();
713        for row in rows {
714            let set = parse_string_set(row.values.get(column));
715            let inter = set.iter().filter(|x| query_set.contains(*x)).count();
716            let union = set.len() + query_set.len() - inter;
717            let sim = if union == 0 {
718                0.0
719            } else {
720                inter as f64 / union as f64
721            };
722            if sim > 0.0 {
723                scored.push(SimilarRow {
724                    row,
725                    similarity: sim,
726                });
727            }
728        }
729        scored.sort_by(|a, b| {
730            b.similarity
731                .partial_cmp(&a.similarity)
732                .unwrap_or(std::cmp::Ordering::Equal)
733        });
734        scored.truncate(k);
735        Ok(scored)
736    }
737
738    /// Flush every table's in-memory writes to durable sorted runs. Besides
739    /// durability, this empties the memtable, which is what enables the engine's
740    /// incremental-aggregate fast path (see [`Self::incremental_aggregate`]).
741    pub fn flush(&self) -> Result<()> {
742        for name in self.inner.table_names() {
743            let handle = self.inner.table(&name).map_err(KitError::from)?;
744            let mut guard = handle.lock();
745            guard.flush().map_err(KitError::from)?;
746        }
747        Ok(())
748    }
749
750    /// Maintain and read an aggregate over `table` that updates by merging only
751    /// newly-committed rows instead of rescanning. `column` is required for
752    /// `Sum`/`Min`/`Max`/`Avg` and ignored for `Count`. An optional `filter`
753    /// restricts the aggregate; it must translate **exactly** to index
754    /// conditions (no residual), otherwise this errors — an inexact filter would
755    /// silently aggregate the wrong rows.
756    ///
757    /// The engine keeps a per-`(table, column, agg, filter)` cached state and,
758    /// on a warm cache with an advanced epoch and no deletes/pending writes,
759    /// folds in just the delta. The returned `value` is always exact; the
760    /// `incremental` flag reports whether the fast path was taken.
761    pub fn incremental_aggregate(
762        &self,
763        table: &str,
764        column: Option<&str>,
765        agg: IncrementalAggKind,
766        filter: Option<&mongreldb_kit_core::query::Expr>,
767    ) -> Result<IncrementalAggregate> {
768        let t = self
769            .schema
770            .tables
771            .iter()
772            .find(|t| t.name == table)
773            .ok_or_else(|| KitError::Validation(format!("unknown table '{table}'")))?;
774        if !matches!(agg, IncrementalAggKind::Count) && column.is_none() {
775            return Err(KitError::Validation(
776                "sum/min/max/avg incremental aggregate requires a column".into(),
777            ));
778        }
779        let cid = match column {
780            Some(name) => Some(
781                t.columns
782                    .iter()
783                    .find(|c| c.name == name)
784                    .ok_or_else(|| KitError::Validation(format!("unknown column '{name}'")))?
785                    .id as u16,
786            ),
787            None => None,
788        };
789        let conditions = match filter {
790            Some(expr) => {
791                let plan = crate::pushdown::translate_predicate(t, expr).ok_or_else(|| {
792                    KitError::Validation(
793                        "filter is not index-translatable for an incremental aggregate".into(),
794                    )
795                })?;
796                if !plan.fully_translated {
797                    return Err(KitError::Validation(
798                        "filter has a residual that an incremental aggregate cannot apply exactly"
799                            .into(),
800                    ));
801                }
802                plan.conditions
803            }
804            None => Vec::new(),
805        };
806        let core_agg = match agg {
807            IncrementalAggKind::Count => NativeAgg::Count,
808            IncrementalAggKind::Sum => NativeAgg::Sum,
809            IncrementalAggKind::Min => NativeAgg::Min,
810            IncrementalAggKind::Max => NativeAgg::Max,
811            IncrementalAggKind::Avg => NativeAgg::Avg,
812        };
813        let cache_key = incremental_cache_key(t.id, cid, agg, &conditions);
814        let handle = self.inner.table(table).map_err(KitError::from)?;
815        let mut guard = handle.lock();
816        let res = guard
817            .aggregate_incremental(cache_key, &conditions, cid, core_agg)
818            .map_err(KitError::from)?;
819        Ok(IncrementalAggregate {
820            value: agg_state_value(&res.state),
821            incremental: res.incremental,
822            delta_rows: res.delta_rows,
823        })
824    }
825
826    /// Return the migrations already recorded in `__kit_schema_migrations`.
827    pub fn applied_migrations(&self) -> Result<Vec<mongreldb_kit_core::migrations::Migration>> {
828        crate::migrate::load_applied_migrations(&self.inner)
829    }
830
831    pub(crate) fn core_db(&self) -> &CoreDatabase {
832        &self.inner
833    }
834
835    /// Best-effort flush-on-close (§4.4): force-flush pending writes on every
836    /// table to `.sr` sorted runs so WAL segments stay bounded across repeated
837    /// short-lived process invocations (e.g. the CLI). Call as the last action
838    /// before exit. The daemon does not need this (auto-compactor handles it).
839    pub fn close(&self) -> Result<()> {
840        self.inner.close().map_err(KitError::from)
841    }
842
843    /// Compact all tables: merge sorted runs into one clean run each so query
844    /// latency stays flat. Returns `(compacted, skipped)`. Safe to run at any
845    /// time — honors snapshot retention. The daemon's background auto-compactor
846    /// already does this periodically; this is for manual/cron use.
847    pub fn compact_all(&self) -> Result<(usize, usize)> {
848        self.inner.compact().map_err(KitError::from)
849    }
850
851    /// Compact a single table by name. Returns `true` if compacted, `false` if
852    /// skipped (< 2 runs).
853    pub fn compact_table(&self, name: &str) -> Result<bool> {
854        self.inner.compact_table(name).map_err(KitError::from)
855    }
856
857    /// Direct HOT (PK → RowId) lookup via the core engine — no full-row
858    /// materialization. Used by the §4.3 delete fast path when the table
859    /// has no Kit-level constraints requiring guard cleanup.
860    pub(crate) fn lookup_row_id(&self, table: &str, key: &[u8]) -> Result<Option<RowId>> {
861        let handle = self.inner.table(table).map_err(KitError::from)?;
862        let mut guard = handle.lock();
863        guard.ensure_indexes_complete()?;
864        Ok(guard.lookup_pk(key))
865    }
866
867    pub(crate) fn root(&self) -> &Path {
868        &self.root
869    }
870
871    /// All visible core rows for a table at a specific read snapshot. Used so
872    /// kit transactions read at their own snapshot (repeatable reads) rather
873    /// than the latest committed state.
874    pub(crate) fn visible_core_rows_at(
875        &self,
876        table_name: &str,
877        snapshot: Snapshot,
878    ) -> Result<Vec<CoreRow>> {
879        let handle = self.inner.table(table_name).map_err(KitError::from)?;
880        let guard = handle.lock();
881        guard.visible_rows(snapshot).map_err(KitError::from)
882    }
883
884    /// Query visible core rows with native `Condition`s at a specific read
885    /// snapshot (Kit Priority 1 pushdown). Resolves `conditions` via core's
886    /// indexes (HOT / bitmap / range) and returns only the matching rows —
887    /// avoiding the full scan that `visible_core_rows_at` does. Returns the
888    /// empty vec when no conditions match, and falls back to
889    /// `visible_core_rows_at` when `conditions` is empty (unfiltered).
890    pub(crate) fn query_core_rows_at(
891        &self,
892        table_name: &str,
893        conditions: &[mongreldb_core::query::Condition],
894        snapshot: Snapshot,
895    ) -> Result<Vec<CoreRow>> {
896        if conditions.is_empty() {
897            return self.visible_core_rows_at(table_name, snapshot);
898        }
899        let handle = self.inner.table(table_name).map_err(KitError::from)?;
900        let mut guard = handle.lock();
901        let q = mongreldb_core::query::Query {
902            conditions: conditions.to_vec(),
903        };
904        guard.query(&q).map_err(KitError::from)
905    }
906
907    /// Drain `table`'s memtable into the mutable-run tier, spilling to a
908    /// durable, checkpointed `.sr` run once the tier crosses its watermark.
909    /// Called after a large batch commit (see `Transaction::commit`) so a
910    /// short-lived process (the CLI, or any fresh `Database::open`) isn't
911    /// stuck replaying the whole batch from the WAL on its next invocation —
912    /// without a flush, committed-but-unflushed writes only exist as WAL
913    /// records and must be fully replayed to rebuild the in-memory indexes.
914    pub(crate) fn flush_table(&self, table_name: &str) -> Result<()> {
915        let handle = self.inner.table(table_name).map_err(KitError::from)?;
916        handle.lock().flush().map_err(KitError::from)?;
917        Ok(())
918    }
919
920    /// Count visible rows matching `conditions` without materializing them
921    /// (Kit Priority 7 pushdown). Returns `None` when the conditions cannot be
922    /// served by indexes, or when `snapshot` is not the latest committed epoch
923    /// (caller falls back to a snapshot-correct row scan).
924    ///
925    /// `count_conditions` counts the engine's latest committed index state, not
926    /// a snapshot-filtered scan, so it only matches a repeatable-read row count
927    /// when the read snapshot IS the latest epoch. We hold the table lock while
928    /// comparing, so no commit can interleave between the check and the count.
929    pub(crate) fn count_core_rows_at(
930        &self,
931        table_name: &str,
932        conditions: &[mongreldb_core::query::Condition],
933        snapshot: Snapshot,
934    ) -> Result<Option<u64>> {
935        let handle = self.inner.table(table_name).map_err(KitError::from)?;
936        let mut guard = handle.lock();
937        if guard.snapshot().epoch != snapshot.epoch {
938            return Ok(None); // stale read snapshot ⇒ caller scans
939        }
940        guard
941            .count_conditions(conditions, snapshot)
942            .map_err(KitError::from)
943    }
944
945    /// Compute `SUM`/`MIN`/`MAX`/`AVG`/`COUNT(col)` over a column without
946    /// materializing rows (Kit Priority 7), via the engine's native aggregate.
947    /// `column` is the engine column id. Returns `None` when the engine fast
948    /// path does not apply (multi-run / non-empty overlay / non-numeric column),
949    /// or when `snapshot` is not the latest committed epoch — the same
950    /// guarantee as [`count_core_rows_at`](Self::count_core_rows_at): the engine
951    /// aggregate matches a snapshot-consistent row scan only at the latest epoch,
952    /// and we compare under the table lock so no commit can interleave.
953    pub(crate) fn aggregate_core_at(
954        &self,
955        table_name: &str,
956        column: Option<u16>,
957        conditions: &[mongreldb_core::query::Condition],
958        agg: NativeAgg,
959        snapshot: Snapshot,
960    ) -> Result<Option<NativeAggResult>> {
961        let handle = self.inner.table(table_name).map_err(KitError::from)?;
962        let guard = handle.lock();
963        if guard.snapshot().epoch != snapshot.epoch {
964            return Ok(None); // stale read snapshot ⇒ caller scans
965        }
966        guard
967            .aggregate_native(snapshot, column, conditions, agg)
968            .map_err(KitError::from)
969    }
970
971    /// `COUNT(DISTINCT col)` from the bitmap index's partition cardinality (Kit
972    /// Priority 7) — the number of distinct indexed values, no scan. Returns
973    /// `None` without a bitmap index on the column, when the table is not
974    /// insert-only, or when `snapshot` is not the latest committed epoch. The
975    /// engine method reads the latest committed index state (no snapshot
976    /// parameter), so — as with [`count_core_rows_at`](Self::count_core_rows_at)
977    /// — it only matches a repeatable-read scan at the latest epoch; we compare
978    /// under the table lock so no commit can interleave.
979    pub(crate) fn count_distinct_core_at(
980        &self,
981        table_name: &str,
982        column_id: u16,
983        snapshot: Snapshot,
984    ) -> Result<Option<u64>> {
985        let handle = self.inner.table(table_name).map_err(KitError::from)?;
986        let mut guard = handle.lock();
987        if guard.snapshot().epoch != snapshot.epoch {
988            return Ok(None); // stale read snapshot ⇒ caller scans
989        }
990        guard
991            .count_distinct_from_bitmap(column_id)
992            .map_err(KitError::from)
993    }
994
995    /// Materialize a single row by storage row id.
996    #[allow(dead_code)]
997    pub(crate) fn get_core_row(&self, table_name: &str, row_id: u64) -> Result<Option<CoreRow>> {
998        let handle = self.inner.table(table_name).map_err(KitError::from)?;
999        let guard = handle.lock();
1000        let snapshot = guard.snapshot();
1001        Ok(guard.get(mongreldb_core::RowId(row_id), snapshot))
1002    }
1003}
1004
1005pub(crate) fn create_core_table(db: &CoreDatabase, name: &str, schema: CoreSchema) -> Result<()> {
1006    if db.table_id(name).is_ok() {
1007        return Ok(());
1008    }
1009    db.create_table(name, schema).map_err(KitError::from)?;
1010    Ok(())
1011}
1012
1013/// Read a `Bytes` column from an internal-table core row as a UTF-8 string.
1014pub(crate) fn internal_bytes(row: &CoreRow, col_id: u16) -> Option<String> {
1015    match row.columns.get(&col_id) {
1016        Some(CoreValue::Bytes(b)) => String::from_utf8(b.clone()).ok(),
1017        _ => None,
1018    }
1019}
1020
1021/// Best-effort: reap any WAL segments a previous session left rotated but
1022/// unreaped, now that this `open()` has minted a fresh active segment
1023/// (`SharedWal::open` never truncates prior segments on its own —
1024/// [`CoreDatabase::gc`] does, but only once every mounted table's data is
1025/// durable in runs). Called before any write in *this* session, so that
1026/// check reflects exactly what the previous session left behind: if that
1027/// session ended with everything flushed (e.g. a bulk `insert_many`
1028/// followed by `Transaction::commit`'s large-batch auto-flush), this is the
1029/// one moment the now-inactive segment holding that batch is actually
1030/// eligible for cleanup. Without it, a short-lived process (the CLI has no
1031/// daemon mode; every invocation opens cold) keeps paying to read and
1032/// deserialize that segment's records on every subsequent open, even though
1033/// none of them still need replaying. Errors are ignored — this is a
1034/// disk-usage/reopen-latency optimization, never a correctness requirement.
1035fn reap_rotated_wal_segments(db: &CoreDatabase) {
1036    let _ = db.gc();
1037}
1038
1039pub(crate) fn load_schema(path: &Path) -> Result<KitSchema> {
1040    let file = path.join(SCHEMA_FILE);
1041    let json = std::fs::read_to_string(&file)
1042        .map_err(|e| KitError::Migration(format!("cannot read schema file: {e}")))?;
1043    let schema: KitSchema = serde_json::from_str(&json)?;
1044    Ok(schema)
1045}
1046
1047pub(crate) fn store_schema(path: &Path, schema: &KitSchema) -> Result<()> {
1048    let file = path.join(SCHEMA_FILE);
1049    let json = serde_json::to_string_pretty(schema)?;
1050    std::fs::write(&file, json)?;
1051    Ok(())
1052}
1053
1054/// Persist a kit schema into the database. Used after migrations.
1055pub(crate) fn persist_schema(db: &Database, schema: &KitSchema) -> Result<()> {
1056    store_schema(&db.root, schema)
1057}