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_indexes, 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::Index as KitIndex;
13use mongreldb_kit_core::schema::IndexKind as KitIndexKind;
14use mongreldb_kit_core::schema::Schema as KitSchema;
15use mongreldb_kit_core::schema::Table as KitTable;
16use mongreldb_kit_core::{ProcedureSpec, TriggerSpec, ViewSpec};
17use serde_json::Value;
18
19use std::collections::HashMap;
20use std::path::{Path, PathBuf};
21use std::sync::Arc;
22
23const SCHEMA_FILE: &str = "kit_schema.json";
24
25/// Knobs for kit-level database opens.
26///
27/// Default is `lock_timeout_ms = 0` (fail-fast), matching the historical
28/// `Database::open` behavior and preserving backwards compatibility.
29#[derive(Clone, Copy, Debug, Default)]
30pub struct OpenOptions {
31    /// How long to wait for the cross-process exclusive lock on
32    /// `<dir>/_meta/.lock` to become available, in milliseconds. `0`
33    /// preserves the fail-fast behavior. Non-zero enables
34    /// SQLite-style `busy_timeout` semantics: 1ms → 10ms → 50ms
35    /// backoff with a hard deadline at `lock_timeout_ms`.
36    pub lock_timeout_ms: u32,
37}
38
39impl OpenOptions {
40    /// Create a new `OpenOptions` with all defaults.
41    pub fn new() -> Self {
42        Self::default()
43    }
44
45    /// Set [`OpenOptions::lock_timeout_ms`]. `0` keeps the fail-fast default;
46    /// SQLite-style applications typically pick 1_000 – 5_000ms.
47    pub fn with_lock_timeout_ms(mut self, ms: u32) -> Self {
48        self.lock_timeout_ms = ms;
49        self
50    }
51}
52
53#[derive(Debug, Clone, Default)]
54pub struct SqlOptions {
55    pub query_id: Option<mongreldb_query::QueryId>,
56    pub timeout: Option<std::time::Duration>,
57}
58
59#[derive(Debug, Clone, Copy, PartialEq, Eq)]
60pub struct SqlOutputLimits {
61    pub max_rows: usize,
62    pub max_bytes: usize,
63}
64
65impl Default for SqlOutputLimits {
66    fn default() -> Self {
67        Self {
68            max_rows: 1_000_000,
69            max_bytes: 64 * 1024 * 1024,
70        }
71    }
72}
73
74pub struct SqlQueryHandle {
75    query_id: mongreldb_query::QueryId,
76    session: Arc<mongreldb_query::MongrelSession>,
77    worker: Option<
78        std::thread::JoinHandle<mongreldb_query::Result<mongreldb_query::ManagedQueryBatches>>,
79    >,
80}
81
82impl SqlQueryHandle {
83    pub fn id(&self) -> mongreldb_query::QueryId {
84        self.query_id
85    }
86
87    pub fn cancel(&self) -> mongreldb_query::CancelOutcome {
88        self.session.cancel_query(self.query_id)
89    }
90
91    pub fn status(&self) -> Option<mongreldb_query::QueryStatus> {
92        self.session.query_registry().status(self.query_id)
93    }
94
95    pub fn wait(self) -> Result<Vec<arrow::record_batch::RecordBatch>> {
96        let output = self.wait_for_serialization()?;
97        let batches = output.batches().to_vec();
98        complete_sql_output(output)?;
99        Ok(batches)
100    }
101
102    pub fn wait_arrow(self) -> Result<Vec<u8>> {
103        self.wait_arrow_with_limits(SqlOutputLimits::default())
104    }
105
106    pub fn wait_arrow_with_limits(self, limits: SqlOutputLimits) -> Result<Vec<u8>> {
107        let output = self.wait_for_serialization()?;
108        let result = crate::arrow_util::batches_to_ipc_controlled_with_limits(
109            output.batches(),
110            output.query(),
111            limits,
112        );
113        match result {
114            Ok(bytes) => {
115                complete_sql_output(output)?;
116                Ok(bytes)
117            }
118            Err(error) => {
119                fail_sql_output(output, &error);
120                Err(error)
121            }
122        }
123    }
124
125    pub fn wait_rows(self) -> Result<Vec<serde_json::Map<String, Value>>> {
126        self.wait_rows_with_limits(SqlOutputLimits::default())
127    }
128
129    pub fn wait_rows_with_limits(
130        self,
131        limits: SqlOutputLimits,
132    ) -> Result<Vec<serde_json::Map<String, Value>>> {
133        let output = self.wait_for_serialization()?;
134        let result = crate::arrow_util::batches_to_rows_controlled_with_limits(
135            output.batches(),
136            output.query(),
137            limits,
138        );
139        match result {
140            Ok(rows) => {
141                complete_sql_output(output)?;
142                Ok(rows)
143            }
144            Err(error) => {
145                fail_sql_output(output, &error);
146                Err(error)
147            }
148        }
149    }
150
151    pub fn wait_for_serialization(mut self) -> Result<mongreldb_query::ManagedQueryBatches> {
152        let result = self
153            .worker
154            .take()
155            .expect("SQL worker is present")
156            .join()
157            .map_err(|_| KitError::Storage("SQL worker panicked".into()))?;
158        let output = result.map_err(|error| {
159            let status = self.session.query_registry().status(self.query_id);
160            crate::error::query_error_with_status(error, status.as_ref())
161        })?;
162        self.session
163            .fire_test_hook(mongreldb_query::SqlTestHookPoint::BeforeSerializationBatch);
164        Ok(output)
165    }
166}
167
168/// Finish controlled SQL conversion after one last cancellation checkpoint.
169#[doc(hidden)]
170pub fn complete_sql_output(output: mongreldb_query::ManagedQueryBatches) -> Result<()> {
171    let query = output.query().clone();
172    loop {
173        if let Err(error) = query.checkpoint() {
174            let status = query.status();
175            output.fail();
176            return Err(crate::error::query_error_with_status(error, Some(&status)));
177        }
178        let phase = query.phase();
179        match phase {
180            mongreldb_query::SqlQueryPhase::Serializing
181            | mongreldb_query::SqlQueryPhase::CommitCritical => {
182                if query
183                    .transition(phase, mongreldb_query::SqlQueryPhase::Completed)
184                    .is_ok()
185                {
186                    break;
187                }
188            }
189            mongreldb_query::SqlQueryPhase::Completed => break,
190            mongreldb_query::SqlQueryPhase::Cancelling => std::thread::yield_now(),
191            phase => {
192                let error = mongreldb_query::MongrelQueryError::InvalidQueryState(format!(
193                    "query {} cannot complete output conversion from {phase:?}",
194                    query.id()
195                ));
196                let status = query.status();
197                output.fail_with_error(
198                    error.code(),
199                    mongreldb_query::QueryTerminalErrorCategory::Execution,
200                );
201                return Err(crate::error::query_error_with_status(error, Some(&status)));
202            }
203        }
204    }
205    output.complete().map_err(|error| {
206        let status = query.status();
207        crate::error::query_error_with_status(error, Some(&status))
208    })
209}
210
211/// Record the exact terminal conversion category before consuming SQL output.
212#[doc(hidden)]
213pub fn fail_sql_output(output: mongreldb_query::ManagedQueryBatches, error: &KitError) {
214    match error {
215        KitError::ResultLimitExceeded { .. } => output.fail_result_limit(),
216        KitError::SerializationFailed { .. } => output.fail_serialization(),
217        _ => output.fail(),
218    }
219}
220
221impl Drop for SqlQueryHandle {
222    fn drop(&mut self) {
223        if self.worker.is_some() {
224            let _ = self.session.cancel_query(self.query_id);
225        }
226    }
227}
228
229/// A named default-value provider registered by the application.
230pub type DefaultProvider = Box<dyn Fn() -> Value + Send + Sync>;
231
232/// The result of [`Database::explain`]: a static description of a predicate's
233/// index push-down, without running the query.
234#[derive(Debug, Clone)]
235pub struct ExplainPlan {
236    /// Whether at least one native index condition pushes down (vs. a full scan).
237    pub index_accelerated: bool,
238    /// Whether the push-down is exact — the whole predicate translated, so no
239    /// Rust-side residual re-filtering is needed.
240    pub exact: bool,
241    /// The kind of each pushed condition (e.g. `BitmapEq`, `RangeInt`, `Ann`).
242    pub pushed_conditions: Vec<String>,
243}
244
245/// A row paired with its Jaccard set-similarity to a query set (`0.0..=1.0`).
246#[derive(Debug, Clone)]
247pub struct SimilarRow {
248    pub row: crate::schema::Row,
249    pub similarity: f64,
250}
251
252/// Collect the string members of a set-valued column cell. Accepts either a
253/// JSON array value or a JSON string holding an array (how the Kit stores
254/// `json`/`text` set columns); anything else yields the empty set.
255fn parse_string_set(value: Option<&Value>) -> std::collections::HashSet<String> {
256    let arr = match value {
257        Some(Value::Array(a)) => Some(a.clone()),
258        Some(Value::String(s)) => serde_json::from_str::<Value>(s)
259            .ok()
260            .and_then(|v| v.as_array().cloned()),
261        _ => None,
262    };
263    arr.into_iter()
264        .flatten()
265        .filter_map(|v| match v {
266            Value::String(s) => Some(s),
267            Value::Number(n) => Some(n.to_string()),
268            Value::Bool(b) => Some(b.to_string()),
269            _ => None,
270        })
271        .collect()
272}
273
274/// Which aggregate to maintain incrementally.
275#[derive(Debug, Clone, Copy, PartialEq, Eq)]
276pub enum IncrementalAggKind {
277    Count,
278    Sum,
279    Min,
280    Max,
281    Avg,
282}
283
284/// The result of [`Database::incremental_aggregate`].
285#[derive(Debug, Clone)]
286pub struct IncrementalAggregate {
287    /// The exact aggregate value at the current epoch: a JSON number, or `null`
288    /// when no rows matched (`COUNT` returns `0`, not null).
289    pub value: Value,
290    /// `true` when produced by merging only newly-committed rows (the fast
291    /// path); `false` when a full recompute was required (cold cache, a delete,
292    /// pending writes, or the same epoch as the cached state).
293    pub incremental: bool,
294    /// Rows processed in the delta pass (`0` for a full recompute).
295    pub delta_rows: u64,
296}
297
298/// Stable per-`(table, column, agg, filter)` cache key for the engine's
299/// incremental-aggregate cache. Deterministic within a process (fixed-seed
300/// hasher); the cache itself is per-`Db`, so cross-process stability is moot.
301fn incremental_cache_key(
302    table_id: u32,
303    column: Option<u16>,
304    agg: IncrementalAggKind,
305    conditions: &[mongreldb_core::query::Condition],
306) -> u64 {
307    use std::hash::{Hash, Hasher};
308    let mut h = std::collections::hash_map::DefaultHasher::new();
309    table_id.hash(&mut h);
310    column.hash(&mut h);
311    (agg as u8).hash(&mut h);
312    // `Condition` has no `Hash`; its `Debug` form is stable and unique enough.
313    format!("{conditions:?}").hash(&mut h);
314    h.finish()
315}
316
317/// Finalize a mergeable [`AggState`] to a JSON scalar, preserving integer-ness
318/// for `COUNT`/`MIN`/`MAX`/int `SUM` and using a float for averages / float
319/// columns. `null` when there were no matching inputs.
320fn agg_state_value(s: &AggState) -> Value {
321    let num_f64 = |x: f64| {
322        serde_json::Number::from_f64(x)
323            .map(Value::Number)
324            .unwrap_or(Value::Null)
325    };
326    match s {
327        AggState::Count(n) => Value::from(*n),
328        AggState::SumI { sum, .. } => i64::try_from(*sum)
329            .map(Value::from)
330            .unwrap_or_else(|_| num_f64(*sum as f64)),
331        AggState::SumF { sum, .. } => num_f64(*sum),
332        AggState::AvgI { sum, count } if *count > 0 => num_f64(*sum as f64 / *count as f64),
333        AggState::AvgF { sum, count } if *count > 0 => num_f64(*sum / *count as f64),
334        AggState::AvgI { .. } | AggState::AvgF { .. } => Value::Null,
335        AggState::MinI(n) | AggState::MaxI(n) => Value::from(*n),
336        AggState::MinF(f) | AggState::MaxF(f) => num_f64(*f),
337        AggState::Empty => Value::Null,
338    }
339}
340
341/// Which approximate aggregate to estimate from the reservoir sample.
342#[derive(Debug, Clone, Copy, PartialEq, Eq)]
343pub enum ApproxAggKind {
344    Count,
345    Sum,
346    Avg,
347}
348
349/// A reservoir-sampled approximate aggregate with a normal-theory confidence
350/// interval. `ci_low`/`ci_high` bracket `point` at the requested z-score; the
351/// interval collapses to zero width when the sample covers the whole table.
352#[derive(Debug, Clone)]
353pub struct ApproxAggregate {
354    pub point: f64,
355    pub ci_low: f64,
356    pub ci_high: f64,
357    pub n_population: u64,
358    pub n_sample_live: usize,
359    pub n_passing: usize,
360}
361
362/// Short kind label for a core `Condition` (the variant name), decoupled from
363/// the enum shape via its `Debug` form.
364fn condition_label(c: &mongreldb_core::query::Condition) -> String {
365    let dbg = format!("{c:?}");
366    dbg.split(['(', '{', ' ']).next().unwrap_or("").to_string()
367}
368
369fn open_core_with_retry<T>(
370    timeout_ms: u32,
371    mut open: impl FnMut() -> mongreldb_core::Result<T>,
372) -> mongreldb_core::Result<T> {
373    if timeout_ms == 0 {
374        return open();
375    }
376    let deadline = std::time::Instant::now() + std::time::Duration::from_millis(timeout_ms as u64);
377    let mut next_sleep = std::time::Duration::from_millis(1);
378    loop {
379        match open() {
380            Ok(db) => return Ok(db),
381            Err(err) if is_lock_contention(&err) => {
382                let now = std::time::Instant::now();
383                if now >= deadline {
384                    return Err(err);
385                }
386                let sleep = next_sleep.min(deadline - now);
387                std::thread::sleep(sleep);
388                next_sleep = next_sleep
389                    .saturating_mul(10)
390                    .min(std::time::Duration::from_millis(50));
391            }
392            Err(err) => return Err(err),
393        }
394    }
395}
396
397fn is_lock_contention(err: &mongreldb_core::MongrelError) -> bool {
398    matches!(err, mongreldb_core::MongrelError::DatabaseLocked { .. })
399}
400
401/// A kit database handle.
402///
403/// Wraps a MongrelDB core database and a kit schema, providing table metadata
404/// and transaction creation.
405pub struct Database {
406    pub(crate) inner: Arc<CoreDatabase>,
407    pub(crate) schema: KitSchema,
408    pub(crate) root: PathBuf,
409    /// Application-registered named default providers (`DefaultKind::CustomName`).
410    pub(crate) default_providers: HashMap<String, DefaultProvider>,
411    /// Lazily-initialized long-lived SQL session. Views, prepared statements,
412    /// and the result cache are session-scoped (the engine does not persist
413    /// them), so the kit holds one session for the database's lifetime rather
414    /// than opening one per `sql()` call — mirroring how the daemon and any
415    /// long-lived application use MongrelDB. Built on first use so tables
416    /// created in `Database::create` are visible to it.
417    pub(crate) session: parking_lot::RwLock<Option<Arc<mongreldb_query::MongrelSession>>>,
418    sequence_lock: parking_lot::Mutex<()>,
419}
420
421impl Database {
422    /// Open an existing kit database.
423    pub fn open(path: &Path) -> Result<Self> {
424        let inner = Arc::new(CoreDatabase::open(path)?);
425        let schema = load_schema(path)?;
426        // Ensure reserved tables exist for databases created by older versions.
427        ensure_internal_tables(&inner)?;
428        reap_rotated_wal_segments(&inner);
429        Ok(Self {
430            inner,
431            schema,
432            root: path.to_path_buf(),
433            default_providers: HashMap::new(),
434            session: parking_lot::RwLock::new(None),
435            sequence_lock: parking_lot::Mutex::new(()),
436        })
437    }
438
439    /// Open an existing kit database with kit-level [`OpenOptions`]. Use this
440    /// when another process may already be holding the cross-process lock
441    /// and you want SQLite-style `busy_timeout` semantics instead of an
442    /// immediate failure.
443    ///
444    /// Existing callers of [`open`](Self::open) keep the fail-fast behavior;
445    /// this method is opt-in.
446    pub fn open_with_options(path: &Path, opts: OpenOptions) -> Result<Self> {
447        let inner = Arc::new(open_core_with_retry(opts.lock_timeout_ms, || {
448            CoreDatabase::open(path)
449        })?);
450        let schema = load_schema(path)?;
451        ensure_internal_tables(&inner)?;
452        reap_rotated_wal_segments(&inner);
453        Ok(Self {
454            inner,
455            schema,
456            root: path.to_path_buf(),
457            default_providers: HashMap::new(),
458            session: parking_lot::RwLock::new(None),
459            sequence_lock: parking_lot::Mutex::new(()),
460        })
461    }
462
463    /// Open an existing page-encrypted kit database with its passphrase.
464    pub fn open_encrypted(path: &Path, passphrase: &str) -> Result<Self> {
465        let inner = Arc::new(CoreDatabase::open_encrypted(path, passphrase)?);
466        let schema = load_schema(path)?;
467        ensure_internal_tables(&inner)?;
468        reap_rotated_wal_segments(&inner);
469        Ok(Self {
470            inner,
471            schema,
472            root: path.to_path_buf(),
473            default_providers: HashMap::new(),
474            session: parking_lot::RwLock::new(None),
475            sequence_lock: parking_lot::Mutex::new(()),
476        })
477    }
478
479    /// Open an existing page-encrypted kit database with its passphrase and
480    /// kit-level [`OpenOptions`]. Opt-in lock-timeout semantics, mirroring
481    /// [`open_with_options`](Self::open_with_options).
482    pub fn open_encrypted_with_options(
483        path: &Path,
484        passphrase: &str,
485        opts: OpenOptions,
486    ) -> Result<Self> {
487        let inner = Arc::new(open_core_with_retry(opts.lock_timeout_ms, || {
488            CoreDatabase::open_encrypted(path, passphrase)
489        })?);
490        let schema = load_schema(path)?;
491        ensure_internal_tables(&inner)?;
492        reap_rotated_wal_segments(&inner);
493        Ok(Self {
494            inner,
495            schema,
496            root: path.to_path_buf(),
497            default_providers: HashMap::new(),
498            session: parking_lot::RwLock::new(None),
499            sequence_lock: parking_lot::Mutex::new(()),
500        })
501    }
502
503    /// Create a fresh page-encrypted kit database (AES-256-GCM; the passphrase
504    /// derives the key hierarchy). Columns flagged `encrypted` /
505    /// `encrypted_indexable` are encrypted at rest.
506    pub fn create_encrypted(path: &Path, schema: KitSchema, passphrase: &str) -> Result<Self> {
507        std::fs::create_dir_all(path)?;
508        let inner = Arc::new(CoreDatabase::create_encrypted(path, passphrase)?);
509        ensure_internal_tables(&inner)?;
510        store_schema(path, &schema)?;
511        for table in &schema.tables {
512            create_core_table(&inner, &table.name, to_core_schema(table)?)?;
513        }
514        Ok(Self {
515            inner,
516            schema,
517            root: path.to_path_buf(),
518            default_providers: HashMap::new(),
519            session: parking_lot::RwLock::new(None),
520            sequence_lock: parking_lot::Mutex::new(()),
521        })
522    }
523
524    /// Create a fresh kit database with the given schema.
525    pub fn create(path: &Path, schema: KitSchema) -> Result<Self> {
526        std::fs::create_dir_all(path)?;
527        let inner = Arc::new(CoreDatabase::create(path)?);
528
529        // Create the reserved kit tables first so we can record migrations,
530        // reserve unique keys, and touch row guards.
531        ensure_internal_tables(&inner)?;
532
533        // Persist the kit schema to a sidecar file (core tables cannot update
534        // a specific row by id, so a file is the pragmatic stable store).
535        store_schema(path, &schema)?;
536
537        // Create core tables for every user table.
538        for table in &schema.tables {
539            create_core_table(&inner, &table.name, to_core_schema(table)?)?;
540        }
541
542        Ok(Self {
543            inner,
544            schema,
545            root: path.to_path_buf(),
546            default_providers: HashMap::new(),
547            session: parking_lot::RwLock::new(None),
548            sequence_lock: parking_lot::Mutex::new(()),
549        })
550    }
551
552    /// Open an existing kit database that has `require_auth = true`,
553    /// verifying credentials up front. Every subsequent operation is checked
554    /// against the authenticated principal's permissions.
555    ///
556    /// Returns an error if the database does not have `require_auth` enabled
557    /// (use [`open`](Self::open) for credentialless databases) or if the
558    /// credentials are invalid.
559    ///
560    /// See `docs/15-credential-enforcement.md`.
561    pub fn open_with_credentials(path: &Path, username: &str, password: &str) -> Result<Self> {
562        let inner = Arc::new(CoreDatabase::open_with_credentials(
563            path, username, password,
564        )?);
565        let schema = load_schema(path)?;
566        ensure_internal_tables(&inner)?;
567        reap_rotated_wal_segments(&inner);
568        Ok(Self {
569            inner,
570            schema,
571            root: path.to_path_buf(),
572            default_providers: HashMap::new(),
573            session: parking_lot::RwLock::new(None),
574            sequence_lock: parking_lot::Mutex::new(()),
575        })
576    }
577
578    /// Open a credentialed kit database with kit-level [`OpenOptions`]. Use
579    /// this when another process may already hold the cross-process lock
580    /// and you want SQLite-style `busy_timeout` semantics.
581    pub fn open_with_credentials_and_options(
582        path: &Path,
583        username: &str,
584        password: &str,
585        opts: OpenOptions,
586    ) -> Result<Self> {
587        let inner = Arc::new(open_core_with_retry(opts.lock_timeout_ms, || {
588            CoreDatabase::open_with_credentials(path, username, password)
589        })?);
590        let schema = load_schema(path)?;
591        ensure_internal_tables(&inner)?;
592        reap_rotated_wal_segments(&inner);
593        Ok(Self {
594            inner,
595            schema,
596            root: path.to_path_buf(),
597            default_providers: HashMap::new(),
598            session: parking_lot::RwLock::new(None),
599            sequence_lock: parking_lot::Mutex::new(()),
600        })
601    }
602
603    /// Create a fresh kit database with `require_auth = true`, a single admin
604    /// user, and the given schema. The returned handle is already authenticated
605    /// as the admin.
606    ///
607    /// See `docs/15-credential-enforcement.md`.
608    pub fn create_with_credentials(
609        path: &Path,
610        schema: KitSchema,
611        admin_username: &str,
612        admin_password: &str,
613    ) -> Result<Self> {
614        std::fs::create_dir_all(path)?;
615        let inner = Arc::new(CoreDatabase::create_with_credentials(
616            path,
617            admin_username,
618            admin_password,
619        )?);
620        ensure_internal_tables(&inner)?;
621        store_schema(path, &schema)?;
622        for table in &schema.tables {
623            create_core_table(&inner, &table.name, to_core_schema(table)?)?;
624        }
625        Ok(Self {
626            inner,
627            schema,
628            root: path.to_path_buf(),
629            default_providers: HashMap::new(),
630            session: parking_lot::RwLock::new(None),
631            sequence_lock: parking_lot::Mutex::new(()),
632        })
633    }
634
635    /// Open an existing page-encrypted kit database that has `require_auth =
636    /// true`, combining the encryption passphrase with credential verification.
637    pub fn open_encrypted_with_credentials(
638        path: &Path,
639        passphrase: &str,
640        username: &str,
641        password: &str,
642    ) -> Result<Self> {
643        let inner = Arc::new(CoreDatabase::open_encrypted_with_credentials(
644            path, passphrase, username, password,
645        )?);
646        let schema = load_schema(path)?;
647        ensure_internal_tables(&inner)?;
648        reap_rotated_wal_segments(&inner);
649        Ok(Self {
650            inner,
651            schema,
652            root: path.to_path_buf(),
653            default_providers: HashMap::new(),
654            session: parking_lot::RwLock::new(None),
655            sequence_lock: parking_lot::Mutex::new(()),
656        })
657    }
658
659    /// Open an encrypted + credentialed kit database with kit-level
660    /// [`OpenOptions`]. Opt-in lock-timeout semantics, mirroring
661    /// [`open_with_credentials_and_options`](Self::open_with_credentials_and_options).
662    pub fn open_encrypted_with_credentials_and_options(
663        path: &Path,
664        passphrase: &str,
665        username: &str,
666        password: &str,
667        opts: OpenOptions,
668    ) -> Result<Self> {
669        let inner = Arc::new(open_core_with_retry(opts.lock_timeout_ms, || {
670            CoreDatabase::open_encrypted_with_credentials(path, passphrase, username, password)
671        })?);
672        let schema = load_schema(path)?;
673        ensure_internal_tables(&inner)?;
674        reap_rotated_wal_segments(&inner);
675        Ok(Self {
676            inner,
677            schema,
678            root: path.to_path_buf(),
679            default_providers: HashMap::new(),
680            session: parking_lot::RwLock::new(None),
681            sequence_lock: parking_lot::Mutex::new(()),
682        })
683    }
684
685    /// Create a fresh page-encrypted kit database with `require_auth = true`
686    /// and a single admin user. Composes encryption-at-rest with credential
687    /// enforcement.
688    pub fn create_encrypted_with_credentials(
689        path: &Path,
690        schema: KitSchema,
691        passphrase: &str,
692        admin_username: &str,
693        admin_password: &str,
694    ) -> Result<Self> {
695        std::fs::create_dir_all(path)?;
696        let inner = Arc::new(CoreDatabase::create_encrypted_with_credentials(
697            path,
698            passphrase,
699            admin_username,
700            admin_password,
701        )?);
702        ensure_internal_tables(&inner)?;
703        store_schema(path, &schema)?;
704        for table in &schema.tables {
705            create_core_table(&inner, &table.name, to_core_schema(table)?)?;
706        }
707        Ok(Self {
708            inner,
709            schema,
710            root: path.to_path_buf(),
711            default_providers: HashMap::new(),
712            session: parking_lot::RwLock::new(None),
713            sequence_lock: parking_lot::Mutex::new(()),
714        })
715    }
716
717    /// Convert a credentialless kit database to a credentialed one in place.
718    /// Creates the first admin user, sets `require_auth = true`, and caches
719    /// the admin principal on this handle.
720    pub fn enable_auth(&self, admin_username: &str, admin_password: &str) -> Result<()> {
721        self.inner
722            .enable_auth(admin_username, admin_password)
723            .map_err(KitError::from)
724    }
725
726    /// Disable `require_auth`, reverting to credentialless mode. The recovery
727    /// path — requires an open (already-authenticated) handle. Users and roles
728    /// are preserved but no longer enforced.
729    pub fn disable_auth(&self) -> Result<()> {
730        self.inner.disable_auth().map_err(KitError::from)
731    }
732
733    /// Returns `true` if this database has `require_auth = true`.
734    pub fn require_auth_enabled(&self) -> bool {
735        self.inner.require_auth_enabled()
736    }
737
738    /// Re-resolve the cached principal from the on-disk catalog, picking up
739    /// role/permission changes made by other handles. No-op on credentialless
740    /// databases.
741    pub fn refresh_principal(&self) -> Result<()> {
742        self.inner.refresh_principal().map_err(KitError::from)?;
743        // Clear the SQL session so cached query results (which bypass the
744        // permission check) don't serve stale data after a permission change.
745        *self.session.write() = None;
746        Ok(())
747    }
748
749    /// Register a named default provider used by `DefaultKind::CustomName`
750    /// columns. Returns the database for chaining.
751    pub fn register_default(
752        &mut self,
753        name: impl Into<String>,
754        provider: impl Fn() -> Value + Send + Sync + 'static,
755    ) {
756        self.default_providers
757            .insert(name.into(), Box::new(provider));
758    }
759
760    /// Process-local embedding provider registry (same instance as core).
761    ///
762    /// Empty by default — dense ANN works when applications supply vectors.
763    /// Register providers with [`EmbeddingProviderRegistry::register_new`] (or
764    /// [`Self::register_embedding_provider`]) before explicit
765    /// [`Self::embed_texts`] calls or `GeneratedColumnSpec` writes.
766    pub fn embedding_providers(&self) -> &mongreldb_core::EmbeddingProviderRegistry {
767        self.inner.embedding_providers()
768    }
769
770    /// Register an embedding provider unless its ID already exists.
771    pub fn register_embedding_provider(
772        &self,
773        provider: Arc<dyn mongreldb_core::EmbeddingProvider>,
774    ) {
775        let _ = self.embedding_providers().register_new(provider);
776    }
777
778    /// Generate dense vectors via the process-local registry for `source`.
779    ///
780    /// Explicit helper for legacy source shapes. `GeneratedColumnSpec`
781    /// insert/update materialization runs transactionally in the engine.
782    /// `SuppliedByApplication` always refuses generation (pass vectors yourself).
783    /// Dimension mismatches and non-finite provider output are errors — Kit does
784    /// not invent hashed/random pseudo-embeddings.
785    pub fn embed_texts(
786        &self,
787        source: &mongreldb_kit_core::schema::EmbeddingSource,
788        texts: &[&str],
789        expected_dim: u32,
790    ) -> Result<Vec<Vec<f32>>> {
791        let core_source = crate::schema::to_core_embedding_source(source);
792        self.inner
793            .embedding_providers()
794            .embed(&core_source, texts, expected_dim)
795            .map_err(|e| KitError::Validation(e.to_string()))
796    }
797
798    /// The raw, unguarded MongrelDB core database. This is the Rust analogue of
799    /// the TypeScript kit's `nativeDb` escape hatch: writes made directly
800    /// against it bypass all kit constraints.
801    pub fn raw(&self) -> &CoreDatabase {
802        &self.inner
803    }
804
805    /// Persist and asynchronously build one secondary index.
806    ///
807    /// Online engine jobs publish one physical single-column index. Kit
808    /// multi-column declarations therefore are not accepted by this API.
809    pub fn start_create_index(&self, table: &str, index: &KitIndex) -> Result<u64> {
810        let table_schema = self
811            .schema
812            .table(table)
813            .ok_or_else(|| KitError::Validation(format!("table {table:?} not found")))?;
814        let definition = one_core_index(table_schema, index)?;
815        self.inner
816            .start_create_index(table, definition)
817            .map_err(KitError::from)
818    }
819
820    /// Persist and asynchronously replace one secondary index.
821    ///
822    /// `expected_old_name` is the logical Kit index name. The full old
823    /// physical name is resolved from the loaded Kit schema.
824    pub fn start_replace_index(
825        &self,
826        table: &str,
827        expected_old_name: &str,
828        replacement: &KitIndex,
829    ) -> Result<u64> {
830        let table_schema = self
831            .schema
832            .table(table)
833            .ok_or_else(|| KitError::Validation(format!("table {table:?} not found")))?;
834        let old = table_schema
835            .indexes
836            .iter()
837            .find(|index| index.name == expected_old_name)
838            .ok_or_else(|| {
839                KitError::Validation(format!(
840                    "index {expected_old_name:?} not found on table {table:?}"
841                ))
842            })?;
843        let old_definition = one_core_index(table_schema, old)?;
844        let definition = one_core_index(table_schema, replacement)?;
845        self.inner
846            .start_replace_index(table, &old_definition.name, definition)
847            .map_err(KitError::from)
848    }
849
850    /// Resume a durable pending or crash-paused index build.
851    pub fn resume_index_build(&self, job_id: u64) -> Result<()> {
852        self.inner
853            .resume_index_build(job_id)
854            .map_err(KitError::from)
855    }
856
857    /// Request cancellation of a pending or running index build.
858    pub fn cancel_index_build(&self, job_id: u64) -> Result<()> {
859        self.inner
860            .job_registry()
861            .cancel(job_id)
862            .map_err(mongreldb_core::MongrelError::from)
863            .map_err(KitError::from)
864    }
865
866    /// Read durable state and progress for one index build.
867    pub fn index_build(&self, job_id: u64) -> Result<mongreldb_core::JobRecord> {
868        let record = self
869            .inner
870            .job_registry()
871            .get(job_id)
872            .ok_or_else(|| KitError::Validation(format!("job {job_id} not found")))?;
873        if record.kind != mongreldb_core::JobKind::IndexBuild {
874            return Err(KitError::Validation(format!(
875                "job {job_id} is not an index build"
876            )));
877        }
878        Ok(record)
879    }
880
881    /// Wait without polling until an index build reaches a terminal state.
882    pub fn wait_index_build(
883        &self,
884        job_id: u64,
885        timeout: std::time::Duration,
886    ) -> Result<mongreldb_core::JobRecord> {
887        self.inner
888            .job_registry()
889            .wait_terminal(job_id, timeout)
890            .map_err(mongreldb_core::MongrelError::from)
891            .map_err(KitError::from)
892    }
893
894    /// Application table names, excluding the reserved `__kit_*` tables.
895    pub fn table_names(&self) -> Vec<String> {
896        self.schema
897            .tables
898            .iter()
899            .map(|t| t.name.clone())
900            .filter(|n| !n.starts_with("__kit_"))
901            .collect()
902    }
903
904    pub fn create_procedure(
905        &self,
906        spec: &ProcedureSpec,
907    ) -> Result<mongreldb_core::StoredProcedure> {
908        let procedure = core_procedure(spec)?;
909        self.inner
910            .create_procedure(procedure)
911            .map_err(KitError::from)
912    }
913
914    pub fn replace_procedure(
915        &self,
916        spec: &ProcedureSpec,
917    ) -> Result<mongreldb_core::StoredProcedure> {
918        let procedure = core_procedure(spec)?;
919        self.inner
920            .create_or_replace_procedure(procedure)
921            .map_err(KitError::from)
922    }
923
924    pub fn drop_procedure(&self, name: &str) -> Result<()> {
925        self.inner.drop_procedure(name).map_err(KitError::from)
926    }
927
928    pub fn call_procedure(
929        &self,
930        name: &str,
931        args: serde_json::Map<String, Value>,
932    ) -> Result<mongreldb_core::ProcedureCallResult> {
933        let args = args
934            .iter()
935            .map(|(key, value)| Ok((key.clone(), json_to_core_value(value)?)))
936            .collect::<Result<HashMap<_, _>>>()?;
937        self.inner
938            .call_procedure(name, args)
939            .map_err(KitError::from)
940    }
941
942    pub fn create_trigger(&self, spec: &TriggerSpec) -> Result<mongreldb_core::StoredTrigger> {
943        let trigger = core_trigger(spec)?;
944        self.inner.create_trigger(trigger).map_err(KitError::from)
945    }
946
947    pub fn replace_trigger(&self, spec: &TriggerSpec) -> Result<mongreldb_core::StoredTrigger> {
948        let trigger = core_trigger(spec)?;
949        self.inner
950            .create_or_replace_trigger(trigger)
951            .map_err(KitError::from)
952    }
953
954    pub fn drop_trigger(&self, name: &str) -> Result<()> {
955        self.inner.drop_trigger(name).map_err(KitError::from)
956    }
957
958    pub fn triggers(&self) -> Vec<mongreldb_core::StoredTrigger> {
959        self.inner.triggers()
960    }
961
962    pub fn trigger(&self, name: &str) -> Option<mongreldb_core::StoredTrigger> {
963        self.inner.trigger(name)
964    }
965
966    /// Allocate `count` values from the named sequence, returning the first
967    /// allocated value. A fresh sequence starts at `1` (SQL AUTO_INCREMENT
968    /// semantics). The allocation
969    /// runs in its own committed transaction and retries on write-write
970    /// conflicts.
971    pub fn allocate_sequence(&self, name: &str, count: i64) -> Result<i64> {
972        use crate::internal::cols;
973        let _guard = self.sequence_lock.lock();
974        let mut attempt = 0;
975        loop {
976            let mut txn = self.inner.begin();
977            let snapshot = txn.read_snapshot();
978            let existing = self
979                .visible_core_rows_at(crate::internal::SEQUENCES, snapshot)?
980                .into_iter()
981                .find(|r| internal_bytes(r, cols::SEQ_NAME) == Some(name.to_string()));
982
983            let now = crate::internal::iso_now();
984            // Sequences are 1-based, matching SQL AUTO_INCREMENT / SERIAL. A
985            // starting value of 0 is unsafe: applications use 0 as the "unset"
986            // sentinel for nullable foreign keys.
987            let (start, next, old_row_id) = match &existing {
988                Some(row) => {
989                    let current = match row.columns.get(&cols::SEQ_NEXT) {
990                        Some(CoreValue::Int64(i)) => *i,
991                        _ => 1,
992                    };
993                    (current, current + count, Some(row.row_id))
994                }
995                None => (1, 1 + count, None),
996            };
997
998            if let Some(rid) = old_row_id {
999                txn.delete(crate::internal::SEQUENCES, rid)
1000                    .map_err(KitError::from)?;
1001            }
1002            txn.put(
1003                crate::internal::SEQUENCES,
1004                vec![
1005                    (cols::SEQ_NAME, CoreValue::Bytes(name.as_bytes().to_vec())),
1006                    (cols::SEQ_NEXT, CoreValue::Int64(next)),
1007                    (cols::SEQ_UPDATED, CoreValue::Bytes(now.into_bytes())),
1008                ],
1009            )
1010            .map_err(KitError::from)?;
1011            match txn.commit() {
1012                Ok(_) => return Ok(start),
1013                Err(mongreldb_core::MongrelError::Conflict(_)) if attempt < 10_000 => {
1014                    attempt += 1;
1015                    std::thread::yield_now();
1016                    continue;
1017                }
1018                Err(e) => return Err(KitError::from(e)),
1019            }
1020        }
1021    }
1022
1023    /// Run `f` inside a kit transaction, committing on success and retrying on
1024    /// retryable write-write conflicts up to `max_retries` times.
1025    pub fn transaction<T, F>(&self, max_retries: usize, mut f: F) -> Result<T>
1026    where
1027        F: FnMut(&mut crate::txn::Transaction<'_>) -> Result<T>,
1028    {
1029        let mut attempt = 0;
1030        loop {
1031            let mut txn = self.begin()?;
1032            match f(&mut txn) {
1033                Ok(value) => match txn.commit() {
1034                    Ok(()) => return Ok(value),
1035                    Err(KitError::Conflict(_)) if attempt < max_retries => {
1036                        attempt += 1;
1037                        continue;
1038                    }
1039                    Err(e) => return Err(e),
1040                },
1041                Err(KitError::Conflict(_)) if attempt < max_retries => {
1042                    txn.rollback();
1043                    attempt += 1;
1044                    continue;
1045                }
1046                Err(e) => {
1047                    txn.rollback();
1048                    return Err(e);
1049                }
1050            }
1051        }
1052    }
1053
1054    /// Look up a table definition by name.
1055    pub fn table(&self, name: &str) -> Option<&KitTable> {
1056        self.schema.table(name)
1057    }
1058
1059    /// Return the currently loaded schema.
1060    pub fn schema(&self) -> &KitSchema {
1061        &self.schema
1062    }
1063
1064    /// Begin a new kit transaction.
1065    pub fn begin(&self) -> Result<crate::txn::Transaction<'_>> {
1066        let core_txn = self.inner.begin();
1067        Ok(crate::txn::Transaction::new(self, core_txn))
1068    }
1069
1070    /// Replace the in-memory schema, usually after a migration.
1071    pub fn set_schema(&mut self, schema: KitSchema) {
1072        self.schema = schema;
1073    }
1074
1075    /// Verify that the sidecar schema file and all reserved `__kit_*` tables
1076    /// are present.
1077    pub fn check_internal_tables(&self) -> Result<()> {
1078        let schema_file = self.root.join(SCHEMA_FILE);
1079        if !schema_file.exists() {
1080            return Err(KitError::Integrity(format!(
1081                "schema file {} is missing",
1082                schema_file.display()
1083            )));
1084        }
1085        for (name, _) in internal_tables_core() {
1086            if self.inner.table_id(name).is_err() {
1087                return Err(KitError::Integrity(format!(
1088                    "internal table {name} is missing"
1089                )));
1090            }
1091        }
1092        Ok(())
1093    }
1094
1095    /// Reclaim orphaned runs and stale WAL/shadow files; returns the count
1096    /// removed. Safe to run on a live database.
1097    pub fn gc(&self) -> Result<usize> {
1098        self.inner.gc().map_err(KitError::from)
1099    }
1100
1101    /// Verify run footer checksums; returns any integrity issues as JSON objects
1102    /// (`table_id`, `table_name`, `severity`, `description`). Empty ⇒ healthy.
1103    pub fn check(&self) -> Vec<serde_json::Value> {
1104        self.inner
1105            .check()
1106            .into_iter()
1107            .map(|i| {
1108                serde_json::json!({
1109                    "table_id": i.table_id,
1110                    "table_name": i.table_name,
1111                    "severity": i.severity,
1112                    "description": i.description,
1113                })
1114            })
1115            .collect()
1116    }
1117
1118    /// Drop corrupt runs; returns the ids of the runs that were dropped.
1119    pub fn doctor(&self) -> Result<Vec<u64>> {
1120        self.inner.doctor().map_err(KitError::from)
1121    }
1122
1123    /// The current visible commit epoch — a monotonically increasing version
1124    /// stamp. A committed write bumps it; a snapshot at this epoch sees all
1125    /// currently-committed data.
1126    pub fn snapshot_epoch(&self) -> u64 {
1127        self.inner.snapshot().0.epoch.0
1128    }
1129
1130    pub fn set_history_retention_epochs(&self, epochs: u64) -> Result<()> {
1131        self.inner
1132            .set_history_retention_epochs(epochs)
1133            .map_err(KitError::from)
1134    }
1135
1136    pub fn history_retention_epochs(&self) -> u64 {
1137        self.inner.history_retention_epochs()
1138    }
1139
1140    pub fn earliest_retained_epoch(&self) -> u64 {
1141        self.inner.earliest_retained_epoch().0
1142    }
1143
1144    /// Export every visible row of `table` as a TSV document (header row of
1145    /// column names, tab-separated cells, `NULL` = empty field). See
1146    /// [`crate::tsv`] for the escaping rules.
1147    pub fn export_tsv(&self, table: &str) -> Result<String> {
1148        let t = self
1149            .schema
1150            .tables
1151            .iter()
1152            .find(|t| t.name == table)
1153            .ok_or_else(|| KitError::Validation(format!("unknown table '{table}'")))?
1154            .clone();
1155        let tx = self.begin()?;
1156        let rows = tx.all_rows(table)?;
1157        Ok(crate::tsv::rows_to_tsv(&t, &rows))
1158    }
1159
1160    /// Import a TSV document into `table` (one committed transaction). Each row
1161    /// passes through defaults, validation, and constraint checks like a normal
1162    /// insert. Returns the number of rows inserted.
1163    pub fn import_tsv(&self, table: &str, text: &str) -> Result<usize> {
1164        let t = self
1165            .schema
1166            .tables
1167            .iter()
1168            .find(|t| t.name == table)
1169            .ok_or_else(|| KitError::Validation(format!("unknown table '{table}'")))?
1170            .clone();
1171        let rows = crate::tsv::tsv_to_rows(&t, text)?;
1172        let n = rows.len();
1173        self.transaction(1, |tx| {
1174            tx.insert_many(table, rows.clone())?;
1175            Ok(())
1176        })?;
1177        Ok(n)
1178    }
1179
1180    /// Describe how `predicate` would be executed against `table`: which native
1181    /// index conditions push down, whether the push-down is exact (no residual
1182    /// re-filtering), and whether any index acceleration applies at all. A pure
1183    /// diagnostic — it plans but does not run the query.
1184    pub fn explain(
1185        &self,
1186        table: &str,
1187        predicate: &mongreldb_kit_core::query::Expr,
1188    ) -> Result<ExplainPlan> {
1189        let t = self
1190            .schema
1191            .tables
1192            .iter()
1193            .find(|t| t.name == table)
1194            .ok_or_else(|| KitError::Validation(format!("unknown table '{table}'")))?;
1195        Ok(match crate::pushdown::translate_predicate(t, predicate) {
1196            Some(p) => ExplainPlan {
1197                index_accelerated: p.can_push(),
1198                exact: p.fully_translated,
1199                pushed_conditions: p.conditions.iter().map(condition_label).collect(),
1200            },
1201            None => ExplainPlan {
1202                index_accelerated: false,
1203                exact: false,
1204                pushed_conditions: Vec::new(),
1205            },
1206        })
1207    }
1208
1209    /// Read every row of `table` visible at commit `epoch` — a point-in-time
1210    /// (MVCC time-travel) read. `epoch` must not exceed the current snapshot
1211    /// epoch. Rows reclaimed by GC/compaction for retired snapshots may no
1212    /// longer be reconstructable; this reads whatever the engine still retains
1213    /// at that epoch.
1214    pub fn rows_at_epoch(&self, table: &str, epoch: u64) -> Result<Vec<crate::schema::Row>> {
1215        let t = self
1216            .schema
1217            .tables
1218            .iter()
1219            .find(|t| t.name == table)
1220            .ok_or_else(|| KitError::Validation(format!("unknown table '{table}'")))?;
1221        let current = self.snapshot_epoch();
1222        if epoch > current {
1223            return Err(KitError::Validation(format!(
1224                "epoch {epoch} is in the future (current committed epoch is {current})"
1225            )));
1226        }
1227        let snap = Snapshot::at(mongreldb_core::epoch::Epoch(epoch));
1228        let rows = self.visible_core_rows_at(table, snap)?;
1229        rows.iter()
1230            .map(|r| crate::schema::core_row_to_json(r, t))
1231            .collect()
1232    }
1233
1234    /// Estimate an aggregate over `table` from the engine's reservoir sample,
1235    /// returning a point estimate and a `z`-score confidence interval (e.g.
1236    /// `z = 1.96` for ~95%). `column` is required for `Sum`/`Avg` and ignored
1237    /// for `Count`. Returns `None` when the reservoir is empty (no sampled rows
1238    /// yet). Fast and O(sample) — trades exactness for speed on large tables.
1239    pub fn approx_aggregate(
1240        &self,
1241        table: &str,
1242        column: Option<&str>,
1243        agg: ApproxAggKind,
1244        z: f64,
1245    ) -> Result<Option<ApproxAggregate>> {
1246        let t = self
1247            .schema
1248            .tables
1249            .iter()
1250            .find(|t| t.name == table)
1251            .ok_or_else(|| KitError::Validation(format!("unknown table '{table}'")))?;
1252        if matches!(agg, ApproxAggKind::Sum | ApproxAggKind::Avg) && column.is_none() {
1253            return Err(KitError::Validation(
1254                "approx sum/avg requires a column".into(),
1255            ));
1256        }
1257        let cid = match column {
1258            Some(name) => Some(
1259                t.columns
1260                    .iter()
1261                    .find(|c| c.name == name)
1262                    .ok_or_else(|| KitError::Validation(format!("unknown column '{name}'")))?
1263                    .id as u16,
1264            ),
1265            None => None,
1266        };
1267        let core_agg = match agg {
1268            ApproxAggKind::Count => ApproxAgg::Count,
1269            ApproxAggKind::Sum => ApproxAgg::Sum,
1270            ApproxAggKind::Avg => ApproxAgg::Avg,
1271        };
1272        let handle = self.inner.table(table).map_err(KitError::from)?;
1273        let mut guard = handle.lock();
1274        let res = guard
1275            .approx_aggregate(&[], cid, core_agg, z)
1276            .map_err(KitError::from)?;
1277        Ok(res.map(|r| ApproxAggregate {
1278            point: r.point,
1279            ci_low: r.ci_low,
1280            ci_high: r.ci_high,
1281            n_population: r.n_population,
1282            n_sample_live: r.n_sample_live,
1283            n_passing: r.n_passing,
1284        }))
1285    }
1286
1287    /// Stream `table` in row batches without materializing the whole table at
1288    /// once. `f` receives successive chunks of at most `batch_size` value-maps,
1289    /// all from one snapshot. Backed by the engine's native scan cursor when the
1290    /// table has a sorted run; for an overlay-only table (no run yet) it falls
1291    /// back to a single in-memory pass, still chunked to `batch_size`.
1292    pub fn scan_batched<F>(&self, table: &str, batch_size: usize, mut f: F) -> Result<()>
1293    where
1294        F: FnMut(&[serde_json::Map<String, Value>]) -> Result<()>,
1295    {
1296        let kit_t = self
1297            .schema
1298            .tables
1299            .iter()
1300            .find(|t| t.name == table)
1301            .ok_or_else(|| KitError::Validation(format!("unknown table '{table}'")))?;
1302        let batch_size = batch_size.max(1);
1303        // Keep the pin guard alive for the whole scan so GC can't reclaim the
1304        // snapshot's versions mid-stream.
1305        let (snapshot, _pin) = self.inner.snapshot();
1306        let handle = self.inner.table(table).map_err(KitError::from)?;
1307        let guard = handle.lock();
1308
1309        // Projection + per-column (name, kit type), index-aligned, in core order.
1310        let mut projection: Vec<(u16, mongreldb_core::schema::TypeId)> = Vec::new();
1311        let mut meta: Vec<(String, mongreldb_kit_core::schema::ColumnType)> = Vec::new();
1312        for c in &guard.schema().columns {
1313            if let Some(kc) = kit_t.columns.iter().find(|kc| kc.id as u16 == c.id) {
1314                projection.push((c.id, c.ty.clone()));
1315                meta.push((kc.name.clone(), kc.storage_type));
1316            }
1317        }
1318
1319        match guard
1320            .scan_cursor(snapshot, projection, &[])
1321            .map_err(KitError::from)?
1322        {
1323            Some(mut cursor) => {
1324                let mut buf: Vec<serde_json::Map<String, Value>> = Vec::with_capacity(batch_size);
1325                while let Some(batch) = cursor.next_batch().map_err(KitError::from)? {
1326                    let nrows = batch.first().map(|c| c.len()).unwrap_or(0);
1327                    for j in 0..nrows {
1328                        let mut m = serde_json::Map::new();
1329                        for (ci, (name, ty)) in meta.iter().enumerate() {
1330                            let cv = batch
1331                                .get(ci)
1332                                .and_then(|col| col.value_at(j))
1333                                .unwrap_or(CoreValue::Null);
1334                            m.insert(name.clone(), crate::schema::core_to_json(&cv, *ty)?);
1335                        }
1336                        buf.push(m);
1337                        if buf.len() >= batch_size {
1338                            f(&buf)?;
1339                            buf.clear();
1340                        }
1341                    }
1342                }
1343                if !buf.is_empty() {
1344                    f(&buf)?;
1345                }
1346                Ok(())
1347            }
1348            None => {
1349                drop(guard);
1350                let rows = self.visible_core_rows_at(table, snapshot)?;
1351                let maps: Vec<serde_json::Map<String, Value>> = rows
1352                    .iter()
1353                    .map(|r| crate::schema::core_row_to_json(r, kit_t).map(|row| row.values))
1354                    .collect::<Result<Vec<_>>>()?;
1355                for chunk in maps.chunks(batch_size) {
1356                    f(chunk)?;
1357                }
1358                Ok(())
1359            }
1360        }
1361    }
1362
1363    /// Rank rows of `table` by Jaccard set-similarity between `query` and the
1364    /// string set stored (as a JSON array) in `column`, returning the top `k`
1365    /// with similarity `> 0`, highest first — the dedup/join primitive.
1366    ///
1367    /// When `column` has a `MinHash` index, candidate rows come from the engine's
1368    /// LSH index (sub-linear) and are then re-verified with exact Jaccard, so the
1369    /// top-k is exact for the recalled candidates (LSH recall is high but < 100%).
1370    /// Without an index it is an exact linear scan.
1371    pub fn set_similarity(
1372        &self,
1373        table: &str,
1374        column: &str,
1375        query: &[String],
1376        k: usize,
1377    ) -> Result<Vec<SimilarRow>> {
1378        let t = self
1379            .schema
1380            .tables
1381            .iter()
1382            .find(|t| t.name == table)
1383            .ok_or_else(|| KitError::Validation(format!("unknown table '{table}'")))?;
1384        let col = t.columns.iter().find(|c| c.name == column).ok_or_else(|| {
1385            KitError::Validation(format!("unknown column '{column}' on table '{table}'"))
1386        })?;
1387        let query_set: std::collections::HashSet<String> = query.iter().cloned().collect();
1388
1389        let has_minhash = t.indexes.iter().any(|idx| {
1390            idx.kind == KitIndexKind::MinHash && idx.columns.iter().any(|c| c == column)
1391        });
1392        let rows = if has_minhash {
1393            // Sub-linear candidate generation via the engine MinHash/LSH index.
1394            let query_hashes: Vec<u64> = query
1395                .iter()
1396                .map(|s| mongreldb_core::index::minhash_token_hash(s))
1397                .collect();
1398            // Generous candidate budget so exact top-k keeps high recall.
1399            let cand_k = k.saturating_mul(8).max(k + 64);
1400            let cond = mongreldb_core::query::Condition::MinHashSimilar {
1401                column_id: col.id as u16,
1402                query: query_hashes,
1403                k: cand_k,
1404            };
1405            let (snapshot, _pin) = self.inner.snapshot();
1406            let core_rows = self.query_core_rows_at(table, &[cond], snapshot)?;
1407            core_rows
1408                .iter()
1409                .map(|r| crate::schema::core_row_to_json(r, t))
1410                .collect::<Result<Vec<_>>>()?
1411        } else {
1412            let tx = self.begin()?;
1413            tx.all_rows(table)?
1414        };
1415
1416        let mut scored: Vec<SimilarRow> = Vec::new();
1417        for row in rows {
1418            let set = parse_string_set(row.values.get(column));
1419            let inter = set.iter().filter(|x| query_set.contains(*x)).count();
1420            let union = set.len() + query_set.len() - inter;
1421            let sim = if union == 0 {
1422                0.0
1423            } else {
1424                inter as f64 / union as f64
1425            };
1426            if sim > 0.0 {
1427                scored.push(SimilarRow {
1428                    row,
1429                    similarity: sim,
1430                });
1431            }
1432        }
1433        scored.sort_by(|a, b| {
1434            b.similarity
1435                .partial_cmp(&a.similarity)
1436                .unwrap_or(std::cmp::Ordering::Equal)
1437        });
1438        scored.truncate(k);
1439        Ok(scored)
1440    }
1441
1442    /// Flush every table's in-memory writes to durable sorted runs. Besides
1443    /// durability, this empties the memtable, which is what enables the engine's
1444    /// incremental-aggregate fast path (see [`Self::incremental_aggregate`]).
1445    pub fn flush(&self) -> Result<()> {
1446        for name in self.inner.table_names() {
1447            let handle = self.inner.table(&name).map_err(KitError::from)?;
1448            let mut guard = handle.lock();
1449            guard.flush().map_err(KitError::from)?;
1450        }
1451        Ok(())
1452    }
1453
1454    /// Maintain and read an aggregate over `table` that updates by merging only
1455    /// newly-committed rows instead of rescanning. `column` is required for
1456    /// `Sum`/`Min`/`Max`/`Avg` and ignored for `Count`. An optional `filter`
1457    /// restricts the aggregate; it must translate **exactly** to index
1458    /// conditions (no residual), otherwise this errors — an inexact filter would
1459    /// silently aggregate the wrong rows.
1460    ///
1461    /// The engine keeps a per-`(table, column, agg, filter)` cached state and,
1462    /// on a warm cache with an advanced epoch and no deletes/pending writes,
1463    /// folds in just the delta. The returned `value` is always exact; the
1464    /// `incremental` flag reports whether the fast path was taken.
1465    pub fn incremental_aggregate(
1466        &self,
1467        table: &str,
1468        column: Option<&str>,
1469        agg: IncrementalAggKind,
1470        filter: Option<&mongreldb_kit_core::query::Expr>,
1471    ) -> Result<IncrementalAggregate> {
1472        let t = self
1473            .schema
1474            .tables
1475            .iter()
1476            .find(|t| t.name == table)
1477            .ok_or_else(|| KitError::Validation(format!("unknown table '{table}'")))?;
1478        if !matches!(agg, IncrementalAggKind::Count) && column.is_none() {
1479            return Err(KitError::Validation(
1480                "sum/min/max/avg incremental aggregate requires a column".into(),
1481            ));
1482        }
1483        let cid = match column {
1484            Some(name) => Some(
1485                t.columns
1486                    .iter()
1487                    .find(|c| c.name == name)
1488                    .ok_or_else(|| KitError::Validation(format!("unknown column '{name}'")))?
1489                    .id as u16,
1490            ),
1491            None => None,
1492        };
1493        let conditions = match filter {
1494            Some(expr) => {
1495                let plan = crate::pushdown::translate_predicate(t, expr).ok_or_else(|| {
1496                    KitError::Validation(
1497                        "filter is not index-translatable for an incremental aggregate".into(),
1498                    )
1499                })?;
1500                if !plan.fully_translated {
1501                    return Err(KitError::Validation(
1502                        "filter has a residual that an incremental aggregate cannot apply exactly"
1503                            .into(),
1504                    ));
1505                }
1506                plan.conditions
1507            }
1508            None => Vec::new(),
1509        };
1510        let core_agg = match agg {
1511            IncrementalAggKind::Count => NativeAgg::Count,
1512            IncrementalAggKind::Sum => NativeAgg::Sum,
1513            IncrementalAggKind::Min => NativeAgg::Min,
1514            IncrementalAggKind::Max => NativeAgg::Max,
1515            IncrementalAggKind::Avg => NativeAgg::Avg,
1516        };
1517        let cache_key = incremental_cache_key(t.id, cid, agg, &conditions);
1518        let handle = self.inner.table(table).map_err(KitError::from)?;
1519        let mut guard = handle.lock();
1520        let res = guard
1521            .aggregate_incremental(cache_key, &conditions, cid, core_agg)
1522            .map_err(KitError::from)?;
1523        Ok(IncrementalAggregate {
1524            value: agg_state_value(&res.state),
1525            incremental: res.incremental,
1526            delta_rows: res.delta_rows,
1527        })
1528    }
1529
1530    /// Return the migrations already recorded in `__kit_schema_migrations`.
1531    pub fn applied_migrations(&self) -> Result<Vec<mongreldb_kit_core::migrations::Migration>> {
1532        crate::migrate::load_applied_migrations(&self.inner)
1533    }
1534
1535    pub(crate) fn core_db(&self) -> &CoreDatabase {
1536        &self.inner
1537    }
1538
1539    /// The underlying engine handle wrapped in an `Arc`, for callers that need
1540    /// a shared/owned reference (e.g. building a `MongrelSession`).
1541    pub(crate) fn core_arc(&self) -> Arc<CoreDatabase> {
1542        Arc::clone(&self.inner)
1543    }
1544
1545    /// Best-effort flush-on-close (§4.4): force-flush pending writes on every
1546    /// table to `.sr` sorted runs so WAL segments stay bounded across repeated
1547    /// short-lived process invocations (e.g. the CLI). Call as the last action
1548    /// before exit. The daemon does not need this (auto-compactor handles it).
1549    pub fn close(&self) -> Result<()> {
1550        self.inner.close().map_err(KitError::from)
1551    }
1552
1553    /// Compact all tables: merge sorted runs into one clean run each so query
1554    /// latency stays flat. Returns `(compacted, skipped)`. Safe to run at any
1555    /// time — honors snapshot retention. The daemon's background auto-compactor
1556    /// already does this periodically; this is for manual/cron use.
1557    pub fn compact_all(&self) -> Result<(usize, usize)> {
1558        self.inner.compact().map_err(KitError::from)
1559    }
1560
1561    /// Compact a single table by name. Returns `true` if compacted, `false` if
1562    /// skipped (< 2 runs).
1563    pub fn compact_table(&self, name: &str) -> Result<bool> {
1564        self.inner.compact_table(name).map_err(KitError::from)
1565    }
1566
1567    /// Rename a live table. Fails if `from` does not exist or `to` is already
1568    /// in use; a no-op when `from == to`. Names beginning with `__kit_` are
1569    /// reserved for internal tables and rejected here (parity with the
1570    /// TypeScript kit).
1571    ///
1572    /// Updates both the engine table and the kit schema catalog (in memory and
1573    /// persisted to `kit_schema.json`), so subsequent `table_names()`,
1574    /// `table(name)`, and transactional reads by the new name all work. Foreign
1575    /// keys in other tables that reference `from` are retargeted to `to`.
1576    pub fn rename_table(&mut self, from: &str, to: &str) -> Result<()> {
1577        if from.starts_with("__kit_") || to.starts_with("__kit_") {
1578            return Err(KitError::Validation(
1579                "rename_table: names beginning with '__kit_' are reserved for internal tables"
1580                    .into(),
1581            ));
1582        }
1583        self.inner.rename_table(from, to).map_err(KitError::from)?;
1584        // Keep the kit schema catalog in sync: rename the table (updating the
1585        // by_name index), retarget any FKs that pointed at it, then persist.
1586        if !self.schema.rename_table(from, to) {
1587            // The engine renamed it but the kit schema didn't have it / had a
1588            // clash — surface the divergence rather than silently desyncing.
1589            return Err(KitError::Integrity(format!(
1590                "rename_table: kit schema has no table '{from}' (or '{to}' already exists)"
1591            )));
1592        }
1593        for table in &mut self.schema.tables {
1594            for fk in &mut table.foreign_keys {
1595                if fk.references_table == from {
1596                    fk.references_table = to.to_string();
1597                }
1598            }
1599        }
1600        store_schema(&self.root, &self.schema)?;
1601        Ok(())
1602    }
1603
1604    /// Rebuild statistics/metadata for every table's indexes (the engine's
1605    /// `ANALYZE` equivalent: `ensure_indexes_complete` on each table). Safe to
1606    /// run at any time; useful after bulk loads so the query planner and
1607    /// learned indexes have fresh data.
1608    pub fn analyze(&self) -> Result<()> {
1609        for name in self.inner.table_names() {
1610            let handle = self.inner.table(&name).map_err(KitError::from)?;
1611            handle.lock().ensure_indexes_complete()?;
1612        }
1613        Ok(())
1614    }
1615
1616    /// Reclaim space across all tables: compacts every table's sorted runs,
1617    /// then runs `gc`. Returns the count of reclaimed orphaned runs/files.
1618    /// Equivalent to the engine's `VACUUM`. Safe to run at any time.
1619    pub fn vacuum(&self) -> Result<usize> {
1620        self.inner.compact().map_err(KitError::from)?;
1621        self.inner.gc().map_err(KitError::from)
1622    }
1623
1624    /// Create a SQL view (`CREATE VIEW <name> AS <select>`). The engine
1625    /// overwrites any existing view with the same name, so this also serves as
1626    /// replace. The view lives in the kit's long-lived SQL session — it is not
1627    /// persisted to the catalog, so reopening the database loses it (re-apply
1628    /// a `CreateView` migration to restore).
1629    pub fn create_view(&self, spec: &ViewSpec) -> Result<()> {
1630        self.sql(&spec.create_sql())?;
1631        Ok(())
1632    }
1633
1634    /// Drop a SQL view by name (idempotent — `DROP VIEW IF EXISTS`).
1635    pub fn drop_view(&self, name: &str) -> Result<()> {
1636        self.sql(&format!("DROP VIEW IF EXISTS {name}"))?;
1637        Ok(())
1638    }
1639
1640    /// Reserve (without inserting) the next engine-native `AUTO_INCREMENT` value
1641    /// for `table`, advancing the per-table counter. Returns `None` when the
1642    /// table has no auto-increment column. This is the escape hatch for callers
1643    /// that stage a row with an explicit id inside a transaction; the
1644    /// reservation becomes durable when a row carrying the id commits, and an
1645    /// unused reservation just leaves a gap. Parity with the TypeScript kit's
1646    /// `reserveAutoIncSync`.
1647    pub fn reserve_auto_inc(&self, table: &str) -> Result<Option<i64>> {
1648        let handle = self.inner.table(table).map_err(KitError::from)?;
1649        let mut guard = handle.lock();
1650        guard.reserve_auto_inc().map_err(KitError::from)
1651    }
1652
1653    // ── user/role/credentials management ─────────────────────────────────
1654
1655    /// Create a catalog user with an Argon2id-hashed password.
1656    pub fn create_user(&self, username: &str, password: &str) -> Result<()> {
1657        self.inner
1658            .create_user(username, password)
1659            .map_err(KitError::from)?;
1660        Ok(())
1661    }
1662
1663    /// Drop a user by username.
1664    pub fn drop_user(&self, username: &str) -> Result<()> {
1665        self.inner.drop_user(username).map_err(KitError::from)
1666    }
1667
1668    /// Change a user's password.
1669    pub fn alter_user_password(&self, username: &str, new_password: &str) -> Result<()> {
1670        self.inner
1671            .alter_user_password(username, new_password)
1672            .map_err(KitError::from)
1673    }
1674
1675    /// Verify credentials. Returns `Some(entry)` on success.
1676    pub fn verify_user(
1677        &self,
1678        username: &str,
1679        password: &str,
1680    ) -> Result<Option<mongreldb_core::auth::UserEntry>> {
1681        self.inner
1682            .verify_user(username, password)
1683            .map_err(KitError::from)
1684    }
1685
1686    /// Grant or revoke admin privileges on a user.
1687    pub fn set_user_admin(&self, username: &str, is_admin: bool) -> Result<()> {
1688        self.inner
1689            .set_user_admin(username, is_admin)
1690            .map_err(KitError::from)
1691    }
1692
1693    /// List all usernames.
1694    pub fn users(&self) -> Vec<String> {
1695        self.inner.users().into_iter().map(|u| u.username).collect()
1696    }
1697
1698    /// Create a role.
1699    pub fn create_role(&self, name: &str) -> Result<()> {
1700        self.inner.create_role(name).map_err(KitError::from)?;
1701        Ok(())
1702    }
1703
1704    /// Drop a role.
1705    pub fn drop_role(&self, name: &str) -> Result<()> {
1706        self.inner.drop_role(name).map_err(KitError::from)
1707    }
1708
1709    /// List all role names.
1710    pub fn roles(&self) -> Vec<String> {
1711        self.inner.roles().into_iter().map(|r| r.name).collect()
1712    }
1713
1714    /// Grant a role to a user.
1715    pub fn grant_role(&self, username: &str, role_name: &str) -> Result<()> {
1716        self.inner
1717            .grant_role(username, role_name)
1718            .map_err(KitError::from)
1719    }
1720
1721    /// Revoke a role from a user.
1722    pub fn revoke_role(&self, username: &str, role_name: &str) -> Result<()> {
1723        self.inner
1724            .revoke_role(username, role_name)
1725            .map_err(KitError::from)
1726    }
1727
1728    /// Grant a permission to a role.
1729    pub fn grant_permission(
1730        &self,
1731        role_name: &str,
1732        permission: mongreldb_core::auth::Permission,
1733    ) -> Result<()> {
1734        self.inner
1735            .grant_permission(role_name, permission)
1736            .map_err(KitError::from)
1737    }
1738
1739    /// Revoke a permission from a role.
1740    pub fn revoke_permission(
1741        &self,
1742        role_name: &str,
1743        permission: mongreldb_core::auth::Permission,
1744    ) -> Result<()> {
1745        self.inner
1746            .revoke_permission(role_name, permission)
1747            .map_err(KitError::from)
1748    }
1749
1750    // ── storage tuning & introspection (Tier 3) ─────────────────────────────
1751
1752    /// Set the per-table spill threshold (bytes). When a transaction's staged
1753    /// bytes for a single table exceed this, rows are written as a uniform-epoch
1754    /// pending run instead of streamed Put records.
1755    pub fn set_spill_threshold(&self, bytes: u64) {
1756        self.inner.set_spill_threshold(bytes);
1757    }
1758
1759    /// Enable or disable recursive trigger execution (database-wide).
1760    pub fn set_recursive_triggers(&self, enabled: bool) {
1761        self.inner.set_recursive_triggers(enabled);
1762    }
1763
1764    /// Read the current trigger execution policy.
1765    pub fn trigger_config(&self) -> mongreldb_core::TriggerConfig {
1766        self.inner.trigger_config()
1767    }
1768
1769    /// Set the trigger execution policy. `max_depth` must be > 0.
1770    pub fn set_trigger_config(&self, config: mongreldb_core::TriggerConfig) -> Result<()> {
1771        self.inner
1772            .set_trigger_config(config)
1773            .map_err(KitError::from)
1774    }
1775
1776    /// Set a table's compaction zstd level (-1 = default, 0 = none, 1..22).
1777    pub fn set_table_compaction_zstd_level(&self, table: &str, level: i32) -> Result<()> {
1778        let handle = self.inner.table(table).map_err(KitError::from)?;
1779        handle.lock().set_compaction_zstd_level(level);
1780        Ok(())
1781    }
1782
1783    /// Set a table's result-cache max bytes.
1784    pub fn set_table_result_cache_max_bytes(&self, table: &str, max_bytes: u64) -> Result<()> {
1785        let handle = self.inner.table(table).map_err(KitError::from)?;
1786        handle.lock().set_result_cache_max_bytes(max_bytes);
1787        Ok(())
1788    }
1789
1790    /// Set a table's mutable-run spill threshold (bytes).
1791    pub fn set_table_mutable_run_spill_bytes(&self, table: &str, bytes: u64) -> Result<()> {
1792        let handle = self.inner.table(table).map_err(KitError::from)?;
1793        handle.lock().set_mutable_run_spill_bytes(bytes);
1794        Ok(())
1795    }
1796
1797    /// Set a table's WAL sync byte threshold (bytes between group-syncs).
1798    pub fn set_table_sync_byte_threshold(&self, table: &str, threshold: u64) -> Result<()> {
1799        let handle = self.inner.table(table).map_err(KitError::from)?;
1800        handle.lock().set_sync_byte_threshold(threshold);
1801        Ok(())
1802    }
1803
1804    /// Set a table's index build policy (`Deferred` for fast ingest, `Eager`
1805    /// for fast first query).
1806    pub fn set_table_index_build_policy(
1807        &self,
1808        table: &str,
1809        policy: mongreldb_core::IndexBuildPolicy,
1810    ) -> Result<()> {
1811        let handle = self.inner.table(table).map_err(KitError::from)?;
1812        handle.lock().set_index_build_policy(policy);
1813        Ok(())
1814    }
1815
1816    /// Page-cache statistics for a table.
1817    pub fn table_page_cache_stats(&self, table: &str) -> Result<mongreldb_core::cache::CacheStats> {
1818        let handle = self.inner.table(table).map_err(KitError::from)?;
1819        let stats = handle.lock().page_cache_stats();
1820        Ok(stats)
1821    }
1822
1823    /// Number of sorted runs a table currently has (compaction target: 1).
1824    pub fn table_run_count(&self, table: &str) -> Result<usize> {
1825        let handle = self.inner.table(table).map_err(KitError::from)?;
1826        let n = handle.lock().run_count();
1827        Ok(n)
1828    }
1829
1830    /// Memtable length (uncommitted staged rows) for a table.
1831    pub fn table_memtable_len(&self, table: &str) -> Result<usize> {
1832        let handle = self.inner.table(table).map_err(KitError::from)?;
1833        let n = handle.lock().memtable_len();
1834        Ok(n)
1835    }
1836
1837    /// Mutable-run length for a table.
1838    pub fn table_mutable_run_len(&self, table: &str) -> Result<usize> {
1839        let handle = self.inner.table(table).map_err(KitError::from)?;
1840        let n = handle.lock().mutable_run_len();
1841        Ok(n)
1842    }
1843
1844    /// Page-cache entry count for a table.
1845    pub fn table_page_cache_len(&self, table: &str) -> Result<usize> {
1846        let handle = self.inner.table(table).map_err(KitError::from)?;
1847        let n = handle.lock().page_cache_len();
1848        Ok(n)
1849    }
1850
1851    /// Decoded-page-cache entry count for a table.
1852    pub fn table_decoded_cache_len(&self, table: &str) -> Result<usize> {
1853        let handle = self.inner.table(table).map_err(KitError::from)?;
1854        let n = handle.lock().decoded_cache_len();
1855        Ok(n)
1856    }
1857
1858    /// Run a SQL statement through the embedded `MongrelSession` (DataFusion
1859    /// frontend) and return the result as Arrow [`RecordBatch`]es. This is the
1860    /// Rust analogue of the TypeScript kit's `db.sql(sql)` (which returns Arrow
1861    /// IPC bytes) and the NAPI `Database.sql`.
1862    ///
1863    /// Read statements return their rows; DDL/DML (e.g. `CREATE VIEW`,
1864    /// `ANALYZE`, `VACUUM`, `CREATE VIRTUAL TABLE`) return an empty vec. Writes
1865    /// made directly through SQL bypass Kit-level constraints (defaults,
1866    /// enums, min/max, length, regex, triggers) — use the transactional
1867    /// [`Transaction`](crate::Transaction) API for constrained writes. The
1868    /// engine's own declarative constraints (unique, FK, check) still apply.
1869    ///
1870    /// The session is held for the database's lifetime, so session-scoped
1871    /// objects (views, prepared statements, the result cache) persist across
1872    /// calls — mirroring a long-lived database connection. After a migration
1873    /// that creates/drops tables, call [`Database::refresh_sql_session`] so the
1874    /// session sees the new table set.
1875    pub fn sql(&self, statement: &str) -> Result<Vec<arrow::record_batch::RecordBatch>> {
1876        self.sql_with_options(statement, SqlOptions::default())
1877    }
1878
1879    fn sql_session(&self) -> Result<Arc<mongreldb_query::MongrelSession>> {
1880        if let Some(session) = self.session.read().as_ref() {
1881            return Ok(Arc::clone(session));
1882        }
1883        let session = Arc::new(
1884            mongreldb_query::MongrelSession::open(self.core_arc()).map_err(KitError::from)?,
1885        );
1886        let mut cached = self.session.write();
1887        Ok(Arc::clone(cached.get_or_insert(session)))
1888    }
1889
1890    #[doc(hidden)]
1891    pub fn set_sql_test_hook(&self, hook: Option<mongreldb_query::SqlTestHook>) -> Result<()> {
1892        self.sql_session()?.set_test_hook(hook);
1893        Ok(())
1894    }
1895
1896    pub fn start_sql(
1897        &self,
1898        statement: impl Into<String>,
1899        options: SqlOptions,
1900    ) -> Result<SqlQueryHandle> {
1901        let session = self.sql_session()?;
1902        let query = session
1903            .register_query(mongreldb_query::SqlQueryOptions {
1904                query_id: options.query_id,
1905                timeout: options.timeout,
1906                ..mongreldb_query::SqlQueryOptions::default()
1907            })
1908            .map_err(KitError::from)?;
1909        let query_id = query.id();
1910        let registration = mongreldb_query::RegisteredQueryGuard::new(query);
1911        let worker_session = Arc::clone(&session);
1912        let statement = statement.into();
1913        let worker = std::thread::Builder::new()
1914            .name(format!("mongreldb-kit-sql-{query_id}"))
1915            .spawn(move || {
1916                sql_runtime().block_on(
1917                    worker_session
1918                        .run_with_query_for_serialization(&statement, registration.into_query()),
1919                )
1920            })
1921            .map_err(|error| KitError::Storage(error.to_string()))?;
1922        Ok(SqlQueryHandle {
1923            query_id,
1924            session,
1925            worker: Some(worker),
1926        })
1927    }
1928
1929    pub fn sql_with_options(
1930        &self,
1931        statement: &str,
1932        options: SqlOptions,
1933    ) -> Result<Vec<arrow::record_batch::RecordBatch>> {
1934        self.start_sql(statement, options)?.wait()
1935    }
1936
1937    pub fn cancel_sql(&self, query_id: mongreldb_query::QueryId) -> mongreldb_query::CancelOutcome {
1938        self.session
1939            .read()
1940            .as_ref()
1941            .map_or(mongreldb_query::CancelOutcome::NotFound, |session| {
1942                session.cancel_query(query_id)
1943            })
1944    }
1945
1946    pub fn sql_query_status(
1947        &self,
1948        query_id: mongreldb_query::QueryId,
1949    ) -> Result<Option<mongreldb_query::QueryStatus>> {
1950        Ok(self.sql_session()?.query_registry().status(query_id))
1951    }
1952
1953    /// (Re)build the cached SQL session so it sees the current table set. The
1954    /// engine's `MongrelSession` snapshots the table list at construction; this
1955    /// rebuilds it after a migration creates or drops tables. Views and other
1956    /// session-scoped state are reset.
1957    pub fn refresh_sql_session(&self) -> Result<()> {
1958        let session =
1959            mongreldb_query::MongrelSession::open(self.core_arc()).map_err(KitError::from)?;
1960        *self.session.write() = Some(Arc::new(session));
1961        Ok(())
1962    }
1963
1964    /// Like [`Database::sql`], but returns the result serialized as Arrow IPC
1965    /// *file* bytes — the same wire format the NAPI addon and the daemon emit.
1966    /// Decode with `pyarrow.ipc.open_file`, the JS `apache-arrow`
1967    /// `tableFromIPC`, or [`crate::arrow_util::read_arrow_ipc`]. Empty for
1968    /// DDL/DML.
1969    pub fn sql_arrow(&self, statement: &str) -> Result<Vec<u8>> {
1970        self.sql_arrow_with_options(statement, SqlOptions::default())
1971    }
1972
1973    pub fn sql_arrow_with_options(&self, statement: &str, options: SqlOptions) -> Result<Vec<u8>> {
1974        self.sql_serialized_with_options(statement, options, |output| {
1975            crate::arrow_util::batches_to_ipc_controlled(output.batches(), output.query())
1976        })
1977    }
1978
1979    /// Like [`Database::sql`], but materializes the result rows into JSON-style
1980    /// maps (column name → value) for callers that don't want to take a direct
1981    /// Arrow dependency. Empty for DDL/DML.
1982    pub fn sql_rows(&self, statement: &str) -> Result<Vec<serde_json::Map<String, Value>>> {
1983        self.sql_rows_with_options(statement, SqlOptions::default())
1984    }
1985
1986    pub fn sql_rows_with_options(
1987        &self,
1988        statement: &str,
1989        options: SqlOptions,
1990    ) -> Result<Vec<serde_json::Map<String, Value>>> {
1991        self.sql_serialized_with_options(statement, options, |output| {
1992            crate::arrow_util::batches_to_rows_controlled(output.batches(), output.query())
1993        })
1994    }
1995
1996    fn sql_serialized_with_options<T>(
1997        &self,
1998        statement: &str,
1999        options: SqlOptions,
2000        serialize: impl FnOnce(&mongreldb_query::ManagedQueryBatches) -> Result<T>,
2001    ) -> Result<T> {
2002        let session = self.sql_session()?;
2003        let query = session
2004            .register_query(mongreldb_query::SqlQueryOptions {
2005                query_id: options.query_id,
2006                timeout: options.timeout,
2007                ..mongreldb_query::SqlQueryOptions::default()
2008            })
2009            .map_err(KitError::from)?;
2010        let query_id = query.id();
2011        let output = sql_runtime()
2012            .block_on(session.run_with_query_for_serialization(statement, query))
2013            .map_err(|error| {
2014                let status = session.query_registry().status(query_id);
2015                crate::error::query_error_with_status(error, status.as_ref())
2016            })?;
2017        session.fire_test_hook(mongreldb_query::SqlTestHookPoint::BeforeSerializationBatch);
2018        match serialize(&output) {
2019            Ok(value) => {
2020                complete_sql_output(output)?;
2021                Ok(value)
2022            }
2023            Err(error) => {
2024                fail_sql_output(output, &error);
2025                Err(error)
2026            }
2027        }
2028    }
2029
2030    /// Direct HOT (PK → RowId) lookup via the core engine — no full-row
2031    /// materialization. Used by the §4.3 delete fast path when the table
2032    /// has no Kit-level constraints requiring guard cleanup.
2033    pub(crate) fn lookup_row_id(&self, table: &str, key: &[u8]) -> Result<Option<RowId>> {
2034        let handle = self.inner.table(table).map_err(KitError::from)?;
2035        let mut guard = handle.lock();
2036        guard.ensure_indexes_complete()?;
2037        Ok(guard.lookup_pk(key))
2038    }
2039
2040    pub(crate) fn root(&self) -> &Path {
2041        &self.root
2042    }
2043
2044    /// All visible core rows for a table at a specific read snapshot. Used so
2045    /// kit transactions read at their own snapshot (repeatable reads) rather
2046    /// than the latest committed state.
2047    pub(crate) fn visible_core_rows_at(
2048        &self,
2049        table_name: &str,
2050        snapshot: Snapshot,
2051    ) -> Result<Vec<CoreRow>> {
2052        let handle = self.inner.table(table_name).map_err(KitError::from)?;
2053        let guard = handle.lock();
2054        guard.visible_rows(snapshot).map_err(KitError::from)
2055    }
2056
2057    /// Query visible core rows with native `Condition`s at a specific read
2058    /// snapshot (Kit Priority 1 pushdown). Resolves `conditions` via core's
2059    /// indexes (HOT / bitmap / range) and returns only the matching rows —
2060    /// avoiding the full scan that `visible_core_rows_at` does. Returns the
2061    /// empty vec when no conditions match, and falls back to
2062    /// `visible_core_rows_at` when `conditions` is empty (unfiltered).
2063    pub(crate) fn query_core_rows_at(
2064        &self,
2065        table_name: &str,
2066        conditions: &[mongreldb_core::query::Condition],
2067        snapshot: Snapshot,
2068    ) -> Result<Vec<CoreRow>> {
2069        if conditions.is_empty() {
2070            return self.visible_core_rows_at(table_name, snapshot);
2071        }
2072        let handle = self.inner.table(table_name).map_err(KitError::from)?;
2073        let mut guard = handle.lock();
2074        let q = conditions
2075            .iter()
2076            .cloned()
2077            .fold(mongreldb_core::query::Query::new(), |query, condition| {
2078                query.and(condition)
2079            });
2080        guard.query(&q).map_err(KitError::from)
2081    }
2082
2083    /// Drain `table`'s memtable into the mutable-run tier, spilling to a
2084    /// durable, checkpointed `.sr` run once the tier crosses its watermark.
2085    /// Called after a large batch commit (see `Transaction::commit`) so a
2086    /// short-lived process (the CLI, or any fresh `Database::open`) isn't
2087    /// stuck replaying the whole batch from the WAL on its next invocation —
2088    /// without a flush, committed-but-unflushed writes only exist as WAL
2089    /// records and must be fully replayed to rebuild the in-memory indexes.
2090    pub(crate) fn flush_table(&self, table_name: &str) -> Result<()> {
2091        let handle = self.inner.table(table_name).map_err(KitError::from)?;
2092        handle.lock().flush().map_err(KitError::from)?;
2093        Ok(())
2094    }
2095
2096    /// Count visible rows matching `conditions` without materializing them
2097    /// (Kit Priority 7 pushdown). Returns `None` when the conditions cannot be
2098    /// served by indexes, or when `snapshot` is not the latest committed epoch
2099    /// (caller falls back to a snapshot-correct row scan).
2100    ///
2101    /// `count_conditions` counts the engine's latest committed index state, not
2102    /// a snapshot-filtered scan, so it only matches a repeatable-read row count
2103    /// when the read snapshot IS the latest epoch. We hold the table lock while
2104    /// comparing, so no commit can interleave between the check and the count.
2105    pub(crate) fn count_core_rows_at(
2106        &self,
2107        table_name: &str,
2108        conditions: &[mongreldb_core::query::Condition],
2109        snapshot: Snapshot,
2110    ) -> Result<Option<u64>> {
2111        let handle = self.inner.table(table_name).map_err(KitError::from)?;
2112        let mut guard = handle.lock();
2113        if guard.snapshot().epoch != snapshot.epoch {
2114            return Ok(None); // stale read snapshot ⇒ caller scans
2115        }
2116        guard
2117            .count_conditions(conditions, snapshot)
2118            .map_err(KitError::from)
2119    }
2120
2121    /// Compute `SUM`/`MIN`/`MAX`/`AVG`/`COUNT(col)` over a column without
2122    /// materializing rows (Kit Priority 7), via the engine's native aggregate.
2123    /// `column` is the engine column id. Returns `None` when the engine fast
2124    /// path does not apply (multi-run / non-empty overlay / non-numeric column),
2125    /// or when `snapshot` is not the latest committed epoch — the same
2126    /// guarantee as [`count_core_rows_at`](Self::count_core_rows_at): the engine
2127    /// aggregate matches a snapshot-consistent row scan only at the latest epoch,
2128    /// and we compare under the table lock so no commit can interleave.
2129    pub(crate) fn aggregate_core_at(
2130        &self,
2131        table_name: &str,
2132        column: Option<u16>,
2133        conditions: &[mongreldb_core::query::Condition],
2134        agg: NativeAgg,
2135        snapshot: Snapshot,
2136    ) -> Result<Option<NativeAggResult>> {
2137        let handle = self.inner.table(table_name).map_err(KitError::from)?;
2138        let guard = handle.lock();
2139        if guard.snapshot().epoch != snapshot.epoch {
2140            return Ok(None); // stale read snapshot ⇒ caller scans
2141        }
2142        guard
2143            .aggregate_native(snapshot, column, conditions, agg)
2144            .map_err(KitError::from)
2145    }
2146
2147    /// `COUNT(DISTINCT col)` from the bitmap index's partition cardinality (Kit
2148    /// Priority 7) — the number of distinct indexed values, no scan. Returns
2149    /// `None` without a bitmap index on the column, when the table is not
2150    /// insert-only, or when `snapshot` is not the latest committed epoch. The
2151    /// engine method reads the latest committed index state (no snapshot
2152    /// parameter), so — as with [`count_core_rows_at`](Self::count_core_rows_at)
2153    /// — it only matches a repeatable-read scan at the latest epoch; we compare
2154    /// under the table lock so no commit can interleave.
2155    pub(crate) fn count_distinct_core_at(
2156        &self,
2157        table_name: &str,
2158        column_id: u16,
2159        snapshot: Snapshot,
2160    ) -> Result<Option<u64>> {
2161        let handle = self.inner.table(table_name).map_err(KitError::from)?;
2162        let mut guard = handle.lock();
2163        if guard.snapshot().epoch != snapshot.epoch {
2164            return Ok(None); // stale read snapshot ⇒ caller scans
2165        }
2166        guard
2167            .count_distinct_from_bitmap(column_id)
2168            .map_err(KitError::from)
2169    }
2170
2171    /// Materialize a single row by storage row id.
2172    #[allow(dead_code)]
2173    pub(crate) fn get_core_row(&self, table_name: &str, row_id: u64) -> Result<Option<CoreRow>> {
2174        let handle = self.inner.table(table_name).map_err(KitError::from)?;
2175        let guard = handle.lock();
2176        let snapshot = guard.snapshot();
2177        Ok(guard.get(mongreldb_core::RowId(row_id), snapshot))
2178    }
2179}
2180
2181fn one_core_index(table: &KitTable, index: &KitIndex) -> Result<mongreldb_core::IndexDef> {
2182    let mut definitions = to_core_indexes(table, index)?;
2183    if definitions.len() != 1 {
2184        return Err(KitError::Validation(format!(
2185            "online index jobs require exactly one column; index {:?} has {}",
2186            index.name,
2187            definitions.len()
2188        )));
2189    }
2190    Ok(definitions.remove(0))
2191}
2192
2193pub(crate) fn create_core_table(db: &CoreDatabase, name: &str, schema: CoreSchema) -> Result<()> {
2194    if db.table_id(name).is_ok() {
2195        return Ok(());
2196    }
2197    db.create_table(name, schema).map_err(KitError::from)?;
2198    Ok(())
2199}
2200
2201/// A cached tokio runtime for driving `MongrelSession` from the kit's blocking
2202/// SQL surface. Multiple workers let independent database sessions progress
2203/// when one query is paused inside a synchronous DataFusion hook.
2204fn sql_runtime() -> &'static tokio::runtime::Runtime {
2205    use std::sync::OnceLock;
2206    static RT: OnceLock<tokio::runtime::Runtime> = OnceLock::new();
2207    RT.get_or_init(|| {
2208        tokio::runtime::Builder::new_multi_thread()
2209            .worker_threads(4)
2210            .enable_all()
2211            .build()
2212            .expect("failed to build kit SQL tokio runtime")
2213    })
2214}
2215
2216fn core_procedure(spec: &ProcedureSpec) -> Result<mongreldb_core::StoredProcedure> {
2217    let parsed: mongreldb_core::StoredProcedure =
2218        serde_json::from_value(spec.json.clone()).map_err(KitError::from)?;
2219    mongreldb_core::StoredProcedure::new(parsed.name, parsed.mode, parsed.params, parsed.body, 0)
2220        .map_err(KitError::from)
2221}
2222
2223fn core_trigger(spec: &TriggerSpec) -> Result<mongreldb_core::StoredTrigger> {
2224    let parsed: mongreldb_core::StoredTrigger =
2225        serde_json::from_value(spec.json.clone()).map_err(KitError::from)?;
2226    mongreldb_core::StoredTrigger::new(
2227        parsed.name,
2228        mongreldb_core::TriggerDefinition {
2229            target: parsed.target,
2230            timing: parsed.timing,
2231            event: parsed.event,
2232            update_of: parsed.update_of,
2233            target_columns: parsed.target_columns,
2234            when: parsed.when,
2235            program: parsed.program,
2236        },
2237        0,
2238    )
2239    .map_err(KitError::from)
2240}
2241
2242fn json_to_core_value(value: &Value) -> Result<CoreValue> {
2243    match value {
2244        Value::Null => Ok(CoreValue::Null),
2245        Value::Bool(value) => Ok(CoreValue::Bool(*value)),
2246        Value::Number(value) => {
2247            if let Some(value) = value.as_i64() {
2248                Ok(CoreValue::Int64(value))
2249            } else if let Some(value) = value.as_f64() {
2250                Ok(CoreValue::Float64(value))
2251            } else {
2252                Err(KitError::Validation("unsupported JSON number".into()))
2253            }
2254        }
2255        Value::String(value) => Ok(CoreValue::Bytes(value.as_bytes().to_vec())),
2256        Value::Array(_) | Value::Object(_) => Err(KitError::Validation(
2257            "procedure args only support scalar JSON values".into(),
2258        )),
2259    }
2260}
2261
2262/// Read a `Bytes` column from an internal-table core row as a UTF-8 string.
2263pub(crate) fn internal_bytes(row: &CoreRow, col_id: u16) -> Option<String> {
2264    match row.columns.get(&col_id) {
2265        Some(CoreValue::Bytes(b)) => String::from_utf8(b.clone()).ok(),
2266        _ => None,
2267    }
2268}
2269
2270/// Best-effort: reap any WAL segments a previous session left rotated but
2271/// unreaped, now that this `open()` has minted a fresh active segment
2272/// (`SharedWal::open` never truncates prior segments on its own —
2273/// [`CoreDatabase::gc`] does, but only once every mounted table's data is
2274/// durable in runs). Called before any write in *this* session, so that
2275/// check reflects exactly what the previous session left behind: if that
2276/// session ended with everything flushed (e.g. a bulk `insert_many`
2277/// followed by `Transaction::commit`'s large-batch auto-flush), this is the
2278/// one moment the now-inactive segment holding that batch is actually
2279/// eligible for cleanup. Without it, a short-lived process (the CLI has no
2280/// daemon mode; every invocation opens cold) keeps paying to read and
2281/// deserialize that segment's records on every subsequent open, even though
2282/// none of them still need replaying. Errors are ignored — this is a
2283/// disk-usage/reopen-latency optimization, never a correctness requirement.
2284fn reap_rotated_wal_segments(db: &CoreDatabase) {
2285    let _ = db.gc();
2286}
2287
2288pub(crate) fn load_schema(path: &Path) -> Result<KitSchema> {
2289    let file = path.join(SCHEMA_FILE);
2290    let json = std::fs::read_to_string(&file)
2291        .map_err(|e| KitError::Migration(format!("cannot read schema file: {e}")))?;
2292    let schema: KitSchema = serde_json::from_str(&json)?;
2293    Ok(schema)
2294}
2295
2296pub(crate) fn store_schema(path: &Path, schema: &KitSchema) -> Result<()> {
2297    let file = path.join(SCHEMA_FILE);
2298    let json = serde_json::to_string_pretty(schema)?;
2299    std::fs::write(&file, json)?;
2300    Ok(())
2301}
2302
2303/// Persist a kit schema into the database. Used after migrations.
2304pub(crate) fn persist_schema(db: &Database, schema: &KitSchema) -> Result<()> {
2305    store_schema(&db.root, schema)
2306}
2307
2308#[cfg(test)]
2309mod tests {
2310    use super::open_core_with_retry;
2311
2312    fn lock_error() -> mongreldb_core::MongrelError {
2313        mongreldb_core::MongrelError::DatabaseLocked {
2314            path: "/tmp/db".into(),
2315            message: "would block".into(),
2316        }
2317    }
2318
2319    #[test]
2320    fn open_retry_waits_for_lock_contention_only() {
2321        let mut calls = 0;
2322        let value = open_core_with_retry(50, || {
2323            calls += 1;
2324            if calls < 3 {
2325                Err(lock_error())
2326            } else {
2327                Ok(7)
2328            }
2329        })
2330        .unwrap();
2331        assert_eq!(value, 7);
2332        assert_eq!(calls, 3);
2333
2334        let mut non_lock_calls = 0;
2335        let err: mongreldb_core::Result<()> = open_core_with_retry(50, || {
2336            non_lock_calls += 1;
2337            Err(mongreldb_core::MongrelError::Other("nope".into()))
2338        });
2339        let err = err.unwrap_err();
2340        assert_eq!(non_lock_calls, 1);
2341        assert!(matches!(err, mongreldb_core::MongrelError::Other(_)));
2342    }
2343}