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    /// Open an existing kit database that has `require_auth = true`,
263    /// verifying credentials up front. Every subsequent operation is checked
264    /// against the authenticated principal's permissions.
265    ///
266    /// Returns an error if the database does not have `require_auth` enabled
267    /// (use [`open`](Self::open) for credentialless databases) or if the
268    /// credentials are invalid.
269    ///
270    /// See `docs/auth-enforcement-spec.md`.
271    pub fn open_with_credentials(path: &Path, username: &str, password: &str) -> Result<Self> {
272        let inner = Arc::new(CoreDatabase::open_with_credentials(
273            path, username, password,
274        )?);
275        let schema = load_schema(path)?;
276        ensure_internal_tables(&inner)?;
277        reap_rotated_wal_segments(&inner);
278        Ok(Self {
279            inner,
280            schema,
281            root: path.to_path_buf(),
282            default_providers: HashMap::new(),
283            session: parking_lot::Mutex::new(None),
284        })
285    }
286
287    /// Create a fresh kit database with `require_auth = true`, a single admin
288    /// user, and the given schema. The returned handle is already authenticated
289    /// as the admin.
290    ///
291    /// See `docs/auth-enforcement-spec.md`.
292    pub fn create_with_credentials(
293        path: &Path,
294        schema: KitSchema,
295        admin_username: &str,
296        admin_password: &str,
297    ) -> Result<Self> {
298        std::fs::create_dir_all(path)?;
299        let inner = Arc::new(CoreDatabase::create_with_credentials(
300            path,
301            admin_username,
302            admin_password,
303        )?);
304        ensure_internal_tables(&inner)?;
305        store_schema(path, &schema)?;
306        for table in &schema.tables {
307            create_core_table(&inner, &table.name, to_core_schema(table))?;
308        }
309        Ok(Self {
310            inner,
311            schema,
312            root: path.to_path_buf(),
313            default_providers: HashMap::new(),
314            session: parking_lot::Mutex::new(None),
315        })
316    }
317
318    /// Open an existing page-encrypted kit database that has `require_auth =
319    /// true`, combining the encryption passphrase with credential verification.
320    pub fn open_encrypted_with_credentials(
321        path: &Path,
322        passphrase: &str,
323        username: &str,
324        password: &str,
325    ) -> Result<Self> {
326        let inner = Arc::new(CoreDatabase::open_encrypted_with_credentials(
327            path, passphrase, username, password,
328        )?);
329        let schema = load_schema(path)?;
330        ensure_internal_tables(&inner)?;
331        reap_rotated_wal_segments(&inner);
332        Ok(Self {
333            inner,
334            schema,
335            root: path.to_path_buf(),
336            default_providers: HashMap::new(),
337            session: parking_lot::Mutex::new(None),
338        })
339    }
340
341    /// Create a fresh page-encrypted kit database with `require_auth = true`
342    /// and a single admin user. Composes encryption-at-rest with credential
343    /// enforcement.
344    pub fn create_encrypted_with_credentials(
345        path: &Path,
346        schema: KitSchema,
347        passphrase: &str,
348        admin_username: &str,
349        admin_password: &str,
350    ) -> Result<Self> {
351        std::fs::create_dir_all(path)?;
352        let inner = Arc::new(CoreDatabase::create_encrypted_with_credentials(
353            path,
354            passphrase,
355            admin_username,
356            admin_password,
357        )?);
358        ensure_internal_tables(&inner)?;
359        store_schema(path, &schema)?;
360        for table in &schema.tables {
361            create_core_table(&inner, &table.name, to_core_schema(table))?;
362        }
363        Ok(Self {
364            inner,
365            schema,
366            root: path.to_path_buf(),
367            default_providers: HashMap::new(),
368            session: parking_lot::Mutex::new(None),
369        })
370    }
371
372    /// Convert a credentialless kit database to a credentialed one in place.
373    /// Creates the first admin user, sets `require_auth = true`, and caches
374    /// the admin principal on this handle.
375    pub fn enable_auth(&self, admin_username: &str, admin_password: &str) -> Result<()> {
376        self.inner
377            .enable_auth(admin_username, admin_password)
378            .map_err(KitError::from)
379    }
380
381    /// Disable `require_auth`, reverting to credentialless mode. The recovery
382    /// path — requires an open (already-authenticated) handle. Users and roles
383    /// are preserved but no longer enforced.
384    pub fn disable_auth(&self) -> Result<()> {
385        self.inner.disable_auth().map_err(KitError::from)
386    }
387
388    /// Returns `true` if this database has `require_auth = true`.
389    pub fn require_auth_enabled(&self) -> bool {
390        self.inner.require_auth_enabled()
391    }
392
393    /// Re-resolve the cached principal from the on-disk catalog, picking up
394    /// role/permission changes made by other handles. No-op on credentialless
395    /// databases.
396    pub fn refresh_principal(&self) -> Result<()> {
397        self.inner.refresh_principal().map_err(KitError::from)
398    }
399
400    /// Register a named default provider used by `DefaultKind::CustomName`
401    /// columns. Returns the database for chaining.
402    pub fn register_default(
403        &mut self,
404        name: impl Into<String>,
405        provider: impl Fn() -> Value + Send + Sync + 'static,
406    ) {
407        self.default_providers
408            .insert(name.into(), Box::new(provider));
409    }
410
411    /// The raw, unguarded MongrelDB core database. This is the Rust analogue of
412    /// the TypeScript kit's `nativeDb` escape hatch: writes made directly
413    /// against it bypass all kit constraints.
414    pub fn raw(&self) -> &CoreDatabase {
415        &self.inner
416    }
417
418    /// Application table names, excluding the reserved `__kit_*` tables.
419    pub fn table_names(&self) -> Vec<String> {
420        self.schema
421            .tables
422            .iter()
423            .map(|t| t.name.clone())
424            .filter(|n| !n.starts_with("__kit_"))
425            .collect()
426    }
427
428    pub fn create_procedure(
429        &self,
430        spec: &ProcedureSpec,
431    ) -> Result<mongreldb_core::StoredProcedure> {
432        let procedure = core_procedure(spec)?;
433        self.inner
434            .create_procedure(procedure)
435            .map_err(KitError::from)
436    }
437
438    pub fn replace_procedure(
439        &self,
440        spec: &ProcedureSpec,
441    ) -> Result<mongreldb_core::StoredProcedure> {
442        let procedure = core_procedure(spec)?;
443        self.inner
444            .create_or_replace_procedure(procedure)
445            .map_err(KitError::from)
446    }
447
448    pub fn drop_procedure(&self, name: &str) -> Result<()> {
449        self.inner.drop_procedure(name).map_err(KitError::from)
450    }
451
452    pub fn call_procedure(
453        &self,
454        name: &str,
455        args: serde_json::Map<String, Value>,
456    ) -> Result<mongreldb_core::ProcedureCallResult> {
457        let args = args
458            .iter()
459            .map(|(key, value)| Ok((key.clone(), json_to_core_value(value)?)))
460            .collect::<Result<HashMap<_, _>>>()?;
461        self.inner
462            .call_procedure(name, args)
463            .map_err(KitError::from)
464    }
465
466    pub fn create_trigger(&self, spec: &TriggerSpec) -> Result<mongreldb_core::StoredTrigger> {
467        let trigger = core_trigger(spec)?;
468        self.inner.create_trigger(trigger).map_err(KitError::from)
469    }
470
471    pub fn replace_trigger(&self, spec: &TriggerSpec) -> Result<mongreldb_core::StoredTrigger> {
472        let trigger = core_trigger(spec)?;
473        self.inner
474            .create_or_replace_trigger(trigger)
475            .map_err(KitError::from)
476    }
477
478    pub fn drop_trigger(&self, name: &str) -> Result<()> {
479        self.inner.drop_trigger(name).map_err(KitError::from)
480    }
481
482    pub fn triggers(&self) -> Vec<mongreldb_core::StoredTrigger> {
483        self.inner.triggers()
484    }
485
486    pub fn trigger(&self, name: &str) -> Option<mongreldb_core::StoredTrigger> {
487        self.inner.trigger(name)
488    }
489
490    /// Allocate `count` values from the named sequence, returning the first
491    /// allocated value. A fresh sequence starts at `1` (SQL AUTO_INCREMENT
492    /// semantics). The allocation
493    /// runs in its own committed transaction and retries on write-write
494    /// conflicts.
495    pub fn allocate_sequence(&self, name: &str, count: i64) -> Result<i64> {
496        use crate::internal::cols;
497        let mut attempt = 0;
498        loop {
499            let mut txn = self.inner.begin();
500            let snapshot = txn.read_snapshot();
501            let existing = self
502                .visible_core_rows_at(crate::internal::SEQUENCES, snapshot)?
503                .into_iter()
504                .find(|r| internal_bytes(r, cols::SEQ_NAME) == Some(name.to_string()));
505
506            let now = crate::internal::iso_now();
507            // Sequences are 1-based, matching SQL AUTO_INCREMENT / SERIAL. A
508            // starting value of 0 is unsafe: applications use 0 as the "unset"
509            // sentinel for nullable foreign keys.
510            let (start, next, old_row_id) = match &existing {
511                Some(row) => {
512                    let current = match row.columns.get(&cols::SEQ_NEXT) {
513                        Some(CoreValue::Int64(i)) => *i,
514                        _ => 1,
515                    };
516                    (current, current + count, Some(row.row_id))
517                }
518                None => (1, 1 + count, None),
519            };
520
521            if let Some(rid) = old_row_id {
522                txn.delete(crate::internal::SEQUENCES, rid)
523                    .map_err(KitError::from)?;
524            }
525            txn.put(
526                crate::internal::SEQUENCES,
527                vec![
528                    (cols::SEQ_NAME, CoreValue::Bytes(name.as_bytes().to_vec())),
529                    (cols::SEQ_NEXT, CoreValue::Int64(next)),
530                    (cols::SEQ_UPDATED, CoreValue::Bytes(now.into_bytes())),
531                ],
532            )
533            .map_err(KitError::from)?;
534            match txn.commit() {
535                Ok(_) => return Ok(start),
536                Err(mongreldb_core::MongrelError::Conflict(_)) if attempt < 10_000 => {
537                    attempt += 1;
538                    std::thread::yield_now();
539                    continue;
540                }
541                Err(e) => return Err(KitError::from(e)),
542            }
543        }
544    }
545
546    /// Run `f` inside a kit transaction, committing on success and retrying on
547    /// retryable write-write conflicts up to `max_retries` times.
548    pub fn transaction<T, F>(&self, max_retries: usize, mut f: F) -> Result<T>
549    where
550        F: FnMut(&mut crate::txn::Transaction<'_>) -> Result<T>,
551    {
552        let mut attempt = 0;
553        loop {
554            let mut txn = self.begin()?;
555            match f(&mut txn) {
556                Ok(value) => match txn.commit() {
557                    Ok(()) => return Ok(value),
558                    Err(KitError::Conflict(_)) if attempt < max_retries => {
559                        attempt += 1;
560                        continue;
561                    }
562                    Err(e) => return Err(e),
563                },
564                Err(KitError::Conflict(_)) if attempt < max_retries => {
565                    txn.rollback();
566                    attempt += 1;
567                    continue;
568                }
569                Err(e) => {
570                    txn.rollback();
571                    return Err(e);
572                }
573            }
574        }
575    }
576
577    /// Look up a table definition by name.
578    pub fn table(&self, name: &str) -> Option<&KitTable> {
579        self.schema.table(name)
580    }
581
582    /// Return the currently loaded schema.
583    pub fn schema(&self) -> &KitSchema {
584        &self.schema
585    }
586
587    /// Begin a new kit transaction.
588    pub fn begin(&self) -> Result<crate::txn::Transaction<'_>> {
589        let core_txn = self.inner.begin();
590        Ok(crate::txn::Transaction::new(self, core_txn))
591    }
592
593    /// Replace the in-memory schema, usually after a migration.
594    pub fn set_schema(&mut self, schema: KitSchema) {
595        self.schema = schema;
596    }
597
598    /// Verify that the sidecar schema file and all reserved `__kit_*` tables
599    /// are present.
600    pub fn check_internal_tables(&self) -> Result<()> {
601        let schema_file = self.root.join(SCHEMA_FILE);
602        if !schema_file.exists() {
603            return Err(KitError::Integrity(format!(
604                "schema file {} is missing",
605                schema_file.display()
606            )));
607        }
608        for (name, _) in internal_tables_core() {
609            if self.inner.table_id(name).is_err() {
610                return Err(KitError::Integrity(format!(
611                    "internal table {name} is missing"
612                )));
613            }
614        }
615        Ok(())
616    }
617
618    /// Reclaim orphaned runs and stale WAL/shadow files; returns the count
619    /// removed. Safe to run on a live database.
620    pub fn gc(&self) -> Result<usize> {
621        self.inner.gc().map_err(KitError::from)
622    }
623
624    /// Verify run footer checksums; returns any integrity issues as JSON objects
625    /// (`table_id`, `table_name`, `severity`, `description`). Empty ⇒ healthy.
626    pub fn check(&self) -> Vec<serde_json::Value> {
627        self.inner
628            .check()
629            .into_iter()
630            .map(|i| {
631                serde_json::json!({
632                    "table_id": i.table_id,
633                    "table_name": i.table_name,
634                    "severity": i.severity,
635                    "description": i.description,
636                })
637            })
638            .collect()
639    }
640
641    /// Drop corrupt runs; returns the ids of the runs that were dropped.
642    pub fn doctor(&self) -> Result<Vec<u64>> {
643        self.inner.doctor().map_err(KitError::from)
644    }
645
646    /// The current visible commit epoch — a monotonically increasing version
647    /// stamp. A committed write bumps it; a snapshot at this epoch sees all
648    /// currently-committed data.
649    pub fn snapshot_epoch(&self) -> u64 {
650        self.inner.snapshot().0.epoch.0
651    }
652
653    /// Export every visible row of `table` as a TSV document (header row of
654    /// column names, tab-separated cells, `NULL` = empty field). See
655    /// [`crate::tsv`] for the escaping rules.
656    pub fn export_tsv(&self, table: &str) -> Result<String> {
657        let t = self
658            .schema
659            .tables
660            .iter()
661            .find(|t| t.name == table)
662            .ok_or_else(|| KitError::Validation(format!("unknown table '{table}'")))?
663            .clone();
664        let tx = self.begin()?;
665        let rows = tx.all_rows(table)?;
666        Ok(crate::tsv::rows_to_tsv(&t, &rows))
667    }
668
669    /// Import a TSV document into `table` (one committed transaction). Each row
670    /// passes through defaults, validation, and constraint checks like a normal
671    /// insert. Returns the number of rows inserted.
672    pub fn import_tsv(&self, table: &str, text: &str) -> Result<usize> {
673        let t = self
674            .schema
675            .tables
676            .iter()
677            .find(|t| t.name == table)
678            .ok_or_else(|| KitError::Validation(format!("unknown table '{table}'")))?
679            .clone();
680        let rows = crate::tsv::tsv_to_rows(&t, text)?;
681        let n = rows.len();
682        self.transaction(1, |tx| {
683            tx.insert_many(table, rows.clone())?;
684            Ok(())
685        })?;
686        Ok(n)
687    }
688
689    /// Describe how `predicate` would be executed against `table`: which native
690    /// index conditions push down, whether the push-down is exact (no residual
691    /// re-filtering), and whether any index acceleration applies at all. A pure
692    /// diagnostic — it plans but does not run the query.
693    pub fn explain(
694        &self,
695        table: &str,
696        predicate: &mongreldb_kit_core::query::Expr,
697    ) -> Result<ExplainPlan> {
698        let t = self
699            .schema
700            .tables
701            .iter()
702            .find(|t| t.name == table)
703            .ok_or_else(|| KitError::Validation(format!("unknown table '{table}'")))?;
704        Ok(match crate::pushdown::translate_predicate(t, predicate) {
705            Some(p) => ExplainPlan {
706                index_accelerated: p.can_push(),
707                exact: p.fully_translated,
708                pushed_conditions: p.conditions.iter().map(condition_label).collect(),
709            },
710            None => ExplainPlan {
711                index_accelerated: false,
712                exact: false,
713                pushed_conditions: Vec::new(),
714            },
715        })
716    }
717
718    /// Read every row of `table` visible at commit `epoch` — a point-in-time
719    /// (MVCC time-travel) read. `epoch` must not exceed the current snapshot
720    /// epoch. Rows reclaimed by GC/compaction for retired snapshots may no
721    /// longer be reconstructable; this reads whatever the engine still retains
722    /// at that epoch.
723    pub fn rows_at_epoch(&self, table: &str, epoch: u64) -> Result<Vec<crate::schema::Row>> {
724        let t = self
725            .schema
726            .tables
727            .iter()
728            .find(|t| t.name == table)
729            .ok_or_else(|| KitError::Validation(format!("unknown table '{table}'")))?;
730        let current = self.snapshot_epoch();
731        if epoch > current {
732            return Err(KitError::Validation(format!(
733                "epoch {epoch} is in the future (current committed epoch is {current})"
734            )));
735        }
736        let snap = Snapshot::at(mongreldb_core::epoch::Epoch(epoch));
737        let rows = self.visible_core_rows_at(table, snap)?;
738        rows.iter()
739            .map(|r| crate::schema::core_row_to_json(r, t))
740            .collect()
741    }
742
743    /// Estimate an aggregate over `table` from the engine's reservoir sample,
744    /// returning a point estimate and a `z`-score confidence interval (e.g.
745    /// `z = 1.96` for ~95%). `column` is required for `Sum`/`Avg` and ignored
746    /// for `Count`. Returns `None` when the reservoir is empty (no sampled rows
747    /// yet). Fast and O(sample) — trades exactness for speed on large tables.
748    pub fn approx_aggregate(
749        &self,
750        table: &str,
751        column: Option<&str>,
752        agg: ApproxAggKind,
753        z: f64,
754    ) -> Result<Option<ApproxAggregate>> {
755        let t = self
756            .schema
757            .tables
758            .iter()
759            .find(|t| t.name == table)
760            .ok_or_else(|| KitError::Validation(format!("unknown table '{table}'")))?;
761        if matches!(agg, ApproxAggKind::Sum | ApproxAggKind::Avg) && column.is_none() {
762            return Err(KitError::Validation(
763                "approx sum/avg requires a column".into(),
764            ));
765        }
766        let cid = match column {
767            Some(name) => Some(
768                t.columns
769                    .iter()
770                    .find(|c| c.name == name)
771                    .ok_or_else(|| KitError::Validation(format!("unknown column '{name}'")))?
772                    .id as u16,
773            ),
774            None => None,
775        };
776        let core_agg = match agg {
777            ApproxAggKind::Count => ApproxAgg::Count,
778            ApproxAggKind::Sum => ApproxAgg::Sum,
779            ApproxAggKind::Avg => ApproxAgg::Avg,
780        };
781        let handle = self.inner.table(table).map_err(KitError::from)?;
782        let mut guard = handle.lock();
783        let res = guard
784            .approx_aggregate(&[], cid, core_agg, z)
785            .map_err(KitError::from)?;
786        Ok(res.map(|r| ApproxAggregate {
787            point: r.point,
788            ci_low: r.ci_low,
789            ci_high: r.ci_high,
790            n_population: r.n_population,
791            n_sample_live: r.n_sample_live,
792            n_passing: r.n_passing,
793        }))
794    }
795
796    /// Stream `table` in row batches without materializing the whole table at
797    /// once. `f` receives successive chunks of at most `batch_size` value-maps,
798    /// all from one snapshot. Backed by the engine's native scan cursor when the
799    /// table has a sorted run; for an overlay-only table (no run yet) it falls
800    /// back to a single in-memory pass, still chunked to `batch_size`.
801    pub fn scan_batched<F>(&self, table: &str, batch_size: usize, mut f: F) -> Result<()>
802    where
803        F: FnMut(&[serde_json::Map<String, Value>]) -> Result<()>,
804    {
805        let kit_t = self
806            .schema
807            .tables
808            .iter()
809            .find(|t| t.name == table)
810            .ok_or_else(|| KitError::Validation(format!("unknown table '{table}'")))?;
811        let batch_size = batch_size.max(1);
812        // Keep the pin guard alive for the whole scan so GC can't reclaim the
813        // snapshot's versions mid-stream.
814        let (snapshot, _pin) = self.inner.snapshot();
815        let handle = self.inner.table(table).map_err(KitError::from)?;
816        let guard = handle.lock();
817
818        // Projection + per-column (name, kit type), index-aligned, in core order.
819        let mut projection: Vec<(u16, mongreldb_core::schema::TypeId)> = Vec::new();
820        let mut meta: Vec<(String, mongreldb_kit_core::schema::ColumnType)> = Vec::new();
821        for c in &guard.schema().columns {
822            if let Some(kc) = kit_t.columns.iter().find(|kc| kc.id as u16 == c.id) {
823                projection.push((c.id, c.ty));
824                meta.push((kc.name.clone(), kc.storage_type));
825            }
826        }
827
828        match guard
829            .scan_cursor(snapshot, projection, &[])
830            .map_err(KitError::from)?
831        {
832            Some(mut cursor) => {
833                let mut buf: Vec<serde_json::Map<String, Value>> = Vec::with_capacity(batch_size);
834                while let Some(batch) = cursor.next_batch().map_err(KitError::from)? {
835                    let nrows = batch.first().map(|c| c.len()).unwrap_or(0);
836                    for j in 0..nrows {
837                        let mut m = serde_json::Map::new();
838                        for (ci, (name, ty)) in meta.iter().enumerate() {
839                            let cv = batch
840                                .get(ci)
841                                .and_then(|col| col.value_at(j))
842                                .unwrap_or(CoreValue::Null);
843                            m.insert(name.clone(), crate::schema::core_to_json(&cv, *ty)?);
844                        }
845                        buf.push(m);
846                        if buf.len() >= batch_size {
847                            f(&buf)?;
848                            buf.clear();
849                        }
850                    }
851                }
852                if !buf.is_empty() {
853                    f(&buf)?;
854                }
855                Ok(())
856            }
857            None => {
858                drop(guard);
859                let rows = self.visible_core_rows_at(table, snapshot)?;
860                let maps: Vec<serde_json::Map<String, Value>> = rows
861                    .iter()
862                    .map(|r| crate::schema::core_row_to_json(r, kit_t).map(|row| row.values))
863                    .collect::<Result<Vec<_>>>()?;
864                for chunk in maps.chunks(batch_size) {
865                    f(chunk)?;
866                }
867                Ok(())
868            }
869        }
870    }
871
872    /// Rank rows of `table` by Jaccard set-similarity between `query` and the
873    /// string set stored (as a JSON array) in `column`, returning the top `k`
874    /// with similarity `> 0`, highest first — the dedup/join primitive.
875    ///
876    /// When `column` has a `MinHash` index, candidate rows come from the engine's
877    /// LSH index (sub-linear) and are then re-verified with exact Jaccard, so the
878    /// top-k is exact for the recalled candidates (LSH recall is high but < 100%).
879    /// Without an index it is an exact linear scan.
880    pub fn set_similarity(
881        &self,
882        table: &str,
883        column: &str,
884        query: &[String],
885        k: usize,
886    ) -> Result<Vec<SimilarRow>> {
887        let t = self
888            .schema
889            .tables
890            .iter()
891            .find(|t| t.name == table)
892            .ok_or_else(|| KitError::Validation(format!("unknown table '{table}'")))?;
893        let col = t.columns.iter().find(|c| c.name == column).ok_or_else(|| {
894            KitError::Validation(format!("unknown column '{column}' on table '{table}'"))
895        })?;
896        let query_set: std::collections::HashSet<String> = query.iter().cloned().collect();
897
898        let has_minhash = t.indexes.iter().any(|idx| {
899            idx.kind == KitIndexKind::MinHash && idx.columns.iter().any(|c| c == column)
900        });
901        let rows = if has_minhash {
902            // Sub-linear candidate generation via the engine MinHash/LSH index.
903            let query_hashes: Vec<u64> = query
904                .iter()
905                .map(|s| mongreldb_core::index::minhash_token_hash(s))
906                .collect();
907            // Generous candidate budget so exact top-k keeps high recall.
908            let cand_k = k.saturating_mul(8).max(k + 64);
909            let cond = mongreldb_core::query::Condition::MinHashSimilar {
910                column_id: col.id as u16,
911                query: query_hashes,
912                k: cand_k,
913            };
914            let (snapshot, _pin) = self.inner.snapshot();
915            let core_rows = self.query_core_rows_at(table, &[cond], snapshot)?;
916            core_rows
917                .iter()
918                .map(|r| crate::schema::core_row_to_json(r, t))
919                .collect::<Result<Vec<_>>>()?
920        } else {
921            let tx = self.begin()?;
922            tx.all_rows(table)?
923        };
924
925        let mut scored: Vec<SimilarRow> = Vec::new();
926        for row in rows {
927            let set = parse_string_set(row.values.get(column));
928            let inter = set.iter().filter(|x| query_set.contains(*x)).count();
929            let union = set.len() + query_set.len() - inter;
930            let sim = if union == 0 {
931                0.0
932            } else {
933                inter as f64 / union as f64
934            };
935            if sim > 0.0 {
936                scored.push(SimilarRow {
937                    row,
938                    similarity: sim,
939                });
940            }
941        }
942        scored.sort_by(|a, b| {
943            b.similarity
944                .partial_cmp(&a.similarity)
945                .unwrap_or(std::cmp::Ordering::Equal)
946        });
947        scored.truncate(k);
948        Ok(scored)
949    }
950
951    /// Flush every table's in-memory writes to durable sorted runs. Besides
952    /// durability, this empties the memtable, which is what enables the engine's
953    /// incremental-aggregate fast path (see [`Self::incremental_aggregate`]).
954    pub fn flush(&self) -> Result<()> {
955        for name in self.inner.table_names() {
956            let handle = self.inner.table(&name).map_err(KitError::from)?;
957            let mut guard = handle.lock();
958            guard.flush().map_err(KitError::from)?;
959        }
960        Ok(())
961    }
962
963    /// Maintain and read an aggregate over `table` that updates by merging only
964    /// newly-committed rows instead of rescanning. `column` is required for
965    /// `Sum`/`Min`/`Max`/`Avg` and ignored for `Count`. An optional `filter`
966    /// restricts the aggregate; it must translate **exactly** to index
967    /// conditions (no residual), otherwise this errors — an inexact filter would
968    /// silently aggregate the wrong rows.
969    ///
970    /// The engine keeps a per-`(table, column, agg, filter)` cached state and,
971    /// on a warm cache with an advanced epoch and no deletes/pending writes,
972    /// folds in just the delta. The returned `value` is always exact; the
973    /// `incremental` flag reports whether the fast path was taken.
974    pub fn incremental_aggregate(
975        &self,
976        table: &str,
977        column: Option<&str>,
978        agg: IncrementalAggKind,
979        filter: Option<&mongreldb_kit_core::query::Expr>,
980    ) -> Result<IncrementalAggregate> {
981        let t = self
982            .schema
983            .tables
984            .iter()
985            .find(|t| t.name == table)
986            .ok_or_else(|| KitError::Validation(format!("unknown table '{table}'")))?;
987        if !matches!(agg, IncrementalAggKind::Count) && column.is_none() {
988            return Err(KitError::Validation(
989                "sum/min/max/avg incremental aggregate requires a column".into(),
990            ));
991        }
992        let cid = match column {
993            Some(name) => Some(
994                t.columns
995                    .iter()
996                    .find(|c| c.name == name)
997                    .ok_or_else(|| KitError::Validation(format!("unknown column '{name}'")))?
998                    .id as u16,
999            ),
1000            None => None,
1001        };
1002        let conditions = match filter {
1003            Some(expr) => {
1004                let plan = crate::pushdown::translate_predicate(t, expr).ok_or_else(|| {
1005                    KitError::Validation(
1006                        "filter is not index-translatable for an incremental aggregate".into(),
1007                    )
1008                })?;
1009                if !plan.fully_translated {
1010                    return Err(KitError::Validation(
1011                        "filter has a residual that an incremental aggregate cannot apply exactly"
1012                            .into(),
1013                    ));
1014                }
1015                plan.conditions
1016            }
1017            None => Vec::new(),
1018        };
1019        let core_agg = match agg {
1020            IncrementalAggKind::Count => NativeAgg::Count,
1021            IncrementalAggKind::Sum => NativeAgg::Sum,
1022            IncrementalAggKind::Min => NativeAgg::Min,
1023            IncrementalAggKind::Max => NativeAgg::Max,
1024            IncrementalAggKind::Avg => NativeAgg::Avg,
1025        };
1026        let cache_key = incremental_cache_key(t.id, cid, agg, &conditions);
1027        let handle = self.inner.table(table).map_err(KitError::from)?;
1028        let mut guard = handle.lock();
1029        let res = guard
1030            .aggregate_incremental(cache_key, &conditions, cid, core_agg)
1031            .map_err(KitError::from)?;
1032        Ok(IncrementalAggregate {
1033            value: agg_state_value(&res.state),
1034            incremental: res.incremental,
1035            delta_rows: res.delta_rows,
1036        })
1037    }
1038
1039    /// Return the migrations already recorded in `__kit_schema_migrations`.
1040    pub fn applied_migrations(&self) -> Result<Vec<mongreldb_kit_core::migrations::Migration>> {
1041        crate::migrate::load_applied_migrations(&self.inner)
1042    }
1043
1044    pub(crate) fn core_db(&self) -> &CoreDatabase {
1045        &self.inner
1046    }
1047
1048    /// The underlying engine handle wrapped in an `Arc`, for callers that need
1049    /// a shared/owned reference (e.g. building a `MongrelSession`).
1050    pub(crate) fn core_arc(&self) -> Arc<CoreDatabase> {
1051        Arc::clone(&self.inner)
1052    }
1053
1054    /// Best-effort flush-on-close (§4.4): force-flush pending writes on every
1055    /// table to `.sr` sorted runs so WAL segments stay bounded across repeated
1056    /// short-lived process invocations (e.g. the CLI). Call as the last action
1057    /// before exit. The daemon does not need this (auto-compactor handles it).
1058    pub fn close(&self) -> Result<()> {
1059        self.inner.close().map_err(KitError::from)
1060    }
1061
1062    /// Compact all tables: merge sorted runs into one clean run each so query
1063    /// latency stays flat. Returns `(compacted, skipped)`. Safe to run at any
1064    /// time — honors snapshot retention. The daemon's background auto-compactor
1065    /// already does this periodically; this is for manual/cron use.
1066    pub fn compact_all(&self) -> Result<(usize, usize)> {
1067        self.inner.compact().map_err(KitError::from)
1068    }
1069
1070    /// Compact a single table by name. Returns `true` if compacted, `false` if
1071    /// skipped (< 2 runs).
1072    pub fn compact_table(&self, name: &str) -> Result<bool> {
1073        self.inner.compact_table(name).map_err(KitError::from)
1074    }
1075
1076    /// Rename a live table. Fails if `from` does not exist or `to` is already
1077    /// in use; a no-op when `from == to`. Names beginning with `__kit_` are
1078    /// reserved for internal tables and rejected here (parity with the
1079    /// TypeScript kit).
1080    ///
1081    /// Updates both the engine table and the kit schema catalog (in memory and
1082    /// persisted to `kit_schema.json`), so subsequent `table_names()`,
1083    /// `table(name)`, and transactional reads by the new name all work. Foreign
1084    /// keys in other tables that reference `from` are retargeted to `to`.
1085    pub fn rename_table(&mut self, from: &str, to: &str) -> Result<()> {
1086        if from.starts_with("__kit_") || to.starts_with("__kit_") {
1087            return Err(KitError::Validation(
1088                "rename_table: names beginning with '__kit_' are reserved for internal tables"
1089                    .into(),
1090            ));
1091        }
1092        self.inner.rename_table(from, to).map_err(KitError::from)?;
1093        // Keep the kit schema catalog in sync: rename the table (updating the
1094        // by_name index), retarget any FKs that pointed at it, then persist.
1095        if !self.schema.rename_table(from, to) {
1096            // The engine renamed it but the kit schema didn't have it / had a
1097            // clash — surface the divergence rather than silently desyncing.
1098            return Err(KitError::Integrity(format!(
1099                "rename_table: kit schema has no table '{from}' (or '{to}' already exists)"
1100            )));
1101        }
1102        for table in &mut self.schema.tables {
1103            for fk in &mut table.foreign_keys {
1104                if fk.references_table == from {
1105                    fk.references_table = to.to_string();
1106                }
1107            }
1108        }
1109        store_schema(&self.root, &self.schema)?;
1110        Ok(())
1111    }
1112
1113    /// Rebuild statistics/metadata for every table's indexes (the engine's
1114    /// `ANALYZE` equivalent: `ensure_indexes_complete` on each table). Safe to
1115    /// run at any time; useful after bulk loads so the query planner and
1116    /// learned indexes have fresh data.
1117    pub fn analyze(&self) -> Result<()> {
1118        for name in self.inner.table_names() {
1119            let handle = self.inner.table(&name).map_err(KitError::from)?;
1120            handle.lock().ensure_indexes_complete()?;
1121        }
1122        Ok(())
1123    }
1124
1125    /// Reclaim space across all tables: compacts every table's sorted runs,
1126    /// then runs `gc`. Returns the count of reclaimed orphaned runs/files.
1127    /// Equivalent to the engine's `VACUUM`. Safe to run at any time.
1128    pub fn vacuum(&self) -> Result<usize> {
1129        self.inner.compact().map_err(KitError::from)?;
1130        self.inner.gc().map_err(KitError::from)
1131    }
1132
1133    /// Create a SQL view (`CREATE VIEW <name> AS <select>`). The engine
1134    /// overwrites any existing view with the same name, so this also serves as
1135    /// replace. The view lives in the kit's long-lived SQL session — it is not
1136    /// persisted to the catalog, so reopening the database loses it (re-apply
1137    /// a `CreateView` migration to restore).
1138    pub fn create_view(&self, spec: &ViewSpec) -> Result<()> {
1139        self.sql(&spec.create_sql())?;
1140        Ok(())
1141    }
1142
1143    /// Drop a SQL view by name (idempotent — `DROP VIEW IF EXISTS`).
1144    pub fn drop_view(&self, name: &str) -> Result<()> {
1145        self.sql(&format!("DROP VIEW IF EXISTS {name}"))?;
1146        Ok(())
1147    }
1148
1149    /// Reserve (without inserting) the next engine-native `AUTO_INCREMENT` value
1150    /// for `table`, advancing the per-table counter. Returns `None` when the
1151    /// table has no auto-increment column. This is the escape hatch for callers
1152    /// that stage a row with an explicit id inside a transaction; the
1153    /// reservation becomes durable when a row carrying the id commits, and an
1154    /// unused reservation just leaves a gap. Parity with the TypeScript kit's
1155    /// `reserveAutoIncSync`.
1156    pub fn reserve_auto_inc(&self, table: &str) -> Result<Option<i64>> {
1157        let handle = self.inner.table(table).map_err(KitError::from)?;
1158        let mut guard = handle.lock();
1159        guard.reserve_auto_inc().map_err(KitError::from)
1160    }
1161
1162    // ── user/role/credentials management ─────────────────────────────────
1163
1164    /// Create a catalog user with an Argon2id-hashed password.
1165    pub fn create_user(&self, username: &str, password: &str) -> Result<()> {
1166        self.inner
1167            .create_user(username, password)
1168            .map_err(KitError::from)?;
1169        Ok(())
1170    }
1171
1172    /// Drop a user by username.
1173    pub fn drop_user(&self, username: &str) -> Result<()> {
1174        self.inner.drop_user(username).map_err(KitError::from)
1175    }
1176
1177    /// Change a user's password.
1178    pub fn alter_user_password(&self, username: &str, new_password: &str) -> Result<()> {
1179        self.inner
1180            .alter_user_password(username, new_password)
1181            .map_err(KitError::from)
1182    }
1183
1184    /// Verify credentials. Returns `Some(entry)` on success.
1185    pub fn verify_user(
1186        &self,
1187        username: &str,
1188        password: &str,
1189    ) -> Result<Option<mongreldb_core::auth::UserEntry>> {
1190        self.inner
1191            .verify_user(username, password)
1192            .map_err(KitError::from)
1193    }
1194
1195    /// Grant or revoke admin privileges on a user.
1196    pub fn set_user_admin(&self, username: &str, is_admin: bool) -> Result<()> {
1197        self.inner
1198            .set_user_admin(username, is_admin)
1199            .map_err(KitError::from)
1200    }
1201
1202    /// List all usernames.
1203    pub fn users(&self) -> Vec<String> {
1204        self.inner.users().into_iter().map(|u| u.username).collect()
1205    }
1206
1207    /// Create a role.
1208    pub fn create_role(&self, name: &str) -> Result<()> {
1209        self.inner.create_role(name).map_err(KitError::from)?;
1210        Ok(())
1211    }
1212
1213    /// Drop a role.
1214    pub fn drop_role(&self, name: &str) -> Result<()> {
1215        self.inner.drop_role(name).map_err(KitError::from)
1216    }
1217
1218    /// List all role names.
1219    pub fn roles(&self) -> Vec<String> {
1220        self.inner.roles().into_iter().map(|r| r.name).collect()
1221    }
1222
1223    /// Grant a role to a user.
1224    pub fn grant_role(&self, username: &str, role_name: &str) -> Result<()> {
1225        self.inner
1226            .grant_role(username, role_name)
1227            .map_err(KitError::from)
1228    }
1229
1230    /// Revoke a role from a user.
1231    pub fn revoke_role(&self, username: &str, role_name: &str) -> Result<()> {
1232        self.inner
1233            .revoke_role(username, role_name)
1234            .map_err(KitError::from)
1235    }
1236
1237    /// Grant a permission to a role.
1238    pub fn grant_permission(
1239        &self,
1240        role_name: &str,
1241        permission: mongreldb_core::auth::Permission,
1242    ) -> Result<()> {
1243        self.inner
1244            .grant_permission(role_name, permission)
1245            .map_err(KitError::from)
1246    }
1247
1248    /// Revoke a permission from a role.
1249    pub fn revoke_permission(
1250        &self,
1251        role_name: &str,
1252        permission: mongreldb_core::auth::Permission,
1253    ) -> Result<()> {
1254        self.inner
1255            .revoke_permission(role_name, permission)
1256            .map_err(KitError::from)
1257    }
1258
1259    // ── storage tuning & introspection (Tier 3) ─────────────────────────────
1260
1261    /// Set the per-table spill threshold (bytes). When a transaction's staged
1262    /// bytes for a single table exceed this, rows are written as a uniform-epoch
1263    /// pending run instead of streamed Put records.
1264    pub fn set_spill_threshold(&self, bytes: u64) {
1265        self.inner.set_spill_threshold(bytes);
1266    }
1267
1268    /// Enable or disable recursive trigger execution (database-wide).
1269    pub fn set_recursive_triggers(&self, enabled: bool) {
1270        self.inner.set_recursive_triggers(enabled);
1271    }
1272
1273    /// Read the current trigger execution policy.
1274    pub fn trigger_config(&self) -> mongreldb_core::TriggerConfig {
1275        self.inner.trigger_config()
1276    }
1277
1278    /// Set the trigger execution policy. `max_depth` must be > 0.
1279    pub fn set_trigger_config(&self, config: mongreldb_core::TriggerConfig) -> Result<()> {
1280        self.inner
1281            .set_trigger_config(config)
1282            .map_err(KitError::from)
1283    }
1284
1285    /// Set a table's compaction zstd level (-1 = default, 0 = none, 1..22).
1286    pub fn set_table_compaction_zstd_level(&self, table: &str, level: i32) -> Result<()> {
1287        let handle = self.inner.table(table).map_err(KitError::from)?;
1288        handle.lock().set_compaction_zstd_level(level);
1289        Ok(())
1290    }
1291
1292    /// Set a table's result-cache max bytes.
1293    pub fn set_table_result_cache_max_bytes(&self, table: &str, max_bytes: u64) -> Result<()> {
1294        let handle = self.inner.table(table).map_err(KitError::from)?;
1295        handle.lock().set_result_cache_max_bytes(max_bytes);
1296        Ok(())
1297    }
1298
1299    /// Set a table's mutable-run spill threshold (bytes).
1300    pub fn set_table_mutable_run_spill_bytes(&self, table: &str, bytes: u64) -> Result<()> {
1301        let handle = self.inner.table(table).map_err(KitError::from)?;
1302        handle.lock().set_mutable_run_spill_bytes(bytes);
1303        Ok(())
1304    }
1305
1306    /// Set a table's WAL sync byte threshold (bytes between group-syncs).
1307    pub fn set_table_sync_byte_threshold(&self, table: &str, threshold: u64) -> Result<()> {
1308        let handle = self.inner.table(table).map_err(KitError::from)?;
1309        handle.lock().set_sync_byte_threshold(threshold);
1310        Ok(())
1311    }
1312
1313    /// Set a table's index build policy (`Deferred` for fast ingest, `Eager`
1314    /// for fast first query).
1315    pub fn set_table_index_build_policy(
1316        &self,
1317        table: &str,
1318        policy: mongreldb_core::IndexBuildPolicy,
1319    ) -> Result<()> {
1320        let handle = self.inner.table(table).map_err(KitError::from)?;
1321        handle.lock().set_index_build_policy(policy);
1322        Ok(())
1323    }
1324
1325    /// Page-cache statistics for a table.
1326    pub fn table_page_cache_stats(&self, table: &str) -> Result<mongreldb_core::cache::CacheStats> {
1327        let handle = self.inner.table(table).map_err(KitError::from)?;
1328        let stats = handle.lock().page_cache_stats();
1329        Ok(stats)
1330    }
1331
1332    /// Number of sorted runs a table currently has (compaction target: 1).
1333    pub fn table_run_count(&self, table: &str) -> Result<usize> {
1334        let handle = self.inner.table(table).map_err(KitError::from)?;
1335        let n = handle.lock().run_count();
1336        Ok(n)
1337    }
1338
1339    /// Memtable length (uncommitted staged rows) for a table.
1340    pub fn table_memtable_len(&self, table: &str) -> Result<usize> {
1341        let handle = self.inner.table(table).map_err(KitError::from)?;
1342        let n = handle.lock().memtable_len();
1343        Ok(n)
1344    }
1345
1346    /// Mutable-run length for a table.
1347    pub fn table_mutable_run_len(&self, table: &str) -> Result<usize> {
1348        let handle = self.inner.table(table).map_err(KitError::from)?;
1349        let n = handle.lock().mutable_run_len();
1350        Ok(n)
1351    }
1352
1353    /// Page-cache entry count for a table.
1354    pub fn table_page_cache_len(&self, table: &str) -> Result<usize> {
1355        let handle = self.inner.table(table).map_err(KitError::from)?;
1356        let n = handle.lock().page_cache_len();
1357        Ok(n)
1358    }
1359
1360    /// Decoded-page-cache entry count for a table.
1361    pub fn table_decoded_cache_len(&self, table: &str) -> Result<usize> {
1362        let handle = self.inner.table(table).map_err(KitError::from)?;
1363        let n = handle.lock().decoded_cache_len();
1364        Ok(n)
1365    }
1366
1367    /// Run a SQL statement through the embedded `MongrelSession` (DataFusion
1368    /// frontend) and return the result as Arrow [`RecordBatch`]es. This is the
1369    /// Rust analogue of the TypeScript kit's `db.sql(sql)` (which returns Arrow
1370    /// IPC bytes) and the NAPI `Database.sql`.
1371    ///
1372    /// Read statements return their rows; DDL/DML (e.g. `CREATE VIEW`,
1373    /// `ANALYZE`, `VACUUM`, `CREATE VIRTUAL TABLE`) return an empty vec. Writes
1374    /// made directly through SQL bypass Kit-level constraints (defaults,
1375    /// enums, min/max, length, regex, triggers) — use the transactional
1376    /// [`Transaction`](crate::Transaction) API for constrained writes. The
1377    /// engine's own declarative constraints (unique, FK, check) still apply.
1378    ///
1379    /// The session is held for the database's lifetime, so session-scoped
1380    /// objects (views, prepared statements, the result cache) persist across
1381    /// calls — mirroring a long-lived database connection. After a migration
1382    /// that creates/drops tables, call [`Database::refresh_sql_session`] so the
1383    /// session sees the new table set.
1384    pub fn sql(&self, statement: &str) -> Result<Vec<arrow::record_batch::RecordBatch>> {
1385        // Take the cached session out of the mutex (or build one on first use)
1386        // so no `MutexGuard` is held across the async `run`. The kit's SQL
1387        // surface is `&self` and blocking; concurrent `sql()` calls on one
1388        // `Database` serialize here (applications needing concurrency should
1389        // use multiple handles, each with its own session).
1390        let session = match self.session.lock().take() {
1391            Some(s) => s,
1392            None => {
1393                mongreldb_query::MongrelSession::open(self.core_arc()).map_err(KitError::from)?
1394            }
1395        };
1396        let runtime = sql_runtime();
1397        let result = runtime
1398            .block_on(session.run(statement))
1399            .map_err(KitError::from);
1400        // Preserve the session (and any views/state created during the call).
1401        *self.session.lock() = Some(session);
1402        result
1403    }
1404
1405    /// (Re)build the cached SQL session so it sees the current table set. The
1406    /// engine's `MongrelSession` snapshots the table list at construction; this
1407    /// rebuilds it after a migration creates or drops tables. Views and other
1408    /// session-scoped state are reset.
1409    pub fn refresh_sql_session(&self) -> Result<()> {
1410        let session =
1411            mongreldb_query::MongrelSession::open(self.core_arc()).map_err(KitError::from)?;
1412        *self.session.lock() = Some(session);
1413        Ok(())
1414    }
1415
1416    /// Like [`Database::sql`], but returns the result serialized as Arrow IPC
1417    /// *file* bytes — the same wire format the NAPI addon and the daemon emit.
1418    /// Decode with `pyarrow.ipc.open_file`, the JS `apache-arrow`
1419    /// `tableFromIPC`, or [`crate::arrow_util::read_arrow_ipc`]. Empty for
1420    /// DDL/DML.
1421    pub fn sql_arrow(&self, statement: &str) -> Result<Vec<u8>> {
1422        let batches = self.sql(statement)?;
1423        crate::arrow_util::batches_to_ipc(&batches)
1424    }
1425
1426    /// Like [`Database::sql`], but materializes the result rows into JSON-style
1427    /// maps (column name → value) for callers that don't want to take a direct
1428    /// Arrow dependency. Empty for DDL/DML.
1429    pub fn sql_rows(&self, statement: &str) -> Result<Vec<serde_json::Map<String, Value>>> {
1430        let batches = self.sql(statement)?;
1431        crate::arrow_util::batches_to_rows(&batches)
1432    }
1433
1434    /// Direct HOT (PK → RowId) lookup via the core engine — no full-row
1435    /// materialization. Used by the §4.3 delete fast path when the table
1436    /// has no Kit-level constraints requiring guard cleanup.
1437    pub(crate) fn lookup_row_id(&self, table: &str, key: &[u8]) -> Result<Option<RowId>> {
1438        let handle = self.inner.table(table).map_err(KitError::from)?;
1439        let mut guard = handle.lock();
1440        guard.ensure_indexes_complete()?;
1441        Ok(guard.lookup_pk(key))
1442    }
1443
1444    pub(crate) fn root(&self) -> &Path {
1445        &self.root
1446    }
1447
1448    /// All visible core rows for a table at a specific read snapshot. Used so
1449    /// kit transactions read at their own snapshot (repeatable reads) rather
1450    /// than the latest committed state.
1451    pub(crate) fn visible_core_rows_at(
1452        &self,
1453        table_name: &str,
1454        snapshot: Snapshot,
1455    ) -> Result<Vec<CoreRow>> {
1456        let handle = self.inner.table(table_name).map_err(KitError::from)?;
1457        let guard = handle.lock();
1458        guard.visible_rows(snapshot).map_err(KitError::from)
1459    }
1460
1461    /// Query visible core rows with native `Condition`s at a specific read
1462    /// snapshot (Kit Priority 1 pushdown). Resolves `conditions` via core's
1463    /// indexes (HOT / bitmap / range) and returns only the matching rows —
1464    /// avoiding the full scan that `visible_core_rows_at` does. Returns the
1465    /// empty vec when no conditions match, and falls back to
1466    /// `visible_core_rows_at` when `conditions` is empty (unfiltered).
1467    pub(crate) fn query_core_rows_at(
1468        &self,
1469        table_name: &str,
1470        conditions: &[mongreldb_core::query::Condition],
1471        snapshot: Snapshot,
1472    ) -> Result<Vec<CoreRow>> {
1473        if conditions.is_empty() {
1474            return self.visible_core_rows_at(table_name, snapshot);
1475        }
1476        let handle = self.inner.table(table_name).map_err(KitError::from)?;
1477        let mut guard = handle.lock();
1478        let q = mongreldb_core::query::Query {
1479            conditions: conditions.to_vec(),
1480        };
1481        guard.query(&q).map_err(KitError::from)
1482    }
1483
1484    /// Drain `table`'s memtable into the mutable-run tier, spilling to a
1485    /// durable, checkpointed `.sr` run once the tier crosses its watermark.
1486    /// Called after a large batch commit (see `Transaction::commit`) so a
1487    /// short-lived process (the CLI, or any fresh `Database::open`) isn't
1488    /// stuck replaying the whole batch from the WAL on its next invocation —
1489    /// without a flush, committed-but-unflushed writes only exist as WAL
1490    /// records and must be fully replayed to rebuild the in-memory indexes.
1491    pub(crate) fn flush_table(&self, table_name: &str) -> Result<()> {
1492        let handle = self.inner.table(table_name).map_err(KitError::from)?;
1493        handle.lock().flush().map_err(KitError::from)?;
1494        Ok(())
1495    }
1496
1497    /// Count visible rows matching `conditions` without materializing them
1498    /// (Kit Priority 7 pushdown). Returns `None` when the conditions cannot be
1499    /// served by indexes, or when `snapshot` is not the latest committed epoch
1500    /// (caller falls back to a snapshot-correct row scan).
1501    ///
1502    /// `count_conditions` counts the engine's latest committed index state, not
1503    /// a snapshot-filtered scan, so it only matches a repeatable-read row count
1504    /// when the read snapshot IS the latest epoch. We hold the table lock while
1505    /// comparing, so no commit can interleave between the check and the count.
1506    pub(crate) fn count_core_rows_at(
1507        &self,
1508        table_name: &str,
1509        conditions: &[mongreldb_core::query::Condition],
1510        snapshot: Snapshot,
1511    ) -> Result<Option<u64>> {
1512        let handle = self.inner.table(table_name).map_err(KitError::from)?;
1513        let mut guard = handle.lock();
1514        if guard.snapshot().epoch != snapshot.epoch {
1515            return Ok(None); // stale read snapshot ⇒ caller scans
1516        }
1517        guard
1518            .count_conditions(conditions, snapshot)
1519            .map_err(KitError::from)
1520    }
1521
1522    /// Compute `SUM`/`MIN`/`MAX`/`AVG`/`COUNT(col)` over a column without
1523    /// materializing rows (Kit Priority 7), via the engine's native aggregate.
1524    /// `column` is the engine column id. Returns `None` when the engine fast
1525    /// path does not apply (multi-run / non-empty overlay / non-numeric column),
1526    /// or when `snapshot` is not the latest committed epoch — the same
1527    /// guarantee as [`count_core_rows_at`](Self::count_core_rows_at): the engine
1528    /// aggregate matches a snapshot-consistent row scan only at the latest epoch,
1529    /// and we compare under the table lock so no commit can interleave.
1530    pub(crate) fn aggregate_core_at(
1531        &self,
1532        table_name: &str,
1533        column: Option<u16>,
1534        conditions: &[mongreldb_core::query::Condition],
1535        agg: NativeAgg,
1536        snapshot: Snapshot,
1537    ) -> Result<Option<NativeAggResult>> {
1538        let handle = self.inner.table(table_name).map_err(KitError::from)?;
1539        let guard = handle.lock();
1540        if guard.snapshot().epoch != snapshot.epoch {
1541            return Ok(None); // stale read snapshot ⇒ caller scans
1542        }
1543        guard
1544            .aggregate_native(snapshot, column, conditions, agg)
1545            .map_err(KitError::from)
1546    }
1547
1548    /// `COUNT(DISTINCT col)` from the bitmap index's partition cardinality (Kit
1549    /// Priority 7) — the number of distinct indexed values, no scan. Returns
1550    /// `None` without a bitmap index on the column, when the table is not
1551    /// insert-only, or when `snapshot` is not the latest committed epoch. The
1552    /// engine method reads the latest committed index state (no snapshot
1553    /// parameter), so — as with [`count_core_rows_at`](Self::count_core_rows_at)
1554    /// — it only matches a repeatable-read scan at the latest epoch; we compare
1555    /// under the table lock so no commit can interleave.
1556    pub(crate) fn count_distinct_core_at(
1557        &self,
1558        table_name: &str,
1559        column_id: u16,
1560        snapshot: Snapshot,
1561    ) -> Result<Option<u64>> {
1562        let handle = self.inner.table(table_name).map_err(KitError::from)?;
1563        let mut guard = handle.lock();
1564        if guard.snapshot().epoch != snapshot.epoch {
1565            return Ok(None); // stale read snapshot ⇒ caller scans
1566        }
1567        guard
1568            .count_distinct_from_bitmap(column_id)
1569            .map_err(KitError::from)
1570    }
1571
1572    /// Materialize a single row by storage row id.
1573    #[allow(dead_code)]
1574    pub(crate) fn get_core_row(&self, table_name: &str, row_id: u64) -> Result<Option<CoreRow>> {
1575        let handle = self.inner.table(table_name).map_err(KitError::from)?;
1576        let guard = handle.lock();
1577        let snapshot = guard.snapshot();
1578        Ok(guard.get(mongreldb_core::RowId(row_id), snapshot))
1579    }
1580}
1581
1582pub(crate) fn create_core_table(db: &CoreDatabase, name: &str, schema: CoreSchema) -> Result<()> {
1583    if db.table_id(name).is_ok() {
1584        return Ok(());
1585    }
1586    db.create_table(name, schema).map_err(KitError::from)?;
1587    Ok(())
1588}
1589
1590/// A cached single-threaded tokio runtime for driving `MongrelSession::run`
1591/// (which is async) from the kit's otherwise-blocking SQL surface. Built once
1592/// per process and reused; `CurrentThread` is sufficient since the kit never
1593/// runs concurrent SQL statements on the same database from one thread.
1594fn sql_runtime() -> &'static tokio::runtime::Runtime {
1595    use std::sync::OnceLock;
1596    static RT: OnceLock<tokio::runtime::Runtime> = OnceLock::new();
1597    RT.get_or_init(|| {
1598        tokio::runtime::Builder::new_current_thread()
1599            .enable_all()
1600            .build()
1601            .expect("failed to build kit SQL tokio runtime")
1602    })
1603}
1604
1605fn core_procedure(spec: &ProcedureSpec) -> Result<mongreldb_core::StoredProcedure> {
1606    let parsed: mongreldb_core::StoredProcedure =
1607        serde_json::from_value(spec.json.clone()).map_err(KitError::from)?;
1608    mongreldb_core::StoredProcedure::new(parsed.name, parsed.mode, parsed.params, parsed.body, 0)
1609        .map_err(KitError::from)
1610}
1611
1612fn core_trigger(spec: &TriggerSpec) -> Result<mongreldb_core::StoredTrigger> {
1613    let parsed: mongreldb_core::StoredTrigger =
1614        serde_json::from_value(spec.json.clone()).map_err(KitError::from)?;
1615    mongreldb_core::StoredTrigger::new(
1616        parsed.name,
1617        mongreldb_core::TriggerDefinition {
1618            target: parsed.target,
1619            timing: parsed.timing,
1620            event: parsed.event,
1621            update_of: parsed.update_of,
1622            target_columns: parsed.target_columns,
1623            when: parsed.when,
1624            program: parsed.program,
1625        },
1626        0,
1627    )
1628    .map_err(KitError::from)
1629}
1630
1631fn json_to_core_value(value: &Value) -> Result<CoreValue> {
1632    match value {
1633        Value::Null => Ok(CoreValue::Null),
1634        Value::Bool(value) => Ok(CoreValue::Bool(*value)),
1635        Value::Number(value) => {
1636            if let Some(value) = value.as_i64() {
1637                Ok(CoreValue::Int64(value))
1638            } else if let Some(value) = value.as_f64() {
1639                Ok(CoreValue::Float64(value))
1640            } else {
1641                Err(KitError::Validation("unsupported JSON number".into()))
1642            }
1643        }
1644        Value::String(value) => Ok(CoreValue::Bytes(value.as_bytes().to_vec())),
1645        Value::Array(_) | Value::Object(_) => Err(KitError::Validation(
1646            "procedure args only support scalar JSON values".into(),
1647        )),
1648    }
1649}
1650
1651/// Read a `Bytes` column from an internal-table core row as a UTF-8 string.
1652pub(crate) fn internal_bytes(row: &CoreRow, col_id: u16) -> Option<String> {
1653    match row.columns.get(&col_id) {
1654        Some(CoreValue::Bytes(b)) => String::from_utf8(b.clone()).ok(),
1655        _ => None,
1656    }
1657}
1658
1659/// Best-effort: reap any WAL segments a previous session left rotated but
1660/// unreaped, now that this `open()` has minted a fresh active segment
1661/// (`SharedWal::open` never truncates prior segments on its own —
1662/// [`CoreDatabase::gc`] does, but only once every mounted table's data is
1663/// durable in runs). Called before any write in *this* session, so that
1664/// check reflects exactly what the previous session left behind: if that
1665/// session ended with everything flushed (e.g. a bulk `insert_many`
1666/// followed by `Transaction::commit`'s large-batch auto-flush), this is the
1667/// one moment the now-inactive segment holding that batch is actually
1668/// eligible for cleanup. Without it, a short-lived process (the CLI has no
1669/// daemon mode; every invocation opens cold) keeps paying to read and
1670/// deserialize that segment's records on every subsequent open, even though
1671/// none of them still need replaying. Errors are ignored — this is a
1672/// disk-usage/reopen-latency optimization, never a correctness requirement.
1673fn reap_rotated_wal_segments(db: &CoreDatabase) {
1674    let _ = db.gc();
1675}
1676
1677pub(crate) fn load_schema(path: &Path) -> Result<KitSchema> {
1678    let file = path.join(SCHEMA_FILE);
1679    let json = std::fs::read_to_string(&file)
1680        .map_err(|e| KitError::Migration(format!("cannot read schema file: {e}")))?;
1681    let schema: KitSchema = serde_json::from_str(&json)?;
1682    Ok(schema)
1683}
1684
1685pub(crate) fn store_schema(path: &Path, schema: &KitSchema) -> Result<()> {
1686    let file = path.join(SCHEMA_FILE);
1687    let json = serde_json::to_string_pretty(schema)?;
1688    std::fs::write(&file, json)?;
1689    Ok(())
1690}
1691
1692/// Persist a kit schema into the database. Used after migrations.
1693pub(crate) fn persist_schema(db: &Database, schema: &KitSchema) -> Result<()> {
1694    store_schema(&db.root, schema)
1695}