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, TriggerSpec};
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    pub fn create_trigger(&self, spec: &TriggerSpec) -> Result<mongreldb_core::StoredTrigger> {
317        let trigger = core_trigger(spec)?;
318        self.inner.create_trigger(trigger).map_err(KitError::from)
319    }
320
321    pub fn replace_trigger(&self, spec: &TriggerSpec) -> Result<mongreldb_core::StoredTrigger> {
322        let trigger = core_trigger(spec)?;
323        self.inner
324            .create_or_replace_trigger(trigger)
325            .map_err(KitError::from)
326    }
327
328    pub fn drop_trigger(&self, name: &str) -> Result<()> {
329        self.inner.drop_trigger(name).map_err(KitError::from)
330    }
331
332    pub fn triggers(&self) -> Vec<mongreldb_core::StoredTrigger> {
333        self.inner.triggers()
334    }
335
336    pub fn trigger(&self, name: &str) -> Option<mongreldb_core::StoredTrigger> {
337        self.inner.trigger(name)
338    }
339
340    /// Allocate `count` values from the named sequence, returning the first
341    /// allocated value. A fresh sequence starts at `1` (SQL AUTO_INCREMENT
342    /// semantics). The allocation
343    /// runs in its own committed transaction and retries on write-write
344    /// conflicts.
345    pub fn allocate_sequence(&self, name: &str, count: i64) -> Result<i64> {
346        use crate::internal::cols;
347        let mut attempt = 0;
348        loop {
349            let mut txn = self.inner.begin();
350            let snapshot = txn.read_snapshot();
351            let existing = self
352                .visible_core_rows_at(crate::internal::SEQUENCES, snapshot)?
353                .into_iter()
354                .find(|r| internal_bytes(r, cols::SEQ_NAME) == Some(name.to_string()));
355
356            let now = crate::internal::iso_now();
357            // Sequences are 1-based, matching SQL AUTO_INCREMENT / SERIAL. A
358            // starting value of 0 is unsafe: applications use 0 as the "unset"
359            // sentinel for nullable foreign keys.
360            let (start, next, old_row_id) = match &existing {
361                Some(row) => {
362                    let current = match row.columns.get(&cols::SEQ_NEXT) {
363                        Some(CoreValue::Int64(i)) => *i,
364                        _ => 1,
365                    };
366                    (current, current + count, Some(row.row_id))
367                }
368                None => (1, 1 + count, None),
369            };
370
371            if let Some(rid) = old_row_id {
372                txn.delete(crate::internal::SEQUENCES, rid)
373                    .map_err(KitError::from)?;
374            }
375            txn.put(
376                crate::internal::SEQUENCES,
377                vec![
378                    (cols::SEQ_NAME, CoreValue::Bytes(name.as_bytes().to_vec())),
379                    (cols::SEQ_NEXT, CoreValue::Int64(next)),
380                    (cols::SEQ_UPDATED, CoreValue::Bytes(now.into_bytes())),
381                ],
382            )
383            .map_err(KitError::from)?;
384            match txn.commit() {
385                Ok(_) => return Ok(start),
386                Err(mongreldb_core::MongrelError::Conflict(_)) if attempt < 10_000 => {
387                    attempt += 1;
388                    std::thread::yield_now();
389                    continue;
390                }
391                Err(e) => return Err(KitError::from(e)),
392            }
393        }
394    }
395
396    /// Run `f` inside a kit transaction, committing on success and retrying on
397    /// retryable write-write conflicts up to `max_retries` times.
398    pub fn transaction<T, F>(&self, max_retries: usize, mut f: F) -> Result<T>
399    where
400        F: FnMut(&mut crate::txn::Transaction<'_>) -> Result<T>,
401    {
402        let mut attempt = 0;
403        loop {
404            let mut txn = self.begin()?;
405            match f(&mut txn) {
406                Ok(value) => match txn.commit() {
407                    Ok(()) => return Ok(value),
408                    Err(KitError::Conflict(_)) if attempt < max_retries => {
409                        attempt += 1;
410                        continue;
411                    }
412                    Err(e) => return Err(e),
413                },
414                Err(KitError::Conflict(_)) if attempt < max_retries => {
415                    txn.rollback();
416                    attempt += 1;
417                    continue;
418                }
419                Err(e) => {
420                    txn.rollback();
421                    return Err(e);
422                }
423            }
424        }
425    }
426
427    /// Look up a table definition by name.
428    pub fn table(&self, name: &str) -> Option<&KitTable> {
429        self.schema.table(name)
430    }
431
432    /// Return the currently loaded schema.
433    pub fn schema(&self) -> &KitSchema {
434        &self.schema
435    }
436
437    /// Begin a new kit transaction.
438    pub fn begin(&self) -> Result<crate::txn::Transaction<'_>> {
439        let core_txn = self.inner.begin();
440        Ok(crate::txn::Transaction::new(self, core_txn))
441    }
442
443    /// Replace the in-memory schema, usually after a migration.
444    pub fn set_schema(&mut self, schema: KitSchema) {
445        self.schema = schema;
446    }
447
448    /// Verify that the sidecar schema file and all reserved `__kit_*` tables
449    /// are present.
450    pub fn check_internal_tables(&self) -> Result<()> {
451        let schema_file = self.root.join(SCHEMA_FILE);
452        if !schema_file.exists() {
453            return Err(KitError::Integrity(format!(
454                "schema file {} is missing",
455                schema_file.display()
456            )));
457        }
458        for (name, _) in internal_tables_core() {
459            if self.inner.table_id(name).is_err() {
460                return Err(KitError::Integrity(format!(
461                    "internal table {name} is missing"
462                )));
463            }
464        }
465        Ok(())
466    }
467
468    /// Reclaim orphaned runs and stale WAL/shadow files; returns the count
469    /// removed. Safe to run on a live database.
470    pub fn gc(&self) -> Result<usize> {
471        self.inner.gc().map_err(KitError::from)
472    }
473
474    /// Verify run footer checksums; returns any integrity issues as JSON objects
475    /// (`table_id`, `table_name`, `severity`, `description`). Empty ⇒ healthy.
476    pub fn check(&self) -> Vec<serde_json::Value> {
477        self.inner
478            .check()
479            .into_iter()
480            .map(|i| {
481                serde_json::json!({
482                    "table_id": i.table_id,
483                    "table_name": i.table_name,
484                    "severity": i.severity,
485                    "description": i.description,
486                })
487            })
488            .collect()
489    }
490
491    /// Drop corrupt runs; returns the ids of the runs that were dropped.
492    pub fn doctor(&self) -> Result<Vec<u64>> {
493        self.inner.doctor().map_err(KitError::from)
494    }
495
496    /// The current visible commit epoch — a monotonically increasing version
497    /// stamp. A committed write bumps it; a snapshot at this epoch sees all
498    /// currently-committed data.
499    pub fn snapshot_epoch(&self) -> u64 {
500        self.inner.snapshot().0.epoch.0
501    }
502
503    /// Export every visible row of `table` as a TSV document (header row of
504    /// column names, tab-separated cells, `NULL` = empty field). See
505    /// [`crate::tsv`] for the escaping rules.
506    pub fn export_tsv(&self, table: &str) -> Result<String> {
507        let t = self
508            .schema
509            .tables
510            .iter()
511            .find(|t| t.name == table)
512            .ok_or_else(|| KitError::Validation(format!("unknown table '{table}'")))?
513            .clone();
514        let tx = self.begin()?;
515        let rows = tx.all_rows(table)?;
516        Ok(crate::tsv::rows_to_tsv(&t, &rows))
517    }
518
519    /// Import a TSV document into `table` (one committed transaction). Each row
520    /// passes through defaults, validation, and constraint checks like a normal
521    /// insert. Returns the number of rows inserted.
522    pub fn import_tsv(&self, table: &str, text: &str) -> Result<usize> {
523        let t = self
524            .schema
525            .tables
526            .iter()
527            .find(|t| t.name == table)
528            .ok_or_else(|| KitError::Validation(format!("unknown table '{table}'")))?
529            .clone();
530        let rows = crate::tsv::tsv_to_rows(&t, text)?;
531        let n = rows.len();
532        self.transaction(1, |tx| {
533            tx.insert_many(table, rows.clone())?;
534            Ok(())
535        })?;
536        Ok(n)
537    }
538
539    /// Describe how `predicate` would be executed against `table`: which native
540    /// index conditions push down, whether the push-down is exact (no residual
541    /// re-filtering), and whether any index acceleration applies at all. A pure
542    /// diagnostic — it plans but does not run the query.
543    pub fn explain(
544        &self,
545        table: &str,
546        predicate: &mongreldb_kit_core::query::Expr,
547    ) -> Result<ExplainPlan> {
548        let t = self
549            .schema
550            .tables
551            .iter()
552            .find(|t| t.name == table)
553            .ok_or_else(|| KitError::Validation(format!("unknown table '{table}'")))?;
554        Ok(match crate::pushdown::translate_predicate(t, predicate) {
555            Some(p) => ExplainPlan {
556                index_accelerated: p.can_push(),
557                exact: p.fully_translated,
558                pushed_conditions: p.conditions.iter().map(condition_label).collect(),
559            },
560            None => ExplainPlan {
561                index_accelerated: false,
562                exact: false,
563                pushed_conditions: Vec::new(),
564            },
565        })
566    }
567
568    /// Read every row of `table` visible at commit `epoch` — a point-in-time
569    /// (MVCC time-travel) read. `epoch` must not exceed the current snapshot
570    /// epoch. Rows reclaimed by GC/compaction for retired snapshots may no
571    /// longer be reconstructable; this reads whatever the engine still retains
572    /// at that epoch.
573    pub fn rows_at_epoch(&self, table: &str, epoch: u64) -> Result<Vec<crate::schema::Row>> {
574        let t = self
575            .schema
576            .tables
577            .iter()
578            .find(|t| t.name == table)
579            .ok_or_else(|| KitError::Validation(format!("unknown table '{table}'")))?;
580        let current = self.snapshot_epoch();
581        if epoch > current {
582            return Err(KitError::Validation(format!(
583                "epoch {epoch} is in the future (current committed epoch is {current})"
584            )));
585        }
586        let snap = Snapshot::at(mongreldb_core::epoch::Epoch(epoch));
587        let rows = self.visible_core_rows_at(table, snap)?;
588        rows.iter()
589            .map(|r| crate::schema::core_row_to_json(r, t))
590            .collect()
591    }
592
593    /// Estimate an aggregate over `table` from the engine's reservoir sample,
594    /// returning a point estimate and a `z`-score confidence interval (e.g.
595    /// `z = 1.96` for ~95%). `column` is required for `Sum`/`Avg` and ignored
596    /// for `Count`. Returns `None` when the reservoir is empty (no sampled rows
597    /// yet). Fast and O(sample) — trades exactness for speed on large tables.
598    pub fn approx_aggregate(
599        &self,
600        table: &str,
601        column: Option<&str>,
602        agg: ApproxAggKind,
603        z: f64,
604    ) -> Result<Option<ApproxAggregate>> {
605        let t = self
606            .schema
607            .tables
608            .iter()
609            .find(|t| t.name == table)
610            .ok_or_else(|| KitError::Validation(format!("unknown table '{table}'")))?;
611        if matches!(agg, ApproxAggKind::Sum | ApproxAggKind::Avg) && column.is_none() {
612            return Err(KitError::Validation(
613                "approx sum/avg requires a column".into(),
614            ));
615        }
616        let cid = match column {
617            Some(name) => Some(
618                t.columns
619                    .iter()
620                    .find(|c| c.name == name)
621                    .ok_or_else(|| KitError::Validation(format!("unknown column '{name}'")))?
622                    .id as u16,
623            ),
624            None => None,
625        };
626        let core_agg = match agg {
627            ApproxAggKind::Count => ApproxAgg::Count,
628            ApproxAggKind::Sum => ApproxAgg::Sum,
629            ApproxAggKind::Avg => ApproxAgg::Avg,
630        };
631        let handle = self.inner.table(table).map_err(KitError::from)?;
632        let mut guard = handle.lock();
633        let res = guard
634            .approx_aggregate(&[], cid, core_agg, z)
635            .map_err(KitError::from)?;
636        Ok(res.map(|r| ApproxAggregate {
637            point: r.point,
638            ci_low: r.ci_low,
639            ci_high: r.ci_high,
640            n_population: r.n_population,
641            n_sample_live: r.n_sample_live,
642            n_passing: r.n_passing,
643        }))
644    }
645
646    /// Stream `table` in row batches without materializing the whole table at
647    /// once. `f` receives successive chunks of at most `batch_size` value-maps,
648    /// all from one snapshot. Backed by the engine's native scan cursor when the
649    /// table has a sorted run; for an overlay-only table (no run yet) it falls
650    /// back to a single in-memory pass, still chunked to `batch_size`.
651    pub fn scan_batched<F>(&self, table: &str, batch_size: usize, mut f: F) -> Result<()>
652    where
653        F: FnMut(&[serde_json::Map<String, Value>]) -> Result<()>,
654    {
655        let kit_t = self
656            .schema
657            .tables
658            .iter()
659            .find(|t| t.name == table)
660            .ok_or_else(|| KitError::Validation(format!("unknown table '{table}'")))?;
661        let batch_size = batch_size.max(1);
662        // Keep the pin guard alive for the whole scan so GC can't reclaim the
663        // snapshot's versions mid-stream.
664        let (snapshot, _pin) = self.inner.snapshot();
665        let handle = self.inner.table(table).map_err(KitError::from)?;
666        let guard = handle.lock();
667
668        // Projection + per-column (name, kit type), index-aligned, in core order.
669        let mut projection: Vec<(u16, mongreldb_core::schema::TypeId)> = Vec::new();
670        let mut meta: Vec<(String, mongreldb_kit_core::schema::ColumnType)> = Vec::new();
671        for c in &guard.schema().columns {
672            if let Some(kc) = kit_t.columns.iter().find(|kc| kc.id as u16 == c.id) {
673                projection.push((c.id, c.ty));
674                meta.push((kc.name.clone(), kc.storage_type));
675            }
676        }
677
678        match guard
679            .scan_cursor(snapshot, projection, &[])
680            .map_err(KitError::from)?
681        {
682            Some(mut cursor) => {
683                let mut buf: Vec<serde_json::Map<String, Value>> = Vec::with_capacity(batch_size);
684                while let Some(batch) = cursor.next_batch().map_err(KitError::from)? {
685                    let nrows = batch.first().map(|c| c.len()).unwrap_or(0);
686                    for j in 0..nrows {
687                        let mut m = serde_json::Map::new();
688                        for (ci, (name, ty)) in meta.iter().enumerate() {
689                            let cv = batch
690                                .get(ci)
691                                .and_then(|col| col.value_at(j))
692                                .unwrap_or(CoreValue::Null);
693                            m.insert(name.clone(), crate::schema::core_to_json(&cv, *ty)?);
694                        }
695                        buf.push(m);
696                        if buf.len() >= batch_size {
697                            f(&buf)?;
698                            buf.clear();
699                        }
700                    }
701                }
702                if !buf.is_empty() {
703                    f(&buf)?;
704                }
705                Ok(())
706            }
707            None => {
708                drop(guard);
709                let rows = self.visible_core_rows_at(table, snapshot)?;
710                let maps: Vec<serde_json::Map<String, Value>> = rows
711                    .iter()
712                    .map(|r| crate::schema::core_row_to_json(r, kit_t).map(|row| row.values))
713                    .collect::<Result<Vec<_>>>()?;
714                for chunk in maps.chunks(batch_size) {
715                    f(chunk)?;
716                }
717                Ok(())
718            }
719        }
720    }
721
722    /// Rank rows of `table` by Jaccard set-similarity between `query` and the
723    /// string set stored (as a JSON array) in `column`, returning the top `k`
724    /// with similarity `> 0`, highest first — the dedup/join primitive.
725    ///
726    /// When `column` has a `MinHash` index, candidate rows come from the engine's
727    /// LSH index (sub-linear) and are then re-verified with exact Jaccard, so the
728    /// top-k is exact for the recalled candidates (LSH recall is high but < 100%).
729    /// Without an index it is an exact linear scan.
730    pub fn set_similarity(
731        &self,
732        table: &str,
733        column: &str,
734        query: &[String],
735        k: usize,
736    ) -> Result<Vec<SimilarRow>> {
737        let t = self
738            .schema
739            .tables
740            .iter()
741            .find(|t| t.name == table)
742            .ok_or_else(|| KitError::Validation(format!("unknown table '{table}'")))?;
743        let col = t.columns.iter().find(|c| c.name == column).ok_or_else(|| {
744            KitError::Validation(format!("unknown column '{column}' on table '{table}'"))
745        })?;
746        let query_set: std::collections::HashSet<String> = query.iter().cloned().collect();
747
748        let has_minhash = t.indexes.iter().any(|idx| {
749            idx.kind == KitIndexKind::MinHash && idx.columns.iter().any(|c| c == column)
750        });
751        let rows = if has_minhash {
752            // Sub-linear candidate generation via the engine MinHash/LSH index.
753            let query_hashes: Vec<u64> = query
754                .iter()
755                .map(|s| mongreldb_core::index::minhash_token_hash(s))
756                .collect();
757            // Generous candidate budget so exact top-k keeps high recall.
758            let cand_k = k.saturating_mul(8).max(k + 64);
759            let cond = mongreldb_core::query::Condition::MinHashSimilar {
760                column_id: col.id as u16,
761                query: query_hashes,
762                k: cand_k,
763            };
764            let (snapshot, _pin) = self.inner.snapshot();
765            let core_rows = self.query_core_rows_at(table, &[cond], snapshot)?;
766            core_rows
767                .iter()
768                .map(|r| crate::schema::core_row_to_json(r, t))
769                .collect::<Result<Vec<_>>>()?
770        } else {
771            let tx = self.begin()?;
772            tx.all_rows(table)?
773        };
774
775        let mut scored: Vec<SimilarRow> = Vec::new();
776        for row in rows {
777            let set = parse_string_set(row.values.get(column));
778            let inter = set.iter().filter(|x| query_set.contains(*x)).count();
779            let union = set.len() + query_set.len() - inter;
780            let sim = if union == 0 {
781                0.0
782            } else {
783                inter as f64 / union as f64
784            };
785            if sim > 0.0 {
786                scored.push(SimilarRow {
787                    row,
788                    similarity: sim,
789                });
790            }
791        }
792        scored.sort_by(|a, b| {
793            b.similarity
794                .partial_cmp(&a.similarity)
795                .unwrap_or(std::cmp::Ordering::Equal)
796        });
797        scored.truncate(k);
798        Ok(scored)
799    }
800
801    /// Flush every table's in-memory writes to durable sorted runs. Besides
802    /// durability, this empties the memtable, which is what enables the engine's
803    /// incremental-aggregate fast path (see [`Self::incremental_aggregate`]).
804    pub fn flush(&self) -> Result<()> {
805        for name in self.inner.table_names() {
806            let handle = self.inner.table(&name).map_err(KitError::from)?;
807            let mut guard = handle.lock();
808            guard.flush().map_err(KitError::from)?;
809        }
810        Ok(())
811    }
812
813    /// Maintain and read an aggregate over `table` that updates by merging only
814    /// newly-committed rows instead of rescanning. `column` is required for
815    /// `Sum`/`Min`/`Max`/`Avg` and ignored for `Count`. An optional `filter`
816    /// restricts the aggregate; it must translate **exactly** to index
817    /// conditions (no residual), otherwise this errors — an inexact filter would
818    /// silently aggregate the wrong rows.
819    ///
820    /// The engine keeps a per-`(table, column, agg, filter)` cached state and,
821    /// on a warm cache with an advanced epoch and no deletes/pending writes,
822    /// folds in just the delta. The returned `value` is always exact; the
823    /// `incremental` flag reports whether the fast path was taken.
824    pub fn incremental_aggregate(
825        &self,
826        table: &str,
827        column: Option<&str>,
828        agg: IncrementalAggKind,
829        filter: Option<&mongreldb_kit_core::query::Expr>,
830    ) -> Result<IncrementalAggregate> {
831        let t = self
832            .schema
833            .tables
834            .iter()
835            .find(|t| t.name == table)
836            .ok_or_else(|| KitError::Validation(format!("unknown table '{table}'")))?;
837        if !matches!(agg, IncrementalAggKind::Count) && column.is_none() {
838            return Err(KitError::Validation(
839                "sum/min/max/avg incremental aggregate requires a column".into(),
840            ));
841        }
842        let cid = match column {
843            Some(name) => Some(
844                t.columns
845                    .iter()
846                    .find(|c| c.name == name)
847                    .ok_or_else(|| KitError::Validation(format!("unknown column '{name}'")))?
848                    .id as u16,
849            ),
850            None => None,
851        };
852        let conditions = match filter {
853            Some(expr) => {
854                let plan = crate::pushdown::translate_predicate(t, expr).ok_or_else(|| {
855                    KitError::Validation(
856                        "filter is not index-translatable for an incremental aggregate".into(),
857                    )
858                })?;
859                if !plan.fully_translated {
860                    return Err(KitError::Validation(
861                        "filter has a residual that an incremental aggregate cannot apply exactly"
862                            .into(),
863                    ));
864                }
865                plan.conditions
866            }
867            None => Vec::new(),
868        };
869        let core_agg = match agg {
870            IncrementalAggKind::Count => NativeAgg::Count,
871            IncrementalAggKind::Sum => NativeAgg::Sum,
872            IncrementalAggKind::Min => NativeAgg::Min,
873            IncrementalAggKind::Max => NativeAgg::Max,
874            IncrementalAggKind::Avg => NativeAgg::Avg,
875        };
876        let cache_key = incremental_cache_key(t.id, cid, agg, &conditions);
877        let handle = self.inner.table(table).map_err(KitError::from)?;
878        let mut guard = handle.lock();
879        let res = guard
880            .aggregate_incremental(cache_key, &conditions, cid, core_agg)
881            .map_err(KitError::from)?;
882        Ok(IncrementalAggregate {
883            value: agg_state_value(&res.state),
884            incremental: res.incremental,
885            delta_rows: res.delta_rows,
886        })
887    }
888
889    /// Return the migrations already recorded in `__kit_schema_migrations`.
890    pub fn applied_migrations(&self) -> Result<Vec<mongreldb_kit_core::migrations::Migration>> {
891        crate::migrate::load_applied_migrations(&self.inner)
892    }
893
894    pub(crate) fn core_db(&self) -> &CoreDatabase {
895        &self.inner
896    }
897
898    /// Best-effort flush-on-close (§4.4): force-flush pending writes on every
899    /// table to `.sr` sorted runs so WAL segments stay bounded across repeated
900    /// short-lived process invocations (e.g. the CLI). Call as the last action
901    /// before exit. The daemon does not need this (auto-compactor handles it).
902    pub fn close(&self) -> Result<()> {
903        self.inner.close().map_err(KitError::from)
904    }
905
906    /// Compact all tables: merge sorted runs into one clean run each so query
907    /// latency stays flat. Returns `(compacted, skipped)`. Safe to run at any
908    /// time — honors snapshot retention. The daemon's background auto-compactor
909    /// already does this periodically; this is for manual/cron use.
910    pub fn compact_all(&self) -> Result<(usize, usize)> {
911        self.inner.compact().map_err(KitError::from)
912    }
913
914    /// Compact a single table by name. Returns `true` if compacted, `false` if
915    /// skipped (< 2 runs).
916    pub fn compact_table(&self, name: &str) -> Result<bool> {
917        self.inner.compact_table(name).map_err(KitError::from)
918    }
919
920    /// Direct HOT (PK → RowId) lookup via the core engine — no full-row
921    /// materialization. Used by the §4.3 delete fast path when the table
922    /// has no Kit-level constraints requiring guard cleanup.
923    pub(crate) fn lookup_row_id(&self, table: &str, key: &[u8]) -> Result<Option<RowId>> {
924        let handle = self.inner.table(table).map_err(KitError::from)?;
925        let mut guard = handle.lock();
926        guard.ensure_indexes_complete()?;
927        Ok(guard.lookup_pk(key))
928    }
929
930    pub(crate) fn root(&self) -> &Path {
931        &self.root
932    }
933
934    /// All visible core rows for a table at a specific read snapshot. Used so
935    /// kit transactions read at their own snapshot (repeatable reads) rather
936    /// than the latest committed state.
937    pub(crate) fn visible_core_rows_at(
938        &self,
939        table_name: &str,
940        snapshot: Snapshot,
941    ) -> Result<Vec<CoreRow>> {
942        let handle = self.inner.table(table_name).map_err(KitError::from)?;
943        let guard = handle.lock();
944        guard.visible_rows(snapshot).map_err(KitError::from)
945    }
946
947    /// Query visible core rows with native `Condition`s at a specific read
948    /// snapshot (Kit Priority 1 pushdown). Resolves `conditions` via core's
949    /// indexes (HOT / bitmap / range) and returns only the matching rows —
950    /// avoiding the full scan that `visible_core_rows_at` does. Returns the
951    /// empty vec when no conditions match, and falls back to
952    /// `visible_core_rows_at` when `conditions` is empty (unfiltered).
953    pub(crate) fn query_core_rows_at(
954        &self,
955        table_name: &str,
956        conditions: &[mongreldb_core::query::Condition],
957        snapshot: Snapshot,
958    ) -> Result<Vec<CoreRow>> {
959        if conditions.is_empty() {
960            return self.visible_core_rows_at(table_name, snapshot);
961        }
962        let handle = self.inner.table(table_name).map_err(KitError::from)?;
963        let mut guard = handle.lock();
964        let q = mongreldb_core::query::Query {
965            conditions: conditions.to_vec(),
966        };
967        guard.query(&q).map_err(KitError::from)
968    }
969
970    /// Drain `table`'s memtable into the mutable-run tier, spilling to a
971    /// durable, checkpointed `.sr` run once the tier crosses its watermark.
972    /// Called after a large batch commit (see `Transaction::commit`) so a
973    /// short-lived process (the CLI, or any fresh `Database::open`) isn't
974    /// stuck replaying the whole batch from the WAL on its next invocation —
975    /// without a flush, committed-but-unflushed writes only exist as WAL
976    /// records and must be fully replayed to rebuild the in-memory indexes.
977    pub(crate) fn flush_table(&self, table_name: &str) -> Result<()> {
978        let handle = self.inner.table(table_name).map_err(KitError::from)?;
979        handle.lock().flush().map_err(KitError::from)?;
980        Ok(())
981    }
982
983    /// Count visible rows matching `conditions` without materializing them
984    /// (Kit Priority 7 pushdown). Returns `None` when the conditions cannot be
985    /// served by indexes, or when `snapshot` is not the latest committed epoch
986    /// (caller falls back to a snapshot-correct row scan).
987    ///
988    /// `count_conditions` counts the engine's latest committed index state, not
989    /// a snapshot-filtered scan, so it only matches a repeatable-read row count
990    /// when the read snapshot IS the latest epoch. We hold the table lock while
991    /// comparing, so no commit can interleave between the check and the count.
992    pub(crate) fn count_core_rows_at(
993        &self,
994        table_name: &str,
995        conditions: &[mongreldb_core::query::Condition],
996        snapshot: Snapshot,
997    ) -> Result<Option<u64>> {
998        let handle = self.inner.table(table_name).map_err(KitError::from)?;
999        let mut guard = handle.lock();
1000        if guard.snapshot().epoch != snapshot.epoch {
1001            return Ok(None); // stale read snapshot ⇒ caller scans
1002        }
1003        guard
1004            .count_conditions(conditions, snapshot)
1005            .map_err(KitError::from)
1006    }
1007
1008    /// Compute `SUM`/`MIN`/`MAX`/`AVG`/`COUNT(col)` over a column without
1009    /// materializing rows (Kit Priority 7), via the engine's native aggregate.
1010    /// `column` is the engine column id. Returns `None` when the engine fast
1011    /// path does not apply (multi-run / non-empty overlay / non-numeric column),
1012    /// or when `snapshot` is not the latest committed epoch — the same
1013    /// guarantee as [`count_core_rows_at`](Self::count_core_rows_at): the engine
1014    /// aggregate matches a snapshot-consistent row scan only at the latest epoch,
1015    /// and we compare under the table lock so no commit can interleave.
1016    pub(crate) fn aggregate_core_at(
1017        &self,
1018        table_name: &str,
1019        column: Option<u16>,
1020        conditions: &[mongreldb_core::query::Condition],
1021        agg: NativeAgg,
1022        snapshot: Snapshot,
1023    ) -> Result<Option<NativeAggResult>> {
1024        let handle = self.inner.table(table_name).map_err(KitError::from)?;
1025        let guard = handle.lock();
1026        if guard.snapshot().epoch != snapshot.epoch {
1027            return Ok(None); // stale read snapshot ⇒ caller scans
1028        }
1029        guard
1030            .aggregate_native(snapshot, column, conditions, agg)
1031            .map_err(KitError::from)
1032    }
1033
1034    /// `COUNT(DISTINCT col)` from the bitmap index's partition cardinality (Kit
1035    /// Priority 7) — the number of distinct indexed values, no scan. Returns
1036    /// `None` without a bitmap index on the column, when the table is not
1037    /// insert-only, or when `snapshot` is not the latest committed epoch. The
1038    /// engine method reads the latest committed index state (no snapshot
1039    /// parameter), so — as with [`count_core_rows_at`](Self::count_core_rows_at)
1040    /// — it only matches a repeatable-read scan at the latest epoch; we compare
1041    /// under the table lock so no commit can interleave.
1042    pub(crate) fn count_distinct_core_at(
1043        &self,
1044        table_name: &str,
1045        column_id: u16,
1046        snapshot: Snapshot,
1047    ) -> Result<Option<u64>> {
1048        let handle = self.inner.table(table_name).map_err(KitError::from)?;
1049        let mut guard = handle.lock();
1050        if guard.snapshot().epoch != snapshot.epoch {
1051            return Ok(None); // stale read snapshot ⇒ caller scans
1052        }
1053        guard
1054            .count_distinct_from_bitmap(column_id)
1055            .map_err(KitError::from)
1056    }
1057
1058    /// Materialize a single row by storage row id.
1059    #[allow(dead_code)]
1060    pub(crate) fn get_core_row(&self, table_name: &str, row_id: u64) -> Result<Option<CoreRow>> {
1061        let handle = self.inner.table(table_name).map_err(KitError::from)?;
1062        let guard = handle.lock();
1063        let snapshot = guard.snapshot();
1064        Ok(guard.get(mongreldb_core::RowId(row_id), snapshot))
1065    }
1066}
1067
1068pub(crate) fn create_core_table(db: &CoreDatabase, name: &str, schema: CoreSchema) -> Result<()> {
1069    if db.table_id(name).is_ok() {
1070        return Ok(());
1071    }
1072    db.create_table(name, schema).map_err(KitError::from)?;
1073    Ok(())
1074}
1075
1076fn core_procedure(spec: &ProcedureSpec) -> Result<mongreldb_core::StoredProcedure> {
1077    let parsed: mongreldb_core::StoredProcedure =
1078        serde_json::from_value(spec.json.clone()).map_err(KitError::from)?;
1079    mongreldb_core::StoredProcedure::new(parsed.name, parsed.mode, parsed.params, parsed.body, 0)
1080        .map_err(KitError::from)
1081}
1082
1083fn core_trigger(spec: &TriggerSpec) -> Result<mongreldb_core::StoredTrigger> {
1084    let parsed: mongreldb_core::StoredTrigger =
1085        serde_json::from_value(spec.json.clone()).map_err(KitError::from)?;
1086    mongreldb_core::StoredTrigger::new(
1087        parsed.name,
1088        mongreldb_core::TriggerDefinition {
1089            target: parsed.target,
1090            timing: parsed.timing,
1091            event: parsed.event,
1092            update_of: parsed.update_of,
1093            target_columns: parsed.target_columns,
1094            when: parsed.when,
1095            program: parsed.program,
1096        },
1097        0,
1098    )
1099    .map_err(KitError::from)
1100}
1101
1102fn json_to_core_value(value: &Value) -> Result<CoreValue> {
1103    match value {
1104        Value::Null => Ok(CoreValue::Null),
1105        Value::Bool(value) => Ok(CoreValue::Bool(*value)),
1106        Value::Number(value) => {
1107            if let Some(value) = value.as_i64() {
1108                Ok(CoreValue::Int64(value))
1109            } else if let Some(value) = value.as_f64() {
1110                Ok(CoreValue::Float64(value))
1111            } else {
1112                Err(KitError::Validation("unsupported JSON number".into()))
1113            }
1114        }
1115        Value::String(value) => Ok(CoreValue::Bytes(value.as_bytes().to_vec())),
1116        Value::Array(_) | Value::Object(_) => Err(KitError::Validation(
1117            "procedure args only support scalar JSON values".into(),
1118        )),
1119    }
1120}
1121
1122/// Read a `Bytes` column from an internal-table core row as a UTF-8 string.
1123pub(crate) fn internal_bytes(row: &CoreRow, col_id: u16) -> Option<String> {
1124    match row.columns.get(&col_id) {
1125        Some(CoreValue::Bytes(b)) => String::from_utf8(b.clone()).ok(),
1126        _ => None,
1127    }
1128}
1129
1130/// Best-effort: reap any WAL segments a previous session left rotated but
1131/// unreaped, now that this `open()` has minted a fresh active segment
1132/// (`SharedWal::open` never truncates prior segments on its own —
1133/// [`CoreDatabase::gc`] does, but only once every mounted table's data is
1134/// durable in runs). Called before any write in *this* session, so that
1135/// check reflects exactly what the previous session left behind: if that
1136/// session ended with everything flushed (e.g. a bulk `insert_many`
1137/// followed by `Transaction::commit`'s large-batch auto-flush), this is the
1138/// one moment the now-inactive segment holding that batch is actually
1139/// eligible for cleanup. Without it, a short-lived process (the CLI has no
1140/// daemon mode; every invocation opens cold) keeps paying to read and
1141/// deserialize that segment's records on every subsequent open, even though
1142/// none of them still need replaying. Errors are ignored — this is a
1143/// disk-usage/reopen-latency optimization, never a correctness requirement.
1144fn reap_rotated_wal_segments(db: &CoreDatabase) {
1145    let _ = db.gc();
1146}
1147
1148pub(crate) fn load_schema(path: &Path) -> Result<KitSchema> {
1149    let file = path.join(SCHEMA_FILE);
1150    let json = std::fs::read_to_string(&file)
1151        .map_err(|e| KitError::Migration(format!("cannot read schema file: {e}")))?;
1152    let schema: KitSchema = serde_json::from_str(&json)?;
1153    Ok(schema)
1154}
1155
1156pub(crate) fn store_schema(path: &Path, schema: &KitSchema) -> Result<()> {
1157    let file = path.join(SCHEMA_FILE);
1158    let json = serde_json::to_string_pretty(schema)?;
1159    std::fs::write(&file, json)?;
1160    Ok(())
1161}
1162
1163/// Persist a kit schema into the database. Used after migrations.
1164pub(crate) fn persist_schema(db: &Database, schema: &KitSchema) -> Result<()> {
1165    store_schema(&db.root, schema)
1166}