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, ViewSpec};
16use serde_json::Value;
17
18use std::collections::HashMap;
19use std::path::{Path, PathBuf};
20use std::sync::Arc;
21
22const SCHEMA_FILE: &str = "kit_schema.json";
23
24/// A named default-value provider registered by the application.
25pub type DefaultProvider = Box<dyn Fn() -> Value + Send + Sync>;
26
27/// The result of [`Database::explain`]: a static description of a predicate's
28/// index push-down, without running the query.
29#[derive(Debug, Clone)]
30pub struct ExplainPlan {
31    /// Whether at least one native index condition pushes down (vs. a full scan).
32    pub index_accelerated: bool,
33    /// Whether the push-down is exact — the whole predicate translated, so no
34    /// Rust-side residual re-filtering is needed.
35    pub exact: bool,
36    /// The kind of each pushed condition (e.g. `BitmapEq`, `RangeInt`, `Ann`).
37    pub pushed_conditions: Vec<String>,
38}
39
40/// A row paired with its Jaccard set-similarity to a query set (`0.0..=1.0`).
41#[derive(Debug, Clone)]
42pub struct SimilarRow {
43    pub row: crate::schema::Row,
44    pub similarity: f64,
45}
46
47/// Collect the string members of a set-valued column cell. Accepts either a
48/// JSON array value or a JSON string holding an array (how the Kit stores
49/// `json`/`text` set columns); anything else yields the empty set.
50fn parse_string_set(value: Option<&Value>) -> std::collections::HashSet<String> {
51    let arr = match value {
52        Some(Value::Array(a)) => Some(a.clone()),
53        Some(Value::String(s)) => serde_json::from_str::<Value>(s)
54            .ok()
55            .and_then(|v| v.as_array().cloned()),
56        _ => None,
57    };
58    arr.into_iter()
59        .flatten()
60        .filter_map(|v| match v {
61            Value::String(s) => Some(s),
62            Value::Number(n) => Some(n.to_string()),
63            Value::Bool(b) => Some(b.to_string()),
64            _ => None,
65        })
66        .collect()
67}
68
69/// Which aggregate to maintain incrementally.
70#[derive(Debug, Clone, Copy, PartialEq, Eq)]
71pub enum IncrementalAggKind {
72    Count,
73    Sum,
74    Min,
75    Max,
76    Avg,
77}
78
79/// The result of [`Database::incremental_aggregate`].
80#[derive(Debug, Clone)]
81pub struct IncrementalAggregate {
82    /// The exact aggregate value at the current epoch: a JSON number, or `null`
83    /// when no rows matched (`COUNT` returns `0`, not null).
84    pub value: Value,
85    /// `true` when produced by merging only newly-committed rows (the fast
86    /// path); `false` when a full recompute was required (cold cache, a delete,
87    /// pending writes, or the same epoch as the cached state).
88    pub incremental: bool,
89    /// Rows processed in the delta pass (`0` for a full recompute).
90    pub delta_rows: u64,
91}
92
93/// Stable per-`(table, column, agg, filter)` cache key for the engine's
94/// incremental-aggregate cache. Deterministic within a process (fixed-seed
95/// hasher); the cache itself is per-`Db`, so cross-process stability is moot.
96fn incremental_cache_key(
97    table_id: u32,
98    column: Option<u16>,
99    agg: IncrementalAggKind,
100    conditions: &[mongreldb_core::query::Condition],
101) -> u64 {
102    use std::hash::{Hash, Hasher};
103    let mut h = std::collections::hash_map::DefaultHasher::new();
104    table_id.hash(&mut h);
105    column.hash(&mut h);
106    (agg as u8).hash(&mut h);
107    // `Condition` has no `Hash`; its `Debug` form is stable and unique enough.
108    format!("{conditions:?}").hash(&mut h);
109    h.finish()
110}
111
112/// Finalize a mergeable [`AggState`] to a JSON scalar, preserving integer-ness
113/// for `COUNT`/`MIN`/`MAX`/int `SUM` and using a float for averages / float
114/// columns. `null` when there were no matching inputs.
115fn agg_state_value(s: &AggState) -> Value {
116    let num_f64 = |x: f64| {
117        serde_json::Number::from_f64(x)
118            .map(Value::Number)
119            .unwrap_or(Value::Null)
120    };
121    match s {
122        AggState::Count(n) => Value::from(*n),
123        AggState::SumI { sum, .. } => i64::try_from(*sum)
124            .map(Value::from)
125            .unwrap_or_else(|_| num_f64(*sum as f64)),
126        AggState::SumF { sum, .. } => num_f64(*sum),
127        AggState::AvgI { sum, count } if *count > 0 => num_f64(*sum as f64 / *count as f64),
128        AggState::AvgF { sum, count } if *count > 0 => num_f64(*sum / *count as f64),
129        AggState::AvgI { .. } | AggState::AvgF { .. } => Value::Null,
130        AggState::MinI(n) | AggState::MaxI(n) => Value::from(*n),
131        AggState::MinF(f) | AggState::MaxF(f) => num_f64(*f),
132        AggState::Empty => Value::Null,
133    }
134}
135
136/// Which approximate aggregate to estimate from the reservoir sample.
137#[derive(Debug, Clone, Copy, PartialEq, Eq)]
138pub enum ApproxAggKind {
139    Count,
140    Sum,
141    Avg,
142}
143
144/// A reservoir-sampled approximate aggregate with a normal-theory confidence
145/// interval. `ci_low`/`ci_high` bracket `point` at the requested z-score; the
146/// interval collapses to zero width when the sample covers the whole table.
147#[derive(Debug, Clone)]
148pub struct ApproxAggregate {
149    pub point: f64,
150    pub ci_low: f64,
151    pub ci_high: f64,
152    pub n_population: u64,
153    pub n_sample_live: usize,
154    pub n_passing: usize,
155}
156
157/// Short kind label for a core `Condition` (the variant name), decoupled from
158/// the enum shape via its `Debug` form.
159fn condition_label(c: &mongreldb_core::query::Condition) -> String {
160    let dbg = format!("{c:?}");
161    dbg.split(['(', '{', ' ']).next().unwrap_or("").to_string()
162}
163
164/// A kit database handle.
165///
166/// Wraps a MongrelDB core database and a kit schema, providing table metadata
167/// and transaction creation.
168pub struct Database {
169    pub(crate) inner: Arc<CoreDatabase>,
170    pub(crate) schema: KitSchema,
171    pub(crate) root: PathBuf,
172    /// Application-registered named default providers (`DefaultKind::CustomName`).
173    pub(crate) default_providers: HashMap<String, DefaultProvider>,
174    /// Lazily-initialized long-lived SQL session. Views, prepared statements,
175    /// and the result cache are session-scoped (the engine does not persist
176    /// them), so the kit holds one session for the database's lifetime rather
177    /// than opening one per `sql()` call — mirroring how the daemon and any
178    /// long-lived application use MongrelDB. Built on first use so tables
179    /// created in `Database::create` are visible to it.
180    pub(crate) session: parking_lot::Mutex<Option<mongreldb_query::MongrelSession>>,
181}
182
183impl Database {
184    /// Open an existing kit database.
185    pub fn open(path: &Path) -> Result<Self> {
186        let inner = Arc::new(CoreDatabase::open(path)?);
187        let schema = load_schema(path)?;
188        // Ensure reserved tables exist for databases created by older versions.
189        ensure_internal_tables(&inner)?;
190        reap_rotated_wal_segments(&inner);
191        Ok(Self {
192            inner,
193            schema,
194            root: path.to_path_buf(),
195            default_providers: HashMap::new(),
196            session: parking_lot::Mutex::new(None),
197        })
198    }
199
200    /// Open an existing page-encrypted kit database with its passphrase.
201    pub fn open_encrypted(path: &Path, passphrase: &str) -> Result<Self> {
202        let inner = Arc::new(CoreDatabase::open_encrypted(path, passphrase)?);
203        let schema = load_schema(path)?;
204        ensure_internal_tables(&inner)?;
205        reap_rotated_wal_segments(&inner);
206        Ok(Self {
207            inner,
208            schema,
209            root: path.to_path_buf(),
210            default_providers: HashMap::new(),
211            session: parking_lot::Mutex::new(None),
212        })
213    }
214
215    /// Create a fresh page-encrypted kit database (AES-256-GCM; the passphrase
216    /// derives the key hierarchy). Columns flagged `encrypted` /
217    /// `encrypted_indexable` are encrypted at rest.
218    pub fn create_encrypted(path: &Path, schema: KitSchema, passphrase: &str) -> Result<Self> {
219        std::fs::create_dir_all(path)?;
220        let inner = Arc::new(CoreDatabase::create_encrypted(path, passphrase)?);
221        ensure_internal_tables(&inner)?;
222        store_schema(path, &schema)?;
223        for table in &schema.tables {
224            create_core_table(&inner, &table.name, to_core_schema(table))?;
225        }
226        Ok(Self {
227            inner,
228            schema,
229            root: path.to_path_buf(),
230            default_providers: HashMap::new(),
231            session: parking_lot::Mutex::new(None),
232        })
233    }
234
235    /// Create a fresh kit database with the given schema.
236    pub fn create(path: &Path, schema: KitSchema) -> Result<Self> {
237        std::fs::create_dir_all(path)?;
238        let inner = Arc::new(CoreDatabase::create(path)?);
239
240        // Create the reserved kit tables first so we can record migrations,
241        // reserve unique keys, and touch row guards.
242        ensure_internal_tables(&inner)?;
243
244        // Persist the kit schema to a sidecar file (core tables cannot update
245        // a specific row by id, so a file is the pragmatic stable store).
246        store_schema(path, &schema)?;
247
248        // Create core tables for every user table.
249        for table in &schema.tables {
250            create_core_table(&inner, &table.name, to_core_schema(table))?;
251        }
252
253        Ok(Self {
254            inner,
255            schema,
256            root: path.to_path_buf(),
257            default_providers: HashMap::new(),
258            session: parking_lot::Mutex::new(None),
259        })
260    }
261
262    /// Register a named default provider used by `DefaultKind::CustomName`
263    /// columns. Returns the database for chaining.
264    pub fn register_default(
265        &mut self,
266        name: impl Into<String>,
267        provider: impl Fn() -> Value + Send + Sync + 'static,
268    ) {
269        self.default_providers
270            .insert(name.into(), Box::new(provider));
271    }
272
273    /// The raw, unguarded MongrelDB core database. This is the Rust analogue of
274    /// the TypeScript kit's `nativeDb` escape hatch: writes made directly
275    /// against it bypass all kit constraints.
276    pub fn raw(&self) -> &CoreDatabase {
277        &self.inner
278    }
279
280    /// Application table names, excluding the reserved `__kit_*` tables.
281    pub fn table_names(&self) -> Vec<String> {
282        self.schema
283            .tables
284            .iter()
285            .map(|t| t.name.clone())
286            .filter(|n| !n.starts_with("__kit_"))
287            .collect()
288    }
289
290    pub fn create_procedure(
291        &self,
292        spec: &ProcedureSpec,
293    ) -> Result<mongreldb_core::StoredProcedure> {
294        let procedure = core_procedure(spec)?;
295        self.inner
296            .create_procedure(procedure)
297            .map_err(KitError::from)
298    }
299
300    pub fn replace_procedure(
301        &self,
302        spec: &ProcedureSpec,
303    ) -> Result<mongreldb_core::StoredProcedure> {
304        let procedure = core_procedure(spec)?;
305        self.inner
306            .create_or_replace_procedure(procedure)
307            .map_err(KitError::from)
308    }
309
310    pub fn drop_procedure(&self, name: &str) -> Result<()> {
311        self.inner.drop_procedure(name).map_err(KitError::from)
312    }
313
314    pub fn call_procedure(
315        &self,
316        name: &str,
317        args: serde_json::Map<String, Value>,
318    ) -> Result<mongreldb_core::ProcedureCallResult> {
319        let args = args
320            .iter()
321            .map(|(key, value)| Ok((key.clone(), json_to_core_value(value)?)))
322            .collect::<Result<HashMap<_, _>>>()?;
323        self.inner
324            .call_procedure(name, args)
325            .map_err(KitError::from)
326    }
327
328    pub fn create_trigger(&self, spec: &TriggerSpec) -> Result<mongreldb_core::StoredTrigger> {
329        let trigger = core_trigger(spec)?;
330        self.inner.create_trigger(trigger).map_err(KitError::from)
331    }
332
333    pub fn replace_trigger(&self, spec: &TriggerSpec) -> Result<mongreldb_core::StoredTrigger> {
334        let trigger = core_trigger(spec)?;
335        self.inner
336            .create_or_replace_trigger(trigger)
337            .map_err(KitError::from)
338    }
339
340    pub fn drop_trigger(&self, name: &str) -> Result<()> {
341        self.inner.drop_trigger(name).map_err(KitError::from)
342    }
343
344    pub fn triggers(&self) -> Vec<mongreldb_core::StoredTrigger> {
345        self.inner.triggers()
346    }
347
348    pub fn trigger(&self, name: &str) -> Option<mongreldb_core::StoredTrigger> {
349        self.inner.trigger(name)
350    }
351
352    /// Allocate `count` values from the named sequence, returning the first
353    /// allocated value. A fresh sequence starts at `1` (SQL AUTO_INCREMENT
354    /// semantics). The allocation
355    /// runs in its own committed transaction and retries on write-write
356    /// conflicts.
357    pub fn allocate_sequence(&self, name: &str, count: i64) -> Result<i64> {
358        use crate::internal::cols;
359        let mut attempt = 0;
360        loop {
361            let mut txn = self.inner.begin();
362            let snapshot = txn.read_snapshot();
363            let existing = self
364                .visible_core_rows_at(crate::internal::SEQUENCES, snapshot)?
365                .into_iter()
366                .find(|r| internal_bytes(r, cols::SEQ_NAME) == Some(name.to_string()));
367
368            let now = crate::internal::iso_now();
369            // Sequences are 1-based, matching SQL AUTO_INCREMENT / SERIAL. A
370            // starting value of 0 is unsafe: applications use 0 as the "unset"
371            // sentinel for nullable foreign keys.
372            let (start, next, old_row_id) = match &existing {
373                Some(row) => {
374                    let current = match row.columns.get(&cols::SEQ_NEXT) {
375                        Some(CoreValue::Int64(i)) => *i,
376                        _ => 1,
377                    };
378                    (current, current + count, Some(row.row_id))
379                }
380                None => (1, 1 + count, None),
381            };
382
383            if let Some(rid) = old_row_id {
384                txn.delete(crate::internal::SEQUENCES, rid)
385                    .map_err(KitError::from)?;
386            }
387            txn.put(
388                crate::internal::SEQUENCES,
389                vec![
390                    (cols::SEQ_NAME, CoreValue::Bytes(name.as_bytes().to_vec())),
391                    (cols::SEQ_NEXT, CoreValue::Int64(next)),
392                    (cols::SEQ_UPDATED, CoreValue::Bytes(now.into_bytes())),
393                ],
394            )
395            .map_err(KitError::from)?;
396            match txn.commit() {
397                Ok(_) => return Ok(start),
398                Err(mongreldb_core::MongrelError::Conflict(_)) if attempt < 10_000 => {
399                    attempt += 1;
400                    std::thread::yield_now();
401                    continue;
402                }
403                Err(e) => return Err(KitError::from(e)),
404            }
405        }
406    }
407
408    /// Run `f` inside a kit transaction, committing on success and retrying on
409    /// retryable write-write conflicts up to `max_retries` times.
410    pub fn transaction<T, F>(&self, max_retries: usize, mut f: F) -> Result<T>
411    where
412        F: FnMut(&mut crate::txn::Transaction<'_>) -> Result<T>,
413    {
414        let mut attempt = 0;
415        loop {
416            let mut txn = self.begin()?;
417            match f(&mut txn) {
418                Ok(value) => match txn.commit() {
419                    Ok(()) => return Ok(value),
420                    Err(KitError::Conflict(_)) if attempt < max_retries => {
421                        attempt += 1;
422                        continue;
423                    }
424                    Err(e) => return Err(e),
425                },
426                Err(KitError::Conflict(_)) if attempt < max_retries => {
427                    txn.rollback();
428                    attempt += 1;
429                    continue;
430                }
431                Err(e) => {
432                    txn.rollback();
433                    return Err(e);
434                }
435            }
436        }
437    }
438
439    /// Look up a table definition by name.
440    pub fn table(&self, name: &str) -> Option<&KitTable> {
441        self.schema.table(name)
442    }
443
444    /// Return the currently loaded schema.
445    pub fn schema(&self) -> &KitSchema {
446        &self.schema
447    }
448
449    /// Begin a new kit transaction.
450    pub fn begin(&self) -> Result<crate::txn::Transaction<'_>> {
451        let core_txn = self.inner.begin();
452        Ok(crate::txn::Transaction::new(self, core_txn))
453    }
454
455    /// Replace the in-memory schema, usually after a migration.
456    pub fn set_schema(&mut self, schema: KitSchema) {
457        self.schema = schema;
458    }
459
460    /// Verify that the sidecar schema file and all reserved `__kit_*` tables
461    /// are present.
462    pub fn check_internal_tables(&self) -> Result<()> {
463        let schema_file = self.root.join(SCHEMA_FILE);
464        if !schema_file.exists() {
465            return Err(KitError::Integrity(format!(
466                "schema file {} is missing",
467                schema_file.display()
468            )));
469        }
470        for (name, _) in internal_tables_core() {
471            if self.inner.table_id(name).is_err() {
472                return Err(KitError::Integrity(format!(
473                    "internal table {name} is missing"
474                )));
475            }
476        }
477        Ok(())
478    }
479
480    /// Reclaim orphaned runs and stale WAL/shadow files; returns the count
481    /// removed. Safe to run on a live database.
482    pub fn gc(&self) -> Result<usize> {
483        self.inner.gc().map_err(KitError::from)
484    }
485
486    /// Verify run footer checksums; returns any integrity issues as JSON objects
487    /// (`table_id`, `table_name`, `severity`, `description`). Empty ⇒ healthy.
488    pub fn check(&self) -> Vec<serde_json::Value> {
489        self.inner
490            .check()
491            .into_iter()
492            .map(|i| {
493                serde_json::json!({
494                    "table_id": i.table_id,
495                    "table_name": i.table_name,
496                    "severity": i.severity,
497                    "description": i.description,
498                })
499            })
500            .collect()
501    }
502
503    /// Drop corrupt runs; returns the ids of the runs that were dropped.
504    pub fn doctor(&self) -> Result<Vec<u64>> {
505        self.inner.doctor().map_err(KitError::from)
506    }
507
508    /// The current visible commit epoch — a monotonically increasing version
509    /// stamp. A committed write bumps it; a snapshot at this epoch sees all
510    /// currently-committed data.
511    pub fn snapshot_epoch(&self) -> u64 {
512        self.inner.snapshot().0.epoch.0
513    }
514
515    /// Export every visible row of `table` as a TSV document (header row of
516    /// column names, tab-separated cells, `NULL` = empty field). See
517    /// [`crate::tsv`] for the escaping rules.
518    pub fn export_tsv(&self, table: &str) -> Result<String> {
519        let t = self
520            .schema
521            .tables
522            .iter()
523            .find(|t| t.name == table)
524            .ok_or_else(|| KitError::Validation(format!("unknown table '{table}'")))?
525            .clone();
526        let tx = self.begin()?;
527        let rows = tx.all_rows(table)?;
528        Ok(crate::tsv::rows_to_tsv(&t, &rows))
529    }
530
531    /// Import a TSV document into `table` (one committed transaction). Each row
532    /// passes through defaults, validation, and constraint checks like a normal
533    /// insert. Returns the number of rows inserted.
534    pub fn import_tsv(&self, table: &str, text: &str) -> Result<usize> {
535        let t = self
536            .schema
537            .tables
538            .iter()
539            .find(|t| t.name == table)
540            .ok_or_else(|| KitError::Validation(format!("unknown table '{table}'")))?
541            .clone();
542        let rows = crate::tsv::tsv_to_rows(&t, text)?;
543        let n = rows.len();
544        self.transaction(1, |tx| {
545            tx.insert_many(table, rows.clone())?;
546            Ok(())
547        })?;
548        Ok(n)
549    }
550
551    /// Describe how `predicate` would be executed against `table`: which native
552    /// index conditions push down, whether the push-down is exact (no residual
553    /// re-filtering), and whether any index acceleration applies at all. A pure
554    /// diagnostic — it plans but does not run the query.
555    pub fn explain(
556        &self,
557        table: &str,
558        predicate: &mongreldb_kit_core::query::Expr,
559    ) -> Result<ExplainPlan> {
560        let t = self
561            .schema
562            .tables
563            .iter()
564            .find(|t| t.name == table)
565            .ok_or_else(|| KitError::Validation(format!("unknown table '{table}'")))?;
566        Ok(match crate::pushdown::translate_predicate(t, predicate) {
567            Some(p) => ExplainPlan {
568                index_accelerated: p.can_push(),
569                exact: p.fully_translated,
570                pushed_conditions: p.conditions.iter().map(condition_label).collect(),
571            },
572            None => ExplainPlan {
573                index_accelerated: false,
574                exact: false,
575                pushed_conditions: Vec::new(),
576            },
577        })
578    }
579
580    /// Read every row of `table` visible at commit `epoch` — a point-in-time
581    /// (MVCC time-travel) read. `epoch` must not exceed the current snapshot
582    /// epoch. Rows reclaimed by GC/compaction for retired snapshots may no
583    /// longer be reconstructable; this reads whatever the engine still retains
584    /// at that epoch.
585    pub fn rows_at_epoch(&self, table: &str, epoch: u64) -> Result<Vec<crate::schema::Row>> {
586        let t = self
587            .schema
588            .tables
589            .iter()
590            .find(|t| t.name == table)
591            .ok_or_else(|| KitError::Validation(format!("unknown table '{table}'")))?;
592        let current = self.snapshot_epoch();
593        if epoch > current {
594            return Err(KitError::Validation(format!(
595                "epoch {epoch} is in the future (current committed epoch is {current})"
596            )));
597        }
598        let snap = Snapshot::at(mongreldb_core::epoch::Epoch(epoch));
599        let rows = self.visible_core_rows_at(table, snap)?;
600        rows.iter()
601            .map(|r| crate::schema::core_row_to_json(r, t))
602            .collect()
603    }
604
605    /// Estimate an aggregate over `table` from the engine's reservoir sample,
606    /// returning a point estimate and a `z`-score confidence interval (e.g.
607    /// `z = 1.96` for ~95%). `column` is required for `Sum`/`Avg` and ignored
608    /// for `Count`. Returns `None` when the reservoir is empty (no sampled rows
609    /// yet). Fast and O(sample) — trades exactness for speed on large tables.
610    pub fn approx_aggregate(
611        &self,
612        table: &str,
613        column: Option<&str>,
614        agg: ApproxAggKind,
615        z: f64,
616    ) -> Result<Option<ApproxAggregate>> {
617        let t = self
618            .schema
619            .tables
620            .iter()
621            .find(|t| t.name == table)
622            .ok_or_else(|| KitError::Validation(format!("unknown table '{table}'")))?;
623        if matches!(agg, ApproxAggKind::Sum | ApproxAggKind::Avg) && column.is_none() {
624            return Err(KitError::Validation(
625                "approx sum/avg requires a column".into(),
626            ));
627        }
628        let cid = match column {
629            Some(name) => Some(
630                t.columns
631                    .iter()
632                    .find(|c| c.name == name)
633                    .ok_or_else(|| KitError::Validation(format!("unknown column '{name}'")))?
634                    .id as u16,
635            ),
636            None => None,
637        };
638        let core_agg = match agg {
639            ApproxAggKind::Count => ApproxAgg::Count,
640            ApproxAggKind::Sum => ApproxAgg::Sum,
641            ApproxAggKind::Avg => ApproxAgg::Avg,
642        };
643        let handle = self.inner.table(table).map_err(KitError::from)?;
644        let mut guard = handle.lock();
645        let res = guard
646            .approx_aggregate(&[], cid, core_agg, z)
647            .map_err(KitError::from)?;
648        Ok(res.map(|r| ApproxAggregate {
649            point: r.point,
650            ci_low: r.ci_low,
651            ci_high: r.ci_high,
652            n_population: r.n_population,
653            n_sample_live: r.n_sample_live,
654            n_passing: r.n_passing,
655        }))
656    }
657
658    /// Stream `table` in row batches without materializing the whole table at
659    /// once. `f` receives successive chunks of at most `batch_size` value-maps,
660    /// all from one snapshot. Backed by the engine's native scan cursor when the
661    /// table has a sorted run; for an overlay-only table (no run yet) it falls
662    /// back to a single in-memory pass, still chunked to `batch_size`.
663    pub fn scan_batched<F>(&self, table: &str, batch_size: usize, mut f: F) -> Result<()>
664    where
665        F: FnMut(&[serde_json::Map<String, Value>]) -> Result<()>,
666    {
667        let kit_t = self
668            .schema
669            .tables
670            .iter()
671            .find(|t| t.name == table)
672            .ok_or_else(|| KitError::Validation(format!("unknown table '{table}'")))?;
673        let batch_size = batch_size.max(1);
674        // Keep the pin guard alive for the whole scan so GC can't reclaim the
675        // snapshot's versions mid-stream.
676        let (snapshot, _pin) = self.inner.snapshot();
677        let handle = self.inner.table(table).map_err(KitError::from)?;
678        let guard = handle.lock();
679
680        // Projection + per-column (name, kit type), index-aligned, in core order.
681        let mut projection: Vec<(u16, mongreldb_core::schema::TypeId)> = Vec::new();
682        let mut meta: Vec<(String, mongreldb_kit_core::schema::ColumnType)> = Vec::new();
683        for c in &guard.schema().columns {
684            if let Some(kc) = kit_t.columns.iter().find(|kc| kc.id as u16 == c.id) {
685                projection.push((c.id, c.ty));
686                meta.push((kc.name.clone(), kc.storage_type));
687            }
688        }
689
690        match guard
691            .scan_cursor(snapshot, projection, &[])
692            .map_err(KitError::from)?
693        {
694            Some(mut cursor) => {
695                let mut buf: Vec<serde_json::Map<String, Value>> = Vec::with_capacity(batch_size);
696                while let Some(batch) = cursor.next_batch().map_err(KitError::from)? {
697                    let nrows = batch.first().map(|c| c.len()).unwrap_or(0);
698                    for j in 0..nrows {
699                        let mut m = serde_json::Map::new();
700                        for (ci, (name, ty)) in meta.iter().enumerate() {
701                            let cv = batch
702                                .get(ci)
703                                .and_then(|col| col.value_at(j))
704                                .unwrap_or(CoreValue::Null);
705                            m.insert(name.clone(), crate::schema::core_to_json(&cv, *ty)?);
706                        }
707                        buf.push(m);
708                        if buf.len() >= batch_size {
709                            f(&buf)?;
710                            buf.clear();
711                        }
712                    }
713                }
714                if !buf.is_empty() {
715                    f(&buf)?;
716                }
717                Ok(())
718            }
719            None => {
720                drop(guard);
721                let rows = self.visible_core_rows_at(table, snapshot)?;
722                let maps: Vec<serde_json::Map<String, Value>> = rows
723                    .iter()
724                    .map(|r| crate::schema::core_row_to_json(r, kit_t).map(|row| row.values))
725                    .collect::<Result<Vec<_>>>()?;
726                for chunk in maps.chunks(batch_size) {
727                    f(chunk)?;
728                }
729                Ok(())
730            }
731        }
732    }
733
734    /// Rank rows of `table` by Jaccard set-similarity between `query` and the
735    /// string set stored (as a JSON array) in `column`, returning the top `k`
736    /// with similarity `> 0`, highest first — the dedup/join primitive.
737    ///
738    /// When `column` has a `MinHash` index, candidate rows come from the engine's
739    /// LSH index (sub-linear) and are then re-verified with exact Jaccard, so the
740    /// top-k is exact for the recalled candidates (LSH recall is high but < 100%).
741    /// Without an index it is an exact linear scan.
742    pub fn set_similarity(
743        &self,
744        table: &str,
745        column: &str,
746        query: &[String],
747        k: usize,
748    ) -> Result<Vec<SimilarRow>> {
749        let t = self
750            .schema
751            .tables
752            .iter()
753            .find(|t| t.name == table)
754            .ok_or_else(|| KitError::Validation(format!("unknown table '{table}'")))?;
755        let col = t.columns.iter().find(|c| c.name == column).ok_or_else(|| {
756            KitError::Validation(format!("unknown column '{column}' on table '{table}'"))
757        })?;
758        let query_set: std::collections::HashSet<String> = query.iter().cloned().collect();
759
760        let has_minhash = t.indexes.iter().any(|idx| {
761            idx.kind == KitIndexKind::MinHash && idx.columns.iter().any(|c| c == column)
762        });
763        let rows = if has_minhash {
764            // Sub-linear candidate generation via the engine MinHash/LSH index.
765            let query_hashes: Vec<u64> = query
766                .iter()
767                .map(|s| mongreldb_core::index::minhash_token_hash(s))
768                .collect();
769            // Generous candidate budget so exact top-k keeps high recall.
770            let cand_k = k.saturating_mul(8).max(k + 64);
771            let cond = mongreldb_core::query::Condition::MinHashSimilar {
772                column_id: col.id as u16,
773                query: query_hashes,
774                k: cand_k,
775            };
776            let (snapshot, _pin) = self.inner.snapshot();
777            let core_rows = self.query_core_rows_at(table, &[cond], snapshot)?;
778            core_rows
779                .iter()
780                .map(|r| crate::schema::core_row_to_json(r, t))
781                .collect::<Result<Vec<_>>>()?
782        } else {
783            let tx = self.begin()?;
784            tx.all_rows(table)?
785        };
786
787        let mut scored: Vec<SimilarRow> = Vec::new();
788        for row in rows {
789            let set = parse_string_set(row.values.get(column));
790            let inter = set.iter().filter(|x| query_set.contains(*x)).count();
791            let union = set.len() + query_set.len() - inter;
792            let sim = if union == 0 {
793                0.0
794            } else {
795                inter as f64 / union as f64
796            };
797            if sim > 0.0 {
798                scored.push(SimilarRow {
799                    row,
800                    similarity: sim,
801                });
802            }
803        }
804        scored.sort_by(|a, b| {
805            b.similarity
806                .partial_cmp(&a.similarity)
807                .unwrap_or(std::cmp::Ordering::Equal)
808        });
809        scored.truncate(k);
810        Ok(scored)
811    }
812
813    /// Flush every table's in-memory writes to durable sorted runs. Besides
814    /// durability, this empties the memtable, which is what enables the engine's
815    /// incremental-aggregate fast path (see [`Self::incremental_aggregate`]).
816    pub fn flush(&self) -> Result<()> {
817        for name in self.inner.table_names() {
818            let handle = self.inner.table(&name).map_err(KitError::from)?;
819            let mut guard = handle.lock();
820            guard.flush().map_err(KitError::from)?;
821        }
822        Ok(())
823    }
824
825    /// Maintain and read an aggregate over `table` that updates by merging only
826    /// newly-committed rows instead of rescanning. `column` is required for
827    /// `Sum`/`Min`/`Max`/`Avg` and ignored for `Count`. An optional `filter`
828    /// restricts the aggregate; it must translate **exactly** to index
829    /// conditions (no residual), otherwise this errors — an inexact filter would
830    /// silently aggregate the wrong rows.
831    ///
832    /// The engine keeps a per-`(table, column, agg, filter)` cached state and,
833    /// on a warm cache with an advanced epoch and no deletes/pending writes,
834    /// folds in just the delta. The returned `value` is always exact; the
835    /// `incremental` flag reports whether the fast path was taken.
836    pub fn incremental_aggregate(
837        &self,
838        table: &str,
839        column: Option<&str>,
840        agg: IncrementalAggKind,
841        filter: Option<&mongreldb_kit_core::query::Expr>,
842    ) -> Result<IncrementalAggregate> {
843        let t = self
844            .schema
845            .tables
846            .iter()
847            .find(|t| t.name == table)
848            .ok_or_else(|| KitError::Validation(format!("unknown table '{table}'")))?;
849        if !matches!(agg, IncrementalAggKind::Count) && column.is_none() {
850            return Err(KitError::Validation(
851                "sum/min/max/avg incremental aggregate requires a column".into(),
852            ));
853        }
854        let cid = match column {
855            Some(name) => Some(
856                t.columns
857                    .iter()
858                    .find(|c| c.name == name)
859                    .ok_or_else(|| KitError::Validation(format!("unknown column '{name}'")))?
860                    .id as u16,
861            ),
862            None => None,
863        };
864        let conditions = match filter {
865            Some(expr) => {
866                let plan = crate::pushdown::translate_predicate(t, expr).ok_or_else(|| {
867                    KitError::Validation(
868                        "filter is not index-translatable for an incremental aggregate".into(),
869                    )
870                })?;
871                if !plan.fully_translated {
872                    return Err(KitError::Validation(
873                        "filter has a residual that an incremental aggregate cannot apply exactly"
874                            .into(),
875                    ));
876                }
877                plan.conditions
878            }
879            None => Vec::new(),
880        };
881        let core_agg = match agg {
882            IncrementalAggKind::Count => NativeAgg::Count,
883            IncrementalAggKind::Sum => NativeAgg::Sum,
884            IncrementalAggKind::Min => NativeAgg::Min,
885            IncrementalAggKind::Max => NativeAgg::Max,
886            IncrementalAggKind::Avg => NativeAgg::Avg,
887        };
888        let cache_key = incremental_cache_key(t.id, cid, agg, &conditions);
889        let handle = self.inner.table(table).map_err(KitError::from)?;
890        let mut guard = handle.lock();
891        let res = guard
892            .aggregate_incremental(cache_key, &conditions, cid, core_agg)
893            .map_err(KitError::from)?;
894        Ok(IncrementalAggregate {
895            value: agg_state_value(&res.state),
896            incremental: res.incremental,
897            delta_rows: res.delta_rows,
898        })
899    }
900
901    /// Return the migrations already recorded in `__kit_schema_migrations`.
902    pub fn applied_migrations(&self) -> Result<Vec<mongreldb_kit_core::migrations::Migration>> {
903        crate::migrate::load_applied_migrations(&self.inner)
904    }
905
906    pub(crate) fn core_db(&self) -> &CoreDatabase {
907        &self.inner
908    }
909
910    /// The underlying engine handle wrapped in an `Arc`, for callers that need
911    /// a shared/owned reference (e.g. building a `MongrelSession`).
912    pub(crate) fn core_arc(&self) -> Arc<CoreDatabase> {
913        Arc::clone(&self.inner)
914    }
915
916    /// Best-effort flush-on-close (§4.4): force-flush pending writes on every
917    /// table to `.sr` sorted runs so WAL segments stay bounded across repeated
918    /// short-lived process invocations (e.g. the CLI). Call as the last action
919    /// before exit. The daemon does not need this (auto-compactor handles it).
920    pub fn close(&self) -> Result<()> {
921        self.inner.close().map_err(KitError::from)
922    }
923
924    /// Compact all tables: merge sorted runs into one clean run each so query
925    /// latency stays flat. Returns `(compacted, skipped)`. Safe to run at any
926    /// time — honors snapshot retention. The daemon's background auto-compactor
927    /// already does this periodically; this is for manual/cron use.
928    pub fn compact_all(&self) -> Result<(usize, usize)> {
929        self.inner.compact().map_err(KitError::from)
930    }
931
932    /// Compact a single table by name. Returns `true` if compacted, `false` if
933    /// skipped (< 2 runs).
934    pub fn compact_table(&self, name: &str) -> Result<bool> {
935        self.inner.compact_table(name).map_err(KitError::from)
936    }
937
938    /// Rename a live table. Fails if `from` does not exist or `to` is already
939    /// in use; a no-op when `from == to`. Names beginning with `__kit_` are
940    /// reserved for internal tables and rejected here (parity with the
941    /// TypeScript kit).
942    ///
943    /// Updates both the engine table and the kit schema catalog (in memory and
944    /// persisted to `kit_schema.json`), so subsequent `table_names()`,
945    /// `table(name)`, and transactional reads by the new name all work. Foreign
946    /// keys in other tables that reference `from` are retargeted to `to`.
947    pub fn rename_table(&mut self, from: &str, to: &str) -> Result<()> {
948        if from.starts_with("__kit_") || to.starts_with("__kit_") {
949            return Err(KitError::Validation(
950                "rename_table: names beginning with '__kit_' are reserved for internal tables"
951                    .into(),
952            ));
953        }
954        self.inner.rename_table(from, to).map_err(KitError::from)?;
955        // Keep the kit schema catalog in sync: rename the table (updating the
956        // by_name index), retarget any FKs that pointed at it, then persist.
957        if !self.schema.rename_table(from, to) {
958            // The engine renamed it but the kit schema didn't have it / had a
959            // clash — surface the divergence rather than silently desyncing.
960            return Err(KitError::Integrity(format!(
961                "rename_table: kit schema has no table '{from}' (or '{to}' already exists)"
962            )));
963        }
964        for table in &mut self.schema.tables {
965            for fk in &mut table.foreign_keys {
966                if fk.references_table == from {
967                    fk.references_table = to.to_string();
968                }
969            }
970        }
971        store_schema(&self.root, &self.schema)?;
972        Ok(())
973    }
974
975    /// Rebuild statistics/metadata for every table's indexes (the engine's
976    /// `ANALYZE` equivalent: `ensure_indexes_complete` on each table). Safe to
977    /// run at any time; useful after bulk loads so the query planner and
978    /// learned indexes have fresh data.
979    pub fn analyze(&self) -> Result<()> {
980        for name in self.inner.table_names() {
981            let handle = self.inner.table(&name).map_err(KitError::from)?;
982            handle.lock().ensure_indexes_complete()?;
983        }
984        Ok(())
985    }
986
987    /// Reclaim space across all tables: compacts every table's sorted runs,
988    /// then runs `gc`. Returns the count of reclaimed orphaned runs/files.
989    /// Equivalent to the engine's `VACUUM`. Safe to run at any time.
990    pub fn vacuum(&self) -> Result<usize> {
991        self.inner.compact().map_err(KitError::from)?;
992        self.inner.gc().map_err(KitError::from)
993    }
994
995    /// Create a SQL view (`CREATE VIEW <name> AS <select>`). The engine
996    /// overwrites any existing view with the same name, so this also serves as
997    /// replace. The view lives in the kit's long-lived SQL session — it is not
998    /// persisted to the catalog, so reopening the database loses it (re-apply
999    /// a `CreateView` migration to restore).
1000    pub fn create_view(&self, spec: &ViewSpec) -> Result<()> {
1001        self.sql(&spec.create_sql())?;
1002        Ok(())
1003    }
1004
1005    /// Drop a SQL view by name (idempotent — `DROP VIEW IF EXISTS`).
1006    pub fn drop_view(&self, name: &str) -> Result<()> {
1007        self.sql(&format!("DROP VIEW IF EXISTS {name}"))?;
1008        Ok(())
1009    }
1010
1011    /// Reserve (without inserting) the next engine-native `AUTO_INCREMENT` value
1012    /// for `table`, advancing the per-table counter. Returns `None` when the
1013    /// table has no auto-increment column. This is the escape hatch for callers
1014    /// that stage a row with an explicit id inside a transaction; the
1015    /// reservation becomes durable when a row carrying the id commits, and an
1016    /// unused reservation just leaves a gap. Parity with the TypeScript kit's
1017    /// `reserveAutoIncSync`.
1018    pub fn reserve_auto_inc(&self, table: &str) -> Result<Option<i64>> {
1019        let handle = self.inner.table(table).map_err(KitError::from)?;
1020        let mut guard = handle.lock();
1021        guard.reserve_auto_inc().map_err(KitError::from)
1022    }
1023
1024    // ── storage tuning & introspection (Tier 3) ─────────────────────────────
1025
1026    /// Set the per-table spill threshold (bytes). When a transaction's staged
1027    /// bytes for a single table exceed this, rows are written as a uniform-epoch
1028    /// pending run instead of streamed Put records.
1029    pub fn set_spill_threshold(&self, bytes: u64) {
1030        self.inner.set_spill_threshold(bytes);
1031    }
1032
1033    /// Enable or disable recursive trigger execution (database-wide).
1034    pub fn set_recursive_triggers(&self, enabled: bool) {
1035        self.inner.set_recursive_triggers(enabled);
1036    }
1037
1038    /// Read the current trigger execution policy.
1039    pub fn trigger_config(&self) -> mongreldb_core::TriggerConfig {
1040        self.inner.trigger_config()
1041    }
1042
1043    /// Set the trigger execution policy. `max_depth` must be > 0.
1044    pub fn set_trigger_config(&self, config: mongreldb_core::TriggerConfig) -> Result<()> {
1045        self.inner
1046            .set_trigger_config(config)
1047            .map_err(KitError::from)
1048    }
1049
1050    /// Set a table's compaction zstd level (-1 = default, 0 = none, 1..22).
1051    pub fn set_table_compaction_zstd_level(&self, table: &str, level: i32) -> Result<()> {
1052        let handle = self.inner.table(table).map_err(KitError::from)?;
1053        handle.lock().set_compaction_zstd_level(level);
1054        Ok(())
1055    }
1056
1057    /// Set a table's result-cache max bytes.
1058    pub fn set_table_result_cache_max_bytes(&self, table: &str, max_bytes: u64) -> Result<()> {
1059        let handle = self.inner.table(table).map_err(KitError::from)?;
1060        handle.lock().set_result_cache_max_bytes(max_bytes);
1061        Ok(())
1062    }
1063
1064    /// Set a table's mutable-run spill threshold (bytes).
1065    pub fn set_table_mutable_run_spill_bytes(&self, table: &str, bytes: u64) -> Result<()> {
1066        let handle = self.inner.table(table).map_err(KitError::from)?;
1067        handle.lock().set_mutable_run_spill_bytes(bytes);
1068        Ok(())
1069    }
1070
1071    /// Set a table's WAL sync byte threshold (bytes between group-syncs).
1072    pub fn set_table_sync_byte_threshold(&self, table: &str, threshold: u64) -> Result<()> {
1073        let handle = self.inner.table(table).map_err(KitError::from)?;
1074        handle.lock().set_sync_byte_threshold(threshold);
1075        Ok(())
1076    }
1077
1078    /// Set a table's index build policy (`Deferred` for fast ingest, `Eager`
1079    /// for fast first query).
1080    pub fn set_table_index_build_policy(
1081        &self,
1082        table: &str,
1083        policy: mongreldb_core::IndexBuildPolicy,
1084    ) -> Result<()> {
1085        let handle = self.inner.table(table).map_err(KitError::from)?;
1086        handle.lock().set_index_build_policy(policy);
1087        Ok(())
1088    }
1089
1090    /// Page-cache statistics for a table.
1091    pub fn table_page_cache_stats(&self, table: &str) -> Result<mongreldb_core::cache::CacheStats> {
1092        let handle = self.inner.table(table).map_err(KitError::from)?;
1093        let stats = handle.lock().page_cache_stats();
1094        Ok(stats)
1095    }
1096
1097    /// Number of sorted runs a table currently has (compaction target: 1).
1098    pub fn table_run_count(&self, table: &str) -> Result<usize> {
1099        let handle = self.inner.table(table).map_err(KitError::from)?;
1100        let n = handle.lock().run_count();
1101        Ok(n)
1102    }
1103
1104    /// Memtable length (uncommitted staged rows) for a table.
1105    pub fn table_memtable_len(&self, table: &str) -> Result<usize> {
1106        let handle = self.inner.table(table).map_err(KitError::from)?;
1107        let n = handle.lock().memtable_len();
1108        Ok(n)
1109    }
1110
1111    /// Mutable-run length for a table.
1112    pub fn table_mutable_run_len(&self, table: &str) -> Result<usize> {
1113        let handle = self.inner.table(table).map_err(KitError::from)?;
1114        let n = handle.lock().mutable_run_len();
1115        Ok(n)
1116    }
1117
1118    /// Page-cache entry count for a table.
1119    pub fn table_page_cache_len(&self, table: &str) -> Result<usize> {
1120        let handle = self.inner.table(table).map_err(KitError::from)?;
1121        let n = handle.lock().page_cache_len();
1122        Ok(n)
1123    }
1124
1125    /// Decoded-page-cache entry count for a table.
1126    pub fn table_decoded_cache_len(&self, table: &str) -> Result<usize> {
1127        let handle = self.inner.table(table).map_err(KitError::from)?;
1128        let n = handle.lock().decoded_cache_len();
1129        Ok(n)
1130    }
1131
1132    /// Run a SQL statement through the embedded `MongrelSession` (DataFusion
1133    /// frontend) and return the result as Arrow [`RecordBatch`]es. This is the
1134    /// Rust analogue of the TypeScript kit's `db.sql(sql)` (which returns Arrow
1135    /// IPC bytes) and the NAPI `Database.sql`.
1136    ///
1137    /// Read statements return their rows; DDL/DML (e.g. `CREATE VIEW`,
1138    /// `ANALYZE`, `VACUUM`, `CREATE VIRTUAL TABLE`) return an empty vec. Writes
1139    /// made directly through SQL bypass Kit-level constraints (defaults,
1140    /// enums, min/max, length, regex, triggers) — use the transactional
1141    /// [`Transaction`](crate::Transaction) API for constrained writes. The
1142    /// engine's own declarative constraints (unique, FK, check) still apply.
1143    ///
1144    /// The session is held for the database's lifetime, so session-scoped
1145    /// objects (views, prepared statements, the result cache) persist across
1146    /// calls — mirroring a long-lived database connection. After a migration
1147    /// that creates/drops tables, call [`Database::refresh_sql_session`] so the
1148    /// session sees the new table set.
1149    pub fn sql(&self, statement: &str) -> Result<Vec<arrow::record_batch::RecordBatch>> {
1150        // Take the cached session out of the mutex (or build one on first use)
1151        // so no `MutexGuard` is held across the async `run`. The kit's SQL
1152        // surface is `&self` and blocking; concurrent `sql()` calls on one
1153        // `Database` serialize here (applications needing concurrency should
1154        // use multiple handles, each with its own session).
1155        let session = match self.session.lock().take() {
1156            Some(s) => s,
1157            None => {
1158                mongreldb_query::MongrelSession::open(self.core_arc()).map_err(KitError::from)?
1159            }
1160        };
1161        let runtime = sql_runtime();
1162        let result = runtime
1163            .block_on(session.run(statement))
1164            .map_err(KitError::from);
1165        // Preserve the session (and any views/state created during the call).
1166        *self.session.lock() = Some(session);
1167        result
1168    }
1169
1170    /// (Re)build the cached SQL session so it sees the current table set. The
1171    /// engine's `MongrelSession` snapshots the table list at construction; this
1172    /// rebuilds it after a migration creates or drops tables. Views and other
1173    /// session-scoped state are reset.
1174    pub fn refresh_sql_session(&self) -> Result<()> {
1175        let session =
1176            mongreldb_query::MongrelSession::open(self.core_arc()).map_err(KitError::from)?;
1177        *self.session.lock() = Some(session);
1178        Ok(())
1179    }
1180
1181    /// Like [`Database::sql`], but returns the result serialized as Arrow IPC
1182    /// *file* bytes — the same wire format the NAPI addon and the daemon emit.
1183    /// Decode with `pyarrow.ipc.open_file`, the JS `apache-arrow`
1184    /// `tableFromIPC`, or [`crate::arrow_util::read_arrow_ipc`]. Empty for
1185    /// DDL/DML.
1186    pub fn sql_arrow(&self, statement: &str) -> Result<Vec<u8>> {
1187        let batches = self.sql(statement)?;
1188        crate::arrow_util::batches_to_ipc(&batches)
1189    }
1190
1191    /// Like [`Database::sql`], but materializes the result rows into JSON-style
1192    /// maps (column name → value) for callers that don't want to take a direct
1193    /// Arrow dependency. Empty for DDL/DML.
1194    pub fn sql_rows(&self, statement: &str) -> Result<Vec<serde_json::Map<String, Value>>> {
1195        let batches = self.sql(statement)?;
1196        crate::arrow_util::batches_to_rows(&batches)
1197    }
1198
1199    /// Direct HOT (PK → RowId) lookup via the core engine — no full-row
1200    /// materialization. Used by the §4.3 delete fast path when the table
1201    /// has no Kit-level constraints requiring guard cleanup.
1202    pub(crate) fn lookup_row_id(&self, table: &str, key: &[u8]) -> Result<Option<RowId>> {
1203        let handle = self.inner.table(table).map_err(KitError::from)?;
1204        let mut guard = handle.lock();
1205        guard.ensure_indexes_complete()?;
1206        Ok(guard.lookup_pk(key))
1207    }
1208
1209    pub(crate) fn root(&self) -> &Path {
1210        &self.root
1211    }
1212
1213    /// All visible core rows for a table at a specific read snapshot. Used so
1214    /// kit transactions read at their own snapshot (repeatable reads) rather
1215    /// than the latest committed state.
1216    pub(crate) fn visible_core_rows_at(
1217        &self,
1218        table_name: &str,
1219        snapshot: Snapshot,
1220    ) -> Result<Vec<CoreRow>> {
1221        let handle = self.inner.table(table_name).map_err(KitError::from)?;
1222        let guard = handle.lock();
1223        guard.visible_rows(snapshot).map_err(KitError::from)
1224    }
1225
1226    /// Query visible core rows with native `Condition`s at a specific read
1227    /// snapshot (Kit Priority 1 pushdown). Resolves `conditions` via core's
1228    /// indexes (HOT / bitmap / range) and returns only the matching rows —
1229    /// avoiding the full scan that `visible_core_rows_at` does. Returns the
1230    /// empty vec when no conditions match, and falls back to
1231    /// `visible_core_rows_at` when `conditions` is empty (unfiltered).
1232    pub(crate) fn query_core_rows_at(
1233        &self,
1234        table_name: &str,
1235        conditions: &[mongreldb_core::query::Condition],
1236        snapshot: Snapshot,
1237    ) -> Result<Vec<CoreRow>> {
1238        if conditions.is_empty() {
1239            return self.visible_core_rows_at(table_name, snapshot);
1240        }
1241        let handle = self.inner.table(table_name).map_err(KitError::from)?;
1242        let mut guard = handle.lock();
1243        let q = mongreldb_core::query::Query {
1244            conditions: conditions.to_vec(),
1245        };
1246        guard.query(&q).map_err(KitError::from)
1247    }
1248
1249    /// Drain `table`'s memtable into the mutable-run tier, spilling to a
1250    /// durable, checkpointed `.sr` run once the tier crosses its watermark.
1251    /// Called after a large batch commit (see `Transaction::commit`) so a
1252    /// short-lived process (the CLI, or any fresh `Database::open`) isn't
1253    /// stuck replaying the whole batch from the WAL on its next invocation —
1254    /// without a flush, committed-but-unflushed writes only exist as WAL
1255    /// records and must be fully replayed to rebuild the in-memory indexes.
1256    pub(crate) fn flush_table(&self, table_name: &str) -> Result<()> {
1257        let handle = self.inner.table(table_name).map_err(KitError::from)?;
1258        handle.lock().flush().map_err(KitError::from)?;
1259        Ok(())
1260    }
1261
1262    /// Count visible rows matching `conditions` without materializing them
1263    /// (Kit Priority 7 pushdown). Returns `None` when the conditions cannot be
1264    /// served by indexes, or when `snapshot` is not the latest committed epoch
1265    /// (caller falls back to a snapshot-correct row scan).
1266    ///
1267    /// `count_conditions` counts the engine's latest committed index state, not
1268    /// a snapshot-filtered scan, so it only matches a repeatable-read row count
1269    /// when the read snapshot IS the latest epoch. We hold the table lock while
1270    /// comparing, so no commit can interleave between the check and the count.
1271    pub(crate) fn count_core_rows_at(
1272        &self,
1273        table_name: &str,
1274        conditions: &[mongreldb_core::query::Condition],
1275        snapshot: Snapshot,
1276    ) -> Result<Option<u64>> {
1277        let handle = self.inner.table(table_name).map_err(KitError::from)?;
1278        let mut guard = handle.lock();
1279        if guard.snapshot().epoch != snapshot.epoch {
1280            return Ok(None); // stale read snapshot ⇒ caller scans
1281        }
1282        guard
1283            .count_conditions(conditions, snapshot)
1284            .map_err(KitError::from)
1285    }
1286
1287    /// Compute `SUM`/`MIN`/`MAX`/`AVG`/`COUNT(col)` over a column without
1288    /// materializing rows (Kit Priority 7), via the engine's native aggregate.
1289    /// `column` is the engine column id. Returns `None` when the engine fast
1290    /// path does not apply (multi-run / non-empty overlay / non-numeric column),
1291    /// or when `snapshot` is not the latest committed epoch — the same
1292    /// guarantee as [`count_core_rows_at`](Self::count_core_rows_at): the engine
1293    /// aggregate matches a snapshot-consistent row scan only at the latest epoch,
1294    /// and we compare under the table lock so no commit can interleave.
1295    pub(crate) fn aggregate_core_at(
1296        &self,
1297        table_name: &str,
1298        column: Option<u16>,
1299        conditions: &[mongreldb_core::query::Condition],
1300        agg: NativeAgg,
1301        snapshot: Snapshot,
1302    ) -> Result<Option<NativeAggResult>> {
1303        let handle = self.inner.table(table_name).map_err(KitError::from)?;
1304        let guard = handle.lock();
1305        if guard.snapshot().epoch != snapshot.epoch {
1306            return Ok(None); // stale read snapshot ⇒ caller scans
1307        }
1308        guard
1309            .aggregate_native(snapshot, column, conditions, agg)
1310            .map_err(KitError::from)
1311    }
1312
1313    /// `COUNT(DISTINCT col)` from the bitmap index's partition cardinality (Kit
1314    /// Priority 7) — the number of distinct indexed values, no scan. Returns
1315    /// `None` without a bitmap index on the column, when the table is not
1316    /// insert-only, or when `snapshot` is not the latest committed epoch. The
1317    /// engine method reads the latest committed index state (no snapshot
1318    /// parameter), so — as with [`count_core_rows_at`](Self::count_core_rows_at)
1319    /// — it only matches a repeatable-read scan at the latest epoch; we compare
1320    /// under the table lock so no commit can interleave.
1321    pub(crate) fn count_distinct_core_at(
1322        &self,
1323        table_name: &str,
1324        column_id: u16,
1325        snapshot: Snapshot,
1326    ) -> Result<Option<u64>> {
1327        let handle = self.inner.table(table_name).map_err(KitError::from)?;
1328        let mut guard = handle.lock();
1329        if guard.snapshot().epoch != snapshot.epoch {
1330            return Ok(None); // stale read snapshot ⇒ caller scans
1331        }
1332        guard
1333            .count_distinct_from_bitmap(column_id)
1334            .map_err(KitError::from)
1335    }
1336
1337    /// Materialize a single row by storage row id.
1338    #[allow(dead_code)]
1339    pub(crate) fn get_core_row(&self, table_name: &str, row_id: u64) -> Result<Option<CoreRow>> {
1340        let handle = self.inner.table(table_name).map_err(KitError::from)?;
1341        let guard = handle.lock();
1342        let snapshot = guard.snapshot();
1343        Ok(guard.get(mongreldb_core::RowId(row_id), snapshot))
1344    }
1345}
1346
1347pub(crate) fn create_core_table(db: &CoreDatabase, name: &str, schema: CoreSchema) -> Result<()> {
1348    if db.table_id(name).is_ok() {
1349        return Ok(());
1350    }
1351    db.create_table(name, schema).map_err(KitError::from)?;
1352    Ok(())
1353}
1354
1355/// A cached single-threaded tokio runtime for driving `MongrelSession::run`
1356/// (which is async) from the kit's otherwise-blocking SQL surface. Built once
1357/// per process and reused; `CurrentThread` is sufficient since the kit never
1358/// runs concurrent SQL statements on the same database from one thread.
1359fn sql_runtime() -> &'static tokio::runtime::Runtime {
1360    use std::sync::OnceLock;
1361    static RT: OnceLock<tokio::runtime::Runtime> = OnceLock::new();
1362    RT.get_or_init(|| {
1363        tokio::runtime::Builder::new_current_thread()
1364            .enable_all()
1365            .build()
1366            .expect("failed to build kit SQL tokio runtime")
1367    })
1368}
1369
1370fn core_procedure(spec: &ProcedureSpec) -> Result<mongreldb_core::StoredProcedure> {
1371    let parsed: mongreldb_core::StoredProcedure =
1372        serde_json::from_value(spec.json.clone()).map_err(KitError::from)?;
1373    mongreldb_core::StoredProcedure::new(parsed.name, parsed.mode, parsed.params, parsed.body, 0)
1374        .map_err(KitError::from)
1375}
1376
1377fn core_trigger(spec: &TriggerSpec) -> Result<mongreldb_core::StoredTrigger> {
1378    let parsed: mongreldb_core::StoredTrigger =
1379        serde_json::from_value(spec.json.clone()).map_err(KitError::from)?;
1380    mongreldb_core::StoredTrigger::new(
1381        parsed.name,
1382        mongreldb_core::TriggerDefinition {
1383            target: parsed.target,
1384            timing: parsed.timing,
1385            event: parsed.event,
1386            update_of: parsed.update_of,
1387            target_columns: parsed.target_columns,
1388            when: parsed.when,
1389            program: parsed.program,
1390        },
1391        0,
1392    )
1393    .map_err(KitError::from)
1394}
1395
1396fn json_to_core_value(value: &Value) -> Result<CoreValue> {
1397    match value {
1398        Value::Null => Ok(CoreValue::Null),
1399        Value::Bool(value) => Ok(CoreValue::Bool(*value)),
1400        Value::Number(value) => {
1401            if let Some(value) = value.as_i64() {
1402                Ok(CoreValue::Int64(value))
1403            } else if let Some(value) = value.as_f64() {
1404                Ok(CoreValue::Float64(value))
1405            } else {
1406                Err(KitError::Validation("unsupported JSON number".into()))
1407            }
1408        }
1409        Value::String(value) => Ok(CoreValue::Bytes(value.as_bytes().to_vec())),
1410        Value::Array(_) | Value::Object(_) => Err(KitError::Validation(
1411            "procedure args only support scalar JSON values".into(),
1412        )),
1413    }
1414}
1415
1416/// Read a `Bytes` column from an internal-table core row as a UTF-8 string.
1417pub(crate) fn internal_bytes(row: &CoreRow, col_id: u16) -> Option<String> {
1418    match row.columns.get(&col_id) {
1419        Some(CoreValue::Bytes(b)) => String::from_utf8(b.clone()).ok(),
1420        _ => None,
1421    }
1422}
1423
1424/// Best-effort: reap any WAL segments a previous session left rotated but
1425/// unreaped, now that this `open()` has minted a fresh active segment
1426/// (`SharedWal::open` never truncates prior segments on its own —
1427/// [`CoreDatabase::gc`] does, but only once every mounted table's data is
1428/// durable in runs). Called before any write in *this* session, so that
1429/// check reflects exactly what the previous session left behind: if that
1430/// session ended with everything flushed (e.g. a bulk `insert_many`
1431/// followed by `Transaction::commit`'s large-batch auto-flush), this is the
1432/// one moment the now-inactive segment holding that batch is actually
1433/// eligible for cleanup. Without it, a short-lived process (the CLI has no
1434/// daemon mode; every invocation opens cold) keeps paying to read and
1435/// deserialize that segment's records on every subsequent open, even though
1436/// none of them still need replaying. Errors are ignored — this is a
1437/// disk-usage/reopen-latency optimization, never a correctness requirement.
1438fn reap_rotated_wal_segments(db: &CoreDatabase) {
1439    let _ = db.gc();
1440}
1441
1442pub(crate) fn load_schema(path: &Path) -> Result<KitSchema> {
1443    let file = path.join(SCHEMA_FILE);
1444    let json = std::fs::read_to_string(&file)
1445        .map_err(|e| KitError::Migration(format!("cannot read schema file: {e}")))?;
1446    let schema: KitSchema = serde_json::from_str(&json)?;
1447    Ok(schema)
1448}
1449
1450pub(crate) fn store_schema(path: &Path, schema: &KitSchema) -> Result<()> {
1451    let file = path.join(SCHEMA_FILE);
1452    let json = serde_json::to_string_pretty(schema)?;
1453    std::fs::write(&file, json)?;
1454    Ok(())
1455}
1456
1457/// Persist a kit schema into the database. Used after migrations.
1458pub(crate) fn persist_schema(db: &Database, schema: &KitSchema) -> Result<()> {
1459    store_schema(&db.root, schema)
1460}