Skip to main content

mongreldb_kit/
db.rs

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