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