Skip to main content

mongreldb_core/
database.rs

1//! Multi-table `Database` container (spec §5, §6, §10).
2//!
3//! Owns the shared services — catalog, dual-counter epoch authority, shared
4//! raw/decoded page caches, snapshot-retention registry, and the DB-wide KEK —
5//! and mounts per-table [`Table`] engines under `tables/<id>/` that borrow them.
6//! P1 scope: per-table WALs remain (collapsed into one shared WAL in P2); the
7//! win here is one consistent commit clock across tables and one reopen path.
8
9use crate::catalog::{self, Catalog, CatalogEntry, TableState, META_DEK_LEN};
10use crate::engine::{SharedCtx, Table};
11use crate::epoch::{Epoch, EpochAuthority, Snapshot};
12use crate::error::{MongrelError, Result};
13use crate::external_table::ExternalTableEntry;
14use crate::memtable::Value;
15use crate::procedure::{
16    ProcedureCallOutput, ProcedureCallResult, ProcedureCallRow, ProcedureCondition, ProcedureEntry,
17    ProcedureStep, ProcedureValue, StoredProcedure,
18};
19use crate::retention::{OwnedSnapshotGuard, SnapshotGuard, SnapshotRegistry};
20use crate::rowid::RowId;
21use crate::schema::{AlterColumn, ColumnDef, Schema, TypeId};
22use crate::trigger::{
23    StoredTrigger, TriggerCondition, TriggerConfig, TriggerEntry, TriggerEvent, TriggerExpr,
24    TriggerRaiseAction, TriggerStep, TriggerTarget, TriggerTiming, TriggerValue,
25};
26use parking_lot::{Mutex, RwLock};
27use std::collections::{HashMap, HashSet, VecDeque};
28use std::io::Write;
29use std::path::{Path, PathBuf};
30use std::sync::atomic::{AtomicBool, AtomicU32, AtomicUsize};
31use std::sync::Arc;
32
33pub const TABLES_DIR: &str = "tables";
34pub const VTAB_DIR: &str = "_vtab";
35pub const META_DIR: &str = "_meta";
36pub const KEYS_FILENAME: &str = "keys";
37pub const HISTORY_RETENTION_FILENAME: &str = "history_retention";
38
39/// Sentinel `table_id` for `CheckIssue`s that concern the shared WAL rather
40/// than any table. `u64::MAX` is never allocated to a real table (the catalog
41/// mints ids from 0 upward), so [`Database::doctor`] can safely skip them.
42pub const WAL_TABLE_ID: u64 = u64::MAX;
43/// Sentinel `table_id` for `CheckIssue`s that concern external-table module
44/// state instead of an ordinary table.
45pub const EXTERNAL_TABLE_ID: u64 = u64::MAX - 1;
46
47fn current_unix_nanos() -> u64 {
48    std::time::SystemTime::now()
49        .duration_since(std::time::UNIX_EPOCH)
50        .unwrap_or_default()
51        .as_nanos() as u64
52}
53
54fn read_history_retention(root: &Path, current_epoch: Epoch) -> Result<(u64, Epoch)> {
55    let path = root.join(META_DIR).join(HISTORY_RETENTION_FILENAME);
56    let text = match std::fs::read_to_string(path) {
57        Ok(text) => text,
58        Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
59            return Ok((0, current_epoch));
60        }
61        Err(error) => return Err(error.into()),
62    };
63    let mut fields = text.split_whitespace();
64    let epochs = fields
65        .next()
66        .ok_or_else(|| MongrelError::Other("history retention file is empty".into()))?
67        .parse::<u64>()
68        .map_err(|error| MongrelError::Other(format!("history retention epochs: {error}")))?;
69    let start = fields
70        .next()
71        .unwrap_or("0")
72        .parse::<u64>()
73        .map_err(|error| MongrelError::Other(format!("history retention start: {error}")))?;
74    Ok((epochs, Epoch(start)))
75}
76
77fn write_history_retention(root: &Path, epochs: u64, start: Epoch) -> Result<()> {
78    let meta = root.join(META_DIR);
79    std::fs::create_dir_all(&meta)?;
80    let path = meta.join(HISTORY_RETENTION_FILENAME);
81    let tmp = meta.join(format!("{HISTORY_RETENTION_FILENAME}.tmp"));
82    {
83        let mut file = std::fs::File::create(&tmp)?;
84        writeln!(file, "{epochs} {}", start.0)?;
85        file.sync_all()?;
86    }
87    std::fs::rename(tmp, path)?;
88    if let Ok(dir) = std::fs::File::open(meta) {
89        let _ = dir.sync_all();
90    }
91    Ok(())
92}
93
94fn prepare_backup_destination(
95    source: &Path,
96    destination: &Path,
97) -> Result<(PathBuf, PathBuf, PathBuf)> {
98    let source = source.canonicalize()?;
99    if destination.exists() {
100        return Err(MongrelError::Conflict(format!(
101            "backup destination already exists: {}",
102            destination.display()
103        )));
104    }
105    let name = destination
106        .file_name()
107        .ok_or_else(|| MongrelError::InvalidArgument("invalid backup destination".into()))?;
108    let requested_parent = destination
109        .parent()
110        .filter(|path| !path.as_os_str().is_empty())
111        .unwrap_or_else(|| Path::new("."));
112    std::fs::create_dir_all(requested_parent)?;
113    let parent = requested_parent.canonicalize()?;
114    if parent.starts_with(&source) {
115        return Err(MongrelError::InvalidArgument(
116            "backup destination must not be inside the source database".into(),
117        ));
118    }
119    let destination = parent.join(name);
120    let nonce = std::time::SystemTime::now()
121        .duration_since(std::time::UNIX_EPOCH)
122        .unwrap_or_default()
123        .as_nanos();
124    let stage = parent.join(format!(
125        ".{}.backup-stage-{}-{nonce}",
126        name.to_string_lossy(),
127        std::process::id()
128    ));
129    if stage.exists() {
130        return Err(MongrelError::Conflict(format!(
131            "backup staging path already exists: {}",
132            stage.display()
133        )));
134    }
135    Ok((destination, parent, stage))
136}
137
138fn copy_backup_boundary(
139    source_root: &Path,
140    destination_root: &Path,
141    deferred_runs: &HashSet<PathBuf>,
142    copied: &mut Vec<PathBuf>,
143) -> Result<()> {
144    fn visit(
145        source_root: &Path,
146        source: &Path,
147        destination_root: &Path,
148        deferred_runs: &HashSet<PathBuf>,
149        copied: &mut Vec<PathBuf>,
150    ) -> Result<()> {
151        let mut entries = std::fs::read_dir(source)?.collect::<std::io::Result<Vec<_>>>()?;
152        entries.sort_by_key(std::fs::DirEntry::file_name);
153        for entry in entries {
154            let path = entry.path();
155            let relative = path
156                .strip_prefix(source_root)
157                .map_err(|error| MongrelError::Other(format!("backup path: {error}")))?;
158            if backup_path_excluded(relative) {
159                continue;
160            }
161            let file_type = entry.file_type()?;
162            if file_type.is_symlink() {
163                return Err(MongrelError::InvalidArgument(format!(
164                    "backup refuses symlink {}",
165                    path.display()
166                )));
167            }
168            if file_type.is_dir() {
169                std::fs::create_dir_all(destination_root.join(relative))?;
170                visit(source_root, &path, destination_root, deferred_runs, copied)?;
171            } else if file_type.is_file() {
172                if deferred_runs.contains(relative) {
173                    continue;
174                }
175                if relative
176                    .parent()
177                    .and_then(Path::file_name)
178                    .is_some_and(|parent| parent == "_runs")
179                    && relative
180                        .extension()
181                        .is_some_and(|extension| extension == "sr")
182                {
183                    continue;
184                }
185                crate::backup::copy_file_synced(&path, &destination_root.join(relative))?;
186                copied.push(relative.to_path_buf());
187            }
188        }
189        Ok(())
190    }
191
192    std::fs::create_dir_all(destination_root)?;
193    visit(
194        source_root,
195        source_root,
196        destination_root,
197        deferred_runs,
198        copied,
199    )
200}
201
202fn backup_path_excluded(relative: &Path) -> bool {
203    relative == Path::new("_meta/.lock")
204        || relative == Path::new("_meta/replica")
205        || relative == Path::new("_meta/repl_epoch")
206        || relative == Path::new(crate::backup::BACKUP_MANIFEST_PATH)
207        || relative.components().any(|component| {
208            matches!(component, std::path::Component::Normal(name) if name == "_cache" || name == "_txn" || name == "backup-pins")
209        })
210}
211
212#[derive(Debug, Clone)]
213pub enum ExternalTriggerWrite {
214    Insert {
215        table: String,
216        cells: Vec<(u16, Value)>,
217    },
218    UpdateByPk {
219        table: String,
220        pk: Value,
221        cells: Vec<(u16, Value)>,
222    },
223    DeleteByPk {
224        table: String,
225        pk: Value,
226    },
227}
228
229impl ExternalTriggerWrite {
230    fn table(&self) -> &str {
231        match self {
232            Self::Insert { table, .. }
233            | Self::UpdateByPk { table, .. }
234            | Self::DeleteByPk { table, .. } => table,
235        }
236    }
237}
238
239#[derive(Debug, Clone, PartialEq)]
240pub enum ExternalTriggerBaseWrite {
241    Put {
242        table: String,
243        cells: Vec<(u16, Value)>,
244    },
245    Delete {
246        table: String,
247        row_id: RowId,
248    },
249}
250
251#[derive(Debug, Clone, PartialEq)]
252pub struct ExternalTriggerWriteResult {
253    pub state: Vec<u8>,
254    pub base_writes: Vec<ExternalTriggerBaseWrite>,
255}
256
257impl ExternalTriggerWriteResult {
258    pub fn new(state: Vec<u8>) -> Self {
259        Self {
260            state,
261            base_writes: Vec::new(),
262        }
263    }
264}
265
266pub trait ExternalTriggerBridge {
267    fn apply_trigger_external_write(
268        &self,
269        entry: &ExternalTableEntry,
270        base_state: Vec<u8>,
271        op: ExternalTriggerWrite,
272    ) -> Result<ExternalTriggerWriteResult>;
273}
274
275/// A pending uniform-epoch run written during a large transaction (spec §8.5).
276struct SpilledRun {
277    table_id: u64,
278    run_id: u128,
279    pending_path: PathBuf,
280    rows: Vec<crate::memtable::Row>,
281    row_count: u64,
282    min_rid: u64,
283    max_rid: u64,
284}
285
286#[derive(Debug, Clone)]
287struct TriggerRowImage {
288    columns: HashMap<u16, Value>,
289}
290
291impl TriggerRowImage {
292    fn from_row(row: crate::memtable::Row) -> Self {
293        Self {
294            columns: row.columns,
295        }
296    }
297
298    fn from_cells(cells: &[(u16, Value)]) -> Self {
299        Self {
300            columns: cells.iter().cloned().collect(),
301        }
302    }
303}
304
305#[derive(Debug, Clone)]
306struct WriteEvent {
307    table: String,
308    kind: TriggerEvent,
309    old: Option<TriggerRowImage>,
310    new: Option<TriggerRowImage>,
311    changed_columns: Vec<u16>,
312    op_indices: Vec<usize>,
313    put_idx: Option<usize>,
314    trigger_stack: Vec<String>,
315}
316
317#[derive(Default)]
318struct TriggerExpansion {
319    before: Vec<(u64, crate::txn::Staged)>,
320    before_stacks: Vec<Vec<String>>,
321    before_external: Vec<ExternalTriggerWrite>,
322    after: Vec<(u64, crate::txn::Staged)>,
323    after_stacks: Vec<Vec<String>>,
324    after_external: Vec<ExternalTriggerWrite>,
325    ignored_indices: std::collections::BTreeSet<usize>,
326}
327
328struct TriggerProgramOutput<'a> {
329    added: &'a mut Vec<(u64, crate::txn::Staged)>,
330    added_stacks: &'a mut Vec<Vec<String>>,
331    added_external: &'a mut Vec<ExternalTriggerWrite>,
332    ignored_indices: &'a mut std::collections::BTreeSet<usize>,
333}
334
335#[derive(Debug, Clone, Copy, PartialEq, Eq)]
336enum TriggerProgramOutcome {
337    Continue,
338    Ignore,
339}
340
341/// An integrity issue found by [`Database::check`] (spec §16).
342#[derive(Debug, Clone)]
343pub struct CheckIssue {
344    pub table_id: u64,
345    pub table_name: String,
346    pub severity: String,
347    pub description: String,
348}
349
350/// One optimistic authorization snapshot for a complete scored read.
351#[derive(Debug, Clone)]
352pub struct AuthorizedReadSnapshot {
353    pub table: String,
354    pub table_snapshot: Snapshot,
355    pub data_generation: u64,
356    pub security_version: u64,
357    pub allowed_row_ids: Option<HashSet<RowId>>,
358}
359
360type RlsCacheKey = (String, u64, u64, String);
361
362/// Runtime statistics for the byte-bounded RLS candidate cache.
363#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
364pub struct RlsCacheStats {
365    pub entries: usize,
366    pub bytes: usize,
367    pub hits: u64,
368    pub misses: u64,
369    pub evictions: u64,
370    pub build_nanos: u64,
371    pub rows_evaluated: u64,
372}
373
374const RLS_CACHE_MAX_BYTES: usize = 64 * 1024 * 1024;
375
376#[derive(Default)]
377struct RlsCache {
378    entries: HashMap<RlsCacheKey, (Arc<HashSet<RowId>>, usize)>,
379    lru: VecDeque<RlsCacheKey>,
380    bytes: usize,
381    hits: u64,
382    misses: u64,
383    evictions: u64,
384    build_nanos: u64,
385    rows_evaluated: u64,
386}
387
388impl RlsCache {
389    fn get(&mut self, key: &RlsCacheKey) -> Option<Arc<HashSet<RowId>>> {
390        let value = self.entries.get(key).map(|(value, _)| Arc::clone(value));
391        if value.is_some() {
392            self.hits = self.hits.saturating_add(1);
393            self.touch(key);
394        } else {
395            self.misses = self.misses.saturating_add(1);
396        }
397        value
398    }
399
400    fn insert(&mut self, key: RlsCacheKey, value: Arc<HashSet<RowId>>) {
401        let bytes = key
402            .0
403            .len()
404            .saturating_add(key.3.len())
405            .saturating_add(
406                value
407                    .capacity()
408                    .saturating_mul(std::mem::size_of::<RowId>() * 3),
409            )
410            .saturating_add(std::mem::size_of::<RlsCacheKey>());
411        if bytes > RLS_CACHE_MAX_BYTES {
412            return;
413        }
414        if let Some((_, old_bytes)) = self.entries.remove(&key) {
415            self.bytes = self.bytes.saturating_sub(old_bytes);
416        }
417        self.lru.retain(|candidate| candidate != &key);
418        while self.bytes.saturating_add(bytes) > RLS_CACHE_MAX_BYTES {
419            let Some(oldest) = self.lru.pop_front() else {
420                break;
421            };
422            if let Some((_, old_bytes)) = self.entries.remove(&oldest) {
423                self.bytes = self.bytes.saturating_sub(old_bytes);
424                self.evictions = self.evictions.saturating_add(1);
425            }
426        }
427        self.bytes = self.bytes.saturating_add(bytes);
428        self.lru.push_back(key.clone());
429        self.entries.insert(key, (value, bytes));
430    }
431
432    fn touch(&mut self, key: &RlsCacheKey) {
433        self.lru.retain(|candidate| candidate != key);
434        self.lru.push_back(key.clone());
435    }
436
437    fn stats(&self) -> RlsCacheStats {
438        RlsCacheStats {
439            entries: self.entries.len(),
440            bytes: self.bytes,
441            hits: self.hits,
442            misses: self.misses,
443            evictions: self.evictions,
444            build_nanos: self.build_nanos,
445            rows_evaluated: self.rows_evaluated,
446        }
447    }
448}
449
450/// Copy-on-write mounted table. Scored reads clone the current `Arc<Table>`;
451/// writers replace it only while a read generation is alive.
452#[derive(Clone)]
453pub struct TableHandle(TableHandleInner);
454
455#[derive(Clone)]
456enum TableHandleInner {
457    CopyOnWrite(Arc<RwLock<Arc<Table>>>),
458    Direct(Arc<Mutex<Table>>),
459}
460
461pub enum TableGuard<'a> {
462    CopyOnWrite(parking_lot::RwLockWriteGuard<'a, Arc<Table>>),
463    Direct(parking_lot::MutexGuard<'a, Table>),
464}
465
466enum TableReadGuard<'a> {
467    CopyOnWrite(parking_lot::RwLockReadGuard<'a, Arc<Table>>),
468    Direct(parking_lot::MutexGuard<'a, Table>),
469}
470
471impl TableHandle {
472    fn new(table: Table) -> Self {
473        Self(TableHandleInner::CopyOnWrite(Arc::new(RwLock::new(
474            Arc::new(table),
475        ))))
476    }
477
478    pub fn from_table(table: Table) -> Self {
479        Self::new(table)
480    }
481
482    pub fn lock(&self) -> TableGuard<'_> {
483        match &self.0 {
484            TableHandleInner::CopyOnWrite(table) => TableGuard::CopyOnWrite(table.write()),
485            TableHandleInner::Direct(table) => TableGuard::Direct(table.lock()),
486        }
487    }
488
489    fn try_lock_for(&self, timeout: std::time::Duration) -> Option<TableGuard<'_>> {
490        match &self.0 {
491            TableHandleInner::CopyOnWrite(table) => {
492                table.try_write_for(timeout).map(TableGuard::CopyOnWrite)
493            }
494            TableHandleInner::Direct(table) => table.try_lock_for(timeout).map(TableGuard::Direct),
495        }
496    }
497
498    fn read(&self) -> TableReadGuard<'_> {
499        match &self.0 {
500            TableHandleInner::CopyOnWrite(table) => TableReadGuard::CopyOnWrite(table.read()),
501            TableHandleInner::Direct(table) => TableReadGuard::Direct(table.lock()),
502        }
503    }
504
505    fn try_read_for(&self, timeout: std::time::Duration) -> Option<TableReadGuard<'_>> {
506        match &self.0 {
507            TableHandleInner::CopyOnWrite(table) => {
508                table.try_read_for(timeout).map(TableReadGuard::CopyOnWrite)
509            }
510            TableHandleInner::Direct(table) => {
511                table.try_lock_for(timeout).map(TableReadGuard::Direct)
512            }
513        }
514    }
515
516    pub fn ptr_eq(&self, other: &Self) -> bool {
517        match (&self.0, &other.0) {
518            (TableHandleInner::CopyOnWrite(left), TableHandleInner::CopyOnWrite(right)) => {
519                Arc::ptr_eq(left, right)
520            }
521            (TableHandleInner::Direct(left), TableHandleInner::Direct(right)) => {
522                Arc::ptr_eq(left, right)
523            }
524            _ => false,
525        }
526    }
527
528    pub fn read_generation_with_context(
529        &self,
530        context: Option<&crate::query::AiExecutionContext>,
531    ) -> Result<(Arc<Table>, Snapshot)> {
532        let mut table = if let Some(context) = context {
533            loop {
534                context.checkpoint()?;
535                let wait = context
536                    .remaining_duration()
537                    .unwrap_or(std::time::Duration::from_millis(5))
538                    .min(std::time::Duration::from_millis(5));
539                if let Some(table) = self.try_read_for(wait) {
540                    break table;
541                }
542            }
543        } else {
544            self.read()
545        };
546        let snapshot = table.snapshot();
547        Ok((table.generation()?, snapshot))
548    }
549}
550
551impl From<Arc<Mutex<Table>>> for TableHandle {
552    fn from(table: Arc<Mutex<Table>>) -> Self {
553        Self(TableHandleInner::Direct(table))
554    }
555}
556
557impl std::ops::Deref for TableGuard<'_> {
558    type Target = Table;
559
560    fn deref(&self) -> &Self::Target {
561        match self {
562            Self::CopyOnWrite(table) => table.as_ref(),
563            Self::Direct(table) => table,
564        }
565    }
566}
567
568impl std::ops::DerefMut for TableGuard<'_> {
569    fn deref_mut(&mut self) -> &mut Self::Target {
570        match self {
571            Self::CopyOnWrite(table) => Arc::make_mut(table),
572            Self::Direct(table) => table,
573        }
574    }
575}
576
577impl TableReadGuard<'_> {
578    fn generation(&mut self) -> Result<Arc<Table>> {
579        match self {
580            Self::CopyOnWrite(table) => Ok(Arc::clone(table)),
581            Self::Direct(table) => Ok(Arc::new(table.clone_read_generation()?)),
582        }
583    }
584}
585
586impl std::ops::Deref for TableReadGuard<'_> {
587    type Target = Table;
588
589    fn deref(&self) -> &Self::Target {
590        match self {
591            Self::CopyOnWrite(table) => table.as_ref(),
592            Self::Direct(table) => table,
593        }
594    }
595}
596
597#[derive(Clone, Debug)]
598pub struct ReadAuthorization {
599    pub operation: crate::auth::ColumnOperation,
600    pub columns: Vec<u16>,
601}
602
603pub fn lock_table_with_context<'a>(
604    handle: &'a TableHandle,
605    context: Option<&crate::query::AiExecutionContext>,
606) -> Result<TableGuard<'a>> {
607    let Some(context) = context else {
608        return Ok(handle.lock());
609    };
610    loop {
611        context.checkpoint()?;
612        let wait = context
613            .remaining_duration()
614            .unwrap_or(std::time::Duration::from_millis(5))
615            .min(std::time::Duration::from_millis(5));
616        if let Some(guard) = handle.try_lock_for(wait) {
617            return Ok(guard);
618        }
619    }
620}
621
622/// Knobs for [`Database::open_with_options`].
623///
624/// All fields default to the same values the convenience
625/// [`Database::open`] / [`Database::open_encrypted`] / etc. constructors use,
626/// so `OpenOptions::default()` round-trips the historical behavior exactly.
627#[derive(Clone, Debug, Default)]
628pub struct OpenOptions {
629    /// Maximum time, in milliseconds, to wait for the cross-process database
630    /// lock (`_meta/.lock`) before failing the open with `MongrelError::Io`.
631    ///
632    /// `0` (the default) preserves the historical fail-fast semantics: a
633    /// single `try_lock_exclusive` call, no retry, no sleep. SQLite-style
634    /// `busy_timeout` semantics kick in once this is non-zero — the open
635    /// sleeps with progressively wider backoff (1ms → 10ms → 50ms) until
636    /// either the lock is acquired or `lock_timeout_ms` elapses, at which
637    /// point the open returns the same `Io(WouldBlock)` error the fail-fast
638    /// path would.
639    ///
640    /// Only the cross-process lock is affected. Mounted tables, page-cache
641    /// misses, and WAL appends already serialize through in-process locks
642    /// that handle their own contention.
643    pub lock_timeout_ms: u32,
644}
645
646impl OpenOptions {
647    /// Set [`OpenOptions::lock_timeout_ms`]. `0` keeps the fail-fast default;
648    /// SQLite-style applications typically pick 1_000 – 5_000ms.
649    pub fn with_lock_timeout_ms(mut self, ms: u32) -> Self {
650        self.lock_timeout_ms = ms;
651        self
652    }
653}
654
655/// A multi-table database: one catalog, one epoch clock, shared caches, a
656/// shared WAL, and a live map of name → `Arc<Table>`.
657pub struct Database {
658    root: PathBuf,
659    /// Set by `_meta/replica`; user writes are rejected on follower copies.
660    read_only: bool,
661    catalog: RwLock<Catalog>,
662    rls_cache: Mutex<RlsCache>,
663    epoch: Arc<EpochAuthority>,
664    snapshots: Arc<SnapshotRegistry>,
665    page_cache: Arc<crate::cache::Sharded<crate::cache::PageCache>>,
666    decoded_cache: Arc<crate::cache::Sharded<crate::cache::DecodedPageCache>>,
667    commit_lock: Arc<Mutex<()>>,
668    /// One shared WAL multiplexing every table's records (spec §7.2). Owned
669    /// behind a `Mutex` so the transaction layer can append + group-sync. Shared
670    /// (via `Arc`) with every mounted `Table` so single-table `put`/`commit`
671    /// writes also land in this one WAL (B1 — one WAL per database).
672    shared_wal: Arc<Mutex<crate::wal::SharedWal>>,
673    /// Monotonic per-open transaction-id counter. Scoped by `open_generation`
674    /// in P2.7; here it just needs to be unique within an open. Shared with
675    /// mounted tables so their auto-commit txn ids never alias cross-table ones.
676    next_txn_id: Arc<Mutex<u64>>,
677    tables: RwLock<HashMap<u64, TableHandle>>,
678    kek: Option<Arc<crate::encryption::Kek>>,
679    /// Serializes DDL (create/drop table); data commits serialize through
680    /// `commit_lock` shared via `SharedCtx`.
681    ddl_lock: Mutex<()>,
682    meta_dek: Option<[u8; META_DEK_LEN]>,
683    /// P3.4: when staged bytes per table exceed this, write a uniform-epoch
684    /// pending run to `_txn/<txn_id>/` instead of streaming Put records (§8.5).
685    spill_threshold: std::sync::atomic::AtomicU64,
686    /// P3.1: write-key → commit_epoch for first-committer-wins conflict
687    /// detection (spec §9.2).
688    conflicts: crate::txn::ConflictIndex,
689    /// P3.1: min read_epoch of all in-flight txns, drives conflict-index
690    /// pruning (spec §9.2, review fix #12).
691    active_txns: crate::txn::ActiveTxns,
692    /// P3.2: set on fsync error — all subsequent writes fail fast (spec §9.3e).
693    /// Shared with mounted tables so a single-table commit also honors poison.
694    poisoned: Arc<std::sync::atomic::AtomicBool>,
695    /// P3.2: group-commit coordinator. The sequencer appends under the WAL lock
696    /// but defers the fsync to one leader here, so concurrent commits share a
697    /// single fsync (spec §9.3). Shared with mounted tables.
698    group: Arc<crate::txn::GroupCommit>,
699    /// P3.6: txn ids currently spilling into `_txn/<id>/`. GC never deletes a
700    /// live spill's pending dir (review fix #14, spec §6.4).
701    active_spills: Arc<crate::retention::ActiveSpills>,
702    /// A write lock captures a consistent bootstrap image; transaction commits
703    /// hold a read lock across spill preparation, WAL append, and publish.
704    replication_barrier: parking_lot::RwLock<()>,
705    /// Number of rotated WAL segments retained for lagging followers.
706    replication_wal_retention_segments: AtomicUsize,
707    /// Live immutable run files used by online backups or scored read
708    /// generations. GC cannot unlink them until every owning guard drops.
709    backup_pins: Arc<Mutex<HashMap<(u64, u128), usize>>>,
710    /// Test-only barrier invoked after a transaction writes its spill runs but
711    /// before the sequencer/publish, so tests can race `gc()` against an
712    /// in-flight spill. `None` in production.
713    #[doc(hidden)]
714    spill_hook: Mutex<Option<Box<dyn Fn() + Send + Sync>>>,
715    /// Test seam after a backup boundary is captured and before pinned runs are
716    /// copied. Lets tests compact+GC the source at the worst possible moment.
717    #[doc(hidden)]
718    backup_hook: Mutex<Option<Box<dyn Fn() + Send + Sync>>>,
719    trigger_recursive: AtomicBool,
720    trigger_max_depth: AtomicU32,
721    trigger_max_loop_iterations: AtomicU32,
722    /// Exclusive cross-process lock held for the database's lifetime to prevent
723    /// two processes from opening the same directory concurrently.
724    _lock: Option<std::fs::File>,
725    /// Lightweight channel for ephemeral SQL NOTIFY messages. Durable row CDC
726    /// is reconstructed from the WAL by [`Database::change_events_since`].
727    notify: tokio::sync::broadcast::Sender<ChangeEvent>,
728    /// Commit-time wake-up for durable CDC consumers. Payloads are reconstructed
729    /// from the WAL, so lagged receivers lose only a wake-up, never data.
730    change_wake: tokio::sync::broadcast::Sender<()>,
731    /// The authenticated principal for this handle. `None` on databases
732    /// opened without credentials (the default — `require_auth = false`),
733    /// `Some` on credentialed opens. Consulted by every enforcement point
734    /// when the catalog's `require_auth` flag is set. Behind an `RwLock`
735    /// because the access pattern is read-heavy: every `require()` call
736    /// reads the principal, while writes happen only at open, `enable_auth`,
737    /// and `refresh_principal`. This matches the engine's existing use of
738    /// `RwLock` for `catalog` and `tables`.
739    /// See `docs/15-credential-enforcement.md`.
740    principal: RwLock<Option<crate::auth::Principal>>,
741    /// Shared, cloneable handle to the auth state (require_auth flag from the
742    /// catalog + the principal). Cloned into every mounted `Table` so the
743    /// Table layer can enforce permissions without holding a reference back
744    /// to `Database` (which would create a cycle). `AuthState` is already
745    /// cheaply cloneable (inner `Arc`), so no outer `Arc` is needed.
746    auth_state: crate::auth_state::AuthState,
747}
748
749/// RAII guard that ensures an assigned epoch is resolved (published or
750/// abandoned) on every code path, including early `?` returns.
751///
752/// On drop, if not disarmed, calls [`EpochAuthority::abandon`] — the operation
753/// failed, so the epoch must not become visible to readers. On success, the
754/// caller calls [`EpochAuthority::publish_in_order`] and then
755/// [`Self::disarm`] to prevent the abandon.
756///
757/// This is the root-cause fix for the epoch-hole bug: previously, if a DDL or
758/// auth operation failed after `bump_assigned` but before `advance_visible`,
759/// the epoch was never published, permanently blocking the in-order watermark
760/// and making all subsequent queries return empty results.
761struct EpochGuard<'a> {
762    authority: &'a EpochAuthority,
763    epoch: Epoch,
764    armed: bool,
765}
766
767struct RunPins {
768    pins: Arc<Mutex<HashMap<(u64, u128), usize>>>,
769    runs: Vec<(u64, u128)>,
770}
771
772struct BackupFilePins {
773    root: PathBuf,
774}
775
776impl Drop for BackupFilePins {
777    fn drop(&mut self) {
778        let _ = std::fs::remove_dir_all(&self.root);
779    }
780}
781
782impl Drop for RunPins {
783    fn drop(&mut self) {
784        let mut pins = self.pins.lock();
785        for run in &self.runs {
786            if let Some(count) = pins.get_mut(run) {
787                *count -= 1;
788                if *count == 0 {
789                    pins.remove(run);
790                }
791            }
792        }
793    }
794}
795
796impl<'a> EpochGuard<'a> {
797    fn new(authority: &'a EpochAuthority, epoch: Epoch) -> Self {
798        Self {
799            authority,
800            epoch,
801            armed: true,
802        }
803    }
804
805    /// Mark the epoch as successfully published. Call this after
806    /// `publish_in_order` to prevent the guard from abandoning the epoch.
807    fn disarm(&mut self) {
808        self.armed = false;
809    }
810}
811
812impl Drop for EpochGuard<'_> {
813    fn drop(&mut self) {
814        if self.armed {
815            self.authority.abandon(self.epoch);
816        }
817    }
818}
819
820/// A durable data-change event reconstructed from committed WAL records, or an
821/// ephemeral SQL `NOTIFY` event when `id` is `None`.
822#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
823pub struct ChangeEvent {
824    pub id: Option<String>,
825    pub channel: String,
826    pub table_id: Option<u64>,
827    pub table: String,
828    pub op: String,
829    pub epoch: u64,
830    pub txn_id: Option<u64>,
831    pub message: Option<String>,
832    pub data: Option<serde_json::Value>,
833}
834
835#[derive(Debug, Clone)]
836pub struct CdcBatch {
837    pub events: Vec<ChangeEvent>,
838    pub current_epoch: u64,
839    pub earliest_epoch: Option<u64>,
840    pub gap: bool,
841}
842
843/// Manual `Debug` for `Database` — surfaces the diagnostics-relevant fields
844/// (root, epoch, table count, encryption/auth state) without requiring every
845/// internal type (Table, GroupCommit, broadcast sender, etc.) to impl Debug.
846/// The raw field types carry locks, trait objects, and channels that have no
847/// useful `Debug` output, so a hand-written impl is clearer than peppering
848/// `#[allow(dead_code)]` skip attributes across two dozen fields.
849impl std::fmt::Debug for Database {
850    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
851        let cat = self.catalog.read();
852        let principal_guard = self.principal.read();
853        let principal: &str = principal_guard
854            .as_ref()
855            .map(|p| p.username.as_str())
856            .unwrap_or("<none>");
857        f.debug_struct("Database")
858            .field("root", &self.root)
859            .field("db_epoch", &cat.db_epoch)
860            .field("open_generation", &"sidecar")
861            .field("tables", &cat.tables.len())
862            .field("visible_epoch", &self.epoch.visible().0)
863            .field("encrypted", &self.kek.is_some())
864            .field("require_auth", &cat.require_auth)
865            .field("principal", &principal)
866            .finish()
867    }
868}
869
870impl Database {
871    /// Create a fresh plaintext database at `root`.
872    pub fn create(root: impl AsRef<Path>) -> Result<Self> {
873        Self::create_inner(root, None)
874    }
875
876    /// Create a fresh encrypted database, deriving the DB-wide KEK from a
877    /// passphrase (Argon2id + HKDF). The salt is persisted at `_meta/keys`.
878    #[cfg(feature = "encryption")]
879    pub fn create_encrypted(root: impl AsRef<Path>, passphrase: &str) -> Result<Self> {
880        let root = root.as_ref();
881        Self::reject_existing_database(root)?;
882        std::fs::create_dir_all(root)?;
883        std::fs::create_dir_all(root.join(META_DIR))?;
884        let salt = crate::encryption::random_salt();
885        std::fs::write(root.join(META_DIR).join(KEYS_FILENAME), salt)?;
886        let kek = Arc::new(crate::encryption::Kek::derive(passphrase, &salt)?);
887        Self::create_inner(root, Some(kek))
888    }
889
890    /// Create a fresh encrypted database, deriving the DB-wide KEK from a raw
891    /// high-entropy key via HKDF. The salt is persisted at `_meta/keys`.
892    #[cfg(feature = "encryption")]
893    pub fn create_with_key(root: impl AsRef<Path>, key: &[u8]) -> Result<Self> {
894        let root = root.as_ref();
895        Self::reject_existing_database(root)?;
896        std::fs::create_dir_all(root)?;
897        std::fs::create_dir_all(root.join(META_DIR))?;
898        let salt = crate::encryption::random_salt();
899        std::fs::write(root.join(META_DIR).join(KEYS_FILENAME), salt)?;
900        let kek = Arc::new(crate::encryption::Kek::from_raw_key(key, &salt)?);
901        Self::create_inner(root, Some(kek))
902    }
903
904    fn create_inner(
905        root: impl AsRef<Path>,
906        kek: Option<Arc<crate::encryption::Kek>>,
907    ) -> Result<Self> {
908        let root = root.as_ref().to_path_buf();
909        Self::reject_existing_database(&root)?;
910        std::fs::create_dir_all(&root)?;
911        std::fs::create_dir_all(root.join(TABLES_DIR))?;
912        let meta_dek = crate::encryption::meta_dek_for(kek.as_deref());
913        let cat = Catalog::empty();
914        catalog::write_atomic(&root, &cat, meta_dek.as_ref())?;
915        Self::finish_open(root, cat, kek, meta_dek, false, None, 0)
916    }
917
918    /// Open an existing plaintext database.
919    pub fn open(root: impl AsRef<Path>) -> Result<Self> {
920        Self::open_inner(root, None, None)
921    }
922
923    /// Open an existing encrypted database with a passphrase.
924    #[cfg(feature = "encryption")]
925    pub fn open_encrypted(root: impl AsRef<Path>, passphrase: &str) -> Result<Self> {
926        let root = root.as_ref();
927        let salt_bytes = std::fs::read(root.join(META_DIR).join(KEYS_FILENAME))
928            .map_err(|e| MongrelError::NotFound(format!("encryption salt file: {e}")))?;
929        let mut salt = [0u8; crate::encryption::SALT_LEN];
930        salt.copy_from_slice(&salt_bytes);
931        let kek = Arc::new(crate::encryption::Kek::derive(passphrase, &salt)?);
932        Self::open_inner(root, Some(kek), None)
933    }
934
935    /// Open an existing encrypted database with a configurable cross-process
936    /// lock timeout. Mirrors [`open_with_options`](Self::open_with_options).
937    #[cfg(feature = "encryption")]
938    pub fn open_encrypted_with_options(
939        root: impl AsRef<Path>,
940        passphrase: &str,
941        options: OpenOptions,
942    ) -> Result<Self> {
943        let root = root.as_ref();
944        let salt_bytes = std::fs::read(root.join(META_DIR).join(KEYS_FILENAME))
945            .map_err(|e| MongrelError::NotFound(format!("encryption salt file: {e}")))?;
946        let mut salt = [0u8; crate::encryption::SALT_LEN];
947        salt.copy_from_slice(&salt_bytes);
948        let kek = Arc::new(crate::encryption::Kek::derive(passphrase, &salt)?);
949        Self::open_inner_with_lock_timeout(root, Some(kek), None, options.lock_timeout_ms)
950    }
951
952    /// Open an existing encrypted database using a raw high-entropy key.
953    #[cfg(feature = "encryption")]
954    pub fn open_with_key(root: impl AsRef<Path>, key: &[u8]) -> Result<Self> {
955        let root = root.as_ref();
956        let salt_path = root.join(META_DIR).join(KEYS_FILENAME);
957        let salt_bytes = std::fs::read(&salt_path).map_err(|e| {
958            MongrelError::NotFound(format!(
959                "encryption salt file {:?}: {e} (database not encrypted, or corrupted)",
960                salt_path
961            ))
962        })?;
963        if salt_bytes.len() != crate::encryption::SALT_LEN {
964            return Err(MongrelError::InvalidArgument(format!(
965                "salt file is {} bytes, expected {}",
966                salt_bytes.len(),
967                crate::encryption::SALT_LEN
968            )));
969        }
970        let mut salt = [0u8; crate::encryption::SALT_LEN];
971        salt.copy_from_slice(&salt_bytes);
972        let kek = Arc::new(crate::encryption::Kek::from_raw_key(key, &salt)?);
973        Self::open_inner(root, Some(kek), None)
974    }
975
976    /// Open an existing plaintext database that has `require_auth = true`,
977    /// verifying the supplied credentials up front and caching the resolved
978    /// [`Principal`] on the returned handle. Every subsequent operation will
979    /// be checked against that principal.
980    ///
981    /// Returns [`MongrelError::AuthNotRequired`] if the database does not have
982    /// `require_auth` enabled — callers must pick the matching constructor for
983    /// the database's mode. Returns [`MongrelError::InvalidCredentials`] on a
984    /// bad username/password.
985    ///
986    /// See `docs/15-credential-enforcement.md`.
987    pub fn open_with_credentials(
988        root: impl AsRef<Path>,
989        username: &str,
990        password: &str,
991    ) -> Result<Self> {
992        Self::open_inner_with_credentials(root, None, username, password)
993    }
994
995    /// Open with credentials and a configurable cross-process lock timeout.
996    /// Mirrors [`open_with_options`](Self::open_with_options) for the
997    /// credentialed path.
998    pub fn open_with_credentials_and_options(
999        root: impl AsRef<Path>,
1000        username: &str,
1001        password: &str,
1002        options: OpenOptions,
1003    ) -> Result<Self> {
1004        Self::open_inner_with_credentials_and_lock_timeout(
1005            root,
1006            None,
1007            username,
1008            password,
1009            options.lock_timeout_ms,
1010        )
1011    }
1012
1013    /// Open an existing encrypted database that has `require_auth = true`,
1014    /// combining the encryption passphrase flow with credential verification.
1015    #[cfg(feature = "encryption")]
1016    pub fn open_encrypted_with_credentials(
1017        root: impl AsRef<Path>,
1018        passphrase: &str,
1019        username: &str,
1020        password: &str,
1021    ) -> Result<Self> {
1022        let root = root.as_ref();
1023        let salt_bytes = std::fs::read(root.join(META_DIR).join(KEYS_FILENAME))
1024            .map_err(|e| MongrelError::NotFound(format!("encryption salt file: {e}")))?;
1025        let mut salt = [0u8; crate::encryption::SALT_LEN];
1026        salt.copy_from_slice(&salt_bytes);
1027        let kek = Arc::new(crate::encryption::Kek::derive(passphrase, &salt)?);
1028        Self::open_inner_with_credentials(root, Some(kek), username, password)
1029    }
1030
1031    /// Open an encrypted + credentialed database with a configurable
1032    /// cross-process lock timeout. Mirrors
1033    /// [`open_encrypted_with_options`](Self::open_encrypted_with_options).
1034    #[cfg(feature = "encryption")]
1035    pub fn open_encrypted_with_credentials_and_options(
1036        root: impl AsRef<Path>,
1037        passphrase: &str,
1038        username: &str,
1039        password: &str,
1040        options: OpenOptions,
1041    ) -> Result<Self> {
1042        let root = root.as_ref();
1043        let salt_bytes = std::fs::read(root.join(META_DIR).join(KEYS_FILENAME))
1044            .map_err(|e| MongrelError::NotFound(format!("encryption salt file: {e}")))?;
1045        let mut salt = [0u8; crate::encryption::SALT_LEN];
1046        salt.copy_from_slice(&salt_bytes);
1047        let kek = Arc::new(crate::encryption::Kek::derive(passphrase, &salt)?);
1048        Self::open_inner_with_credentials_and_lock_timeout(
1049            root,
1050            Some(kek),
1051            username,
1052            password,
1053            options.lock_timeout_ms,
1054        )
1055    }
1056
1057    /// Open an existing database with non-default [`OpenOptions`].
1058    ///
1059    /// Use this when you need cross-process lock retries (`lock_timeout_ms`)
1060    /// rather than the fail-fast default. The other open constructors keep
1061    /// their previous defaults; use their `*_with_options` variants when they
1062    /// need the same timeout behavior.
1063    pub fn open_with_options(root: impl AsRef<Path>, options: OpenOptions) -> Result<Self> {
1064        // No encryption, no auth; encrypted and credentialed paths have their
1065        // own `*_with_options` constructors.
1066        Self::open_inner_with_lock_timeout(root, None, None, options.lock_timeout_ms)
1067    }
1068
1069    fn open_inner_with_lock_timeout(
1070        root: impl AsRef<Path>,
1071        kek: Option<Arc<crate::encryption::Kek>>,
1072        _meta_dek_override: Option<[u8; META_DEK_LEN]>,
1073        lock_timeout_ms: u32,
1074    ) -> Result<Self> {
1075        let root = root.as_ref().to_path_buf();
1076        let meta_dek = crate::encryption::meta_dek_for(kek.as_deref());
1077        let cat = catalog::read(&root, meta_dek.as_ref())?
1078            .ok_or_else(|| MongrelError::NotFound(format!("no catalog found at {:?}", root)))?;
1079        Self::finish_open(root, cat, kek, meta_dek, true, None, lock_timeout_ms)
1080    }
1081
1082    /// Shared credentialed-open inner: read the catalog, verify the database
1083    /// requires auth, verify the password, resolve the principal, and pass
1084    /// everything to `finish_open` in one shot. This avoids the chicken-and-egg
1085    /// problem where `finish_open`'s fail-closed check (`require_auth &&
1086    /// principal.is_none()`) would fire before a post-open `authenticate()`
1087    /// could supply the principal.
1088    fn open_inner_with_credentials(
1089        root: impl AsRef<Path>,
1090        kek: Option<Arc<crate::encryption::Kek>>,
1091        username: &str,
1092        password: &str,
1093    ) -> Result<Self> {
1094        Self::open_inner_with_credentials_and_lock_timeout(root, kek, username, password, 0)
1095    }
1096
1097    /// Credentialed-open with an explicit cross-process lock timeout. The
1098    /// timeout is opt-in: callers that don't pass `OpenOptions` keep the
1099    /// historical fail-fast behavior via the wrapper above.
1100    fn open_inner_with_credentials_and_lock_timeout(
1101        root: impl AsRef<Path>,
1102        kek: Option<Arc<crate::encryption::Kek>>,
1103        username: &str,
1104        password: &str,
1105        lock_timeout_ms: u32,
1106    ) -> Result<Self> {
1107        let root = root.as_ref().to_path_buf();
1108        let meta_dek = crate::encryption::meta_dek_for(kek.as_deref());
1109        let cat = catalog::read(&root, meta_dek.as_ref())?
1110            .ok_or_else(|| MongrelError::NotFound(format!("no catalog found at {:?}", root)))?;
1111
1112        // Fail early if the database is not in require_auth mode — the caller
1113        // picked the wrong constructor.
1114        if !cat.require_auth {
1115            return Err(MongrelError::AuthNotRequired);
1116        }
1117
1118        // Verify credentials against the on-disk catalog before constructing
1119        // the full Database handle. This reads users/hashes directly from the
1120        // loaded catalog rather than going through the Database::verify_user
1121        // method (which requires a constructed Database).
1122        let user = cat
1123            .users
1124            .iter()
1125            .find(|u| u.username == username)
1126            .filter(|u| !u.password_hash.is_empty())
1127            .ok_or_else(|| MongrelError::InvalidCredentials {
1128                username: username.to_string(),
1129            })?;
1130        let password_ok = crate::auth::verify_password(password, &user.password_hash)
1131            .map_err(MongrelError::Other)?;
1132        if !password_ok {
1133            return Err(MongrelError::InvalidCredentials {
1134                username: username.to_string(),
1135            });
1136        }
1137
1138        // Resolve the principal from the catalog (roles + permissions).
1139        let principal =
1140            Self::resolve_principal_from_catalog(&cat, &user.username).ok_or_else(|| {
1141                MongrelError::InvalidCredentials {
1142                    username: username.to_string(),
1143                }
1144            })?;
1145
1146        Self::finish_open(
1147            root,
1148            cat,
1149            kek,
1150            meta_dek,
1151            true,
1152            Some(principal),
1153            lock_timeout_ms,
1154        )
1155    }
1156
1157    /// Create a fresh plaintext database with `require_auth = true` and a
1158    /// single admin user. The returned handle is already authenticated as
1159    /// that admin — every subsequent operation is checked against the admin
1160    /// principal (which bypasses all permission checks via `is_admin`).
1161    ///
1162    /// This is the bootstrap path: there is no window where the database
1163    /// requires auth but has no users.
1164    ///
1165    /// See `docs/15-credential-enforcement.md`.
1166    pub fn create_with_credentials(
1167        root: impl AsRef<Path>,
1168        admin_username: &str,
1169        admin_password: &str,
1170    ) -> Result<Self> {
1171        Self::create_inner_with_credentials(root, None, admin_username, admin_password)
1172    }
1173
1174    /// Create a fresh encrypted database with `require_auth = true` and a
1175    /// single admin user. Composes encryption-at-rest with credential
1176    /// enforcement.
1177    #[cfg(feature = "encryption")]
1178    pub fn create_encrypted_with_credentials(
1179        root: impl AsRef<Path>,
1180        passphrase: &str,
1181        admin_username: &str,
1182        admin_password: &str,
1183    ) -> Result<Self> {
1184        let root = root.as_ref();
1185        Self::reject_existing_database(root)?;
1186        std::fs::create_dir_all(root)?;
1187        std::fs::create_dir_all(root.join(META_DIR))?;
1188        let salt = crate::encryption::random_salt();
1189        std::fs::write(root.join(META_DIR).join(KEYS_FILENAME), salt)?;
1190        let kek = Arc::new(crate::encryption::Kek::derive(passphrase, &salt)?);
1191        Self::create_inner_with_credentials(root, Some(kek), admin_username, admin_password)
1192    }
1193
1194    fn create_inner_with_credentials(
1195        root: impl AsRef<Path>,
1196        kek: Option<Arc<crate::encryption::Kek>>,
1197        admin_username: &str,
1198        admin_password: &str,
1199    ) -> Result<Self> {
1200        let root = root.as_ref().to_path_buf();
1201        Self::reject_existing_database(&root)?;
1202        std::fs::create_dir_all(&root)?;
1203        std::fs::create_dir_all(root.join(TABLES_DIR))?;
1204        let meta_dek = crate::encryption::meta_dek_for(kek.as_deref());
1205
1206        // Build the initial catalog with require_auth = true and one admin user.
1207        let password_hash =
1208            crate::auth::hash_password(admin_password).map_err(MongrelError::Other)?;
1209        let mut cat = Catalog::empty();
1210        cat.require_auth = true;
1211        cat.next_user_id = 1;
1212        cat.users.push(crate::auth::UserEntry {
1213            id: 1,
1214            username: admin_username.to_string(),
1215            password_hash,
1216            roles: Vec::new(),
1217            is_admin: true,
1218            created_epoch: 0,
1219        });
1220        catalog::write_atomic(&root, &cat, meta_dek.as_ref())?;
1221
1222        // The handle is constructed already authenticated as the admin user
1223        // it just created — no separate verify step needed.
1224        let admin_principal = crate::auth::Principal {
1225            username: admin_username.to_string(),
1226            is_admin: true,
1227            roles: Vec::new(),
1228            permissions: Vec::new(),
1229        };
1230        Self::finish_open(root, cat, kek, meta_dek, false, Some(admin_principal), 0)
1231    }
1232
1233    fn reject_existing_database(root: &Path) -> Result<()> {
1234        // Refuse to overwrite an existing database. If CATALOG exists, the
1235        // directory already contains a real database; replacing it destroys data.
1236        if root.join(catalog::CATALOG_FILENAME).exists() {
1237            return Err(MongrelError::InvalidArgument(format!(
1238                "database already exists at {}; use Database::open() to open it, \
1239                 or remove the directory first",
1240                root.display()
1241            )));
1242        }
1243        Ok(())
1244    }
1245
1246    fn open_inner(
1247        root: impl AsRef<Path>,
1248        kek: Option<Arc<crate::encryption::Kek>>,
1249        _meta_dek_override: Option<[u8; META_DEK_LEN]>,
1250    ) -> Result<Self> {
1251        let root = root.as_ref().to_path_buf();
1252        let meta_dek = crate::encryption::meta_dek_for(kek.as_deref());
1253        let cat = catalog::read(&root, meta_dek.as_ref())?
1254            .ok_or_else(|| MongrelError::NotFound(format!("no catalog found at {:?}", root)))?;
1255        Self::finish_open(root, cat, kek, meta_dek, true, None, 0)
1256    }
1257
1258    /// Acquire an exclusive advisory lock on `f`, retrying on `EAGAIN`/`EWOULDBLOCK`
1259    /// until `timeout_ms` elapses, mirroring SQLite's `busy_timeout` semantics.
1260    ///
1261    /// `timeout_ms == 0` is the fail-fast path: a single `try_lock_exclusive` call,
1262    /// no retry, no sleep. Existing open paths rely on that fail-fast default for
1263    /// backwards compatibility — opt in with `OpenOptions::lock_timeout_ms`.
1264    ///
1265    /// Backoff schedule: 1ms → 10ms → 50ms → 50ms → ... until `timeout_ms`.
1266    /// Total elapsed (not just sleep time) is bounded by `timeout_ms`, so the
1267    /// caller never blocks past its budget even at the tail of a busy lock
1268    /// holder's lock-window.
1269    fn fs_lock_exclusive(f: &std::fs::File, timeout_ms: u32) -> std::io::Result<()> {
1270        use fs2::FileExt;
1271        if timeout_ms == 0 {
1272            return f.try_lock_exclusive();
1273        }
1274        // Per-call deadline so a single stray 50ms sleep can't overshoot the budget.
1275        let deadline =
1276            std::time::Instant::now() + std::time::Duration::from_millis(timeout_ms as u64);
1277        let mut next_sleep = std::time::Duration::from_millis(1);
1278        loop {
1279            match f.try_lock_exclusive() {
1280                Ok(()) => return Ok(()),
1281                Err(e) if e.kind() == std::io::ErrorKind::WouldBlock => {
1282                    let now = std::time::Instant::now();
1283                    if now >= deadline {
1284                        return Err(std::io::Error::new(
1285                            std::io::ErrorKind::WouldBlock,
1286                            format!("could not acquire database lock within {timeout_ms}ms"),
1287                        ));
1288                    }
1289                    let remaining = deadline - now;
1290                    let sleep = next_sleep.min(remaining);
1291                    std::thread::sleep(sleep);
1292                    // Cap the per-iteration sleep so a single back-off step
1293                    // never overshoots the remaining budget.
1294                    next_sleep = next_sleep
1295                        .saturating_mul(10)
1296                        .min(std::time::Duration::from_millis(50));
1297                }
1298                Err(e) => return Err(e),
1299            }
1300        }
1301    }
1302
1303    fn finish_open(
1304        root: PathBuf,
1305        cat: Catalog,
1306        kek: Option<Arc<crate::encryption::Kek>>,
1307        meta_dek: Option<[u8; META_DEK_LEN]>,
1308        existing: bool,
1309        principal: Option<crate::auth::Principal>,
1310        lock_timeout_ms: u32,
1311    ) -> Result<Self> {
1312        let read_only = existing && root.join(META_DIR).join("replica").exists();
1313        // Acquire an exclusive cross-process lock on the database directory.
1314        // This prevents two *processes* from opening the same DB simultaneously
1315        // (which would corrupt data). Multiple opens within the *same* process
1316        // are allowed (they share memory via Arc) — so we track locked paths in
1317        // a process-global set and skip re-locking if already held.
1318        std::fs::create_dir_all(root.join("_meta")).ok();
1319        let lock_path = root.join("_meta").join(".lock");
1320        let canonical = lock_path.canonicalize().unwrap_or(lock_path.clone());
1321        let lock_file = {
1322            static LOCKED_PATHS: std::sync::OnceLock<
1323                std::sync::Mutex<std::collections::HashSet<PathBuf>>,
1324            > = std::sync::OnceLock::new();
1325            let locked = LOCKED_PATHS
1326                .get_or_init(|| std::sync::Mutex::new(std::collections::HashSet::new()));
1327            let mut guard = locked.lock().unwrap();
1328            if guard.contains(&canonical) {
1329                // Already locked by this process — allow the re-open.
1330                None
1331            } else {
1332                let f = std::fs::OpenOptions::new()
1333                    .create(true)
1334                    .truncate(false)
1335                    .write(true)
1336                    .open(&lock_path)?;
1337                Self::fs_lock_exclusive(&f, lock_timeout_ms).map_err(|e| {
1338                    MongrelError::Io(std::io::Error::other(format!(
1339                        "database at {} is locked by another process: {e}",
1340                        root.display()
1341                    )))
1342                })?;
1343                guard.insert(canonical.clone());
1344                Some(f)
1345            }
1346        };
1347        if lock_file.is_some() {
1348            let stale_backup_pins = root.join(META_DIR).join("backup-pins");
1349            if stale_backup_pins.exists() {
1350                std::fs::remove_dir_all(stale_backup_pins)?;
1351            }
1352        }
1353
1354        let epoch = Arc::new(EpochAuthority::new(cat.db_epoch));
1355        let snapshots = Arc::new(SnapshotRegistry::new());
1356        let (history_epochs, history_start) = read_history_retention(&root, Epoch(cat.db_epoch))?;
1357        snapshots.configure_history(history_epochs, history_start);
1358        let page_cache = Arc::new(crate::cache::Sharded::new(
1359            crate::cache::CACHE_SHARDS,
1360            || {
1361                crate::cache::PageCache::new(
1362                    crate::engine::PAGE_CACHE_CAPACITY / crate::cache::CACHE_SHARDS as u64,
1363                )
1364            },
1365        ));
1366        let decoded_cache = Arc::new(crate::cache::Sharded::new(
1367            crate::cache::CACHE_SHARDS,
1368            || {
1369                crate::cache::DecodedPageCache::new(
1370                    crate::engine::DECODED_CACHE_CAPACITY / crate::cache::CACHE_SHARDS as u64,
1371                )
1372            },
1373        ));
1374        let commit_lock = Arc::new(Mutex::new(()));
1375        let wal_dek = crate::encryption::wal_dek_for(kek.as_deref());
1376        let shared_wal = Arc::new(Mutex::new(if existing {
1377            crate::wal::SharedWal::open(&root, Epoch(cat.db_epoch), wal_dek.clone())?
1378        } else {
1379            crate::wal::SharedWal::create_with_dek(&root, Epoch(cat.db_epoch), wal_dek.clone())?
1380        }));
1381        // Shared write-path state handed to every mounted table so single-table
1382        // `put`/`commit` writes route through the one shared WAL, the one group-
1383        // commit coordinator, and the one poison flag (B1).
1384        let poisoned = Arc::new(std::sync::atomic::AtomicBool::new(false));
1385        let group = Arc::new(crate::txn::GroupCommit::new(
1386            shared_wal.lock().durable_seq(),
1387        ));
1388        let (change_wake, _change_rx) = tokio::sync::broadcast::channel(256);
1389        // Final base value is set after the open-generation bump below; tables
1390        // only draw ids once the user issues a write (post-open), so the
1391        // placeholder is never observed.
1392        let txn_ids = Arc::new(Mutex::new(1u64));
1393
1394        // Recover DDL from the shared WAL BEFORE opening tables (spec §15,
1395        // review fix #16). A crash between WAL fsync and the catalog
1396        // checkpoint leaves committed DDL durable in the WAL but absent from
1397        // the on-disk catalog; replay it here so the table-mounting loop and
1398        // data-record recovery see a correct catalog.
1399        let mut cat = cat;
1400        if existing {
1401            recover_ddl_from_wal(&root, &mut cat, meta_dek.as_ref(), wal_dek.as_ref())?;
1402        }
1403
1404        // Build the shared auth state early — it's cloned into every mounted
1405        // Table's SharedCtx so the Table layer can enforce permissions without
1406        // a reference back to Database. The `require_auth` flag is mirrored
1407        // from the catalog; `enable_auth` / `refresh_principal` update it live.
1408        let auth_state = crate::auth_state::AuthState::new(cat.require_auth, principal.clone());
1409        let auth_checker: Option<Arc<dyn crate::auth_state::TableAuthChecker>> = Some(Arc::new(
1410            crate::auth_state::DefaultTableAuthChecker::new(auth_state.clone()),
1411        ));
1412
1413        // Open every live table against the shared context. Mounted tables have
1414        // no private WAL (B1) — `open_in` just loads the manifest/runs and
1415        // advances the shared epoch authority to its manifest epoch, so the
1416        // final shared watermark is the max across all tables. All of a mounted
1417        // table's committed records are replayed below from the shared WAL.
1418        let mut tables: HashMap<u64, TableHandle> = HashMap::new();
1419        for entry in &cat.tables {
1420            if !matches!(entry.state, TableState::Live) {
1421                continue;
1422            }
1423            let tdir = root.join(TABLES_DIR).join(entry.table_id.to_string());
1424            let ctx = SharedCtx {
1425                epoch: Arc::clone(&epoch),
1426                page_cache: Arc::clone(&page_cache),
1427                decoded_cache: Arc::clone(&decoded_cache),
1428                snapshots: Arc::clone(&snapshots),
1429                kek: kek.clone(),
1430                commit_lock: Arc::clone(&commit_lock),
1431                shared: Some(crate::engine::SharedWalCtx {
1432                    wal: Arc::clone(&shared_wal),
1433                    group: Arc::clone(&group),
1434                    poisoned: Arc::clone(&poisoned),
1435                    txn_ids: Arc::clone(&txn_ids),
1436                    change_wake: change_wake.clone(),
1437                }),
1438                table_name: Some(entry.name.clone()),
1439                auth: auth_checker.clone(),
1440                read_only,
1441            };
1442            let t = Table::open_in(&tdir, ctx)?;
1443            tables.insert(entry.table_id, TableHandle::new(t));
1444        }
1445
1446        // Recover transaction writes from the shared WAL (spec §15). This is the
1447        // single durability source for mounted tables: it applies every committed
1448        // record — both single-table `Table::commit` writes and cross-table
1449        // transactions — gated by each table's `flushed_epoch` (records already
1450        // durable in a run are not re-applied).
1451        if existing {
1452            recover_shared_wal(&root, &tables, &epoch, wal_dek.as_ref())?;
1453            // P3.4: sweep stale `_txn/<txn_id>/` dirs left by aborted/crashed
1454            // large transactions (spec §8.5, review fix #14).
1455            sweep_pending_txn_dirs(&root, &cat);
1456        }
1457
1458        // Bump `open_generation` on every open and scope transaction ids by it
1459        // (`txn_id = (generation << 32) | counter`), so ids never alias across
1460        // reopens (review fix #11). Persist the bumped generation to a sidecar
1461        // file (`_meta/generation`) rather than CATALOG, so CATALOG stays
1462        // byte-stable across bare opens for content-addressed storage.
1463        let open_generation = if existing {
1464            let meta_dir = root.join(META_DIR);
1465            let gen = catalog::read_generation(&meta_dir);
1466            let bumped = gen.wrapping_add(1);
1467            catalog::write_generation(&meta_dir, bumped)?;
1468            bumped
1469        } else {
1470            0
1471        };
1472        let next_txn_id = (open_generation << 32) | 1;
1473        // Seed the shared txn-id allocator now that the generation is final.
1474        *txn_ids.lock() = next_txn_id;
1475
1476        // Fail-closed: an existing database with `require_auth = true` must be
1477        // opened with credentials (a non-None principal). The credentialed
1478        // constructors pass the principal through finish_open; the plain
1479        // open/open_encrypted paths pass None and are rejected here. A brand-
1480        // new database (`existing = false`) never has require_auth set yet
1481        // (create_with_credentials sets it in the catalog before construction
1482        // AND passes the principal), so the check only gates the reopen path.
1483        if existing && cat.require_auth && principal.is_none() {
1484            return Err(MongrelError::AuthRequired);
1485        }
1486
1487        Ok(Self {
1488            root,
1489            read_only,
1490            catalog: RwLock::new(cat),
1491            rls_cache: Mutex::new(RlsCache::default()),
1492            epoch,
1493            snapshots,
1494            page_cache,
1495            decoded_cache,
1496            commit_lock,
1497            shared_wal,
1498            next_txn_id: txn_ids,
1499            tables: RwLock::new(tables),
1500            kek,
1501            ddl_lock: Mutex::new(()),
1502            meta_dek,
1503            conflicts: crate::txn::ConflictIndex::new(),
1504            active_txns: crate::txn::ActiveTxns::new(),
1505            poisoned,
1506            group,
1507            spill_threshold: std::sync::atomic::AtomicU64::new(64 * 1024 * 1024),
1508            active_spills: Arc::new(crate::retention::ActiveSpills::new()),
1509            replication_barrier: parking_lot::RwLock::new(()),
1510            replication_wal_retention_segments: AtomicUsize::new(0),
1511            backup_pins: Arc::new(Mutex::new(HashMap::new())),
1512            spill_hook: Mutex::new(None),
1513            backup_hook: Mutex::new(None),
1514            trigger_recursive: AtomicBool::new(TriggerConfig::default().recursive_triggers),
1515            trigger_max_depth: AtomicU32::new(TriggerConfig::default().max_depth),
1516            trigger_max_loop_iterations: AtomicU32::new(
1517                TriggerConfig::default().max_loop_iterations,
1518            ),
1519            _lock: lock_file,
1520            notify: {
1521                let (tx, _rx) = tokio::sync::broadcast::channel(256);
1522                tx
1523            },
1524            change_wake,
1525            principal: RwLock::new(principal),
1526            auth_state,
1527        })
1528    }
1529
1530    /// The current reader-visible epoch.
1531    pub fn visible_epoch(&self) -> Epoch {
1532        self.epoch.visible()
1533    }
1534
1535    /// Clone the in-memory catalog (for diagnostics / tests).
1536    pub fn catalog_snapshot(&self) -> Catalog {
1537        self.catalog.read().clone()
1538    }
1539
1540    pub fn materialized_view(&self, name: &str) -> Option<crate::catalog::MaterializedViewEntry> {
1541        self.catalog
1542            .read()
1543            .materialized_views
1544            .iter()
1545            .find(|definition| definition.name == name)
1546            .cloned()
1547    }
1548
1549    pub fn materialized_views(&self) -> Vec<crate::catalog::MaterializedViewEntry> {
1550        self.catalog.read().materialized_views.clone()
1551    }
1552
1553    pub fn security_catalog(&self) -> crate::security::SecurityCatalog {
1554        self.catalog.read().security.clone()
1555    }
1556
1557    pub fn security_active_for(&self, table: &str) -> bool {
1558        self.catalog.read().security.table_has_security(table)
1559    }
1560
1561    /// Persist a complete validated RLS/masking catalog through the WAL.
1562    pub fn set_security_catalog(&self, security: crate::security::SecurityCatalog) -> Result<()> {
1563        self.set_security_catalog_as(security, None)
1564    }
1565
1566    /// Persist security policy changes on behalf of an explicit request principal.
1567    pub fn set_security_catalog_as(
1568        &self,
1569        security: crate::security::SecurityCatalog,
1570        principal: Option<&crate::auth::Principal>,
1571    ) -> Result<()> {
1572        use crate::wal::DdlOp;
1573        use std::sync::atomic::Ordering;
1574
1575        self.require_for(principal, &crate::auth::Permission::Admin)?;
1576        if self.poisoned.load(Ordering::Relaxed) {
1577            return Err(MongrelError::Other(
1578                "database poisoned by fsync error".into(),
1579            ));
1580        }
1581        let _ddl = self.ddl_lock.lock();
1582        validate_security_catalog(&self.catalog.read(), &security)?;
1583        let payload = DdlOp::encode_security(&security)?;
1584        let _commit = self.commit_lock.lock();
1585        let epoch = self.epoch.bump_assigned();
1586        let mut epoch_guard = EpochGuard::new(self.epoch.as_ref(), epoch);
1587        let txn_id = self.alloc_txn_id();
1588        let commit_seq = {
1589            let mut wal = self.shared_wal.lock();
1590            wal.append(
1591                txn_id,
1592                WAL_TABLE_ID,
1593                crate::wal::Op::Ddl(DdlOp::SetSecurityCatalog {
1594                    security_json: payload,
1595                }),
1596            )?;
1597            wal.append_commit(txn_id, epoch, &[])?
1598        };
1599        self.group
1600            .await_durable(&self.shared_wal, commit_seq)
1601            .inspect_err(|_| {
1602                self.poisoned.store(true, Ordering::Relaxed);
1603            })?;
1604        {
1605            let mut catalog = self.catalog.write();
1606            catalog.security = security;
1607            catalog.security_version = catalog.security_version.wrapping_add(1);
1608            catalog.db_epoch = catalog.db_epoch.max(epoch.0);
1609        }
1610        catalog::write_atomic(&self.root, &self.catalog.read(), self.meta_dek.as_ref())?;
1611        self.epoch.publish_in_order(epoch);
1612        epoch_guard.disarm();
1613        Ok(())
1614    }
1615
1616    pub fn require_for(
1617        &self,
1618        principal: Option<&crate::auth::Principal>,
1619        permission: &crate::auth::Permission,
1620    ) -> Result<()> {
1621        let Some(principal) = principal else {
1622            return self.require(permission);
1623        };
1624        if principal.has_permission(permission) {
1625            Ok(())
1626        } else {
1627            Err(MongrelError::PermissionDenied {
1628                required: permission.clone(),
1629                principal: principal.username.clone(),
1630            })
1631        }
1632    }
1633
1634    pub fn principal_snapshot(&self) -> Option<crate::auth::Principal> {
1635        self.principal.read().clone()
1636    }
1637
1638    pub fn require_columns_for(
1639        &self,
1640        table: &str,
1641        operation: crate::auth::ColumnOperation,
1642        column_ids: &[u16],
1643        principal: Option<&crate::auth::Principal>,
1644    ) -> Result<()> {
1645        let schema = self
1646            .catalog
1647            .read()
1648            .live(table)
1649            .ok_or_else(|| MongrelError::NotFound(format!("table {table:?} not found")))?
1650            .schema
1651            .clone();
1652        let cached = self.principal.read().clone();
1653        let principal = principal.or(cached.as_ref());
1654        let Some(principal) = principal else {
1655            let permission = match operation {
1656                crate::auth::ColumnOperation::Select => crate::auth::Permission::Select {
1657                    table: table.to_string(),
1658                },
1659                crate::auth::ColumnOperation::Insert => crate::auth::Permission::Insert {
1660                    table: table.to_string(),
1661                },
1662                crate::auth::ColumnOperation::Update => crate::auth::Permission::Update {
1663                    table: table.to_string(),
1664                },
1665            };
1666            return self.require(&permission);
1667        };
1668        match principal.column_access(table, operation) {
1669            crate::auth::ColumnAccess::All => Ok(()),
1670            crate::auth::ColumnAccess::Columns(allowed) => {
1671                let denied = column_ids.iter().find_map(|column_id| {
1672                    schema
1673                        .columns
1674                        .iter()
1675                        .find(|column| column.id == *column_id)
1676                        .filter(|column| !allowed.contains(&column.name))
1677                });
1678                if denied.is_none() {
1679                    Ok(())
1680                } else {
1681                    Err(MongrelError::PermissionDenied {
1682                        required: match operation {
1683                            crate::auth::ColumnOperation::Select => {
1684                                crate::auth::Permission::SelectColumns {
1685                                    table: table.to_string(),
1686                                    columns: denied
1687                                        .into_iter()
1688                                        .map(|column| column.name.clone())
1689                                        .collect(),
1690                                }
1691                            }
1692                            crate::auth::ColumnOperation::Insert => {
1693                                crate::auth::Permission::InsertColumns {
1694                                    table: table.to_string(),
1695                                    columns: denied
1696                                        .into_iter()
1697                                        .map(|column| column.name.clone())
1698                                        .collect(),
1699                                }
1700                            }
1701                            crate::auth::ColumnOperation::Update => {
1702                                crate::auth::Permission::UpdateColumns {
1703                                    table: table.to_string(),
1704                                    columns: denied
1705                                        .into_iter()
1706                                        .map(|column| column.name.clone())
1707                                        .collect(),
1708                                }
1709                            }
1710                        },
1711                        principal: principal.username.clone(),
1712                    })
1713                }
1714            }
1715            crate::auth::ColumnAccess::Denied => Err(MongrelError::PermissionDenied {
1716                required: match operation {
1717                    crate::auth::ColumnOperation::Select => crate::auth::Permission::Select {
1718                        table: table.to_string(),
1719                    },
1720                    crate::auth::ColumnOperation::Insert => crate::auth::Permission::Insert {
1721                        table: table.to_string(),
1722                    },
1723                    crate::auth::ColumnOperation::Update => crate::auth::Permission::Update {
1724                        table: table.to_string(),
1725                    },
1726                },
1727                principal: principal.username.clone(),
1728            }),
1729        }
1730    }
1731
1732    pub fn select_column_ids_for(
1733        &self,
1734        table: &str,
1735        principal: Option<&crate::auth::Principal>,
1736    ) -> Result<Vec<u16>> {
1737        let catalog = self.catalog.read();
1738        let columns = catalog
1739            .live(table)
1740            .ok_or_else(|| MongrelError::NotFound(format!("table {table:?} not found")))?
1741            .schema
1742            .columns
1743            .iter()
1744            .map(|column| (column.id, column.name.clone()))
1745            .collect::<Vec<_>>();
1746        drop(catalog);
1747        let cached_principal = self.principal.read().clone();
1748        let principal = principal.or(cached_principal.as_ref());
1749        let Some(principal) = principal else {
1750            self.require(&crate::auth::Permission::Select {
1751                table: table.to_string(),
1752            })?;
1753            return Ok(columns.iter().map(|(id, _)| *id).collect());
1754        };
1755        match principal.column_access(table, crate::auth::ColumnOperation::Select) {
1756            crate::auth::ColumnAccess::All => Ok(columns.iter().map(|(id, _)| *id).collect()),
1757            crate::auth::ColumnAccess::Columns(allowed) => Ok(columns
1758                .iter()
1759                .filter(|(_, name)| allowed.contains(name))
1760                .map(|(id, _)| *id)
1761                .collect()),
1762            crate::auth::ColumnAccess::Denied => Err(MongrelError::PermissionDenied {
1763                required: crate::auth::Permission::Select {
1764                    table: table.to_string(),
1765                },
1766                principal: principal.username.clone(),
1767            }),
1768        }
1769    }
1770
1771    pub fn secure_rows_for(
1772        &self,
1773        table: &str,
1774        rows: Vec<crate::memtable::Row>,
1775        principal: Option<&crate::auth::Principal>,
1776    ) -> Result<Vec<crate::memtable::Row>> {
1777        self.secure_rows_for_with_context(table, rows, principal, None)
1778    }
1779
1780    pub fn secure_rows_for_with_context(
1781        &self,
1782        table: &str,
1783        rows: Vec<crate::memtable::Row>,
1784        principal: Option<&crate::auth::Principal>,
1785        context: Option<&crate::query::AiExecutionContext>,
1786    ) -> Result<Vec<crate::memtable::Row>> {
1787        let security = self.catalog.read().security.clone();
1788        if !security.table_has_security(table) {
1789            return Ok(rows);
1790        }
1791        let owned;
1792        let principal = match principal {
1793            Some(principal) => principal,
1794            None => {
1795                owned = self
1796                    .principal
1797                    .read()
1798                    .clone()
1799                    .ok_or(MongrelError::AuthRequired)?;
1800                &owned
1801            }
1802        };
1803        let mut output = Vec::new();
1804        for mut row in rows {
1805            if let Some(context) = context {
1806                context.consume(1)?;
1807            }
1808            if security.row_allowed(
1809                table,
1810                crate::security::PolicyCommand::Select,
1811                &row,
1812                principal,
1813                false,
1814            ) {
1815                security.apply_masks(table, &mut row, principal);
1816                output.push(row);
1817            }
1818        }
1819        Ok(output)
1820    }
1821
1822    /// Apply column masks to already RLS-authorized scored hits without a
1823    /// second row gather or policy evaluation.
1824    pub fn mask_search_hits_for(
1825        &self,
1826        table: &str,
1827        hits: &mut [crate::query::SearchHit],
1828        principal: Option<&crate::auth::Principal>,
1829    ) -> Result<()> {
1830        let security = self.catalog.read().security.clone();
1831        if !security.table_has_security(table) {
1832            return Ok(());
1833        }
1834        let owned;
1835        let principal = match principal {
1836            Some(principal) => principal,
1837            None => {
1838                owned = self.principal.read().clone();
1839                let Some(principal) = owned.as_ref() else {
1840                    return Ok(());
1841                };
1842                principal
1843            }
1844        };
1845        for hit in hits {
1846            security.apply_masks_to_cells(table, &mut hit.cells, principal);
1847        }
1848        Ok(())
1849    }
1850
1851    /// Apply masks to rows already admitted by candidate-aware RLS.
1852    pub fn mask_rows_for(
1853        &self,
1854        table: &str,
1855        rows: &mut [crate::memtable::Row],
1856        principal: Option<&crate::auth::Principal>,
1857    ) -> Result<()> {
1858        let security = self.catalog.read().security.clone();
1859        if !security.table_has_security(table) {
1860            return Ok(());
1861        }
1862        let owned;
1863        let principal = match principal {
1864            Some(principal) => principal,
1865            None => {
1866                owned = self
1867                    .principal
1868                    .read()
1869                    .clone()
1870                    .ok_or(MongrelError::AuthRequired)?;
1871                &owned
1872            }
1873        };
1874        for row in rows {
1875            security.apply_masks(table, row, principal);
1876        }
1877        Ok(())
1878    }
1879
1880    /// Row IDs allowed to enter scored ranking. `None` means no RLS filter.
1881    pub fn authorized_candidate_ids_for(
1882        &self,
1883        table: &str,
1884        principal: Option<&crate::auth::Principal>,
1885    ) -> Result<Option<std::collections::HashSet<RowId>>> {
1886        Ok(self
1887            .authorized_read_snapshot(table, principal)?
1888            .allowed_row_ids)
1889    }
1890
1891    fn allowed_row_ids_locked(
1892        &self,
1893        table_name: &str,
1894        table: &Table,
1895        table_snapshot: Snapshot,
1896        security_state: (&crate::security::SecurityCatalog, u64),
1897        principal: Option<&crate::auth::Principal>,
1898        context: Option<&crate::query::AiExecutionContext>,
1899    ) -> Result<Option<Arc<HashSet<RowId>>>> {
1900        let (security, security_version) = security_state;
1901        if !security.rls_enabled(table_name) {
1902            return Ok(None);
1903        }
1904        let authorization_started = std::time::Instant::now();
1905        let principal = principal.ok_or(MongrelError::AuthRequired)?;
1906        let mut roles = principal.roles.clone();
1907        roles.sort_unstable();
1908        let principal_key = format!("{}:{}:{roles:?}", principal.username, principal.is_admin);
1909        let cache_key = (
1910            table_name.to_string(),
1911            table.data_generation(),
1912            security_version,
1913            principal_key,
1914        );
1915        if let Some(allowed) = self.rls_cache.lock().get(&cache_key) {
1916            crate::trace::QueryTrace::record(|trace| {
1917                trace.rls_cache_hit = true;
1918                trace.authorization_nanos = trace
1919                    .authorization_nanos
1920                    .saturating_add(authorization_started.elapsed().as_nanos() as u64);
1921            });
1922            return Ok(Some(allowed));
1923        }
1924        if let Some(context) = context {
1925            context.checkpoint()?;
1926        }
1927        // ponytail: full RLS universe scan; replace with policy-column candidate checks if RLS search throughput matters.
1928        let started = std::time::Instant::now();
1929        let rows = table.visible_rows(table_snapshot)?;
1930        let rows_evaluated = rows.len() as u64;
1931        let mut allowed = HashSet::new();
1932        for chunk in rows.chunks(256) {
1933            if let Some(context) = context {
1934                context.consume(chunk.len())?;
1935            }
1936            allowed.extend(chunk.iter().filter_map(|row| {
1937                security
1938                    .row_allowed(
1939                        table_name,
1940                        crate::security::PolicyCommand::Select,
1941                        row,
1942                        principal,
1943                        false,
1944                    )
1945                    .then_some(row.row_id)
1946            }));
1947        }
1948        let allowed = Arc::new(allowed);
1949        let mut cache = self.rls_cache.lock();
1950        cache.build_nanos = cache
1951            .build_nanos
1952            .saturating_add(started.elapsed().as_nanos() as u64);
1953        cache.rows_evaluated = cache.rows_evaluated.saturating_add(rows_evaluated);
1954        cache.insert(cache_key, Arc::clone(&allowed));
1955        crate::trace::QueryTrace::record(|trace| {
1956            trace.rls_rows_evaluated = trace
1957                .rls_rows_evaluated
1958                .saturating_add(rows_evaluated as usize);
1959            trace.authorization_nanos = trace
1960                .authorization_nanos
1961                .saturating_add(authorization_started.elapsed().as_nanos() as u64);
1962        });
1963        Ok(Some(allowed))
1964    }
1965
1966    fn principal_for_authorized_read(
1967        &self,
1968        catalog: &Catalog,
1969        principal: Option<&crate::auth::Principal>,
1970        catalog_bound: bool,
1971    ) -> Result<Option<crate::auth::Principal>> {
1972        let principal = principal.cloned().or_else(|| self.principal.read().clone());
1973        let Some(principal) = principal else {
1974            return Ok(None);
1975        };
1976        if catalog_bound
1977            || catalog
1978                .users
1979                .iter()
1980                .any(|user| user.username == principal.username)
1981        {
1982            return Self::resolve_principal_from_catalog(catalog, &principal.username)
1983                .map(Some)
1984                .ok_or(MongrelError::AuthRequired);
1985        }
1986        Ok(Some(principal))
1987    }
1988
1989    /// Run authorization, candidate generation, ranking, and materialization
1990    /// while holding one table generation. Security changes cause a bounded
1991    /// retry before any result is published.
1992    pub fn with_authorized_read<T, F>(
1993        &self,
1994        table_name: &str,
1995        principal: Option<&crate::auth::Principal>,
1996        catalog_bound: bool,
1997        read: F,
1998    ) -> Result<T>
1999    where
2000        F: FnMut(
2001            &mut Table,
2002            Snapshot,
2003            Option<&HashSet<RowId>>,
2004            Option<&crate::auth::Principal>,
2005        ) -> Result<T>,
2006    {
2007        self.with_authorized_read_context(
2008            table_name,
2009            principal,
2010            catalog_bound,
2011            None,
2012            None,
2013            None,
2014            read,
2015        )
2016    }
2017
2018    #[allow(clippy::too_many_arguments)]
2019    pub fn with_authorized_read_context<T, F>(
2020        &self,
2021        table_name: &str,
2022        principal: Option<&crate::auth::Principal>,
2023        catalog_bound: bool,
2024        authorization: Option<&ReadAuthorization>,
2025        context: Option<&crate::query::AiExecutionContext>,
2026        snapshot_override: Option<Snapshot>,
2027        mut read: F,
2028    ) -> Result<T>
2029    where
2030        F: FnMut(
2031            &mut Table,
2032            Snapshot,
2033            Option<&HashSet<RowId>>,
2034            Option<&crate::auth::Principal>,
2035        ) -> Result<T>,
2036    {
2037        if principal.is_none() && self.principal.read().is_some() {
2038            self.refresh_principal()?;
2039        }
2040        const RETRIES: usize = 3;
2041        let handle = self.table(table_name)?;
2042        for attempt in 0..RETRIES {
2043            crate::trace::QueryTrace::record(|trace| {
2044                trace.authorization_retries = attempt;
2045            });
2046            let (security, security_version, effective_principal) = {
2047                let catalog = self.catalog.read();
2048                (
2049                    catalog.security.clone(),
2050                    catalog.security_version,
2051                    self.principal_for_authorized_read(&catalog, principal, catalog_bound)?,
2052                )
2053            };
2054            if let Some(authorization) = authorization {
2055                self.require_columns_for(
2056                    table_name,
2057                    authorization.operation,
2058                    &authorization.columns,
2059                    effective_principal.as_ref(),
2060                )?;
2061            }
2062            let result = {
2063                let mut table = lock_table_with_context(&handle, context)?;
2064                let snapshot = snapshot_override.unwrap_or_else(|| table.snapshot());
2065                let allowed = self.allowed_row_ids_locked(
2066                    table_name,
2067                    &table,
2068                    snapshot,
2069                    (&security, security_version),
2070                    effective_principal.as_ref(),
2071                    context,
2072                )?;
2073                read(
2074                    &mut table,
2075                    snapshot,
2076                    allowed.as_deref(),
2077                    effective_principal.as_ref(),
2078                )?
2079            };
2080            if let Some(context) = context {
2081                context.checkpoint()?;
2082            }
2083            if self.catalog.read().security_version == security_version {
2084                return Ok(result);
2085            }
2086            if attempt + 1 == RETRIES {
2087                return Err(MongrelError::Conflict(
2088                    "security policy changed during scored read".into(),
2089                ));
2090            }
2091        }
2092        unreachable!()
2093    }
2094
2095    /// Scored-read authorization that evaluates RLS only for approximate
2096    /// candidates. This avoids a full-table policy scan on cache misses while
2097    /// preserving one table generation and security-version retry.
2098    pub fn with_authorized_scored_read_context<T, F>(
2099        &self,
2100        table_name: &str,
2101        principal: Option<&crate::auth::Principal>,
2102        catalog_bound: bool,
2103        authorization: Option<&ReadAuthorization>,
2104        context: Option<&crate::query::AiExecutionContext>,
2105        mut read: F,
2106    ) -> Result<T>
2107    where
2108        F: FnMut(
2109            &mut Table,
2110            Snapshot,
2111            Option<&crate::security::CandidateAuthorization<'_>>,
2112            Option<&crate::auth::Principal>,
2113        ) -> Result<T>,
2114    {
2115        self.with_authorized_scored_read_context_at(
2116            table_name,
2117            principal,
2118            catalog_bound,
2119            authorization,
2120            context,
2121            None,
2122            |table, snapshot, authorization, principal| {
2123                let mut table = table.clone();
2124                read(&mut table, snapshot, authorization, principal)
2125            },
2126        )
2127    }
2128
2129    #[allow(clippy::too_many_arguments)]
2130    pub fn with_authorized_scored_read_context_at<T, F>(
2131        &self,
2132        table_name: &str,
2133        principal: Option<&crate::auth::Principal>,
2134        catalog_bound: bool,
2135        authorization: Option<&ReadAuthorization>,
2136        context: Option<&crate::query::AiExecutionContext>,
2137        snapshot_override: Option<Snapshot>,
2138        mut read: F,
2139    ) -> Result<T>
2140    where
2141        F: FnMut(
2142            &Table,
2143            Snapshot,
2144            Option<&crate::security::CandidateAuthorization<'_>>,
2145            Option<&crate::auth::Principal>,
2146        ) -> Result<T>,
2147    {
2148        if principal.is_none() && self.principal.read().is_some() {
2149            self.refresh_principal()?;
2150        }
2151        const RETRIES: usize = 3;
2152        let handle = self.table(table_name)?;
2153        for attempt in 0..RETRIES {
2154            if let Some(context) = context {
2155                context.checkpoint()?;
2156            }
2157            crate::trace::QueryTrace::record(|trace| {
2158                trace.authorization_retries = attempt;
2159            });
2160            let (security, security_version, effective_principal) = {
2161                let catalog = self.catalog.read();
2162                (
2163                    catalog.security.clone(),
2164                    catalog.security_version,
2165                    self.principal_for_authorized_read(&catalog, principal, catalog_bound)?,
2166                )
2167            };
2168            if let Some(authorization) = authorization {
2169                self.require_columns_for(
2170                    table_name,
2171                    authorization.operation,
2172                    &authorization.columns,
2173                    effective_principal.as_ref(),
2174                )?;
2175            }
2176            let result = {
2177                let (table, snapshot, _snapshot_guard, _run_pins) =
2178                    self.scored_read_generation(&handle, context, snapshot_override)?;
2179                let candidate_authorization = if security.rls_enabled(table_name) {
2180                    Some(crate::security::CandidateAuthorization {
2181                        table: table_name,
2182                        security: &security,
2183                        principal: effective_principal
2184                            .as_ref()
2185                            .ok_or(MongrelError::AuthRequired)?,
2186                    })
2187                } else {
2188                    None
2189                };
2190                read(
2191                    table.as_ref(),
2192                    snapshot,
2193                    candidate_authorization.as_ref(),
2194                    effective_principal.as_ref(),
2195                )?
2196            };
2197            if let Some(context) = context {
2198                context.checkpoint()?;
2199            }
2200            if self.catalog.read().security_version == security_version {
2201                return Ok(result);
2202            }
2203            if attempt + 1 == RETRIES {
2204                return Err(MongrelError::Conflict(
2205                    "security policy changed during scored read".into(),
2206                ));
2207            }
2208        }
2209        unreachable!()
2210    }
2211
2212    fn scored_read_generation(
2213        &self,
2214        handle: &TableHandle,
2215        context: Option<&crate::query::AiExecutionContext>,
2216        snapshot_override: Option<Snapshot>,
2217    ) -> Result<(
2218        Arc<Table>,
2219        Snapshot,
2220        crate::retention::OwnedSnapshotGuard,
2221        RunPins,
2222    )> {
2223        let mut table = if let Some(context) = context {
2224            loop {
2225                context.checkpoint()?;
2226                let wait = context
2227                    .remaining_duration()
2228                    .unwrap_or(std::time::Duration::from_millis(5))
2229                    .min(std::time::Duration::from_millis(5));
2230                if let Some(table) = handle.try_read_for(wait) {
2231                    break table;
2232                }
2233            }
2234        } else {
2235            handle.read()
2236        };
2237        let (snapshot, snapshot_guard) = if let Some(snapshot) = snapshot_override {
2238            self.snapshot_at_owned(snapshot.epoch)?
2239        } else {
2240            let snapshot = table.snapshot();
2241            let guard = self.snapshots.register_owned(snapshot.epoch);
2242            (snapshot, guard)
2243        };
2244        let table_id = table.table_id();
2245        let run_keys: Vec<_> = table
2246            .active_run_ids()
2247            .map(|run_id| (table_id, run_id))
2248            .collect();
2249        let generation = table.generation()?;
2250        let run_pins = self.pin_runs(&run_keys);
2251        Ok((generation, snapshot, snapshot_guard, run_pins))
2252    }
2253
2254    fn pin_runs(&self, runs: &[(u64, u128)]) -> RunPins {
2255        let mut pins = self.backup_pins.lock();
2256        for run in runs {
2257            *pins.entry(*run).or_insert(0) += 1;
2258        }
2259        drop(pins);
2260        RunPins {
2261            pins: Arc::clone(&self.backup_pins),
2262            runs: runs.to_vec(),
2263        }
2264    }
2265
2266    /// Execute a native conjunctive read with the database principal's row
2267    /// policy, column grants, and masks applied. Raw [`Table`] methods remain
2268    /// policy-unaware; language bindings must use this boundary for reads.
2269    pub fn query_for_current_principal(
2270        &self,
2271        table_name: &str,
2272        query: &crate::query::Query,
2273        projection: Option<&[u16]>,
2274    ) -> Result<Vec<crate::memtable::Row>> {
2275        let condition_columns = crate::query::condition_columns(&query.conditions);
2276        self.with_authorized_read(
2277            table_name,
2278            None,
2279            true,
2280            |table, snapshot, allowed, principal| {
2281                let allowed_columns = self.select_column_ids_for(table_name, principal)?;
2282                self.require_columns_for(
2283                    table_name,
2284                    crate::auth::ColumnOperation::Select,
2285                    &condition_columns,
2286                    principal,
2287                )?;
2288                if let Some(projection) = projection {
2289                    self.require_columns_for(
2290                        table_name,
2291                        crate::auth::ColumnOperation::Select,
2292                        projection,
2293                        principal,
2294                    )?;
2295                }
2296                let mut rows = table.query_at_with_allowed(query, snapshot, allowed)?;
2297                let projection =
2298                    projection.map(|columns| columns.iter().copied().collect::<HashSet<_>>());
2299                for row in &mut rows {
2300                    row.columns.retain(|column, _| {
2301                        allowed_columns.contains(column)
2302                            && projection
2303                                .as_ref()
2304                                .map_or(true, |projection| projection.contains(column))
2305                    });
2306                }
2307                self.secure_rows_for(table_name, rows, principal)
2308            },
2309        )
2310    }
2311
2312    /// Read one row with the database principal's row policy, column grants,
2313    /// and masks applied.
2314    pub fn get_for_current_principal(
2315        &self,
2316        table_name: &str,
2317        row_id: RowId,
2318    ) -> Result<Option<crate::memtable::Row>> {
2319        self.with_authorized_read(
2320            table_name,
2321            None,
2322            true,
2323            |table, snapshot, allowed, principal| {
2324                let allowed_columns = self.select_column_ids_for(table_name, principal)?;
2325                let Some(row) = table.get(row_id, snapshot) else {
2326                    return Ok(None);
2327                };
2328                if allowed.is_some_and(|allowed| !allowed.contains(&row.row_id)) {
2329                    return Ok(None);
2330                }
2331                let mut rows = self.secure_rows_for(table_name, vec![row], principal)?;
2332                if let Some(row) = rows.first_mut() {
2333                    row.columns
2334                        .retain(|column, _| allowed_columns.contains(column));
2335                }
2336                Ok(rows.pop())
2337            },
2338        )
2339    }
2340
2341    /// Run exact ANN reranking over only rows authorized for this database
2342    /// handle. The embedding column still requires normal column access.
2343    pub fn ann_rerank_for_current_principal(
2344        &self,
2345        table_name: &str,
2346        request: &crate::query::AnnRerankRequest,
2347    ) -> Result<Vec<crate::query::AnnRerankHit>> {
2348        self.with_authorized_scored_read_context_at(
2349            table_name,
2350            None,
2351            true,
2352            Some(&ReadAuthorization {
2353                operation: crate::auth::ColumnOperation::Select,
2354                columns: vec![request.column_id],
2355            }),
2356            None,
2357            None,
2358            |table, snapshot, authorization, principal| {
2359                self.require_columns_for(
2360                    table_name,
2361                    crate::auth::ColumnOperation::Select,
2362                    &[request.column_id],
2363                    principal,
2364                )?;
2365                table.ann_rerank_at_with_candidate_authorization_on_generation(
2366                    request,
2367                    snapshot,
2368                    authorization,
2369                    None,
2370                )
2371            },
2372        )
2373    }
2374
2375    /// Capture one table snapshot and the security version used to authorize it.
2376    /// The caller must validate the returned version before publishing results.
2377    pub fn authorized_read_snapshot(
2378        &self,
2379        table: &str,
2380        principal: Option<&crate::auth::Principal>,
2381    ) -> Result<AuthorizedReadSnapshot> {
2382        let (security, security_version, effective_principal) = {
2383            let catalog = self.catalog.read();
2384            (
2385                catalog.security.clone(),
2386                catalog.security_version,
2387                self.principal_for_authorized_read(&catalog, principal, false)?,
2388            )
2389        };
2390        let handle = self.table(table)?;
2391        let (table_snapshot, data_generation, allowed_row_ids) = {
2392            let table_handle = handle.lock();
2393            let table_snapshot = table_handle.snapshot();
2394            let data_generation = table_handle.data_generation();
2395            let allowed = self.allowed_row_ids_locked(
2396                table,
2397                &table_handle,
2398                table_snapshot,
2399                (&security, security_version),
2400                effective_principal.as_ref(),
2401                None,
2402            )?;
2403            (
2404                table_snapshot,
2405                data_generation,
2406                allowed.map(|allowed| (*allowed).clone()),
2407            )
2408        };
2409        Ok(AuthorizedReadSnapshot {
2410            table: table.to_string(),
2411            table_snapshot,
2412            data_generation,
2413            security_version,
2414            allowed_row_ids,
2415        })
2416    }
2417
2418    pub fn authorized_read_snapshot_valid(&self, snapshot: &AuthorizedReadSnapshot) -> bool {
2419        if self.catalog.read().security_version != snapshot.security_version {
2420            return false;
2421        }
2422        self.table(&snapshot.table)
2423            .ok()
2424            .is_some_and(|table| table.lock().data_generation() == snapshot.data_generation)
2425    }
2426
2427    pub fn rls_cache_stats(&self) -> RlsCacheStats {
2428        self.rls_cache.lock().stats()
2429    }
2430
2431    /// Read visible rows with column authorization, RLS, and masks applied.
2432    pub fn rows_for(
2433        &self,
2434        table: &str,
2435        principal: Option<&crate::auth::Principal>,
2436    ) -> Result<Vec<crate::memtable::Row>> {
2437        if principal.is_none() && self.principal.read().is_some() {
2438            self.refresh_principal()?;
2439        }
2440        let allowed = self.select_column_ids_for(table, principal)?;
2441        let handle = self.table(table)?;
2442        let rows = {
2443            let table = handle.lock();
2444            table.visible_rows(table.snapshot())?
2445        };
2446        let mut rows = self.secure_rows_for(table, rows, principal)?;
2447        for row in &mut rows {
2448            row.columns.retain(|column, _| allowed.contains(column));
2449        }
2450        Ok(rows)
2451    }
2452
2453    /// Historical rows use the current principal and security catalog against
2454    /// the row values visible at the requested snapshot.
2455    pub fn rows_at_epoch_for_current_principal(
2456        &self,
2457        table_name: &str,
2458        snapshot: Snapshot,
2459    ) -> Result<Vec<crate::memtable::Row>> {
2460        self.with_authorized_read_context(
2461            table_name,
2462            None,
2463            true,
2464            Some(&ReadAuthorization {
2465                operation: crate::auth::ColumnOperation::Select,
2466                columns: Vec::new(),
2467            }),
2468            None,
2469            Some(snapshot),
2470            |table, snapshot, allowed, principal| {
2471                let allowed_columns = self.select_column_ids_for(table_name, principal)?;
2472                let mut rows = table.visible_rows(snapshot)?;
2473                if let Some(allowed) = allowed {
2474                    rows.retain(|row| allowed.contains(&row.row_id));
2475                }
2476                rows = self.secure_rows_for(table_name, rows, principal)?;
2477                for row in &mut rows {
2478                    row.columns
2479                        .retain(|column, _| allowed_columns.contains(column));
2480                }
2481                Ok(rows)
2482            },
2483        )
2484    }
2485
2486    /// Count rows visible to a principal without bypassing RLS.
2487    pub fn count_for(
2488        &self,
2489        table: &str,
2490        principal: Option<&crate::auth::Principal>,
2491    ) -> Result<u64> {
2492        if principal.is_none() && self.principal.read().is_some() {
2493            self.refresh_principal()?;
2494        }
2495        self.select_column_ids_for(table, principal)?;
2496        if self.security_active_for(table) {
2497            Ok(self.rows_for(table, principal)?.len() as u64)
2498        } else {
2499            Ok(self.table(table)?.lock().count())
2500        }
2501    }
2502
2503    /// Authorize and write one native-API row for an explicit principal.
2504    pub fn put_for(
2505        &self,
2506        table: &str,
2507        mut cells: Vec<(u16, crate::memtable::Value)>,
2508        principal: Option<&crate::auth::Principal>,
2509    ) -> Result<RowId> {
2510        let columns = cells.iter().map(|(column, _)| *column).collect::<Vec<_>>();
2511        self.require_columns_for(
2512            table,
2513            crate::auth::ColumnOperation::Insert,
2514            &columns,
2515            principal,
2516        )?;
2517        let handle = self.table(table)?;
2518        let mut table_handle = handle.lock();
2519        table_handle.fill_auto_inc(&mut cells)?;
2520        table_handle.apply_defaults(&mut cells)?;
2521        let mut row = crate::memtable::Row::new(RowId(0), self.epoch.visible());
2522        row.columns.extend(cells.iter().cloned());
2523        self.check_row_policy_for(
2524            table,
2525            crate::security::PolicyCommand::Insert,
2526            &row,
2527            true,
2528            principal,
2529        )?;
2530        table_handle.put(cells)
2531    }
2532
2533    pub fn check_row_policy_for(
2534        &self,
2535        table: &str,
2536        command: crate::security::PolicyCommand,
2537        row: &crate::memtable::Row,
2538        check_new: bool,
2539        principal: Option<&crate::auth::Principal>,
2540    ) -> Result<()> {
2541        let security = self.catalog.read().security.clone();
2542        if !security.rls_enabled(table) {
2543            return Ok(());
2544        }
2545        let cached = self.principal.read().clone();
2546        let principal = principal
2547            .or(cached.as_ref())
2548            .ok_or(MongrelError::AuthRequired)?;
2549        if security.row_allowed(table, command, row, principal, check_new) {
2550            return Ok(());
2551        }
2552        let required = match command {
2553            crate::security::PolicyCommand::Insert => crate::auth::Permission::Insert {
2554                table: table.to_string(),
2555            },
2556            crate::security::PolicyCommand::Update => crate::auth::Permission::Update {
2557                table: table.to_string(),
2558            },
2559            crate::security::PolicyCommand::Select => crate::auth::Permission::Select {
2560                table: table.to_string(),
2561            },
2562            crate::security::PolicyCommand::Delete | crate::security::PolicyCommand::All => {
2563                crate::auth::Permission::Delete {
2564                    table: table.to_string(),
2565                }
2566            }
2567        };
2568        Err(MongrelError::PermissionDenied {
2569            required,
2570            principal: principal.username.clone(),
2571        })
2572    }
2573
2574    /// Durably create or replace a materialized-view definition after its
2575    /// physical table has been populated.
2576    pub fn set_materialized_view(
2577        &self,
2578        definition: crate::catalog::MaterializedViewEntry,
2579    ) -> Result<()> {
2580        use crate::wal::DdlOp;
2581        use std::sync::atomic::Ordering;
2582
2583        self.require(&crate::auth::Permission::Ddl)?;
2584        if self.poisoned.load(Ordering::Relaxed) {
2585            return Err(MongrelError::Other(
2586                "database poisoned by fsync error".into(),
2587            ));
2588        }
2589        if definition.name.is_empty() || definition.query.trim().is_empty() {
2590            return Err(MongrelError::InvalidArgument(
2591                "materialized view name and query must not be empty".into(),
2592            ));
2593        }
2594
2595        let _ddl = self.ddl_lock.lock();
2596        let table_id = self
2597            .catalog
2598            .read()
2599            .live(&definition.name)
2600            .ok_or_else(|| {
2601                MongrelError::NotFound(format!(
2602                    "materialized view table {:?} not found",
2603                    definition.name
2604                ))
2605            })?
2606            .table_id;
2607        let definition_json = DdlOp::encode_materialized_view(&definition)?;
2608        let _commit = self.commit_lock.lock();
2609        let epoch = self.epoch.bump_assigned();
2610        let mut epoch_guard = EpochGuard::new(self.epoch.as_ref(), epoch);
2611        let txn_id = self.alloc_txn_id();
2612        let commit_seq = {
2613            let mut wal = self.shared_wal.lock();
2614            wal.append(
2615                txn_id,
2616                table_id,
2617                crate::wal::Op::Ddl(DdlOp::SetMaterializedView {
2618                    name: definition.name.clone(),
2619                    definition_json,
2620                }),
2621            )?;
2622            wal.append_commit(txn_id, epoch, &[])?
2623        };
2624        self.group
2625            .await_durable(&self.shared_wal, commit_seq)
2626            .inspect_err(|_| {
2627                self.poisoned.store(true, Ordering::Relaxed);
2628            })?;
2629
2630        {
2631            let mut catalog = self.catalog.write();
2632            if let Some(existing) = catalog
2633                .materialized_views
2634                .iter_mut()
2635                .find(|existing| existing.name == definition.name)
2636            {
2637                *existing = definition;
2638            } else {
2639                catalog.materialized_views.push(definition);
2640            }
2641        }
2642        catalog::write_atomic(&self.root, &self.catalog.read(), self.meta_dek.as_ref())?;
2643        self.epoch.publish_in_order(epoch);
2644        epoch_guard.disarm();
2645        Ok(())
2646    }
2647
2648    /// The filesystem root this database was opened/created at.
2649    pub fn root(&self) -> &Path {
2650        &self.root
2651    }
2652
2653    pub fn is_read_only_replica(&self) -> bool {
2654        self.read_only
2655    }
2656
2657    pub fn set_replication_wal_retention_segments(&self, segments: usize) {
2658        self.replication_wal_retention_segments
2659            .store(segments, std::sync::atomic::Ordering::Relaxed);
2660    }
2661
2662    /// Capture a consistent bootstrap image. DDL, transaction spill/publish,
2663    /// direct table commits, compaction, and WAL append are quiesced while the
2664    /// file image is read. WAL records newer than manifests remain sufficient
2665    /// for recovery, so no flush or compaction is required.
2666    pub fn replication_snapshot(&self) -> Result<crate::replication::ReplicationSnapshot> {
2667        let _barrier = self.replication_barrier.write();
2668        let _ddl = self.ddl_lock.lock();
2669        let mut handles: Vec<_> = self
2670            .tables
2671            .read()
2672            .iter()
2673            .map(|(id, handle)| (*id, handle.clone()))
2674            .collect();
2675        handles.sort_by_key(|(id, _)| *id);
2676        let _table_guards: Vec<_> = handles.iter().map(|(_, handle)| handle.lock()).collect();
2677        let _commit = self.commit_lock.lock();
2678        let mut wal = self.shared_wal.lock();
2679        wal.group_sync()?;
2680        let wal_dek = crate::encryption::wal_dek_for(self.kek.as_deref());
2681        let records = crate::wal::SharedWal::replay_with_dek(&self.root, wal_dek.as_ref())?;
2682        let epoch = records
2683            .iter()
2684            .filter_map(|record| match &record.op {
2685                crate::wal::Op::TxnCommit { epoch, .. } => Some(*epoch),
2686                _ => None,
2687            })
2688            .max()
2689            .unwrap_or(0)
2690            .max(self.visible_epoch().0);
2691        let files = crate::replication::capture_files(&self.root)?;
2692        drop(wal);
2693        Ok(crate::replication::ReplicationSnapshot::new(epoch, files))
2694    }
2695
2696    /// Create an online, directly-openable backup at `destination`.
2697    ///
2698    /// The short boundary phase quiesces commits/DDL, syncs the WAL, copies
2699    /// mutable metadata, and pins the exact immutable runs named by the copied
2700    /// manifests. Writers resume while those runs stream into a sibling staging
2701    /// directory. A checksummed backup manifest is written last, then the stage
2702    /// is atomically renamed into place.
2703    pub fn hot_backup(&self, destination: impl AsRef<Path>) -> Result<crate::backup::BackupReport> {
2704        self.require(&crate::auth::Permission::Ddl)?;
2705        let (destination, parent, stage) =
2706            prepare_backup_destination(&self.root, destination.as_ref())?;
2707        std::fs::create_dir(&stage)?;
2708
2709        let outcome = (|| {
2710            let barrier = self.replication_barrier.write();
2711            let ddl = self.ddl_lock.lock();
2712            let mut handles: Vec<_> = self
2713                .tables
2714                .read()
2715                .iter()
2716                .map(|(id, handle)| (*id, handle.clone()))
2717                .collect();
2718            handles.sort_by_key(|(id, _)| *id);
2719            let table_guards: Vec<_> = handles.iter().map(|(_, handle)| handle.lock()).collect();
2720            let commit = self.commit_lock.lock();
2721            let mut wal = self.shared_wal.lock();
2722            wal.group_sync()?;
2723            let epoch = self.visible_epoch().0;
2724
2725            let pin_nonce = std::time::SystemTime::now()
2726                .duration_since(std::time::UNIX_EPOCH)
2727                .unwrap_or_default()
2728                .as_nanos();
2729            let file_pin_root = self
2730                .root
2731                .join(META_DIR)
2732                .join("backup-pins")
2733                .join(format!("{}-{pin_nonce}", std::process::id()));
2734            std::fs::create_dir_all(&file_pin_root)?;
2735            let _file_pins = BackupFilePins {
2736                root: file_pin_root.clone(),
2737            };
2738            let mut run_files = Vec::new();
2739            for (index, (table_id, _)) in handles.iter().enumerate() {
2740                let table = &table_guards[index];
2741                for run in table.run_refs() {
2742                    let source = table.runs_dir().join(format!("r-{}.sr", run.run_id));
2743                    let relative = source
2744                        .strip_prefix(&self.root)
2745                        .map_err(|error| MongrelError::Other(format!("backup run path: {error}")))?
2746                        .to_path_buf();
2747                    let pinned = file_pin_root.join(format!("{table_id}-{}.sr", run.run_id));
2748                    if std::fs::hard_link(&source, &pinned).is_err() {
2749                        crate::backup::copy_file_synced(&source, &pinned)?;
2750                    }
2751                    run_files.push(((*table_id, run.run_id), pinned, relative));
2752                }
2753            }
2754            std::fs::File::open(&file_pin_root)?.sync_all()?;
2755            let run_keys: Vec<_> = run_files.iter().map(|(key, _, _)| *key).collect();
2756            {
2757                let mut pins = self.backup_pins.lock();
2758                for key in &run_keys {
2759                    *pins.entry(*key).or_insert(0) += 1;
2760                }
2761            }
2762            let _run_pins = RunPins {
2763                pins: Arc::clone(&self.backup_pins),
2764                runs: run_keys,
2765            };
2766            let deferred: HashSet<_> = run_files
2767                .iter()
2768                .map(|(_, _, relative)| relative.clone())
2769                .collect();
2770            let mut copied = Vec::new();
2771            copy_backup_boundary(&self.root, &stage, &deferred, &mut copied)?;
2772
2773            drop(wal);
2774            drop(commit);
2775            drop(table_guards);
2776            drop(ddl);
2777            drop(barrier);
2778
2779            if let Some(hook) = self.backup_hook.lock().as_ref() {
2780                hook();
2781            }
2782            for (_, source, relative) in run_files {
2783                crate::backup::copy_file_synced(&source, &stage.join(&relative))?;
2784                copied.push(relative);
2785            }
2786
2787            let manifest = crate::backup::BackupManifest::create(&stage, epoch, &copied)?;
2788            manifest.write(&stage)?;
2789            crate::backup::sync_directories(&stage)?;
2790            if destination.exists() {
2791                return Err(MongrelError::Conflict(format!(
2792                    "backup destination already exists: {}",
2793                    destination.display()
2794                )));
2795            }
2796            std::fs::rename(&stage, &destination)?;
2797            std::fs::File::open(&parent)?.sync_all()?;
2798            Ok(crate::backup::BackupReport {
2799                destination,
2800                epoch,
2801                files: manifest.files.len(),
2802                bytes: manifest.total_bytes(),
2803            })
2804        })();
2805
2806        if outcome.is_err() && stage.exists() {
2807            let _ = std::fs::remove_dir_all(&stage);
2808        }
2809        outcome
2810    }
2811
2812    /// Return complete committed transactions after `since_epoch`. A gap or a
2813    /// transaction backed by a spilled run requires a fresh bootstrap image.
2814    pub fn replication_batch_since(
2815        &self,
2816        since_epoch: u64,
2817    ) -> Result<crate::replication::ReplicationBatch> {
2818        use crate::wal::Op;
2819
2820        let mut wal = self.shared_wal.lock();
2821        wal.group_sync()?;
2822        let wal_dek = crate::encryption::wal_dek_for(self.kek.as_deref());
2823        let records = crate::wal::SharedWal::replay_with_dek(&self.root, wal_dek.as_ref())?;
2824        drop(wal);
2825
2826        let commits: HashMap<u64, u64> = records
2827            .iter()
2828            .filter_map(|record| match &record.op {
2829                Op::TxnCommit { epoch, .. } => Some((record.txn_id, *epoch)),
2830                _ => None,
2831            })
2832            .collect();
2833        let earliest_epoch = commits.values().copied().min();
2834        let current_epoch = commits
2835            .values()
2836            .copied()
2837            .max()
2838            .unwrap_or(0)
2839            .max(self.visible_epoch().0);
2840        let selected: HashSet<u64> = commits
2841            .iter()
2842            .filter_map(|(txn_id, epoch)| (*epoch > since_epoch).then_some(*txn_id))
2843            .collect();
2844        let retention_gap = since_epoch < current_epoch
2845            && earliest_epoch.map_or(true, |epoch| epoch > since_epoch.saturating_add(1));
2846        let spilled = records.iter().any(|record| {
2847            selected.contains(&record.txn_id)
2848                && matches!(
2849                    &record.op,
2850                    Op::TxnCommit { added_runs, .. } if !added_runs.is_empty()
2851                )
2852        });
2853        let records = records
2854            .into_iter()
2855            .filter(|record| record.txn_id != crate::wal::SYSTEM_TXN_ID)
2856            .filter(|record| selected.contains(&record.txn_id))
2857            .collect();
2858        Ok(crate::replication::ReplicationBatch {
2859            current_epoch,
2860            earliest_epoch,
2861            requires_snapshot: retention_gap || spilled,
2862            records,
2863        })
2864    }
2865
2866    /// Durably append a leader batch to a follower's local WAL. The caller
2867    /// must drop and reopen this handle to run ordinary WAL recovery before it
2868    /// advances `_meta/repl_epoch`.
2869    pub fn append_replication_batch(&self, records: &[crate::wal::Record]) -> Result<u64> {
2870        use crate::wal::Op;
2871
2872        if !self.read_only {
2873            return Err(MongrelError::InvalidArgument(
2874                "replication batches may only target a marked replica".into(),
2875            ));
2876        }
2877        let current = crate::replication::replica_epoch(&self.root)?;
2878        let mut commits = HashMap::new();
2879        let mut commit_timestamps = HashMap::new();
2880        for record in records {
2881            match &record.op {
2882                Op::TxnCommit { epoch, added_runs } => {
2883                    if !added_runs.is_empty() {
2884                        return Err(MongrelError::Conflict(
2885                            "replication snapshot required for spilled-run transaction".into(),
2886                        ));
2887                    }
2888                    if commits.insert(record.txn_id, *epoch).is_some() {
2889                        return Err(MongrelError::InvalidArgument(format!(
2890                            "duplicate commit for replication transaction {}",
2891                            record.txn_id
2892                        )));
2893                    }
2894                }
2895                Op::CommitTimestamp { unix_nanos } => {
2896                    commit_timestamps.insert(record.txn_id, *unix_nanos);
2897                }
2898                _ => {}
2899            }
2900        }
2901        for record in records {
2902            if record.txn_id != crate::wal::SYSTEM_TXN_ID
2903                && !matches!(&record.op, Op::TxnAbort)
2904                && !commits.contains_key(&record.txn_id)
2905            {
2906                return Err(MongrelError::InvalidArgument(format!(
2907                    "incomplete replication transaction {}",
2908                    record.txn_id
2909                )));
2910            }
2911        }
2912        let target_epoch = commits
2913            .values()
2914            .copied()
2915            .filter(|epoch| *epoch > current)
2916            .max()
2917            .unwrap_or(current);
2918        let mut selected: HashSet<u64> = commits
2919            .iter()
2920            .filter_map(|(txn_id, epoch)| (*epoch > current).then_some(*txn_id))
2921            .collect();
2922        if selected.is_empty() {
2923            return Ok(current);
2924        }
2925        let mut wal = self.shared_wal.lock();
2926        wal.group_sync()?;
2927        let wal_dek = crate::encryption::wal_dek_for(self.kek.as_deref());
2928        let existing: HashSet<(u64, u64)> =
2929            crate::wal::SharedWal::replay_with_dek(&self.root, wal_dek.as_ref())?
2930                .into_iter()
2931                .filter_map(|record| match record.op {
2932                    Op::TxnCommit { epoch, .. } => Some((record.txn_id, epoch)),
2933                    _ => None,
2934                })
2935                .collect();
2936        selected.retain(|txn_id| {
2937            commits
2938                .get(txn_id)
2939                .is_some_and(|epoch| !existing.contains(&(*txn_id, *epoch)))
2940        });
2941        for record in records {
2942            if !selected.contains(&record.txn_id) {
2943                continue;
2944            }
2945            match &record.op {
2946                Op::TxnCommit { epoch, added_runs } => {
2947                    let timestamp = commit_timestamps
2948                        .get(&record.txn_id)
2949                        .copied()
2950                        .unwrap_or_else(current_unix_nanos);
2951                    wal.append_commit_at(record.txn_id, Epoch(*epoch), added_runs, timestamp)?;
2952                }
2953                Op::TxnAbort | Op::Flush { .. } | Op::CommitTimestamp { .. } => {}
2954                op => {
2955                    wal.append(record.txn_id, 0, op.clone())?;
2956                }
2957            }
2958        }
2959        if !selected.is_empty() {
2960            wal.group_sync()?;
2961        }
2962        Ok(target_epoch)
2963    }
2964
2965    /// Resolve a table name → id (live tables only). pub(crate) so the
2966    /// transaction layer can stage by name.
2967    pub fn table_id(&self, name: &str) -> Result<u64> {
2968        let cat = self.catalog.read();
2969        cat.live(name)
2970            .map(|e| e.table_id)
2971            .ok_or_else(|| MongrelError::NotFound(format!("table {name:?} not found")))
2972    }
2973
2974    pub fn procedures(&self) -> Vec<StoredProcedure> {
2975        self.catalog
2976            .read()
2977            .procedures
2978            .iter()
2979            .map(|p| p.procedure.clone())
2980            .collect()
2981    }
2982
2983    pub fn procedure(&self, name: &str) -> Option<StoredProcedure> {
2984        self.catalog
2985            .read()
2986            .procedures
2987            .iter()
2988            .find(|p| p.procedure.name == name)
2989            .map(|p| p.procedure.clone())
2990    }
2991
2992    pub fn create_procedure(&self, mut procedure: StoredProcedure) -> Result<StoredProcedure> {
2993        self.require(&crate::auth::Permission::Ddl)?;
2994        let _g = self.ddl_lock.lock();
2995        procedure.validate()?;
2996        self.validate_procedure_references(&procedure)?;
2997        {
2998            let cat = self.catalog.read();
2999            if cat
3000                .procedures
3001                .iter()
3002                .any(|p| p.procedure.name == procedure.name)
3003            {
3004                return Err(MongrelError::InvalidArgument(format!(
3005                    "procedure {:?} already exists",
3006                    procedure.name
3007                )));
3008            }
3009        }
3010        let commit_lock = Arc::clone(&self.commit_lock);
3011        let _c = commit_lock.lock();
3012        let epoch = self.epoch.bump_assigned();
3013        let mut _epoch_guard = EpochGuard::new(self.epoch.as_ref(), epoch);
3014        procedure.created_epoch = epoch.0;
3015        procedure.updated_epoch = epoch.0;
3016        {
3017            let mut cat = self.catalog.write();
3018            cat.procedures.push(ProcedureEntry::from(procedure.clone()));
3019            cat.db_epoch = epoch.0;
3020        }
3021        catalog::write_atomic(&self.root, &self.catalog.read(), self.meta_dek.as_ref())?;
3022        self.epoch.publish_in_order(epoch);
3023        _epoch_guard.disarm();
3024        Ok(procedure)
3025    }
3026
3027    pub fn create_or_replace_procedure(
3028        &self,
3029        procedure: StoredProcedure,
3030    ) -> Result<StoredProcedure> {
3031        let _g = self.ddl_lock.lock();
3032        procedure.validate()?;
3033        self.validate_procedure_references(&procedure)?;
3034        let commit_lock = Arc::clone(&self.commit_lock);
3035        let _c = commit_lock.lock();
3036        let epoch = self.epoch.bump_assigned();
3037        let mut _epoch_guard = EpochGuard::new(self.epoch.as_ref(), epoch);
3038        let replaced = {
3039            let mut cat = self.catalog.write();
3040            let next = match cat
3041                .procedures
3042                .iter()
3043                .position(|p| p.procedure.name == procedure.name)
3044            {
3045                Some(idx) => {
3046                    let next = cat.procedures[idx]
3047                        .procedure
3048                        .replaced(procedure.clone(), epoch.0)?;
3049                    cat.procedures[idx] = ProcedureEntry::from(next.clone());
3050                    next
3051                }
3052                None => {
3053                    let mut next = procedure;
3054                    next.created_epoch = epoch.0;
3055                    next.updated_epoch = epoch.0;
3056                    cat.procedures.push(ProcedureEntry::from(next.clone()));
3057                    next
3058                }
3059            };
3060            cat.db_epoch = epoch.0;
3061            next
3062        };
3063        catalog::write_atomic(&self.root, &self.catalog.read(), self.meta_dek.as_ref())?;
3064        self.epoch.publish_in_order(epoch);
3065        _epoch_guard.disarm();
3066        Ok(replaced)
3067    }
3068
3069    pub fn drop_procedure(&self, name: &str) -> Result<()> {
3070        self.require(&crate::auth::Permission::Ddl)?;
3071        let _g = self.ddl_lock.lock();
3072        let commit_lock = Arc::clone(&self.commit_lock);
3073        let _c = commit_lock.lock();
3074        let epoch = self.epoch.bump_assigned();
3075        let mut _epoch_guard = EpochGuard::new(self.epoch.as_ref(), epoch);
3076        {
3077            let mut cat = self.catalog.write();
3078            let before = cat.procedures.len();
3079            cat.procedures.retain(|p| p.procedure.name != name);
3080            if cat.procedures.len() == before {
3081                return Err(MongrelError::NotFound(format!(
3082                    "procedure {name:?} not found"
3083                )));
3084            }
3085            cat.db_epoch = epoch.0;
3086        }
3087        catalog::write_atomic(&self.root, &self.catalog.read(), self.meta_dek.as_ref())?;
3088        self.epoch.publish_in_order(epoch);
3089        _epoch_guard.disarm();
3090        Ok(())
3091    }
3092
3093    // ── User / role / credentials management ─────────────────────────────
3094
3095    /// List all catalog users (password hashes included — callers should not
3096    /// serialize them externally).
3097    pub fn users(&self) -> Vec<crate::auth::UserEntry> {
3098        self.catalog.read().users.clone()
3099    }
3100
3101    /// List all catalog roles.
3102    pub fn roles(&self) -> Vec<crate::auth::RoleEntry> {
3103        self.catalog.read().roles.clone()
3104    }
3105
3106    /// Create a new user with an Argon2id-hashed password.
3107    pub fn create_user(&self, username: &str, password: &str) -> Result<crate::auth::UserEntry> {
3108        self.require(&crate::auth::Permission::Admin)?;
3109        let hash = crate::auth::hash_password(password).map_err(MongrelError::Other)?;
3110        let epoch = self.epoch.bump_assigned();
3111        let mut _epoch_guard = EpochGuard::new(self.epoch.as_ref(), epoch);
3112        let id = {
3113            let mut cat = self.catalog.write();
3114            if cat.users.iter().any(|u| u.username == username) {
3115                return Err(MongrelError::InvalidArgument(format!(
3116                    "user {username:?} already exists"
3117                )));
3118            }
3119            cat.next_user_id += 1;
3120            let id = cat.next_user_id;
3121            let entry = crate::auth::UserEntry {
3122                id,
3123                username: username.into(),
3124                password_hash: hash,
3125                roles: Vec::new(),
3126                is_admin: false,
3127                created_epoch: epoch.0,
3128            };
3129            cat.users.push(entry.clone());
3130            cat.security_version = cat.security_version.wrapping_add(1);
3131            cat.db_epoch = epoch.0;
3132            entry
3133        };
3134        catalog::write_atomic(&self.root, &self.catalog.read(), self.meta_dek.as_ref())?;
3135        self.epoch.publish_in_order(epoch);
3136        _epoch_guard.disarm();
3137        Ok(id)
3138    }
3139
3140    /// Drop a user by username.
3141    pub fn drop_user(&self, username: &str) -> Result<()> {
3142        self.require(&crate::auth::Permission::Admin)?;
3143        let epoch = self.epoch.bump_assigned();
3144        let mut _epoch_guard = EpochGuard::new(self.epoch.as_ref(), epoch);
3145        {
3146            let mut cat = self.catalog.write();
3147            let before = cat.users.len();
3148            cat.users.retain(|u| u.username != username);
3149            if cat.users.len() == before {
3150                return Err(MongrelError::NotFound(format!(
3151                    "user {username:?} not found"
3152                )));
3153            }
3154            cat.security_version = cat.security_version.wrapping_add(1);
3155            cat.db_epoch = epoch.0;
3156        }
3157        catalog::write_atomic(&self.root, &self.catalog.read(), self.meta_dek.as_ref())?;
3158        self.epoch.publish_in_order(epoch);
3159        _epoch_guard.disarm();
3160        Ok(())
3161    }
3162
3163    /// Change a user's password.
3164    pub fn alter_user_password(&self, username: &str, new_password: &str) -> Result<()> {
3165        self.require(&crate::auth::Permission::Admin)?;
3166        let hash = crate::auth::hash_password(new_password).map_err(MongrelError::Other)?;
3167        let epoch = self.epoch.bump_assigned();
3168        let mut _epoch_guard = EpochGuard::new(self.epoch.as_ref(), epoch);
3169        {
3170            let mut cat = self.catalog.write();
3171            let user = cat
3172                .users
3173                .iter_mut()
3174                .find(|u| u.username == username)
3175                .ok_or_else(|| MongrelError::NotFound(format!("user {username:?} not found")))?;
3176            user.password_hash = hash;
3177            cat.security_version = cat.security_version.wrapping_add(1);
3178            cat.db_epoch = epoch.0;
3179        }
3180        catalog::write_atomic(&self.root, &self.catalog.read(), self.meta_dek.as_ref())?;
3181        self.epoch.publish_in_order(epoch);
3182        _epoch_guard.disarm();
3183        Ok(())
3184    }
3185
3186    /// Verify credentials. Returns `Some(entry)` on success, `None` on
3187    /// mismatch, `Err` on engine error.
3188    pub fn verify_user(
3189        &self,
3190        username: &str,
3191        password: &str,
3192    ) -> Result<Option<crate::auth::UserEntry>> {
3193        let cat = self.catalog.read();
3194        let Some(user) = cat.users.iter().find(|u| u.username == username) else {
3195            return Ok(None);
3196        };
3197        if user.password_hash.is_empty() {
3198            return Ok(None);
3199        }
3200        let ok = crate::auth::verify_password(password, &user.password_hash)
3201            .map_err(MongrelError::Other)?;
3202        if ok {
3203            Ok(Some(user.clone()))
3204        } else {
3205            Ok(None)
3206        }
3207    }
3208
3209    /// Grant admin privileges to a user (bypasses all permission checks).
3210    pub fn set_user_admin(&self, username: &str, is_admin: bool) -> Result<()> {
3211        self.require(&crate::auth::Permission::Admin)?;
3212        let epoch = self.epoch.bump_assigned();
3213        let mut _epoch_guard = EpochGuard::new(self.epoch.as_ref(), epoch);
3214        {
3215            let mut cat = self.catalog.write();
3216            let user = cat
3217                .users
3218                .iter_mut()
3219                .find(|u| u.username == username)
3220                .ok_or_else(|| MongrelError::NotFound(format!("user {username:?} not found")))?;
3221            user.is_admin = is_admin;
3222            cat.security_version = cat.security_version.wrapping_add(1);
3223            cat.db_epoch = epoch.0;
3224        }
3225        catalog::write_atomic(&self.root, &self.catalog.read(), self.meta_dek.as_ref())?;
3226        self.epoch.publish_in_order(epoch);
3227        _epoch_guard.disarm();
3228        Ok(())
3229    }
3230
3231    /// Create a new role.
3232    pub fn create_role(&self, name: &str) -> Result<crate::auth::RoleEntry> {
3233        self.require(&crate::auth::Permission::Admin)?;
3234        let epoch = self.epoch.bump_assigned();
3235        let mut _epoch_guard = EpochGuard::new(self.epoch.as_ref(), epoch);
3236        let entry = {
3237            let mut cat = self.catalog.write();
3238            if cat.roles.iter().any(|r| r.name == name) {
3239                return Err(MongrelError::InvalidArgument(format!(
3240                    "role {name:?} already exists"
3241                )));
3242            }
3243            let entry = crate::auth::RoleEntry {
3244                name: name.into(),
3245                permissions: Vec::new(),
3246                created_epoch: epoch.0,
3247            };
3248            cat.roles.push(entry.clone());
3249            cat.security_version = cat.security_version.wrapping_add(1);
3250            cat.db_epoch = epoch.0;
3251            entry
3252        };
3253        catalog::write_atomic(&self.root, &self.catalog.read(), self.meta_dek.as_ref())?;
3254        self.epoch.publish_in_order(epoch);
3255        _epoch_guard.disarm();
3256        Ok(entry)
3257    }
3258
3259    /// Drop a role by name.
3260    pub fn drop_role(&self, name: &str) -> Result<()> {
3261        self.require(&crate::auth::Permission::Admin)?;
3262        let epoch = self.epoch.bump_assigned();
3263        let mut _epoch_guard = EpochGuard::new(self.epoch.as_ref(), epoch);
3264        {
3265            let mut cat = self.catalog.write();
3266            let before = cat.roles.len();
3267            cat.roles.retain(|r| r.name != name);
3268            if cat.roles.len() == before {
3269                return Err(MongrelError::NotFound(format!("role {name:?} not found")));
3270            }
3271            // Remove the role from all users.
3272            for user in &mut cat.users {
3273                user.roles.retain(|r| r != name);
3274            }
3275            cat.security_version = cat.security_version.wrapping_add(1);
3276            cat.db_epoch = epoch.0;
3277        }
3278        catalog::write_atomic(&self.root, &self.catalog.read(), self.meta_dek.as_ref())?;
3279        self.epoch.publish_in_order(epoch);
3280        _epoch_guard.disarm();
3281        Ok(())
3282    }
3283
3284    /// Grant a role to a user.
3285    pub fn grant_role(&self, username: &str, role_name: &str) -> Result<()> {
3286        self.require(&crate::auth::Permission::Admin)?;
3287        let epoch = self.epoch.bump_assigned();
3288        let mut _epoch_guard = EpochGuard::new(self.epoch.as_ref(), epoch);
3289        {
3290            let mut cat = self.catalog.write();
3291            if !cat.roles.iter().any(|r| r.name == role_name) {
3292                return Err(MongrelError::NotFound(format!(
3293                    "role {role_name:?} not found"
3294                )));
3295            }
3296            let user = cat
3297                .users
3298                .iter_mut()
3299                .find(|u| u.username == username)
3300                .ok_or_else(|| MongrelError::NotFound(format!("user {username:?} not found")))?;
3301            if !user.roles.contains(&role_name.to_string()) {
3302                user.roles.push(role_name.into());
3303            }
3304            cat.security_version = cat.security_version.wrapping_add(1);
3305            cat.db_epoch = epoch.0;
3306        }
3307        catalog::write_atomic(&self.root, &self.catalog.read(), self.meta_dek.as_ref())?;
3308        self.epoch.publish_in_order(epoch);
3309        _epoch_guard.disarm();
3310        Ok(())
3311    }
3312
3313    /// Revoke a role from a user.
3314    pub fn revoke_role(&self, username: &str, role_name: &str) -> Result<()> {
3315        self.require(&crate::auth::Permission::Admin)?;
3316        let epoch = self.epoch.bump_assigned();
3317        let mut _epoch_guard = EpochGuard::new(self.epoch.as_ref(), epoch);
3318        {
3319            let mut cat = self.catalog.write();
3320            let user = cat
3321                .users
3322                .iter_mut()
3323                .find(|u| u.username == username)
3324                .ok_or_else(|| MongrelError::NotFound(format!("user {username:?} not found")))?;
3325            user.roles.retain(|r| r != role_name);
3326            cat.security_version = cat.security_version.wrapping_add(1);
3327            cat.db_epoch = epoch.0;
3328        }
3329        catalog::write_atomic(&self.root, &self.catalog.read(), self.meta_dek.as_ref())?;
3330        self.epoch.publish_in_order(epoch);
3331        _epoch_guard.disarm();
3332        Ok(())
3333    }
3334
3335    /// Grant a permission to a role.
3336    pub fn grant_permission(
3337        &self,
3338        role_name: &str,
3339        permission: crate::auth::Permission,
3340    ) -> Result<()> {
3341        self.require(&crate::auth::Permission::Admin)?;
3342        let epoch = self.epoch.bump_assigned();
3343        let mut _epoch_guard = EpochGuard::new(self.epoch.as_ref(), epoch);
3344        {
3345            let mut cat = self.catalog.write();
3346            let role = cat
3347                .roles
3348                .iter_mut()
3349                .find(|r| r.name == role_name)
3350                .ok_or_else(|| MongrelError::NotFound(format!("role {role_name:?} not found")))?;
3351            merge_permission(&mut role.permissions, permission);
3352            cat.security_version = cat.security_version.wrapping_add(1);
3353            cat.db_epoch = epoch.0;
3354        }
3355        catalog::write_atomic(&self.root, &self.catalog.read(), self.meta_dek.as_ref())?;
3356        self.epoch.publish_in_order(epoch);
3357        _epoch_guard.disarm();
3358        Ok(())
3359    }
3360
3361    /// Revoke a permission from a role.
3362    pub fn revoke_permission(
3363        &self,
3364        role_name: &str,
3365        permission: crate::auth::Permission,
3366    ) -> Result<()> {
3367        self.require(&crate::auth::Permission::Admin)?;
3368        let epoch = self.epoch.bump_assigned();
3369        let mut _epoch_guard = EpochGuard::new(self.epoch.as_ref(), epoch);
3370        {
3371            let mut cat = self.catalog.write();
3372            let role = cat
3373                .roles
3374                .iter_mut()
3375                .find(|r| r.name == role_name)
3376                .ok_or_else(|| MongrelError::NotFound(format!("role {role_name:?} not found")))?;
3377            revoke_permission_from(&mut role.permissions, &permission);
3378            cat.security_version = cat.security_version.wrapping_add(1);
3379            cat.db_epoch = epoch.0;
3380        }
3381        catalog::write_atomic(&self.root, &self.catalog.read(), self.meta_dek.as_ref())?;
3382        self.epoch.publish_in_order(epoch);
3383        _epoch_guard.disarm();
3384        Ok(())
3385    }
3386
3387    /// Resolve a user into a [`crate::auth::Principal`] by collecting all
3388    /// permissions from their roles. Returns `None` if the user doesn't exist.
3389    pub fn resolve_principal(&self, username: &str) -> Option<crate::auth::Principal> {
3390        let cat = self.catalog.read();
3391        Self::resolve_principal_from_catalog(&cat, username)
3392    }
3393
3394    /// Resolve a username to a [`Principal`] directly from a catalog snapshot,
3395    /// without needing a constructed `Database`. Used by the credentialed open
3396    /// path (which must verify credentials before the `Database` exists) and
3397    /// by [`resolve_principal`](Self::resolve_principal).
3398    fn resolve_principal_from_catalog(
3399        cat: &Catalog,
3400        username: &str,
3401    ) -> Option<crate::auth::Principal> {
3402        let user = cat.users.iter().find(|u| u.username == username)?;
3403        let mut permissions = Vec::new();
3404        for role_name in &user.roles {
3405            if let Some(role) = cat.roles.iter().find(|r| &r.name == role_name) {
3406                permissions.extend(role.permissions.iter().cloned());
3407            }
3408        }
3409        Some(crate::auth::Principal {
3410            username: user.username.clone(),
3411            is_admin: user.is_admin,
3412            roles: user.roles.clone(),
3413            permissions,
3414        })
3415    }
3416
3417    /// Check whether a user has a specific permission (via their roles).
3418    pub fn check_permission(&self, username: &str, permission: &crate::auth::Permission) -> bool {
3419        match self.resolve_principal(username) {
3420            Some(p) => p.has_permission(permission),
3421            None => false,
3422        }
3423    }
3424
3425    /// Returns `true` if this database's catalog has `require_auth = true`.
3426    /// When true, every operation consults the cached [`Principal`] via
3427    /// [`require`](Self::require).
3428    pub fn require_auth_enabled(&self) -> bool {
3429        self.catalog.read().require_auth
3430    }
3431
3432    /// A snapshot of the cached principal for this handle, if any. `None` for
3433    /// databases opened without credentials (the default). Returns a clone
3434    /// because the principal lives behind an `RwLock`.
3435    pub fn principal(&self) -> Option<crate::auth::Principal> {
3436        self.principal.read().clone()
3437    }
3438
3439    /// Build a `TableAuthChecker` from the current auth state. Used when
3440    /// mounting a new table (`create_table`) so the table inherits the
3441    /// database's enforcement configuration. The checker reads the live
3442    /// `require_auth` flag and cached principal, so changes via `enable_auth`
3443    /// / `refresh_principal` propagate to already-mounted tables.
3444    fn table_auth_checker(&self) -> Option<Arc<dyn crate::auth_state::TableAuthChecker>> {
3445        Some(Arc::new(crate::auth_state::DefaultTableAuthChecker::new(
3446            self.auth_state.clone(),
3447        )))
3448    }
3449
3450    /// Re-resolve the cached principal from the on-disk catalog. Long-lived
3451    /// handles (e.g. a daemon) call this after a `REVOKE` or role change —
3452    /// possibly made by a different handle to the same database — to pick up
3453    /// the new effective permissions without re-verifying the password.
3454    ///
3455    /// This reloads the catalog from disk first, so changes committed by other
3456    /// handles (or other processes) are visible. The username is taken from
3457    /// the existing cached principal; if the user has since been dropped,
3458    /// returns [`MongrelError::InvalidCredentials`].
3459    ///
3460    /// No-op (returns `Ok(())`) on a credentialless database, or on a
3461    /// credentialed database whose cached principal is `None`.
3462    pub fn refresh_principal(&self) -> Result<()> {
3463        let username = match self.principal.read().clone() {
3464            Some(p) => p.username,
3465            None => return Ok(()),
3466        };
3467        // Reload the catalog from disk so role/permission changes made by
3468        // other handles (or processes) are reflected. The in-memory catalog
3469        // is only updated by mutations on *this* handle.
3470        let cat = catalog::read(&self.root, self.meta_dek.as_ref())?
3471            .ok_or_else(|| MongrelError::NotFound("catalog vanished during refresh".into()))?;
3472        // Swap in the reloaded catalog so subsequent operations on this handle
3473        // also see the updated permissions/roles.
3474        *self.catalog.write() = cat.clone();
3475        match Self::resolve_principal_from_catalog(&cat, &username) {
3476            Some(p) => {
3477                *self.principal.write() = Some(p.clone());
3478                // Update the shared auth state so mounted Tables see the new
3479                // permissions immediately (Tables read from AuthState, not from
3480                // self.principal).
3481                self.auth_state.set_principal(Some(p));
3482                Ok(())
3483            }
3484            None => Err(MongrelError::InvalidCredentials { username }),
3485        }
3486    }
3487
3488    /// Convert a credentialless database to a credentialed one: create the
3489    /// first admin user, set `require_auth = true`, and cache the admin
3490    /// principal on this handle so subsequent operations on the same handle
3491    /// continue to work. After this call, the database can only be reopened
3492    /// via `open_with_credentials` / `open_encrypted_with_credentials`.
3493    ///
3494    /// Refuses if the database already has `require_auth = true`. This is
3495    /// the conversion path for existing databases; for fresh databases,
3496    /// `create_with_credentials` sets everything up atomically.
3497    ///
3498    /// See `docs/15-credential-enforcement.md`.
3499    pub fn enable_auth(&self, admin_username: &str, admin_password: &str) -> Result<()> {
3500        let password_hash =
3501            crate::auth::hash_password(admin_password).map_err(MongrelError::Other)?;
3502        let epoch = self.epoch.bump_assigned();
3503        let mut _epoch_guard = EpochGuard::new(self.epoch.as_ref(), epoch);
3504        {
3505            let mut cat = self.catalog.write();
3506            if cat.require_auth {
3507                return Err(MongrelError::InvalidArgument(
3508                    "database already has require_auth enabled".into(),
3509                ));
3510            }
3511            // Reject a duplicate username so the bootstrap doesn't silently
3512            // shadow an existing user.
3513            if cat.users.iter().any(|u| u.username == admin_username) {
3514                return Err(MongrelError::InvalidArgument(format!(
3515                    "user {admin_username:?} already exists"
3516                )));
3517            }
3518            cat.next_user_id = cat.next_user_id.max(1);
3519            let id = cat.next_user_id;
3520            cat.next_user_id += 1;
3521            cat.users.push(crate::auth::UserEntry {
3522                id,
3523                username: admin_username.to_string(),
3524                password_hash,
3525                roles: Vec::new(),
3526                is_admin: true,
3527                created_epoch: epoch.0,
3528            });
3529            cat.require_auth = true;
3530            cat.security_version = cat.security_version.wrapping_add(1);
3531            cat.db_epoch = epoch.0;
3532        }
3533        catalog::write_atomic(&self.root, &self.catalog.read(), self.meta_dek.as_ref())?;
3534        // Cache the admin principal on this handle + update the shared auth
3535        // state so mounted tables start enforcing immediately.
3536        *self.principal.write() = Some(crate::auth::Principal {
3537            username: admin_username.to_string(),
3538            is_admin: true,
3539            roles: Vec::new(),
3540            permissions: Vec::new(),
3541        });
3542        self.auth_state.set_require_auth(true);
3543        self.epoch.publish_in_order(epoch);
3544        _epoch_guard.disarm();
3545        Ok(())
3546    }
3547
3548    /// Disable `require_auth` on this database, reverting it to credentialless
3549    /// mode. This is the **recovery** path — it requires the handle to already
3550    /// be open (and therefore already authenticated if `require_auth` was on).
3551    ///
3552    /// After this call, the database can be reopened with plain
3553    /// [`open`](Self::open) / [`open_encrypted`](Self::open_encrypted) without
3554    /// credentials. All existing users and roles are preserved in the catalog
3555    /// (so `require_auth` can be re-enabled without recreating them), but they
3556    /// are no longer consulted for enforcement.
3557    ///
3558    /// For true **offline** recovery (when credentials are lost and no
3559    /// authenticated handle is available), the caller opens the database
3560    /// directly via the catalog file (filesystem access required) and calls
3561    /// this method — see the CLI's `auth disable-offline` command.
3562    ///
3563    /// See `docs/15-credential-enforcement.md` §4.7.
3564    pub fn disable_auth(&self) -> Result<()> {
3565        let epoch = self.epoch.bump_assigned();
3566        let mut _epoch_guard = EpochGuard::new(self.epoch.as_ref(), epoch);
3567        {
3568            let mut cat = self.catalog.write();
3569            if !cat.require_auth {
3570                return Err(MongrelError::InvalidArgument(
3571                    "database does not have require_auth enabled".into(),
3572                ));
3573            }
3574            cat.require_auth = false;
3575            cat.security_version = cat.security_version.wrapping_add(1);
3576            cat.db_epoch = epoch.0;
3577        }
3578        catalog::write_atomic(&self.root, &self.catalog.read(), self.meta_dek.as_ref())?;
3579        // Clear the cached principal — enforcement is now off.
3580        *self.principal.write() = None;
3581        // Update the shared auth state so mounted tables also stop enforcing.
3582        self.auth_state.set_require_auth(false);
3583        self.epoch.publish_in_order(epoch);
3584        _epoch_guard.disarm();
3585        Ok(())
3586    }
3587
3588    /// Enforcement check: if the catalog has `require_auth = true`, verify
3589    /// that the cached principal satisfies `perm`. Called by every
3590    /// enforcement point (DDL, admin, maintenance, and — in Phase 2 —
3591    /// Table/Transaction/MongrelSession operations).
3592    ///
3593    /// On a credentialless database this is a no-op (`Ok(())`).
3594    pub fn require(&self, perm: &crate::auth::Permission) -> Result<()> {
3595        if self.read_only && !matches!(perm, crate::auth::Permission::Select { .. }) {
3596            return Err(MongrelError::ReadOnlyReplica);
3597        }
3598        if self.principal.read().is_some() {
3599            self.refresh_principal().map_err(|error| match error {
3600                MongrelError::InvalidCredentials { .. } => MongrelError::AuthRequired,
3601                error => error,
3602            })?;
3603        }
3604        if !self.catalog.read().require_auth {
3605            return Ok(());
3606        }
3607        let guard = self.principal.read();
3608        let p = guard.as_ref().ok_or(MongrelError::AuthRequired)?;
3609        if p.has_permission(perm) {
3610            Ok(())
3611        } else {
3612            Err(MongrelError::PermissionDenied {
3613                required: perm.clone(),
3614                principal: p.username.clone(),
3615            })
3616        }
3617    }
3618
3619    /// Convenience: enforce a table-level permission (`Select`/`Insert`/
3620    /// `Update`/`Delete`) by table name. Used by the Transaction layer and
3621    /// other callers that know the operation kind + table name but don't want
3622    /// to construct the full `Permission` enum value themselves.
3623    pub fn require_table(
3624        &self,
3625        table: &str,
3626        perm: crate::auth_state::RequiredPermission,
3627    ) -> Result<()> {
3628        self.require(&perm.into_permission(table))
3629    }
3630
3631    pub fn triggers(&self) -> Vec<StoredTrigger> {
3632        self.catalog
3633            .read()
3634            .triggers
3635            .iter()
3636            .map(|t| t.trigger.clone())
3637            .collect()
3638    }
3639
3640    pub fn trigger(&self, name: &str) -> Option<StoredTrigger> {
3641        self.catalog
3642            .read()
3643            .triggers
3644            .iter()
3645            .find(|t| t.trigger.name == name)
3646            .map(|t| t.trigger.clone())
3647    }
3648
3649    pub fn create_trigger(&self, mut trigger: StoredTrigger) -> Result<StoredTrigger> {
3650        self.require(&crate::auth::Permission::Ddl)?;
3651        let _g = self.ddl_lock.lock();
3652        trigger.validate()?;
3653        self.validate_trigger_references(&trigger)?;
3654        {
3655            let cat = self.catalog.read();
3656            if cat.triggers.iter().any(|t| t.trigger.name == trigger.name) {
3657                return Err(MongrelError::InvalidArgument(format!(
3658                    "trigger {:?} already exists",
3659                    trigger.name
3660                )));
3661            }
3662        }
3663        let commit_lock = Arc::clone(&self.commit_lock);
3664        let _c = commit_lock.lock();
3665        let epoch = self.epoch.bump_assigned();
3666        let mut _epoch_guard = EpochGuard::new(self.epoch.as_ref(), epoch);
3667        trigger.created_epoch = epoch.0;
3668        trigger.updated_epoch = epoch.0;
3669        {
3670            let mut cat = self.catalog.write();
3671            cat.triggers.push(TriggerEntry::from(trigger.clone()));
3672            cat.db_epoch = epoch.0;
3673        }
3674        catalog::write_atomic(&self.root, &self.catalog.read(), self.meta_dek.as_ref())?;
3675        self.epoch.publish_in_order(epoch);
3676        _epoch_guard.disarm();
3677        Ok(trigger)
3678    }
3679
3680    pub fn create_or_replace_trigger(&self, trigger: StoredTrigger) -> Result<StoredTrigger> {
3681        let _g = self.ddl_lock.lock();
3682        trigger.validate()?;
3683        self.validate_trigger_references(&trigger)?;
3684        let commit_lock = Arc::clone(&self.commit_lock);
3685        let _c = commit_lock.lock();
3686        let epoch = self.epoch.bump_assigned();
3687        let mut _epoch_guard = EpochGuard::new(self.epoch.as_ref(), epoch);
3688        let replaced = {
3689            let mut cat = self.catalog.write();
3690            let next = match cat
3691                .triggers
3692                .iter()
3693                .position(|t| t.trigger.name == trigger.name)
3694            {
3695                Some(idx) => {
3696                    let next = cat.triggers[idx]
3697                        .trigger
3698                        .replaced(trigger.clone(), epoch.0)?;
3699                    cat.triggers[idx] = TriggerEntry::from(next.clone());
3700                    next
3701                }
3702                None => {
3703                    let mut next = trigger;
3704                    next.created_epoch = epoch.0;
3705                    next.updated_epoch = epoch.0;
3706                    cat.triggers.push(TriggerEntry::from(next.clone()));
3707                    next
3708                }
3709            };
3710            cat.db_epoch = epoch.0;
3711            next
3712        };
3713        catalog::write_atomic(&self.root, &self.catalog.read(), self.meta_dek.as_ref())?;
3714        self.epoch.publish_in_order(epoch);
3715        _epoch_guard.disarm();
3716        Ok(replaced)
3717    }
3718
3719    pub fn drop_trigger(&self, name: &str) -> Result<()> {
3720        self.require(&crate::auth::Permission::Ddl)?;
3721        let _g = self.ddl_lock.lock();
3722        let commit_lock = Arc::clone(&self.commit_lock);
3723        let _c = commit_lock.lock();
3724        let epoch = self.epoch.bump_assigned();
3725        let mut _epoch_guard = EpochGuard::new(self.epoch.as_ref(), epoch);
3726        {
3727            let mut cat = self.catalog.write();
3728            let before = cat.triggers.len();
3729            cat.triggers.retain(|t| t.trigger.name != name);
3730            if cat.triggers.len() == before {
3731                return Err(MongrelError::NotFound(format!(
3732                    "trigger {name:?} not found"
3733                )));
3734            }
3735            cat.db_epoch = epoch.0;
3736        }
3737        catalog::write_atomic(&self.root, &self.catalog.read(), self.meta_dek.as_ref())?;
3738        self.epoch.publish_in_order(epoch);
3739        _epoch_guard.disarm();
3740        Ok(())
3741    }
3742
3743    pub fn external_tables(&self) -> Vec<ExternalTableEntry> {
3744        self.catalog.read().external_tables.clone()
3745    }
3746
3747    pub fn external_table(&self, name: &str) -> Option<ExternalTableEntry> {
3748        self.catalog
3749            .read()
3750            .external_tables
3751            .iter()
3752            .find(|t| t.name == name)
3753            .cloned()
3754    }
3755
3756    pub fn create_external_table(
3757        &self,
3758        mut entry: ExternalTableEntry,
3759    ) -> Result<ExternalTableEntry> {
3760        self.require(&crate::auth::Permission::Ddl)?;
3761        let _g = self.ddl_lock.lock();
3762        entry.validate()?;
3763        {
3764            let cat = self.catalog.read();
3765            if cat.live(&entry.name).is_some()
3766                || cat.external_tables.iter().any(|t| t.name == entry.name)
3767            {
3768                return Err(MongrelError::InvalidArgument(format!(
3769                    "table {:?} already exists",
3770                    entry.name
3771                )));
3772            }
3773        }
3774        let commit_lock = Arc::clone(&self.commit_lock);
3775        let _c = commit_lock.lock();
3776        let epoch = self.epoch.bump_assigned();
3777        let mut _epoch_guard = EpochGuard::new(self.epoch.as_ref(), epoch);
3778        entry.created_epoch = epoch.0;
3779        {
3780            let mut cat = self.catalog.write();
3781            cat.external_tables.push(entry.clone());
3782            cat.db_epoch = epoch.0;
3783        }
3784        catalog::write_atomic(&self.root, &self.catalog.read(), self.meta_dek.as_ref())?;
3785        self.epoch.publish_in_order(epoch);
3786        _epoch_guard.disarm();
3787        Ok(entry)
3788    }
3789
3790    pub fn drop_external_table(&self, name: &str) -> Result<()> {
3791        self.require(&crate::auth::Permission::Ddl)?;
3792        let _g = self.ddl_lock.lock();
3793        let commit_lock = Arc::clone(&self.commit_lock);
3794        let _c = commit_lock.lock();
3795        let epoch = self.epoch.bump_assigned();
3796        let mut _epoch_guard = EpochGuard::new(self.epoch.as_ref(), epoch);
3797        {
3798            let mut cat = self.catalog.write();
3799            let before = cat.external_tables.len();
3800            cat.external_tables.retain(|t| t.name != name);
3801            if cat.external_tables.len() == before {
3802                return Err(MongrelError::NotFound(format!(
3803                    "external table {name:?} not found"
3804                )));
3805            }
3806            cat.db_epoch = epoch.0;
3807        }
3808        let state_dir = self.root.join(VTAB_DIR).join(name);
3809        if state_dir.exists() {
3810            std::fs::remove_dir_all(state_dir)?;
3811        }
3812        catalog::write_atomic(&self.root, &self.catalog.read(), self.meta_dek.as_ref())?;
3813        self.epoch.publish_in_order(epoch);
3814        _epoch_guard.disarm();
3815        Ok(())
3816    }
3817
3818    pub fn commit_external_table_state(&self, name: &str, state: &[u8]) -> Result<Epoch> {
3819        let txn_id = self.alloc_txn_id();
3820        self.commit_transaction_with_external_states(
3821            txn_id,
3822            self.epoch.visible(),
3823            Vec::new(),
3824            vec![(name.to_string(), state.to_vec())],
3825            Vec::new(),
3826            None,
3827            false,
3828            None,
3829        )
3830        .map(|(epoch, _)| epoch)
3831    }
3832
3833    pub fn trigger_config(&self) -> TriggerConfig {
3834        use std::sync::atomic::Ordering;
3835        TriggerConfig {
3836            recursive_triggers: self.trigger_recursive.load(Ordering::Relaxed),
3837            max_depth: self.trigger_max_depth.load(Ordering::Relaxed),
3838            max_loop_iterations: self.trigger_max_loop_iterations.load(Ordering::Relaxed),
3839        }
3840    }
3841
3842    pub fn set_trigger_config(&self, config: TriggerConfig) -> Result<()> {
3843        use std::sync::atomic::Ordering;
3844        if config.max_depth == 0 {
3845            return Err(MongrelError::InvalidArgument(
3846                "trigger max_depth must be greater than 0".into(),
3847            ));
3848        }
3849        self.trigger_recursive
3850            .store(config.recursive_triggers, Ordering::Relaxed);
3851        self.trigger_max_depth
3852            .store(config.max_depth, Ordering::Relaxed);
3853        self.trigger_max_loop_iterations
3854            .store(config.max_loop_iterations, Ordering::Relaxed);
3855        Ok(())
3856    }
3857
3858    pub fn set_recursive_triggers(&self, recursive: bool) {
3859        use std::sync::atomic::Ordering;
3860        self.trigger_recursive.store(recursive, Ordering::Relaxed);
3861    }
3862
3863    /// Subscribe to ephemeral SQL NOTIFY messages. Durable row changes use
3864    /// [`Self::change_events_since`], with [`Self::subscribe_change_commits`]
3865    /// as a low-latency wake-up.
3866    pub fn subscribe_changes(&self) -> tokio::sync::broadcast::Receiver<ChangeEvent> {
3867        self.notify.subscribe()
3868    }
3869
3870    pub fn subscribe_change_commits(&self) -> tokio::sync::broadcast::Receiver<()> {
3871        self.change_wake.subscribe()
3872    }
3873
3874    /// Reconstruct committed row changes from the retained shared WAL. Event
3875    /// ids are stable `<commit_epoch>:<operation_index>` pairs. A caller that
3876    /// resumes before the oldest retained commit receives `gap = true` and
3877    /// must rebootstrap instead of silently skipping changes.
3878    pub fn change_events_since(&self, last_event_id: Option<&str>) -> Result<CdcBatch> {
3879        use crate::wal::Op;
3880
3881        let resume = match last_event_id {
3882            Some(id) => {
3883                let (epoch, index) = id.split_once(':').ok_or_else(|| {
3884                    MongrelError::InvalidArgument(format!(
3885                        "invalid CDC event id {id:?}; expected <epoch>:<index>"
3886                    ))
3887                })?;
3888                Some((
3889                    epoch.parse::<u64>().map_err(|error| {
3890                        MongrelError::InvalidArgument(format!("invalid CDC epoch: {error}"))
3891                    })?,
3892                    index.parse::<u32>().map_err(|error| {
3893                        MongrelError::InvalidArgument(format!("invalid CDC index: {error}"))
3894                    })?,
3895                ))
3896            }
3897            None => None,
3898        };
3899
3900        let mut wal = self.shared_wal.lock();
3901        wal.group_sync()?;
3902        let wal_dek = crate::encryption::wal_dek_for(self.kek.as_deref());
3903        let records = crate::wal::SharedWal::replay_with_dek(&self.root, wal_dek.as_ref())?;
3904        drop(wal);
3905
3906        let commits: HashMap<u64, (u64, Vec<crate::wal::AddedRun>)> = records
3907            .iter()
3908            .filter_map(|record| match &record.op {
3909                Op::TxnCommit { epoch, added_runs } => {
3910                    Some((record.txn_id, (*epoch, added_runs.clone())))
3911                }
3912                _ => None,
3913            })
3914            .collect();
3915        let earliest_epoch = commits.values().map(|(epoch, _)| *epoch).min();
3916        let current_epoch = self.epoch.visible().0;
3917        let gap = resume.is_some_and(|(epoch, _)| {
3918            epoch < current_epoch
3919                && earliest_epoch.map_or(true, |earliest| earliest > epoch.saturating_add(1))
3920        });
3921        if gap {
3922            return Ok(CdcBatch {
3923                events: Vec::new(),
3924                current_epoch,
3925                earliest_epoch,
3926                gap: true,
3927            });
3928        }
3929
3930        let table_names: HashMap<u64, String> = self
3931            .catalog
3932            .read()
3933            .tables
3934            .iter()
3935            .map(|entry| (entry.table_id, entry.name.clone()))
3936            .collect();
3937        let before_images: HashMap<(u64, u64, u64), crate::memtable::Row> = records
3938            .iter()
3939            .filter_map(|record| {
3940                if !commits.contains_key(&record.txn_id) {
3941                    return None;
3942                }
3943                let Op::BeforeImage {
3944                    table_id,
3945                    row_id,
3946                    row,
3947                } = &record.op
3948                else {
3949                    return None;
3950                };
3951                bincode::deserialize(row)
3952                    .ok()
3953                    .map(|before| ((record.txn_id, *table_id, row_id.0), before))
3954            })
3955            .collect();
3956        let mut operation_indices: HashMap<u64, u32> = HashMap::new();
3957        let mut events = Vec::new();
3958        for record in &records {
3959            let Some((commit_epoch, _)) = commits.get(&record.txn_id) else {
3960                continue;
3961            };
3962            let event = match &record.op {
3963                Op::Put { table_id, rows } => {
3964                    let rows: Vec<crate::memtable::Row> = bincode::deserialize(rows)?;
3965                    let data = serde_json::to_value(rows)
3966                        .map_err(|error| MongrelError::Other(format!("CDC JSON: {error}")))?;
3967                    Some((*table_id, "put", data))
3968                }
3969                Op::Delete { table_id, row_ids } => {
3970                    let before = row_ids
3971                        .iter()
3972                        .filter_map(|row_id| {
3973                            before_images
3974                                .get(&(record.txn_id, *table_id, row_id.0))
3975                                .cloned()
3976                        })
3977                        .collect::<Vec<_>>();
3978                    Some((
3979                        *table_id,
3980                        "delete",
3981                        serde_json::json!({
3982                            "row_ids": row_ids.iter().map(|row_id| row_id.0).collect::<Vec<_>>(),
3983                            "before": before,
3984                        }),
3985                    ))
3986                }
3987                Op::TruncateTable { table_id } => {
3988                    Some((*table_id, "truncate", serde_json::Value::Null))
3989                }
3990                _ => None,
3991            };
3992            if let Some((table_id, op, data)) = event {
3993                let index = operation_indices.entry(record.txn_id).or_insert(0);
3994                let event_position = (*commit_epoch, *index);
3995                *index = index.saturating_add(1);
3996                if resume.is_some_and(|position| event_position <= position) {
3997                    continue;
3998                }
3999                events.push(ChangeEvent {
4000                    id: Some(format!("{}:{}", event_position.0, event_position.1)),
4001                    channel: "changes".into(),
4002                    table_id: Some(table_id),
4003                    table: table_names.get(&table_id).cloned().unwrap_or_default(),
4004                    op: op.into(),
4005                    epoch: *commit_epoch,
4006                    txn_id: Some(record.txn_id),
4007                    message: None,
4008                    data: Some(data),
4009                });
4010            }
4011            if let Op::TxnCommit { added_runs, .. } = &record.op {
4012                for run in added_runs {
4013                    let index = operation_indices.entry(record.txn_id).or_insert(0);
4014                    let event_position = (*commit_epoch, *index);
4015                    *index = index.saturating_add(1);
4016                    if resume.is_some_and(|position| event_position <= position) {
4017                        continue;
4018                    }
4019                    let handle = self.tables.read().get(&run.table_id).cloned();
4020                    let rows = handle.and_then(|handle| {
4021                        let table = handle.lock();
4022                        let mut reader = table.open_reader(run.run_id).ok()?;
4023                        let mut rows = reader.all_rows().ok()?;
4024                        for row in &mut rows {
4025                            row.committed_epoch = Epoch(*commit_epoch);
4026                        }
4027                        Some(rows)
4028                    });
4029                    let Some(rows) = rows else {
4030                        // Spilled transactions keep row payloads in an immutable
4031                        // run instead of duplicating them in the WAL. If that run
4032                        // was already compacted/reaped, resuming cannot provide a
4033                        // complete row image and must fail closed.
4034                        return Ok(CdcBatch {
4035                            events: Vec::new(),
4036                            current_epoch,
4037                            earliest_epoch,
4038                            gap: true,
4039                        });
4040                    };
4041                    events.push(ChangeEvent {
4042                        id: Some(format!("{}:{}", event_position.0, event_position.1)),
4043                        channel: "changes".into(),
4044                        table_id: Some(run.table_id),
4045                        table: table_names.get(&run.table_id).cloned().unwrap_or_default(),
4046                        op: "put_run".into(),
4047                        epoch: *commit_epoch,
4048                        txn_id: Some(record.txn_id),
4049                        message: None,
4050                        data: Some(serde_json::json!({
4051                            "run_id": run.run_id.to_string(),
4052                            "row_count": run.row_count,
4053                            "min_row_id": run.min_row_id,
4054                            "max_row_id": run.max_row_id,
4055                            "rows": rows,
4056                        })),
4057                    });
4058                }
4059            }
4060        }
4061        Ok(CdcBatch {
4062            events,
4063            current_epoch,
4064            earliest_epoch,
4065            gap: false,
4066        })
4067    }
4068
4069    /// Publish a notification message on a named channel. Reaches all active
4070    /// subscribers (daemon `/events`, application listeners).
4071    pub fn notify(&self, channel: &str, message: Option<String>) {
4072        let _ = self.notify.send(ChangeEvent {
4073            id: None,
4074            channel: channel.to_string(),
4075            table_id: None,
4076            table: String::new(),
4077            op: "notify".into(),
4078            epoch: self.epoch.visible().0,
4079            txn_id: None,
4080            message,
4081            data: None,
4082        });
4083    }
4084
4085    pub fn call_procedure(
4086        &self,
4087        name: &str,
4088        args: HashMap<String, crate::Value>,
4089    ) -> Result<ProcedureCallResult> {
4090        self.call_procedure_as(name, args, None)
4091    }
4092
4093    pub fn call_procedure_as(
4094        &self,
4095        name: &str,
4096        args: HashMap<String, crate::Value>,
4097        principal: Option<&crate::auth::Principal>,
4098    ) -> Result<ProcedureCallResult> {
4099        // v1 requires ALL to call procedures on a require_auth database; a
4100        // finer SECURITY DEFINER-style marker is a future extension (spec §9
4101        // decision 1).
4102        self.require_for(principal, &crate::auth::Permission::All)?;
4103        let procedure = self
4104            .procedure(name)
4105            .ok_or_else(|| MongrelError::NotFound(format!("procedure {name:?} not found")))?;
4106        let args = bind_procedure_args(&procedure, args)?;
4107        let has_writes = procedure.body.steps.iter().any(ProcedureStep::is_write);
4108        let mut outputs: HashMap<String, ProcedureCallOutput> = HashMap::new();
4109        if has_writes {
4110            let mut tx = self.begin_as(principal.cloned());
4111            let run = (|| {
4112                for step in &procedure.body.steps {
4113                    let output = self.execute_procedure_step(
4114                        step,
4115                        &args,
4116                        &outputs,
4117                        Some(&mut tx),
4118                        principal,
4119                    )?;
4120                    outputs.insert(step.id().to_string(), output);
4121                }
4122                eval_return_output(&procedure.body.return_value, &args, &outputs)
4123            })();
4124            match run {
4125                Ok(output) => {
4126                    let epoch = tx.commit()?.0;
4127                    Ok(ProcedureCallResult {
4128                        epoch: Some(epoch),
4129                        output,
4130                    })
4131                }
4132                Err(e) => {
4133                    tx.rollback();
4134                    Err(e)
4135                }
4136            }
4137        } else {
4138            for step in &procedure.body.steps {
4139                let output = self.execute_procedure_step(step, &args, &outputs, None, principal)?;
4140                outputs.insert(step.id().to_string(), output);
4141            }
4142            Ok(ProcedureCallResult {
4143                epoch: None,
4144                output: eval_return_output(&procedure.body.return_value, &args, &outputs)?,
4145            })
4146        }
4147    }
4148
4149    fn execute_procedure_step(
4150        &self,
4151        step: &ProcedureStep,
4152        args: &HashMap<String, crate::Value>,
4153        outputs: &HashMap<String, ProcedureCallOutput>,
4154        tx: Option<&mut crate::txn::Transaction<'_>>,
4155        principal: Option<&crate::auth::Principal>,
4156    ) -> Result<ProcedureCallOutput> {
4157        match step {
4158            ProcedureStep::NativeQuery {
4159                table,
4160                conditions,
4161                projection,
4162                limit,
4163                ..
4164            } => {
4165                let mut q = crate::Query::new();
4166                for condition in conditions {
4167                    q = q.and(eval_condition(condition, args, outputs)?);
4168                }
4169                let handle = self.table(table)?;
4170                let rows = handle.lock().query(&q)?;
4171                let mut rows = self.secure_rows_for(table, rows, principal)?;
4172                if let Some(limit) = limit {
4173                    rows.truncate(*limit);
4174                }
4175                let projection = projection.as_ref();
4176                Ok(ProcedureCallOutput::Rows(
4177                    rows.into_iter()
4178                        .map(|row| ProcedureCallRow {
4179                            row_id: Some(row.row_id),
4180                            columns: match projection {
4181                                Some(ids) => row
4182                                    .columns
4183                                    .into_iter()
4184                                    .filter(|(id, _)| ids.contains(id))
4185                                    .collect(),
4186                                None => row.columns,
4187                            },
4188                        })
4189                        .collect(),
4190                ))
4191            }
4192            ProcedureStep::Put {
4193                table,
4194                cells,
4195                returning,
4196                ..
4197            } => {
4198                let tx = tx.ok_or_else(|| {
4199                    MongrelError::InvalidArgument(
4200                        "write procedure step requires a transaction".into(),
4201                    )
4202                })?;
4203                let cells = eval_cells(cells, args, outputs)?;
4204                if *returning {
4205                    let out = tx.put_returning(table, cells)?;
4206                    Ok(ProcedureCallOutput::Row(ProcedureCallRow {
4207                        row_id: None,
4208                        columns: out.row.columns.into_iter().collect(),
4209                    }))
4210                } else {
4211                    tx.put(table, cells)?;
4212                    Ok(ProcedureCallOutput::Null)
4213                }
4214            }
4215            ProcedureStep::Upsert {
4216                table,
4217                cells,
4218                update_cells,
4219                returning,
4220                ..
4221            } => {
4222                let tx = tx.ok_or_else(|| {
4223                    MongrelError::InvalidArgument(
4224                        "write procedure step requires a transaction".into(),
4225                    )
4226                })?;
4227                let cells = eval_cells(cells, args, outputs)?;
4228                let action = match update_cells {
4229                    Some(update_cells) => {
4230                        crate::UpsertAction::DoUpdate(eval_cells(update_cells, args, outputs)?)
4231                    }
4232                    None => crate::UpsertAction::DoNothing,
4233                };
4234                let out = tx.upsert(table, cells, action)?;
4235                if *returning {
4236                    Ok(ProcedureCallOutput::Row(ProcedureCallRow {
4237                        row_id: None,
4238                        columns: out.row.columns.into_iter().collect(),
4239                    }))
4240                } else {
4241                    Ok(ProcedureCallOutput::Null)
4242                }
4243            }
4244            ProcedureStep::DeleteByPk { table, pk, .. } => {
4245                let tx = tx.ok_or_else(|| {
4246                    MongrelError::InvalidArgument(
4247                        "write procedure step requires a transaction".into(),
4248                    )
4249                })?;
4250                let pk = eval_value(pk, args, outputs)?;
4251                let handle = self.table(table)?;
4252                let row_id = handle.lock().lookup_pk(&pk.encode_key()).ok_or_else(|| {
4253                    MongrelError::NotFound("procedure delete_by_pk target not found".into())
4254                })?;
4255                tx.delete(table, row_id)?;
4256                Ok(ProcedureCallOutput::Scalar(crate::Value::Bool(true)))
4257            }
4258            ProcedureStep::DeleteRows { .. } => Err(MongrelError::InvalidArgument(
4259                "DeleteRows procedure step is not supported by the core executor yet".into(),
4260            )),
4261            ProcedureStep::SqlQuery { .. } => Err(MongrelError::InvalidArgument(
4262                "SqlQuery procedure step must be executed by mongreldb-query".into(),
4263            )),
4264        }
4265    }
4266
4267    fn validate_procedure_references(&self, procedure: &StoredProcedure) -> Result<()> {
4268        let cat = self.catalog.read();
4269        for step in &procedure.body.steps {
4270            let Some(table_name) = step.table() else {
4271                continue;
4272            };
4273            let schema = &cat
4274                .live(table_name)
4275                .ok_or_else(|| {
4276                    MongrelError::InvalidArgument(format!(
4277                        "procedure {:?} references unknown table {table_name:?}",
4278                        procedure.name
4279                    ))
4280                })?
4281                .schema;
4282            match step {
4283                ProcedureStep::NativeQuery {
4284                    conditions,
4285                    projection,
4286                    ..
4287                } => {
4288                    for condition in conditions {
4289                        validate_condition_columns(condition, schema)?;
4290                    }
4291                    if let Some(projection) = projection {
4292                        for id in projection {
4293                            validate_column_id(*id, schema)?;
4294                        }
4295                    }
4296                }
4297                ProcedureStep::Put { cells, .. } => {
4298                    for cell in cells {
4299                        validate_column_id(cell.column_id, schema)?;
4300                    }
4301                }
4302                ProcedureStep::Upsert {
4303                    cells,
4304                    update_cells,
4305                    ..
4306                } => {
4307                    for cell in cells {
4308                        validate_column_id(cell.column_id, schema)?;
4309                    }
4310                    if let Some(update_cells) = update_cells {
4311                        for cell in update_cells {
4312                            validate_column_id(cell.column_id, schema)?;
4313                        }
4314                    }
4315                }
4316                ProcedureStep::DeleteByPk { .. } => {
4317                    if schema.primary_key().is_none() {
4318                        return Err(MongrelError::InvalidArgument(format!(
4319                            "procedure {:?} references DeleteByPk on table {table_name:?} without a primary key",
4320                            procedure.name
4321                        )));
4322                    }
4323                }
4324                ProcedureStep::DeleteRows { .. } | ProcedureStep::SqlQuery { .. } => {}
4325            }
4326        }
4327        Ok(())
4328    }
4329
4330    fn validate_trigger_references(&self, trigger: &StoredTrigger) -> Result<()> {
4331        let cat = self.catalog.read();
4332        let target_schema = match &trigger.target {
4333            TriggerTarget::Table(target_name) => cat
4334                .live(target_name)
4335                .ok_or_else(|| {
4336                    MongrelError::InvalidArgument(format!(
4337                        "trigger {:?} references unknown target table {target_name:?}",
4338                        trigger.name
4339                    ))
4340                })?
4341                .schema
4342                .clone(),
4343            TriggerTarget::View(_) => Schema {
4344                columns: trigger.target_columns.clone(),
4345                ..Schema::default()
4346            },
4347        };
4348        for col in &trigger.update_of {
4349            if target_schema.column(col).is_none() {
4350                return Err(MongrelError::InvalidArgument(format!(
4351                    "trigger {:?} UPDATE OF references unknown column {col:?}",
4352                    trigger.name
4353                )));
4354            }
4355        }
4356        if let Some(expr) = &trigger.when {
4357            validate_trigger_expr(expr, &target_schema, trigger.event)?;
4358        }
4359        let mut select_schemas: HashMap<String, &Schema> = HashMap::new();
4360        for step in &trigger.program.steps {
4361            if matches!(step, TriggerStep::SetNew { .. }) && trigger.timing != TriggerTiming::Before
4362            {
4363                return Err(MongrelError::InvalidArgument(
4364                    "SetNew trigger steps are only valid in BEFORE triggers".into(),
4365                ));
4366            }
4367            validate_trigger_step(
4368                step,
4369                &cat,
4370                &target_schema,
4371                trigger.event,
4372                &mut select_schemas,
4373            )?;
4374        }
4375        Ok(())
4376    }
4377
4378    /// Begin a new transaction reading at the current visible epoch.
4379    pub fn begin(&self) -> crate::txn::Transaction<'_> {
4380        self.begin_with_isolation(crate::txn::IsolationLevel::default())
4381    }
4382
4383    pub fn begin_as(
4384        &self,
4385        principal: Option<crate::auth::Principal>,
4386    ) -> crate::txn::Transaction<'_> {
4387        let catalog_bound = principal.as_ref().is_some_and(|principal| {
4388            self.catalog
4389                .read()
4390                .users
4391                .iter()
4392                .any(|user| user.username == principal.username)
4393        });
4394        let txn_id = self.alloc_txn_id();
4395        let read = Snapshot::at(self.epoch.visible());
4396        crate::txn::Transaction::new(self, txn_id, read).with_principal(principal, catalog_bound)
4397    }
4398
4399    /// Begin a transaction with a specific isolation level.
4400    pub fn begin_with_isolation(
4401        &self,
4402        level: crate::txn::IsolationLevel,
4403    ) -> crate::txn::Transaction<'_> {
4404        let txn_id = self.alloc_txn_id();
4405        let epoch = match level {
4406            crate::txn::IsolationLevel::ReadCommitted => self.epoch.visible(),
4407            _ => self.epoch.visible(),
4408        };
4409        let read = Snapshot::at(epoch);
4410        crate::txn::Transaction::new(self, txn_id, read)
4411    }
4412
4413    /// Begin a transaction whose trigger programs may route external-table DML
4414    /// through an application/query-layer module bridge.
4415    pub fn begin_with_external_trigger_bridge<'a>(
4416        &'a self,
4417        bridge: &'a dyn ExternalTriggerBridge,
4418    ) -> crate::txn::Transaction<'a> {
4419        let txn_id = self.alloc_txn_id();
4420        let read = Snapshot::at(self.epoch.visible());
4421        crate::txn::Transaction::new(self, txn_id, read).with_external_trigger_bridge(bridge)
4422    }
4423
4424    pub fn begin_with_external_trigger_bridge_as<'a>(
4425        &'a self,
4426        bridge: &'a dyn ExternalTriggerBridge,
4427        principal: Option<crate::auth::Principal>,
4428    ) -> crate::txn::Transaction<'a> {
4429        let catalog_bound = principal.as_ref().is_some_and(|principal| {
4430            self.catalog
4431                .read()
4432                .users
4433                .iter()
4434                .any(|user| user.username == principal.username)
4435        });
4436        let txn_id = self.alloc_txn_id();
4437        let read = Snapshot::at(self.epoch.visible());
4438        crate::txn::Transaction::new(self, txn_id, read)
4439            .with_external_trigger_bridge(bridge)
4440            .with_principal(principal, catalog_bound)
4441    }
4442
4443    /// Run `f` in a transaction; commit on `Ok`, rollback on `Err`.
4444    pub fn transaction<T>(
4445        &self,
4446        f: impl FnOnce(&mut crate::txn::Transaction) -> Result<T>,
4447    ) -> Result<T> {
4448        let mut tx = self.begin();
4449        match f(&mut tx) {
4450            Ok(out) => {
4451                tx.commit()?;
4452                Ok(out)
4453            }
4454            Err(e) => {
4455                tx.rollback();
4456                Err(e)
4457            }
4458        }
4459    }
4460
4461    pub fn transaction_with_row_ids<T>(
4462        &self,
4463        f: impl FnOnce(&mut crate::txn::Transaction) -> Result<T>,
4464    ) -> Result<(T, Vec<RowId>)> {
4465        let mut tx = self.begin();
4466        match f(&mut tx) {
4467            Ok(output) => {
4468                let (_, row_ids) = tx.commit_with_row_ids()?;
4469                Ok((output, row_ids))
4470            }
4471            Err(error) => {
4472                tx.rollback();
4473                Err(error)
4474            }
4475        }
4476    }
4477
4478    pub fn transaction_for_current_principal<T>(
4479        &self,
4480        f: impl FnOnce(&mut crate::txn::Transaction) -> Result<T>,
4481    ) -> Result<T> {
4482        if self.principal.read().is_some() {
4483            self.refresh_principal()?;
4484        }
4485        let mut transaction = self.begin_as(self.principal.read().clone());
4486        match f(&mut transaction) {
4487            Ok(output) => {
4488                transaction.commit()?;
4489                Ok(output)
4490            }
4491            Err(error) => {
4492                transaction.rollback();
4493                Err(error)
4494            }
4495        }
4496    }
4497
4498    pub fn transaction_for_current_principal_with_epoch<T>(
4499        &self,
4500        f: impl FnOnce(&mut crate::txn::Transaction) -> Result<T>,
4501    ) -> Result<(Epoch, T)> {
4502        if self.principal.read().is_some() {
4503            self.refresh_principal()?;
4504        }
4505        let mut transaction = self.begin_as(self.principal.read().clone());
4506        match f(&mut transaction) {
4507            Ok(output) => {
4508                let epoch = transaction.commit()?;
4509                Ok((epoch, output))
4510            }
4511            Err(error) => {
4512                transaction.rollback();
4513                Err(error)
4514            }
4515        }
4516    }
4517
4518    pub fn transaction_with_row_ids_for_current_principal<T>(
4519        &self,
4520        f: impl FnOnce(&mut crate::txn::Transaction) -> Result<T>,
4521    ) -> Result<(T, Vec<RowId>)> {
4522        if self.principal.read().is_some() {
4523            self.refresh_principal()?;
4524        }
4525        let mut transaction = self.begin_as(self.principal.read().clone());
4526        match f(&mut transaction) {
4527            Ok(output) => {
4528                let (_, row_ids) = transaction.commit_with_row_ids()?;
4529                Ok((output, row_ids))
4530            }
4531            Err(error) => {
4532                transaction.rollback();
4533                Err(error)
4534            }
4535        }
4536    }
4537
4538    /// Run `f` in a transaction with an external-trigger bridge; commit on
4539    /// `Ok`, rollback on `Err`.
4540    pub fn transaction_with_external_trigger_bridge<'a, T>(
4541        &'a self,
4542        bridge: &'a dyn ExternalTriggerBridge,
4543        f: impl FnOnce(&mut crate::txn::Transaction) -> Result<T>,
4544    ) -> Result<T> {
4545        let mut tx = self.begin_with_external_trigger_bridge(bridge);
4546        match f(&mut tx) {
4547            Ok(out) => {
4548                tx.commit()?;
4549                Ok(out)
4550            }
4551            Err(e) => {
4552                tx.rollback();
4553                Err(e)
4554            }
4555        }
4556    }
4557
4558    pub fn transaction_with_external_trigger_bridge_as<'a, T>(
4559        &'a self,
4560        bridge: &'a dyn ExternalTriggerBridge,
4561        principal: Option<crate::auth::Principal>,
4562        f: impl FnOnce(&mut crate::txn::Transaction) -> Result<T>,
4563    ) -> Result<T> {
4564        let mut tx = self.begin_with_external_trigger_bridge_as(bridge, principal);
4565        match f(&mut tx) {
4566            Ok(output) => {
4567                tx.commit()?;
4568                Ok(output)
4569            }
4570            Err(error) => {
4571                tx.rollback();
4572                Err(error)
4573            }
4574        }
4575    }
4576
4577    /// Register a txn in `ActiveTxns` (spec §9.2, review fix #12). Called from
4578    /// `Transaction::new` so registration happens **before** any read.
4579    pub(crate) fn register_active(&self, epoch: Epoch) -> crate::txn::ActiveTxnGuard<'_> {
4580        self.active_txns.register(epoch)
4581    }
4582
4583    fn fill_auto_increment_for_staging(
4584        &self,
4585        staging: &mut [(u64, crate::txn::Staged)],
4586    ) -> Result<()> {
4587        let tables = self.tables.read();
4588        for (table_id, staged) in staging {
4589            if let crate::txn::Staged::Put(cells) = staged {
4590                if let Some(handle) = tables.get(table_id) {
4591                    let mut t = handle.lock();
4592                    t.fill_auto_inc(cells)?;
4593                }
4594            }
4595        }
4596        Ok(())
4597    }
4598
4599    fn expand_table_triggers(
4600        &self,
4601        staging: &mut Vec<(u64, crate::txn::Staged)>,
4602        read_epoch: Epoch,
4603        external_trigger_bridge: Option<&dyn ExternalTriggerBridge>,
4604        external_states: &mut Vec<(String, Vec<u8>)>,
4605    ) -> Result<()> {
4606        let mut external_writes = Vec::new();
4607        let config = self.trigger_config();
4608        if config.recursive_triggers {
4609            let chunk = std::mem::take(staging);
4610            let stacks = vec![Vec::new(); chunk.len()];
4611            *staging = self.expand_trigger_chunk(
4612                chunk,
4613                stacks,
4614                read_epoch,
4615                0,
4616                config.max_depth,
4617                &mut external_writes,
4618                &config,
4619            )?;
4620            self.apply_external_trigger_writes(
4621                external_writes,
4622                external_trigger_bridge,
4623                external_states,
4624                staging,
4625            )?;
4626            return Ok(());
4627        }
4628
4629        let mut expansion = self.expand_table_triggers_once(staging, read_epoch, None, &config)?;
4630        if !expansion.before.is_empty() {
4631            let mut final_staging = expansion.before;
4632            final_staging.extend(filter_ignored_staging(
4633                std::mem::take(staging),
4634                &expansion.ignored_indices,
4635            ));
4636            *staging = final_staging;
4637        } else if !expansion.ignored_indices.is_empty() {
4638            *staging = filter_ignored_staging(std::mem::take(staging), &expansion.ignored_indices);
4639        }
4640        staging.append(&mut expansion.after);
4641        external_writes.append(&mut expansion.before_external);
4642        external_writes.append(&mut expansion.after_external);
4643        self.apply_external_trigger_writes(
4644            external_writes,
4645            external_trigger_bridge,
4646            external_states,
4647            staging,
4648        )?;
4649        Ok(())
4650    }
4651
4652    #[allow(clippy::too_many_arguments)]
4653    fn expand_trigger_chunk(
4654        &self,
4655        mut chunk: Vec<(u64, crate::txn::Staged)>,
4656        stacks: Vec<Vec<String>>,
4657        read_epoch: Epoch,
4658        depth: u32,
4659        max_depth: u32,
4660        external_writes: &mut Vec<ExternalTriggerWrite>,
4661        config: &TriggerConfig,
4662    ) -> Result<Vec<(u64, crate::txn::Staged)>> {
4663        if chunk.is_empty() {
4664            return Ok(Vec::new());
4665        }
4666        self.fill_auto_increment_for_staging(&mut chunk)?;
4667        let expansion =
4668            self.expand_table_triggers_once(&mut chunk, read_epoch, Some(&stacks), config)?;
4669        if depth >= max_depth && (!expansion.before.is_empty() || !expansion.after.is_empty()) {
4670            let stack = expansion
4671                .before_stacks
4672                .first()
4673                .or_else(|| expansion.after_stacks.first())
4674                .cloned()
4675                .unwrap_or_default();
4676            return Err(MongrelError::Conflict(format!(
4677                "trigger recursion exceeded max depth {max_depth}; trigger stack: {}",
4678                Self::format_trigger_stack(&stack)
4679            )));
4680        }
4681
4682        let mut out = Vec::new();
4683        external_writes.extend(expansion.before_external);
4684        out.extend(self.expand_trigger_chunk(
4685            expansion.before,
4686            expansion.before_stacks,
4687            read_epoch,
4688            depth + 1,
4689            max_depth,
4690            external_writes,
4691            config,
4692        )?);
4693        out.extend(filter_ignored_staging(chunk, &expansion.ignored_indices));
4694        external_writes.extend(expansion.after_external);
4695        out.extend(self.expand_trigger_chunk(
4696            expansion.after,
4697            expansion.after_stacks,
4698            read_epoch,
4699            depth + 1,
4700            max_depth,
4701            external_writes,
4702            config,
4703        )?);
4704        Ok(out)
4705    }
4706
4707    fn apply_external_trigger_writes(
4708        &self,
4709        writes: Vec<ExternalTriggerWrite>,
4710        bridge: Option<&dyn ExternalTriggerBridge>,
4711        external_states: &mut Vec<(String, Vec<u8>)>,
4712        staging: &mut Vec<(u64, crate::txn::Staged)>,
4713    ) -> Result<()> {
4714        if writes.is_empty() {
4715            return Ok(());
4716        }
4717        let bridge = bridge.ok_or_else(|| {
4718            MongrelError::InvalidArgument(
4719                "trigger program wrote an external table, but this transaction has no external trigger bridge".into(),
4720            )
4721        })?;
4722        for write in writes {
4723            let table = write.table().to_string();
4724            let entry = self.external_table(&table).ok_or_else(|| {
4725                MongrelError::NotFound(format!("external table {table:?} not found"))
4726            })?;
4727            let base_state = current_external_state_bytes(&self.root, external_states, &table)?;
4728            let result = bridge.apply_trigger_external_write(&entry, base_state, write)?;
4729            external_states.push((table, result.state));
4730            for base_write in result.base_writes {
4731                match base_write {
4732                    ExternalTriggerBaseWrite::Put { table, cells } => {
4733                        let table_id = self.table_id(&table)?;
4734                        staging.push((table_id, crate::txn::Staged::Put(cells)));
4735                    }
4736                    ExternalTriggerBaseWrite::Delete { table, row_id } => {
4737                        let table_id = self.table_id(&table)?;
4738                        staging.push((table_id, crate::txn::Staged::Delete(row_id)));
4739                    }
4740                }
4741            }
4742        }
4743        dedup_external_states_in_place(external_states);
4744        Ok(())
4745    }
4746
4747    fn expand_table_triggers_once(
4748        &self,
4749        staging: &mut Vec<(u64, crate::txn::Staged)>,
4750        read_epoch: Epoch,
4751        trigger_stacks: Option<&[Vec<String>]>,
4752        config: &TriggerConfig,
4753    ) -> Result<TriggerExpansion> {
4754        let triggers: Vec<StoredTrigger> = self
4755            .catalog
4756            .read()
4757            .triggers
4758            .iter()
4759            .filter(|entry| {
4760                entry.trigger.enabled
4761                    && matches!(
4762                        entry.trigger.timing,
4763                        TriggerTiming::Before | TriggerTiming::After
4764                    )
4765                    && matches!(entry.trigger.target, TriggerTarget::Table(_))
4766            })
4767            .map(|entry| entry.trigger.clone())
4768            .collect();
4769        if triggers.is_empty() || staging.is_empty() {
4770            return Ok(TriggerExpansion::default());
4771        }
4772
4773        let before_triggers = triggers
4774            .iter()
4775            .filter(|trigger| trigger.timing == TriggerTiming::Before)
4776            .cloned()
4777            .collect::<Vec<_>>();
4778        let after_triggers = triggers
4779            .iter()
4780            .filter(|trigger| trigger.timing == TriggerTiming::After)
4781            .cloned()
4782            .collect::<Vec<_>>();
4783
4784        let mut before_added = Vec::new();
4785        let mut before_stacks = Vec::new();
4786        let mut before_external = Vec::new();
4787        let mut ignored_indices = std::collections::BTreeSet::new();
4788        if !before_triggers.is_empty() {
4789            let before_events =
4790                self.trigger_events_for_staging(staging, read_epoch, trigger_stacks)?;
4791            let mut out = TriggerProgramOutput {
4792                added: &mut before_added,
4793                added_stacks: &mut before_stacks,
4794                added_external: &mut before_external,
4795                ignored_indices: &mut ignored_indices,
4796            };
4797            self.execute_triggers_for_events(
4798                &before_triggers,
4799                &before_events,
4800                Some(staging),
4801                &mut out,
4802                config,
4803                read_epoch,
4804            )?;
4805        }
4806
4807        let after_events = if after_triggers.is_empty() {
4808            Vec::new()
4809        } else {
4810            self.trigger_events_for_staging(staging, read_epoch, trigger_stacks)?
4811                .into_iter()
4812                .filter(|event| {
4813                    !event
4814                        .op_indices
4815                        .iter()
4816                        .any(|idx| ignored_indices.contains(idx))
4817                })
4818                .collect()
4819        };
4820
4821        let mut after_added = Vec::new();
4822        let mut after_stacks = Vec::new();
4823        let mut after_external = Vec::new();
4824        let mut out = TriggerProgramOutput {
4825            added: &mut after_added,
4826            added_stacks: &mut after_stacks,
4827            added_external: &mut after_external,
4828            ignored_indices: &mut ignored_indices,
4829        };
4830        self.execute_triggers_for_events(
4831            &after_triggers,
4832            &after_events,
4833            None,
4834            &mut out,
4835            config,
4836            read_epoch,
4837        )?;
4838        Ok(TriggerExpansion {
4839            before: before_added,
4840            before_stacks,
4841            before_external,
4842            after: after_added,
4843            after_stacks,
4844            after_external,
4845            ignored_indices,
4846        })
4847    }
4848
4849    fn execute_triggers_for_events(
4850        &self,
4851        triggers: &[StoredTrigger],
4852        events: &[WriteEvent],
4853        mut staging: Option<&mut Vec<(u64, crate::txn::Staged)>>,
4854        out: &mut TriggerProgramOutput<'_>,
4855        config: &TriggerConfig,
4856        read_epoch: Epoch,
4857    ) -> Result<()> {
4858        for event in events {
4859            for trigger in triggers {
4860                if event
4861                    .op_indices
4862                    .iter()
4863                    .any(|idx| out.ignored_indices.contains(idx))
4864                {
4865                    break;
4866                }
4867                let matches = {
4868                    let cat = self.catalog.read();
4869                    trigger_matches_event(trigger, event, &cat)?
4870                };
4871                if !matches {
4872                    continue;
4873                }
4874                if let Some(when) = &trigger.when {
4875                    if !eval_trigger_expr(when, event)? {
4876                        continue;
4877                    }
4878                }
4879                let trigger_stack = Self::trigger_stack_with(&event.trigger_stack, &trigger.name);
4880                if event.trigger_stack.iter().any(|name| name == &trigger.name) {
4881                    return Err(MongrelError::Conflict(format!(
4882                        "trigger recursion cycle detected; trigger stack: {}",
4883                        Self::format_trigger_stack(&trigger_stack)
4884                    )));
4885                }
4886                let outcome = match staging.as_mut() {
4887                    Some(staging) => self.execute_trigger_program(
4888                        trigger,
4889                        event,
4890                        Some(&mut **staging),
4891                        out,
4892                        &trigger_stack,
4893                        config,
4894                        read_epoch,
4895                    )?,
4896                    None => self.execute_trigger_program(
4897                        trigger,
4898                        event,
4899                        None,
4900                        out,
4901                        &trigger_stack,
4902                        config,
4903                        read_epoch,
4904                    )?,
4905                };
4906                if outcome == TriggerProgramOutcome::Ignore {
4907                    out.ignored_indices.extend(event.op_indices.iter().copied());
4908                    break;
4909                }
4910            }
4911        }
4912        Ok(())
4913    }
4914
4915    fn trigger_events_for_staging(
4916        &self,
4917        staging: &[(u64, crate::txn::Staged)],
4918        read_epoch: Epoch,
4919        trigger_stacks: Option<&[Vec<String>]>,
4920    ) -> Result<Vec<WriteEvent>> {
4921        use crate::txn::Staged;
4922        use std::collections::{HashMap, VecDeque};
4923
4924        let snapshot = Snapshot::at(read_epoch);
4925        let cat = self.catalog.read();
4926        let mut table_names = HashMap::new();
4927        let mut table_schemas = HashMap::new();
4928        for entry in cat
4929            .tables
4930            .iter()
4931            .filter(|entry| matches!(entry.state, TableState::Live))
4932        {
4933            table_names.insert(entry.table_id, entry.name.clone());
4934            table_schemas.insert(entry.table_id, entry.schema.clone());
4935        }
4936        drop(cat);
4937
4938        let mut old_rows: HashMap<usize, TriggerRowImage> = HashMap::new();
4939        let mut delete_by_key: HashMap<(u64, Vec<u8>), VecDeque<usize>> = HashMap::new();
4940        let mut put_by_key: HashMap<(u64, Vec<u8>), VecDeque<usize>> = HashMap::new();
4941
4942        for (idx, (table_id, staged)) in staging.iter().enumerate() {
4943            let Some(schema) = table_schemas.get(table_id) else {
4944                continue;
4945            };
4946            let Some(pk) = schema.primary_key() else {
4947                continue;
4948            };
4949            match staged {
4950                Staged::Delete(row_id) => {
4951                    let handle = self.table_by_id(*table_id)?;
4952                    let Some(row) = handle.lock().get(*row_id, snapshot) else {
4953                        continue;
4954                    };
4955                    let Some(pk_value) = row.columns.get(&pk.id) else {
4956                        continue;
4957                    };
4958                    old_rows.insert(idx, TriggerRowImage::from_row(row.clone()));
4959                    delete_by_key
4960                        .entry((*table_id, pk_value.encode_key()))
4961                        .or_default()
4962                        .push_back(idx);
4963                }
4964                Staged::Put(cells) => {
4965                    if let Some((_, value)) = cells.iter().find(|(id, _)| *id == pk.id) {
4966                        put_by_key
4967                            .entry((*table_id, value.encode_key()))
4968                            .or_default()
4969                            .push_back(idx);
4970                    }
4971                }
4972                Staged::Update(row_id, _) => {
4973                    let handle = self.table_by_id(*table_id)?;
4974                    let row = handle.lock().get(*row_id, snapshot);
4975                    if let Some(row) = row {
4976                        old_rows.insert(idx, TriggerRowImage::from_row(row));
4977                    }
4978                }
4979                Staged::Truncate => {}
4980            }
4981        }
4982
4983        let mut paired_delete = std::collections::HashSet::new();
4984        let mut paired_put = std::collections::HashSet::new();
4985        let mut events = Vec::new();
4986
4987        for (key, deletes) in delete_by_key.iter_mut() {
4988            let Some(puts) = put_by_key.get_mut(key) else {
4989                continue;
4990            };
4991            while let (Some(delete_idx), Some(put_idx)) = (deletes.pop_front(), puts.pop_front()) {
4992                paired_delete.insert(delete_idx);
4993                paired_put.insert(put_idx);
4994                let (table_id, _) = &staging[put_idx];
4995                let Some(table_name) = table_names.get(table_id).cloned() else {
4996                    continue;
4997                };
4998                let old = old_rows.get(&delete_idx).cloned();
4999                let new = match &staging[put_idx].1 {
5000                    Staged::Put(cells) => Some(TriggerRowImage::from_cells(cells)),
5001                    _ => None,
5002                };
5003                let changed_columns = changed_columns(old.as_ref(), new.as_ref());
5004                events.push(WriteEvent {
5005                    table: table_name,
5006                    kind: TriggerEvent::Update,
5007                    old,
5008                    new,
5009                    changed_columns,
5010                    op_indices: vec![delete_idx, put_idx],
5011                    put_idx: Some(put_idx),
5012                    trigger_stack: Self::trigger_stack_for_indices(
5013                        trigger_stacks,
5014                        &[delete_idx, put_idx],
5015                    ),
5016                });
5017            }
5018        }
5019
5020        for (idx, (table_id, staged)) in staging.iter().enumerate() {
5021            let Some(table_name) = table_names.get(table_id).cloned() else {
5022                continue;
5023            };
5024            match staged {
5025                Staged::Put(cells) if !paired_put.contains(&idx) => {
5026                    let new = Some(TriggerRowImage::from_cells(cells));
5027                    let changed_columns = cells.iter().map(|(id, _)| *id).collect();
5028                    events.push(WriteEvent {
5029                        table: table_name,
5030                        kind: TriggerEvent::Insert,
5031                        old: None,
5032                        new,
5033                        changed_columns,
5034                        op_indices: vec![idx],
5035                        put_idx: Some(idx),
5036                        trigger_stack: Self::trigger_stack_for_indices(trigger_stacks, &[idx]),
5037                    });
5038                }
5039                Staged::Delete(row_id) if !paired_delete.contains(&idx) => {
5040                    let old = match old_rows.get(&idx).cloned() {
5041                        Some(old) => Some(old),
5042                        None => {
5043                            let handle = self.table_by_id(*table_id)?;
5044                            let row = handle.lock().get(*row_id, snapshot);
5045                            row.map(TriggerRowImage::from_row)
5046                        }
5047                    };
5048                    let Some(old) = old else {
5049                        continue;
5050                    };
5051                    let changed_columns = old.columns.keys().copied().collect();
5052                    events.push(WriteEvent {
5053                        table: table_name,
5054                        kind: TriggerEvent::Delete,
5055                        old: Some(old),
5056                        new: None,
5057                        changed_columns,
5058                        op_indices: vec![idx],
5059                        put_idx: None,
5060                        trigger_stack: Self::trigger_stack_for_indices(trigger_stacks, &[idx]),
5061                    });
5062                }
5063                Staged::Update(_, cells) => {
5064                    let old = old_rows.get(&idx).cloned();
5065                    let new = Some(TriggerRowImage::from_cells(cells));
5066                    let changed_columns = changed_columns(old.as_ref(), new.as_ref());
5067                    events.push(WriteEvent {
5068                        table: table_name,
5069                        kind: TriggerEvent::Update,
5070                        old,
5071                        new,
5072                        changed_columns,
5073                        op_indices: vec![idx],
5074                        put_idx: Some(idx),
5075                        trigger_stack: Self::trigger_stack_for_indices(trigger_stacks, &[idx]),
5076                    });
5077                }
5078                Staged::Truncate => {}
5079                _ => {}
5080            }
5081        }
5082
5083        Ok(events)
5084    }
5085
5086    #[allow(clippy::too_many_arguments)]
5087    fn execute_trigger_program(
5088        &self,
5089        trigger: &StoredTrigger,
5090        event: &WriteEvent,
5091        staging: Option<&mut Vec<(u64, crate::txn::Staged)>>,
5092        out: &mut TriggerProgramOutput<'_>,
5093        trigger_stack: &[String],
5094        config: &TriggerConfig,
5095        read_epoch: Epoch,
5096    ) -> Result<TriggerProgramOutcome> {
5097        let mut event = event.clone();
5098        let mut select_results: HashMap<String, Vec<TriggerRowImage>> = HashMap::new();
5099        self.execute_trigger_steps(
5100            trigger,
5101            &trigger.program.steps,
5102            &mut event,
5103            staging,
5104            out,
5105            trigger_stack,
5106            config,
5107            &mut select_results,
5108            0,
5109            None,
5110            read_epoch,
5111        )
5112    }
5113
5114    #[allow(clippy::too_many_arguments)]
5115    fn execute_trigger_steps(
5116        &self,
5117        trigger: &StoredTrigger,
5118        steps: &[TriggerStep],
5119        event: &mut WriteEvent,
5120        mut staging: Option<&mut Vec<(u64, crate::txn::Staged)>>,
5121        out: &mut TriggerProgramOutput<'_>,
5122        trigger_stack: &[String],
5123        config: &TriggerConfig,
5124        select_results: &mut HashMap<String, Vec<TriggerRowImage>>,
5125        depth: u32,
5126        selected: Option<&TriggerRowImage>,
5127        read_epoch: Epoch,
5128    ) -> Result<TriggerProgramOutcome> {
5129        let _ = depth;
5130        for step in steps {
5131            match step {
5132                TriggerStep::SetNew { cells } => {
5133                    if trigger.timing != TriggerTiming::Before {
5134                        return Err(MongrelError::InvalidArgument(
5135                            "SetNew trigger step is only valid in BEFORE triggers".into(),
5136                        ));
5137                    }
5138                    let put_idx = event.put_idx.ok_or_else(|| {
5139                        MongrelError::InvalidArgument(
5140                            "SetNew trigger step requires INSERT or UPDATE NEW row".into(),
5141                        )
5142                    })?;
5143                    let staging = staging.as_deref_mut().ok_or_else(|| {
5144                        MongrelError::InvalidArgument(
5145                            "SetNew trigger step requires mutable trigger staging".into(),
5146                        )
5147                    })?;
5148                    let row_cells = match staging.get_mut(put_idx).map(|(_, op)| op) {
5149                        Some(crate::txn::Staged::Put(cells))
5150                        | Some(crate::txn::Staged::Update(_, cells)) => cells,
5151                        _ => {
5152                            return Err(MongrelError::InvalidArgument(
5153                                "SetNew trigger step target row is not mutable".into(),
5154                            ))
5155                        }
5156                    };
5157                    for (column_id, value) in eval_trigger_cells(cells, event, selected)? {
5158                        row_cells.retain(|(id, _)| *id != column_id);
5159                        row_cells.push((column_id, value.clone()));
5160                        if let Some(new) = &mut event.new {
5161                            new.columns.insert(column_id, value);
5162                        }
5163                    }
5164                    row_cells.sort_by_key(|(id, _)| *id);
5165                }
5166                TriggerStep::Insert { table, cells } => {
5167                    let cells = eval_trigger_cells(cells, event, selected)?;
5168                    if let Ok(table_id) = self.table_id(table) {
5169                        out.added.push((table_id, crate::txn::Staged::Put(cells)));
5170                        out.added_stacks.push(trigger_stack.to_vec());
5171                    } else if self.external_table(table).is_some() {
5172                        out.added_external.push(ExternalTriggerWrite::Insert {
5173                            table: table.clone(),
5174                            cells,
5175                        });
5176                    } else {
5177                        return Err(MongrelError::NotFound(format!(
5178                            "trigger {:?} insert target {table:?} not found",
5179                            trigger.name
5180                        )));
5181                    }
5182                }
5183                TriggerStep::UpdateByPk { table, pk, cells } => {
5184                    let pk = eval_trigger_value(pk, event, selected)?;
5185                    let cells = eval_trigger_cells(cells, event, selected)?;
5186                    if self.external_table(table).is_some() {
5187                        out.added_external.push(ExternalTriggerWrite::UpdateByPk {
5188                            table: table.clone(),
5189                            pk,
5190                            cells,
5191                        });
5192                    } else {
5193                        let row_id = self
5194                            .table(table)?
5195                            .lock()
5196                            .lookup_pk(&pk.encode_key())
5197                            .ok_or_else(|| {
5198                                MongrelError::NotFound(format!(
5199                                    "trigger {:?} update target not found",
5200                                    trigger.name
5201                                ))
5202                            })?;
5203                        let handle = self.table(table)?;
5204                        let snapshot = Snapshot::at(self.epoch.visible());
5205                        let old = handle.lock().get(row_id, snapshot).ok_or_else(|| {
5206                            MongrelError::NotFound(format!(
5207                                "trigger {:?} update target not visible",
5208                                trigger.name
5209                            ))
5210                        })?;
5211                        let mut merged = old.columns;
5212                        for (column_id, value) in cells {
5213                            merged.insert(column_id, value);
5214                        }
5215                        out.added.push((
5216                            self.table_id(table)?,
5217                            crate::txn::Staged::Update(row_id, merged.into_iter().collect()),
5218                        ));
5219                        out.added_stacks.push(trigger_stack.to_vec());
5220                    }
5221                }
5222                TriggerStep::DeleteByPk { table, pk } => {
5223                    let pk = eval_trigger_value(pk, event, selected)?;
5224                    if self.external_table(table).is_some() {
5225                        out.added_external.push(ExternalTriggerWrite::DeleteByPk {
5226                            table: table.clone(),
5227                            pk,
5228                        });
5229                    } else {
5230                        let row_id = self
5231                            .table(table)?
5232                            .lock()
5233                            .lookup_pk(&pk.encode_key())
5234                            .ok_or_else(|| {
5235                                MongrelError::NotFound(format!(
5236                                    "trigger {:?} delete target not found",
5237                                    trigger.name
5238                                ))
5239                            })?;
5240                        out.added
5241                            .push((self.table_id(table)?, crate::txn::Staged::Delete(row_id)));
5242                        out.added_stacks.push(trigger_stack.to_vec());
5243                    }
5244                }
5245                TriggerStep::Select {
5246                    id,
5247                    table,
5248                    conditions,
5249                } => {
5250                    let schema = self.table(table)?.lock().schema().clone();
5251                    let snapshot = Snapshot::at(read_epoch);
5252                    let rows = self.table(table)?.lock().visible_rows(snapshot)?;
5253                    let mut matched = Vec::new();
5254                    for row in rows {
5255                        let image = TriggerRowImage::from_row(row);
5256                        let passes = conditions
5257                            .iter()
5258                            .map(|cond| eval_trigger_condition(cond, event, &image, &schema))
5259                            .collect::<Result<Vec<_>>>()?
5260                            .into_iter()
5261                            .all(|b| b);
5262                        if passes {
5263                            matched.push(image);
5264                        }
5265                    }
5266                    if let Some(pk) = schema.primary_key() {
5267                        matched.sort_by(|a, b| {
5268                            let av = a.columns.get(&pk.id).unwrap_or(&Value::Null);
5269                            let bv = b.columns.get(&pk.id).unwrap_or(&Value::Null);
5270                            value_order(av, bv).unwrap_or(std::cmp::Ordering::Equal)
5271                        });
5272                    }
5273                    select_results.insert(id.clone(), matched);
5274                }
5275                TriggerStep::Foreach { id, steps } => {
5276                    let rows = select_results.get(id).ok_or_else(|| {
5277                        MongrelError::InvalidArgument(format!(
5278                            "trigger {:?} foreach references unknown select id {id:?}",
5279                            trigger.name
5280                        ))
5281                    })?;
5282                    if rows.len() > config.max_loop_iterations as usize {
5283                        return Err(MongrelError::InvalidArgument(format!(
5284                            "trigger {:?} foreach exceeded max_loop_iterations ({})",
5285                            trigger.name, config.max_loop_iterations
5286                        )));
5287                    }
5288                    for row in rows.clone() {
5289                        let result = self.execute_trigger_steps(
5290                            trigger,
5291                            steps,
5292                            event,
5293                            staging.as_deref_mut(),
5294                            out,
5295                            trigger_stack,
5296                            config,
5297                            select_results,
5298                            depth + 1,
5299                            Some(&row),
5300                            read_epoch,
5301                        )?;
5302                        if result == TriggerProgramOutcome::Ignore {
5303                            return Ok(TriggerProgramOutcome::Ignore);
5304                        }
5305                    }
5306                }
5307                TriggerStep::DeleteWhere { table, conditions } => {
5308                    let schema = self.table(table)?.lock().schema().clone();
5309                    let snapshot = Snapshot::at(read_epoch);
5310                    let rows = self.table(table)?.lock().visible_rows(snapshot)?;
5311                    let table_id = self.table_id(table)?;
5312                    let mut to_delete = Vec::new();
5313                    for row in rows {
5314                        let image = TriggerRowImage::from_row(row.clone());
5315                        let passes = conditions
5316                            .iter()
5317                            .map(|cond| eval_trigger_condition(cond, event, &image, &schema))
5318                            .collect::<Result<Vec<_>>>()?
5319                            .into_iter()
5320                            .all(|b| b);
5321                        if passes {
5322                            to_delete.push((table_id, row.row_id));
5323                        }
5324                    }
5325                    for (table_id, row_id) in to_delete {
5326                        out.added
5327                            .push((table_id, crate::txn::Staged::Delete(row_id)));
5328                        out.added_stacks.push(trigger_stack.to_vec());
5329                    }
5330                }
5331                TriggerStep::UpdateWhere {
5332                    table,
5333                    conditions,
5334                    cells,
5335                } => {
5336                    let schema = self.table(table)?.lock().schema().clone();
5337                    let snapshot = Snapshot::at(read_epoch);
5338                    let rows = self.table(table)?.lock().visible_rows(snapshot)?;
5339                    let table_id = self.table_id(table)?;
5340                    let mut to_update = Vec::new();
5341                    for row in rows {
5342                        let image = TriggerRowImage::from_row(row.clone());
5343                        let passes = conditions
5344                            .iter()
5345                            .map(|cond| eval_trigger_condition(cond, event, &image, &schema))
5346                            .collect::<Result<Vec<_>>>()?
5347                            .into_iter()
5348                            .all(|b| b);
5349                        if passes {
5350                            let new_cells = cells
5351                                .iter()
5352                                .map(|cell| {
5353                                    Ok((
5354                                        cell.column_id,
5355                                        eval_trigger_value(&cell.value, event, Some(&image))?,
5356                                    ))
5357                                })
5358                                .collect::<Result<Vec<_>>>()?;
5359                            let mut merged = row.columns.clone();
5360                            for (column_id, value) in new_cells {
5361                                merged.insert(column_id, value);
5362                            }
5363                            to_update.push((table_id, row.row_id, merged));
5364                        }
5365                    }
5366                    for (table_id, row_id, merged) in to_update {
5367                        out.added.push((
5368                            table_id,
5369                            crate::txn::Staged::Update(row_id, merged.into_iter().collect()),
5370                        ));
5371                        out.added_stacks.push(trigger_stack.to_vec());
5372                    }
5373                }
5374                TriggerStep::Raise { action, message } => match action {
5375                    TriggerRaiseAction::Ignore => return Ok(TriggerProgramOutcome::Ignore),
5376                    TriggerRaiseAction::Abort
5377                    | TriggerRaiseAction::Fail
5378                    | TriggerRaiseAction::Rollback => {
5379                        let message = eval_trigger_value(message, event, selected)?;
5380                        return Err(MongrelError::Conflict(format!(
5381                            "trigger {:?} raised: {}; trigger stack: {}",
5382                            trigger.name,
5383                            trigger_message(message),
5384                            Self::format_trigger_stack(trigger_stack)
5385                        )));
5386                    }
5387                },
5388            }
5389        }
5390        Ok(TriggerProgramOutcome::Continue)
5391    }
5392
5393    fn trigger_stack_for_indices(stacks: Option<&[Vec<String>]>, indices: &[usize]) -> Vec<String> {
5394        let Some(stacks) = stacks else {
5395            return Vec::new();
5396        };
5397        let mut out = Vec::new();
5398        for idx in indices {
5399            let Some(stack) = stacks.get(*idx) else {
5400                continue;
5401            };
5402            for name in stack {
5403                if !out.iter().any(|existing| existing == name) {
5404                    out.push(name.clone());
5405                }
5406            }
5407        }
5408        out
5409    }
5410
5411    fn trigger_stack_with(stack: &[String], trigger_name: &str) -> Vec<String> {
5412        let mut out = stack.to_vec();
5413        out.push(trigger_name.to_string());
5414        out
5415    }
5416
5417    fn format_trigger_stack(stack: &[String]) -> String {
5418        if stack.is_empty() {
5419            "<root>".into()
5420        } else {
5421            stack.join(" -> ")
5422        }
5423    }
5424
5425    /// Authoritatively validate every declared constraint on the staged write
5426    /// set under the transaction's read snapshot, AND expand ON DELETE CASCADE /
5427    /// SET NULL actions into explicit child ops. Called from
5428    /// [`Self::commit_transaction`] outside the WAL mutex. Returns the first
5429    /// violation as an `Err`, aborting the commit atomically. This is the
5430    /// server-side authority point: concurrent remote writers that each pass
5431    /// their own client-side checks still cannot both commit a violating batch.
5432    ///
5433    /// Scope: CHECK (full, three-valued), UNIQUE beyond the PK (existence scan +
5434    /// intra-transaction dedup; concurrent-txn races are additionally caught by
5435    /// `WriteKey::Unique`), and FK insert-side parent existence + ON DELETE
5436    /// {RESTRICT, CASCADE, SET NULL}. CASCADE appends child deletes (transitive
5437    /// fixpoint); SET NULL appends child updates (FK columns nulled). Truncate is
5438    /// RESTRICT-only (cascade-truncate is unsupported).
5439    fn validate_constraints(
5440        &self,
5441        staging: &mut Vec<(u64, crate::txn::Staged)>,
5442        read_epoch: Epoch,
5443    ) -> Result<()> {
5444        use crate::constraint::{encode_composite_key, validate_checks, FkAction};
5445        use crate::memtable::Row;
5446        use crate::txn::Staged;
5447        use std::collections::HashSet;
5448
5449        let snapshot = Snapshot::at(read_epoch);
5450        let cat = self.catalog.read();
5451
5452        // Collect live (id, name, constraints-bearing?) for staged tables.
5453        let live: Vec<(u64, &str, &crate::schema::Schema)> = cat
5454            .tables
5455            .iter()
5456            .filter(|e| matches!(e.state, TableState::Live))
5457            .map(|e| (e.table_id, e.name.as_str(), &e.schema))
5458            .collect();
5459
5460        // Fast path: bail if no live table declares any constraints at all.
5461        let any_constraints = live.iter().any(|(_, _, s)| !s.constraints.is_empty());
5462        if !any_constraints {
5463            return Ok(());
5464        }
5465
5466        // Lazily-loaded visible rows per table, shared across checks.
5467        let mut rows_cache: HashMap<u64, Vec<Row>> = HashMap::new();
5468        let mut load_rows = |table_id: u64| -> Result<Vec<Row>> {
5469            if let Some(r) = rows_cache.get(&table_id) {
5470                return Ok(r.clone());
5471            }
5472            let handle = self.table_by_id(table_id)?;
5473            let rows = handle.lock().visible_rows(snapshot)?;
5474            rows_cache.insert(table_id, rows.clone());
5475            Ok(rows)
5476        };
5477
5478        // ── Phase A1: expand ON UPDATE CASCADE / SET NULL while updates still
5479        // carry an explicit old RowId + full new image. This makes action choice
5480        // reliable even when the referenced key itself changes; a delete+put
5481        // heuristic cannot distinguish that from unrelated operations.
5482        let mut processed_updates = HashSet::new();
5483        type PendingUpdate = (usize, u64, crate::rowid::RowId, Vec<(u16, Value)>);
5484        loop {
5485            let updates: Vec<PendingUpdate> = staging
5486                .iter()
5487                .enumerate()
5488                .filter_map(|(index, (table_id, op))| match op {
5489                    Staged::Update(row_id, cells) if !processed_updates.contains(&index) => {
5490                        Some((index, *table_id, *row_id, cells.clone()))
5491                    }
5492                    _ => None,
5493                })
5494                .collect();
5495            if updates.is_empty() {
5496                break;
5497            }
5498            let mut new_ops = Vec::new();
5499            for (index, table_id, row_id, new_cells) in updates {
5500                processed_updates.insert(index);
5501                let Some(tname) = live
5502                    .iter()
5503                    .find(|(id, _, _)| *id == table_id)
5504                    .map(|(_, name, _)| *name)
5505                else {
5506                    continue;
5507                };
5508                let Some(old_row) = self.table_by_id(table_id)?.lock().get(row_id, snapshot) else {
5509                    continue;
5510                };
5511                let new_map: HashMap<u16, Value> = new_cells.iter().cloned().collect();
5512                for (child_id, _child_name, child_schema) in &live {
5513                    for fk in &child_schema.constraints.foreign_keys {
5514                        if fk.ref_table != tname {
5515                            continue;
5516                        }
5517                        let Some(old_key) = encode_composite_key(&fk.ref_columns, &old_row.columns)
5518                        else {
5519                            continue;
5520                        };
5521                        if encode_composite_key(&fk.ref_columns, &new_map).as_deref()
5522                            == Some(old_key.as_slice())
5523                        {
5524                            continue;
5525                        }
5526                        if fk.on_update == FkAction::Restrict {
5527                            continue;
5528                        }
5529                        let child_rows = load_rows(*child_id)?;
5530                        for child in child_rows {
5531                            if encode_composite_key(&fk.columns, &child.columns).as_deref()
5532                                != Some(old_key.as_slice())
5533                            {
5534                                continue;
5535                            }
5536                            if staging.iter().any(|(id, op)| {
5537                                *id == *child_id
5538                                    && matches!(op, Staged::Delete(id) if *id == child.row_id)
5539                            }) {
5540                                continue;
5541                            }
5542                            let mut cells: Vec<(u16, Value)> = child
5543                                .columns
5544                                .iter()
5545                                .map(|(column_id, value)| (*column_id, value.clone()))
5546                                .collect();
5547                            for (child_column, parent_column) in
5548                                fk.columns.iter().zip(&fk.ref_columns)
5549                            {
5550                                cells.retain(|(column_id, _)| column_id != child_column);
5551                                let value = match fk.on_update {
5552                                    FkAction::Cascade => {
5553                                        new_map.get(parent_column).cloned().unwrap_or(Value::Null)
5554                                    }
5555                                    FkAction::SetNull => Value::Null,
5556                                    FkAction::Restrict => unreachable!(),
5557                                };
5558                                cells.push((*child_column, value));
5559                            }
5560                            cells.sort_by_key(|(column_id, _)| *column_id);
5561                            if let Some(existing_index) = staging.iter().position(|(id, op)| {
5562                                *id == *child_id
5563                                    && matches!(op, Staged::Update(id, _) if *id == child.row_id)
5564                            }) {
5565                                if let Staged::Update(_, existing) = &mut staging[existing_index].1
5566                                {
5567                                    if *existing != cells {
5568                                        *existing = cells;
5569                                        processed_updates.remove(&existing_index);
5570                                    }
5571                                }
5572                            } else {
5573                                new_ops.push((*child_id, Staged::Update(child.row_id, cells)));
5574                            }
5575                        }
5576                    }
5577                }
5578            }
5579            staging.extend(new_ops);
5580        }
5581
5582        // ── Phase A2: expand ON DELETE CASCADE / SET NULL into explicit child
5583        // ops (transitive fixpoint). RESTRICT is not expanded here — it is
5584        // enforced as a violation in Phase B. `cascaded` records every delete
5585        // we have already expanded so a self-referential CASCADE FK cannot loop.
5586        let mut cascaded: HashSet<(u64, u64)> = HashSet::new();
5587        loop {
5588            let mut new_ops: Vec<(u64, Staged)> = Vec::new();
5589            let deletes: Vec<(u64, crate::rowid::RowId)> = staging
5590                .iter()
5591                .filter_map(|(t, op)| match op {
5592                    Staged::Delete(rid) => Some((*t, *rid)),
5593                    _ => None,
5594                })
5595                .collect();
5596            for (table_id, rid) in deletes {
5597                if !cascaded.insert((table_id, rid.0)) {
5598                    continue;
5599                }
5600                let Some(tname) = live
5601                    .iter()
5602                    .find(|(t, _, _)| *t == table_id)
5603                    .map(|(_, n, _)| *n)
5604                else {
5605                    continue;
5606                };
5607                let parent_handle = self.table_by_id(table_id)?;
5608                let Some(parent_row) = parent_handle.lock().get(rid, snapshot) else {
5609                    continue;
5610                };
5611                for (child_id, _child_name, child_schema) in &live {
5612                    for fk in &child_schema.constraints.foreign_keys {
5613                        if fk.ref_table != tname {
5614                            continue;
5615                        }
5616                        let Some(parent_key) =
5617                            encode_composite_key(&fk.ref_columns, &parent_row.columns)
5618                        else {
5619                            continue;
5620                        };
5621                        // Suppress ON DELETE cascade/set-null when this "delete"
5622                        // is actually half of an UPDATE encoded as Delete(old)+
5623                        // Put(new): if a staged Put in the SAME table still
5624                        // provides the referenced parent key, the parent still
5625                        // exists (its non-key columns changed) and the children
5626                        // must be left alone. A genuine delete, or an update
5627                        // that CHANGES the referenced key, has no preserving Put
5628                        // → cascade fires as before.
5629                        let key_preserved = staging.iter().any(|(t, op)| {
5630                            if *t != table_id {
5631                                return false;
5632                            }
5633                            let Staged::Put(cells) = op else {
5634                                return false;
5635                            };
5636                            let map: HashMap<u16, crate::memtable::Value> =
5637                                cells.iter().cloned().collect();
5638                            encode_composite_key(&fk.ref_columns, &map).as_deref()
5639                                == Some(parent_key.as_slice())
5640                        });
5641                        if key_preserved {
5642                            continue;
5643                        }
5644                        match fk.on_delete {
5645                            FkAction::Restrict => continue,
5646                            FkAction::Cascade => {
5647                                let child_rows = load_rows(*child_id)?;
5648                                for cr in &child_rows {
5649                                    if !cascaded.contains(&(*child_id, cr.row_id.0))
5650                                        && encode_composite_key(&fk.columns, &cr.columns).as_deref()
5651                                            == Some(parent_key.as_slice())
5652                                    {
5653                                        new_ops.push((*child_id, Staged::Delete(cr.row_id)));
5654                                    }
5655                                }
5656                            }
5657                            FkAction::SetNull => {
5658                                let child_rows = load_rows(*child_id)?;
5659                                for cr in &child_rows {
5660                                    if !cascaded.contains(&(*child_id, cr.row_id.0))
5661                                        && encode_composite_key(&fk.columns, &cr.columns).as_deref()
5662                                            == Some(parent_key.as_slice())
5663                                    {
5664                                        // Re-emit the child row with the FK
5665                                        // columns set to NULL (delete + put).
5666                                        let mut cells: Vec<(u16, crate::memtable::Value)> = cr
5667                                            .columns
5668                                            .iter()
5669                                            .map(|(k, v)| (*k, v.clone()))
5670                                            .collect();
5671                                        for cid in &fk.columns {
5672                                            cells.retain(|(k, _)| k != cid);
5673                                            cells.push((*cid, crate::memtable::Value::Null));
5674                                        }
5675                                        new_ops.push((*child_id, Staged::Update(cr.row_id, cells)));
5676                                    }
5677                                }
5678                            }
5679                        }
5680                    }
5681                }
5682            }
5683            if new_ops.is_empty() {
5684                break;
5685            }
5686            staging.extend(new_ops);
5687        }
5688
5689        // Rows staged for deletion in THIS transaction (now including cascaded
5690        // deletes). Used to exclude the old version of an updated row from
5691        // unique-existence scans.
5692        let staged_deletes: HashSet<(u64, u64)> = staging
5693            .iter()
5694            .filter_map(|(t, op)| match op {
5695                Staged::Delete(rid) | Staged::Update(rid, _) => Some((*t, rid.0)),
5696                _ => None,
5697            })
5698            .collect();
5699
5700        // Intra-transaction unique-key dedup: (table_id, uc_id, key).
5701        let mut seen_unique: HashSet<(u64, u16, Vec<u8>)> = HashSet::new();
5702
5703        // ── Phase B: validate the fully-expanded staging set.
5704        for (table_id, op) in staging.iter() {
5705            let Some((_, tname, schema)) = live.iter().find(|(t, _, _)| t == table_id).copied()
5706            else {
5707                continue;
5708            };
5709            let cells_map: HashMap<u16, crate::memtable::Value>;
5710            match op {
5711                Staged::Put(cells) | Staged::Update(_, cells) => {
5712                    cells_map = cells.iter().cloned().collect();
5713
5714                    // CHECK constraints.
5715                    if !schema.constraints.checks.is_empty() {
5716                        validate_checks(&schema.constraints.checks, &cells_map)?;
5717                    }
5718
5719                    // UNIQUE (non-PK) constraints.
5720                    for uc in &schema.constraints.uniques {
5721                        let Some(key) = encode_composite_key(&uc.columns, &cells_map) else {
5722                            continue; // NULL in a constrained column → skip (SQL).
5723                        };
5724                        let marker = (*table_id, uc.id, key.clone());
5725                        if !seen_unique.insert(marker) {
5726                            return Err(MongrelError::Conflict(format!(
5727                                "UNIQUE constraint '{}' on table '{tname}' violated within batch",
5728                                uc.name
5729                            )));
5730                        }
5731                        let rows = load_rows(*table_id)?;
5732                        for r in &rows {
5733                            // Skip rows this same transaction is deleting (the
5734                            // old version of an updated/cascade-deleted row).
5735                            if staged_deletes.contains(&(*table_id, r.row_id.0)) {
5736                                continue;
5737                            }
5738                            if let Some(theirs) = encode_composite_key(&uc.columns, &r.columns) {
5739                                if theirs == key {
5740                                    return Err(MongrelError::Conflict(format!(
5741                                        "UNIQUE constraint '{}' on table '{tname}' violated",
5742                                        uc.name
5743                                    )));
5744                                }
5745                            }
5746                        }
5747                    }
5748
5749                    // FK insert-side: parent must exist.
5750                    for fk in &schema.constraints.foreign_keys {
5751                        let Some(child_key) = encode_composite_key(&fk.columns, &cells_map) else {
5752                            continue; // NULL FK component → not checked (SQL).
5753                        };
5754                        let Some(parent_id) = cat
5755                            .tables
5756                            .iter()
5757                            .find(|t| t.name == fk.ref_table)
5758                            .map(|t| t.table_id)
5759                        else {
5760                            return Err(MongrelError::InvalidArgument(format!(
5761                                "FOREIGN KEY '{}' references unknown table '{}'",
5762                                fk.name, fk.ref_table
5763                            )));
5764                        };
5765                        let parent_rows = load_rows(parent_id)?;
5766                        let mut found = false;
5767                        for r in &parent_rows {
5768                            if staged_deletes.contains(&(parent_id, r.row_id.0)) {
5769                                continue;
5770                            }
5771                            if let Some(pkey) = encode_composite_key(&fk.ref_columns, &r.columns) {
5772                                if pkey == child_key {
5773                                    found = true;
5774                                    break;
5775                                }
5776                            }
5777                        }
5778                        // Final-write-set FK validation: a parent inserted in
5779                        // THIS transaction also satisfies the FK. This enables
5780                        // atomic parent+child batches and cyclical/mutual FK
5781                        // inserts within a single transaction — the child sees
5782                        // the staged parent put even though it is not committed
5783                        // yet.
5784                        if !found {
5785                            for (st_table, st_op) in staging.iter() {
5786                                if *st_table != parent_id {
5787                                    continue;
5788                                }
5789                                if let Staged::Put(pcells) | Staged::Update(_, pcells) = st_op {
5790                                    let pmap: HashMap<u16, crate::memtable::Value> =
5791                                        pcells.iter().cloned().collect();
5792                                    if let Some(pkey) = encode_composite_key(&fk.ref_columns, &pmap)
5793                                    {
5794                                        if pkey == child_key {
5795                                            found = true;
5796                                            break;
5797                                        }
5798                                    }
5799                                }
5800                            }
5801                        }
5802                        if !found {
5803                            return Err(MongrelError::Conflict(format!(
5804                                "FOREIGN KEY '{}' on table '{tname}' has no matching parent in '{}'",
5805                                fk.name, fk.ref_table
5806                            )));
5807                        }
5808                    }
5809
5810                    // Parent-side ON UPDATE RESTRICT. CASCADE/SET NULL were
5811                    // expanded in Phase A; here the final child write set is
5812                    // known, so a child explicitly moved/deleted by this same
5813                    // transaction does not cause a false violation.
5814                    if let Staged::Update(row_id, _) = op {
5815                        let parent_handle = self.table_by_id(*table_id)?;
5816                        let Some(old_parent) = parent_handle.lock().get(*row_id, snapshot) else {
5817                            continue;
5818                        };
5819                        for (child_id, child_name, child_schema) in &live {
5820                            for fk in &child_schema.constraints.foreign_keys {
5821                                if fk.ref_table != tname || fk.on_update != FkAction::Restrict {
5822                                    continue;
5823                                }
5824                                let Some(old_key) =
5825                                    encode_composite_key(&fk.ref_columns, &old_parent.columns)
5826                                else {
5827                                    continue;
5828                                };
5829                                if encode_composite_key(&fk.ref_columns, &cells_map).as_deref()
5830                                    == Some(old_key.as_slice())
5831                                {
5832                                    continue;
5833                                }
5834                                for child in load_rows(*child_id)? {
5835                                    if encode_composite_key(&fk.columns, &child.columns).as_deref()
5836                                        != Some(old_key.as_slice())
5837                                    {
5838                                        continue;
5839                                    }
5840                                    let replacement = staging.iter().find_map(|(id, op)| {
5841                                        if *id != *child_id {
5842                                            return None;
5843                                        }
5844                                        match op {
5845                                            Staged::Delete(id) if *id == child.row_id => Some(None),
5846                                            Staged::Update(id, cells) if *id == child.row_id => {
5847                                                let map: HashMap<u16, Value> =
5848                                                    cells.iter().cloned().collect();
5849                                                Some(encode_composite_key(&fk.columns, &map))
5850                                            }
5851                                            _ => None,
5852                                        }
5853                                    });
5854                                    if replacement.is_some_and(|key| {
5855                                        key.as_deref() != Some(old_key.as_slice())
5856                                    }) {
5857                                        continue;
5858                                    }
5859                                    return Err(MongrelError::Conflict(format!(
5860                                        "FOREIGN KEY '{}' on table '{child_name}' restricts update (parent key referenced)",
5861                                        fk.name
5862                                    )));
5863                                }
5864                            }
5865                        }
5866                    }
5867                }
5868                Staged::Delete(rid) => {
5869                    // FK ON DELETE RESTRICT: a child row (whose FK action is
5870                    // RESTRICT) referencing this parent blocks the delete.
5871                    // CASCADE/SET NULL children were expanded in Phase A.
5872                    let parent_handle = self.table_by_id(*table_id)?;
5873                    let Some(parent_row) = parent_handle.lock().get(*rid, snapshot) else {
5874                        continue;
5875                    };
5876                    for (child_id, child_name, child_schema) in &live {
5877                        for fk in &child_schema.constraints.foreign_keys {
5878                            if fk.ref_table != tname || fk.on_delete != FkAction::Restrict {
5879                                continue;
5880                            }
5881                            let Some(parent_key) =
5882                                encode_composite_key(&fk.ref_columns, &parent_row.columns)
5883                            else {
5884                                continue;
5885                            };
5886                            let child_rows = load_rows(*child_id)?;
5887                            for r in &child_rows {
5888                                // A child already being deleted by this txn
5889                                // (cascade/inline) is not a restrict violation.
5890                                if staged_deletes.contains(&(*child_id, r.row_id.0)) {
5891                                    continue;
5892                                }
5893                                if let Some(ck) = encode_composite_key(&fk.columns, &r.columns) {
5894                                    if ck == parent_key {
5895                                        return Err(MongrelError::Conflict(format!(
5896                                            "FOREIGN KEY '{}' on table '{child_name}' restricts delete (parent referenced)",
5897                                            fk.name
5898                                        )));
5899                                    }
5900                                }
5901                            }
5902                        }
5903                    }
5904                }
5905                Staged::Truncate => {
5906                    // Truncate is RESTRICT-only: reject if any child references
5907                    // this table (any FK action), since cascade-truncate is
5908                    // unsupported.
5909                    for (child_id, child_name, child_schema) in &live {
5910                        for fk in &child_schema.constraints.foreign_keys {
5911                            if fk.ref_table != tname {
5912                                continue;
5913                            }
5914                            let child_rows = load_rows(*child_id)?;
5915                            if child_rows
5916                                .iter()
5917                                .any(|r| encode_composite_key(&fk.columns, &r.columns).is_some())
5918                            {
5919                                return Err(MongrelError::Conflict(format!(
5920                                    "FOREIGN KEY '{}' on table '{child_name}' restricts truncate of '{tname}'",
5921                                    fk.name
5922                                )));
5923                            }
5924                        }
5925                    }
5926                }
5927            }
5928        }
5929        Ok(())
5930    }
5931
5932    fn validate_write_permissions(
5933        &self,
5934        staging: &[(u64, crate::txn::Staged)],
5935        principal: Option<&crate::auth::Principal>,
5936    ) -> Result<()> {
5937        use crate::txn::Staged;
5938
5939        let table_names = self
5940            .catalog
5941            .read()
5942            .tables
5943            .iter()
5944            .filter(|entry| matches!(entry.state, TableState::Live))
5945            .map(|entry| (entry.table_id, entry.name.clone()))
5946            .collect::<HashMap<_, _>>();
5947        for (table_id, operation) in staging {
5948            let Some(table) = table_names.get(table_id) else {
5949                continue;
5950            };
5951            match operation {
5952                Staged::Put(cells) => self.require_columns_for(
5953                    table,
5954                    crate::auth::ColumnOperation::Insert,
5955                    &cells.iter().map(|(column, _)| *column).collect::<Vec<_>>(),
5956                    principal,
5957                )?,
5958                Staged::Update(_, cells) => self.require_columns_for(
5959                    table,
5960                    crate::auth::ColumnOperation::Update,
5961                    &cells.iter().map(|(column, _)| *column).collect::<Vec<_>>(),
5962                    principal,
5963                )?,
5964                Staged::Delete(_) => self.require_for(
5965                    principal,
5966                    &crate::auth::Permission::Delete {
5967                        table: table.clone(),
5968                    },
5969                )?,
5970                Staged::Truncate => self.require_for(principal, &crate::auth::Permission::Admin)?,
5971            }
5972        }
5973        Ok(())
5974    }
5975
5976    fn validate_security_writes(
5977        &self,
5978        staging: &[(u64, crate::txn::Staged)],
5979        read_epoch: Epoch,
5980        explicit_principal: Option<&crate::auth::Principal>,
5981    ) -> Result<()> {
5982        use crate::security::PolicyCommand;
5983        use crate::txn::Staged;
5984
5985        let catalog = self.catalog.read();
5986        if catalog.security.rls_tables.is_empty() {
5987            return Ok(());
5988        }
5989        let security = catalog.security.clone();
5990        let table_names = catalog
5991            .tables
5992            .iter()
5993            .filter(|entry| matches!(entry.state, TableState::Live))
5994            .map(|entry| (entry.table_id, entry.name.clone()))
5995            .collect::<HashMap<_, _>>();
5996        drop(catalog);
5997        if !staging.iter().any(|(table_id, _)| {
5998            table_names
5999                .get(table_id)
6000                .is_some_and(|table| security.rls_enabled(table))
6001        }) {
6002            return Ok(());
6003        }
6004        let cached = self.principal.read().clone();
6005        let principal = explicit_principal
6006            .or(cached.as_ref())
6007            .ok_or(MongrelError::AuthRequired)?;
6008
6009        for (table_id, operation) in staging {
6010            let Some(table) = table_names.get(table_id) else {
6011                continue;
6012            };
6013            if !security.rls_enabled(table) || principal.is_admin {
6014                continue;
6015            }
6016            let denied = |command| MongrelError::PermissionDenied {
6017                required: match command {
6018                    PolicyCommand::Insert => crate::auth::Permission::Insert {
6019                        table: table.clone(),
6020                    },
6021                    PolicyCommand::Update => crate::auth::Permission::Update {
6022                        table: table.clone(),
6023                    },
6024                    PolicyCommand::Delete | PolicyCommand::All | PolicyCommand::Select => {
6025                        crate::auth::Permission::Delete {
6026                            table: table.clone(),
6027                        }
6028                    }
6029                },
6030                principal: principal.username.clone(),
6031            };
6032            match operation {
6033                Staged::Put(cells) => {
6034                    let mut row = crate::memtable::Row::new(RowId(0), Epoch(read_epoch.0));
6035                    row.columns.extend(cells.iter().cloned());
6036                    if !security.row_allowed(table, PolicyCommand::Insert, &row, principal, true) {
6037                        return Err(denied(PolicyCommand::Insert));
6038                    }
6039                }
6040                Staged::Update(row_id, cells) => {
6041                    let old = self
6042                        .table_by_id(*table_id)?
6043                        .lock()
6044                        .get(*row_id, Snapshot::at(read_epoch))
6045                        .ok_or_else(|| {
6046                            MongrelError::NotFound(format!("row {} not found", row_id.0))
6047                        })?;
6048                    if !security.row_allowed(table, PolicyCommand::Update, &old, principal, false) {
6049                        return Err(denied(PolicyCommand::Update));
6050                    }
6051                    let mut new = crate::memtable::Row::new(*row_id, Epoch(read_epoch.0));
6052                    new.columns.extend(cells.iter().cloned());
6053                    if !security.row_allowed(table, PolicyCommand::Update, &new, principal, true) {
6054                        return Err(denied(PolicyCommand::Update));
6055                    }
6056                }
6057                Staged::Delete(row_id) => {
6058                    let old = self
6059                        .table_by_id(*table_id)?
6060                        .lock()
6061                        .get(*row_id, Snapshot::at(read_epoch))
6062                        .ok_or_else(|| {
6063                            MongrelError::NotFound(format!("row {} not found", row_id.0))
6064                        })?;
6065                    if !security.row_allowed(table, PolicyCommand::Delete, &old, principal, false) {
6066                        return Err(denied(PolicyCommand::Delete));
6067                    }
6068                }
6069                Staged::Truncate => return Err(denied(PolicyCommand::Delete)),
6070            }
6071        }
6072        Ok(())
6073    }
6074
6075    /// Seal a transaction (spec §9.3):
6076    /// 1. Prepare — derive write keys, allocate row ids (brief table locks).
6077    /// 2. Sequencer — validate-first under the WAL mutex; abort on conflict
6078    ///    with no epoch consumed; assign epoch, append data records + TxnCommit,
6079    ///    group-sync, record conflict keys.
6080    /// 3. Publish — apply to tables, advance visible in-order.
6081    #[allow(clippy::too_many_arguments)]
6082    pub(crate) fn commit_transaction_with_external_states(
6083        &self,
6084        txn_id: u64,
6085        read_epoch: Epoch,
6086        mut staging: Vec<(u64, crate::txn::Staged)>,
6087        external_states: Vec<(String, Vec<u8>)>,
6088        materialized_view_updates: Vec<crate::catalog::MaterializedViewEntry>,
6089        mut security_principal: Option<crate::auth::Principal>,
6090        principal_catalog_bound: bool,
6091        external_trigger_bridge: Option<&dyn ExternalTriggerBridge>,
6092    ) -> Result<(Epoch, Vec<RowId>)> {
6093        use crate::memtable::Row;
6094        use crate::txn::{Staged, StagedOp, WriteKey};
6095        use crate::wal::Op;
6096        use std::collections::hash_map::DefaultHasher;
6097        use std::hash::{Hash, Hasher};
6098        use std::sync::atomic::Ordering;
6099
6100        if self.read_only {
6101            return Err(MongrelError::ReadOnlyReplica);
6102        }
6103        if principal_catalog_bound {
6104            let fresh = catalog::read(&self.root, self.meta_dek.as_ref())?
6105                .ok_or_else(|| MongrelError::NotFound("catalog vanished during write".into()))?;
6106            *self.catalog.write() = fresh;
6107        }
6108        let security_version = {
6109            let catalog = self.catalog.read();
6110            if principal_catalog_bound {
6111                let username = security_principal
6112                    .as_ref()
6113                    .map(|principal| principal.username.as_str())
6114                    .ok_or(MongrelError::AuthRequired)?;
6115                security_principal = Self::resolve_principal_from_catalog(&catalog, username);
6116                if security_principal.is_none() {
6117                    return Err(MongrelError::AuthRequired);
6118                }
6119            }
6120            catalog.security_version
6121        };
6122        let _replication_guard = self.replication_barrier.read();
6123        if self.poisoned.load(Ordering::Relaxed) {
6124            return Err(MongrelError::Other(
6125                "database poisoned by fsync error".into(),
6126            ));
6127        }
6128        let mut external_states = dedup_external_states(external_states);
6129        if !external_states.is_empty() {
6130            let cat = self.catalog.read();
6131            for (name, _) in &external_states {
6132                if !cat.external_tables.iter().any(|entry| entry.name == *name) {
6133                    return Err(MongrelError::NotFound(format!(
6134                        "external table {name:?} not found"
6135                    )));
6136                }
6137            }
6138        }
6139        let prepared_materialized_views = {
6140            let mut deduplicated = HashMap::new();
6141            for definition in materialized_view_updates {
6142                if definition.name.is_empty() || definition.query.trim().is_empty() {
6143                    return Err(MongrelError::InvalidArgument(
6144                        "materialized view name and query must not be empty".into(),
6145                    ));
6146                }
6147                deduplicated.insert(definition.name.clone(), definition);
6148            }
6149            let catalog = self.catalog.read();
6150            let mut prepared = Vec::with_capacity(deduplicated.len());
6151            for definition in deduplicated.into_values() {
6152                let table_id = catalog
6153                    .live(&definition.name)
6154                    .ok_or_else(|| {
6155                        MongrelError::NotFound(format!(
6156                            "materialized view table {:?} not found",
6157                            definition.name
6158                        ))
6159                    })?
6160                    .table_id;
6161                prepared.push((table_id, definition));
6162            }
6163            prepared.sort_by(|left, right| left.1.name.cmp(&right.1.name));
6164            prepared
6165        };
6166
6167        // ── 1. Prepare: fill generated values, expand triggers, validate, then
6168        // derive write keys from the final atomic write set.
6169        self.fill_auto_increment_for_staging(&mut staging)?;
6170        self.expand_table_triggers(
6171            &mut staging,
6172            read_epoch,
6173            external_trigger_bridge,
6174            &mut external_states,
6175        )?;
6176        self.fill_auto_increment_for_staging(&mut staging)?;
6177        external_states = dedup_external_states(external_states);
6178
6179        self.validate_write_permissions(&staging, security_principal.as_ref())?;
6180
6181        // Validate declarative constraints (unique / FK / check) under the read
6182        // snapshot, outside the WAL mutex. Trigger-produced writes are included
6183        // here, so the batch either satisfies every declared constraint or is
6184        // rejected atomically.
6185        self.validate_constraints(&mut staging, read_epoch)?;
6186        self.validate_security_writes(&staging, read_epoch, security_principal.as_ref())?;
6187        let mut normalized = Vec::with_capacity(staging.len() * 2);
6188        for (table_id, op) in staging {
6189            match op {
6190                crate::txn::Staged::Update(row_id, cells) => {
6191                    normalized.push((table_id, crate::txn::Staged::Delete(row_id)));
6192                    normalized.push((table_id, crate::txn::Staged::Put(cells)));
6193                }
6194                op => normalized.push((table_id, op)),
6195            }
6196        }
6197        staging = normalized;
6198        let has_changes = !staging.is_empty()
6199            || !external_states.is_empty()
6200            || !prepared_materialized_views.is_empty();
6201        let truncated_tables: HashSet<u64> = staging
6202            .iter()
6203            .filter_map(|(table_id, op)| matches!(op, Staged::Truncate).then_some(*table_id))
6204            .collect();
6205
6206        let write_keys = {
6207            let cat = self.catalog.read();
6208            let mut keys: Vec<WriteKey> = Vec::new();
6209            for (table_id, staged) in &staging {
6210                match staged {
6211                    Staged::Put(cells) => {
6212                        if let Some(entry) = cat.tables.iter().find(|t| t.table_id == *table_id) {
6213                            for col in &entry.schema.columns {
6214                                if col.flags.contains(crate::schema::ColumnFlags::PRIMARY_KEY) {
6215                                    if let Some((_, val)) =
6216                                        cells.iter().find(|(id, _)| *id == col.id)
6217                                    {
6218                                        let mut h = DefaultHasher::new();
6219                                        val.encode_key().hash(&mut h);
6220                                        keys.push(WriteKey::Unique {
6221                                            table_id: *table_id,
6222                                            index_id: 0,
6223                                            key_hash: h.finish(),
6224                                        });
6225                                    }
6226                                }
6227                            }
6228                            // Declared non-PK unique constraints register a
6229                            // `WriteKey::Unique` (namespace-separated from the
6230                            // PK's index_id==0 by setting the high bit) so two
6231                            // concurrent transactions inserting the same key
6232                            // cannot both commit. Rows with any NULL constrained
6233                            // column are skipped (SQL semantics).
6234                            for uc in &entry.schema.constraints.uniques {
6235                                if let Some(key_bytes) = crate::constraint::encode_composite_key(
6236                                    &uc.columns,
6237                                    &cells.iter().cloned().collect(),
6238                                ) {
6239                                    let mut h = DefaultHasher::new();
6240                                    key_bytes.hash(&mut h);
6241                                    keys.push(WriteKey::Unique {
6242                                        table_id: *table_id,
6243                                        index_id: uc.id | 0x8000,
6244                                        key_hash: h.finish(),
6245                                    });
6246                                }
6247                            }
6248                        }
6249                    }
6250                    Staged::Delete(rid) => keys.push(WriteKey::Row {
6251                        table_id: *table_id,
6252                        row_id: rid.0,
6253                    }),
6254                    Staged::Truncate => keys.push(WriteKey::Table {
6255                        table_id: *table_id,
6256                    }),
6257                    Staged::Update(_, _) => unreachable!("updates normalized before prepare"),
6258                }
6259            }
6260            for (name, _) in &external_states {
6261                let mut h = DefaultHasher::new();
6262                name.hash(&mut h);
6263                keys.push(WriteKey::Unique {
6264                    table_id: EXTERNAL_TABLE_ID,
6265                    index_id: 0,
6266                    key_hash: h.finish(),
6267                });
6268            }
6269            keys
6270        };
6271
6272        // Opportunistic pruning.
6273        let min_active = self.active_txns.min_read_epoch();
6274        if min_active < u64::MAX {
6275            self.conflicts.prune_below(Epoch(min_active));
6276        }
6277
6278        // ── 1a. Pre-validate the full write-set OUTSIDE the sequencer (spec
6279        // §8.5, review fix #17). Snapshot the conflict-index version so the
6280        // sequencer only re-checks if new commits arrived in the interim.
6281        if self.conflicts.conflicts(&write_keys, read_epoch) {
6282            return Err(MongrelError::Conflict(
6283                "write-write conflict (pre-validate, first-committer-wins)".into(),
6284            ));
6285        }
6286        let pre_validate_version = self.conflicts.version();
6287
6288        // ── 1b. Spill: if a table's staged puts exceed the threshold, write a
6289        // uniform-epoch pending run (spec §8.5). Rows in the run are NOT
6290        // streamed as Put records; they are linked at publish time.
6291        let mut spilled: Vec<SpilledRun> = Vec::new();
6292        let mut spilled_tables: std::collections::HashSet<u64> = std::collections::HashSet::new();
6293        // Protect this txn's `_txn/<id>/` dir from a concurrent `gc()` for as long
6294        // as the spill runs are live (registered on first spill, dropped at the
6295        // end of this function on commit/abort/error).
6296        let mut spill_guard: Option<crate::retention::SpillGuard> = None;
6297        {
6298            let mut table_bytes: HashMap<u64, usize> = HashMap::new();
6299            for (table_id, staged) in &staging {
6300                if let Staged::Put(cells) = staged {
6301                    *table_bytes.entry(*table_id).or_default() += cells.len() * 16;
6302                }
6303            }
6304            let tables = self.tables.read();
6305            for (&table_id, &bytes) in &table_bytes {
6306                if bytes as u64
6307                    <= self
6308                        .spill_threshold
6309                        .load(std::sync::atomic::Ordering::Relaxed)
6310                {
6311                    continue;
6312                }
6313                let Some(handle) = tables.get(&table_id) else {
6314                    continue;
6315                };
6316                spill_guard.get_or_insert_with(|| self.active_spills.register(txn_id));
6317                let mut t = handle.lock();
6318                let tdir = t.table_dir().to_path_buf();
6319                let txn_dir = tdir.join("_txn").join(txn_id.to_string());
6320                std::fs::create_dir_all(&txn_dir)?;
6321                let run_id = t.alloc_run_id() as u128;
6322                let pending_path = txn_dir.join(format!("r-{run_id}.sr"));
6323
6324                let mut rows: Vec<Row> = Vec::new();
6325                for (tid, staged) in &staging {
6326                    if *tid != table_id {
6327                        continue;
6328                    }
6329                    if let Staged::Put(cells) = staged {
6330                        t.validate_cells_not_null(cells)?;
6331                        let row_id = t.alloc_row_id();
6332                        let mut row = Row::new(row_id, Epoch(0));
6333                        for (c, v) in cells {
6334                            row.columns.insert(*c, v.clone());
6335                        }
6336                        rows.push(row);
6337                    }
6338                }
6339                let schema = t.schema_ref().clone();
6340                let kek = t.kek_ref().cloned();
6341                let specs = t.indexable_column_specs();
6342                drop(t);
6343
6344                let mut writer = crate::sorted_run::RunWriter::new(&schema, run_id, Epoch(0), 0)
6345                    .uniform_epoch(true);
6346                if let Some(ref kek) = kek {
6347                    writer = writer.with_encryption(kek.as_ref(), specs);
6348                }
6349                let header = writer.write(&pending_path, &rows)?;
6350                let row_count = header.row_count;
6351                let min_rid = rows.first().map(|r| r.row_id.0).unwrap_or(0);
6352                let max_rid = rows.last().map(|r| r.row_id.0).unwrap_or(0);
6353
6354                spilled.push(SpilledRun {
6355                    table_id,
6356                    run_id,
6357                    pending_path,
6358                    rows,
6359                    row_count,
6360                    min_rid,
6361                    max_rid,
6362                });
6363                spilled_tables.insert(table_id);
6364            }
6365        }
6366
6367        // Test seam: let a test race `gc()` against this in-flight spill.
6368        if spill_guard.is_some() {
6369            if let Some(hook) = self.spill_hook.lock().as_ref() {
6370                hook();
6371            }
6372        }
6373
6374        // ── 1c. Pre-build non-spilled put rows OUTSIDE the WAL critical section.
6375        // Allocating row ids + building the rows here (lock order: table handle →
6376        // nothing) means the sequencer never locks a table handle while holding
6377        // the shared-WAL mutex. That matters because `Table::commit`/`flush` lock
6378        // the table handle THEN the shared WAL; if the sequencer did the reverse
6379        // (WAL then handle) the two paths would deadlock (review fix: B1).
6380        // Aligned 1:1 with `staging`; `None` for deletes and spilled puts.
6381        // Row ids are allocated here, before the sequencer's delta conflict
6382        // re-check, so a losing txn leaks the ids it reserved — harmless, the
6383        // u64 row-id space is monotonic and gaps are expected (spills do the same).
6384        let mut prebuilt: Vec<Option<Row>> = Vec::with_capacity(staging.len());
6385        let mut delete_images: Vec<Option<Row>> = Vec::with_capacity(staging.len());
6386        {
6387            let tables = self.tables.read();
6388            for (table_id, staged) in &staging {
6389                match staged {
6390                    Staged::Put(cells) if !spilled_tables.contains(table_id) => {
6391                        let handle = tables.get(table_id).ok_or_else(|| {
6392                            MongrelError::NotFound(format!("table {table_id} not mounted"))
6393                        })?;
6394                        let mut t = handle.lock();
6395                        t.validate_cells_not_null(cells)?;
6396                        let row_id = t.alloc_row_id();
6397                        drop(t);
6398                        let mut row = Row::new(row_id, Epoch(0));
6399                        for (c, v) in cells {
6400                            row.columns.insert(*c, v.clone());
6401                        }
6402                        prebuilt.push(Some(row));
6403                        delete_images.push(None);
6404                    }
6405                    Staged::Delete(row_id) => {
6406                        let before = tables.get(table_id).and_then(|handle| {
6407                            handle.lock().get(*row_id, Snapshot::at(read_epoch))
6408                        });
6409                        prebuilt.push(None);
6410                        delete_images.push(before);
6411                    }
6412                    Staged::Put(_) | Staged::Truncate => {
6413                        prebuilt.push(None);
6414                        delete_images.push(None);
6415                    }
6416                    Staged::Update(_, _) => unreachable!("updates normalized before prepare"),
6417                }
6418            }
6419        }
6420
6421        let mut spilled_row_ids: HashMap<u64, VecDeque<RowId>> = spilled
6422            .iter()
6423            .map(|run| {
6424                (
6425                    run.table_id,
6426                    run.rows.iter().map(|row| row.row_id).collect(),
6427                )
6428            })
6429            .collect();
6430        let committed_row_ids = staging
6431            .iter()
6432            .enumerate()
6433            .filter_map(|(index, (table_id, staged))| {
6434                if !matches!(staged, Staged::Put(_)) {
6435                    return None;
6436                }
6437                prebuilt[index].as_ref().map(|row| row.row_id).or_else(|| {
6438                    spilled_row_ids
6439                        .get_mut(table_id)
6440                        .and_then(VecDeque::pop_front)
6441                })
6442            })
6443            .collect();
6444
6445        let mut prepared_external = Vec::with_capacity(external_states.len());
6446        for (name, state) in &external_states {
6447            let pending = prepare_external_state_file(&self.root, name, state, txn_id)?;
6448            prepared_external.push((name.clone(), state.clone(), pending));
6449        }
6450
6451        // ── 2. Sequencer: validate-first → assign → append → sync → record ──
6452        let added_runs: Vec<crate::wal::AddedRun> = spilled
6453            .iter()
6454            .map(|s| crate::wal::AddedRun {
6455                table_id: s.table_id,
6456                run_id: s.run_id,
6457                row_count: s.row_count,
6458                level: 0,
6459                min_row_id: s.min_rid,
6460                max_row_id: s.max_rid,
6461                content_hash: [0u8; 32],
6462            })
6463            .collect();
6464        let (new_epoch, mut _epoch_guard, applies, committed_materialized_views, commit_seq) = {
6465            let mut wal = self.shared_wal.lock();
6466
6467            let current_security_version = catalog::read(&self.root, self.meta_dek.as_ref())?
6468                .ok_or_else(|| MongrelError::NotFound("catalog vanished during write".into()))?
6469                .security_version;
6470            if current_security_version != security_version {
6471                return Err(MongrelError::Conflict(
6472                    "security policy changed during write".into(),
6473                ));
6474            }
6475
6476            // Re-check only if the conflict index advanced since pre-validation
6477            // (bounded delta — spec §8.5, review fix #17). If the version is
6478            // unchanged, the pre-check result is still valid and the sequencer
6479            // does O(1) work regardless of write-set size.
6480            if self.conflicts.version() != pre_validate_version
6481                && self.conflicts.conflicts(&write_keys, read_epoch)
6482            {
6483                // Abort: this txn assigned no epoch yet, so drop the quarantined
6484                // spill runs we wrote during prepare instead of leaking them in
6485                // `_txn/` until the next GC/reopen sweep.
6486                drop(wal);
6487                for s in &spilled {
6488                    if let Some(parent) = s.pending_path.parent() {
6489                        let _ = std::fs::remove_dir_all(parent);
6490                    }
6491                }
6492                for (_, _, pending) in &prepared_external {
6493                    let _ = std::fs::remove_file(pending);
6494                }
6495                return Err(MongrelError::Conflict(
6496                    "write-write conflict (sequencer delta re-check)".into(),
6497                ));
6498            }
6499
6500            let new_epoch = self.epoch.bump_assigned();
6501            let _epoch_guard = EpochGuard::new(self.epoch.as_ref(), new_epoch);
6502            let mut applies: Vec<(u64, Vec<StagedOp>)> = Vec::new();
6503            let mut committed_materialized_views = Vec::new();
6504
6505            for (idx, (table_id, staged)) in staging.iter().enumerate() {
6506                // Skip puts for tables that were spilled — their data is in a
6507                // pending run, not in streamed Put records.
6508                if spilled_tables.contains(table_id) && matches!(staged, Staged::Put(_)) {
6509                    continue;
6510                }
6511                let mut ops = Vec::new();
6512                match staged {
6513                    Staged::Put(_) => {
6514                        // Stamp the pre-built row at the real assigned epoch.
6515                        let mut row = prebuilt[idx].take().expect("prebuilt put row");
6516                        row.committed_epoch = new_epoch;
6517                        let payload = bincode::serialize(&vec![row.clone()])
6518                            .map_err(|e| MongrelError::Other(format!("row serialize: {e}")))?;
6519                        wal.append(
6520                            txn_id,
6521                            *table_id,
6522                            Op::Put {
6523                                table_id: *table_id,
6524                                rows: payload,
6525                            },
6526                        )?;
6527                        ops.push(StagedOp::Put(row));
6528                    }
6529                    Staged::Delete(rid) => {
6530                        if let Some(before) = &delete_images[idx] {
6531                            wal.append(
6532                                txn_id,
6533                                *table_id,
6534                                Op::BeforeImage {
6535                                    table_id: *table_id,
6536                                    row_id: *rid,
6537                                    row: bincode::serialize(before).map_err(|error| {
6538                                        MongrelError::Other(format!(
6539                                            "before-image serialize: {error}"
6540                                        ))
6541                                    })?,
6542                                },
6543                            )?;
6544                        }
6545                        wal.append(
6546                            txn_id,
6547                            *table_id,
6548                            Op::Delete {
6549                                table_id: *table_id,
6550                                row_ids: vec![*rid],
6551                            },
6552                        )?;
6553                        ops.push(StagedOp::Delete(*rid));
6554                    }
6555                    Staged::Truncate => {
6556                        wal.append(
6557                            txn_id,
6558                            *table_id,
6559                            Op::TruncateTable {
6560                                table_id: *table_id,
6561                            },
6562                        )?;
6563                        ops.push(StagedOp::Truncate);
6564                    }
6565                    Staged::Update(_, _) => unreachable!("updates normalized before sequencer"),
6566                }
6567                applies.push((*table_id, ops));
6568            }
6569
6570            for (name, state, _) in &prepared_external {
6571                wal.append(
6572                    txn_id,
6573                    EXTERNAL_TABLE_ID,
6574                    Op::ExternalTableState {
6575                        name: name.clone(),
6576                        state: state.clone(),
6577                    },
6578                )?;
6579            }
6580
6581            for (table_id, definition) in &prepared_materialized_views {
6582                let mut definition = definition.clone();
6583                definition.last_refresh_epoch = new_epoch.0;
6584                wal.append(
6585                    txn_id,
6586                    *table_id,
6587                    Op::Ddl(crate::wal::DdlOp::SetMaterializedView {
6588                        name: definition.name.clone(),
6589                        definition_json: crate::wal::DdlOp::encode_materialized_view(&definition)?,
6590                    }),
6591                )?;
6592                committed_materialized_views.push(definition);
6593            }
6594
6595            let commit_seq = wal.append_commit(txn_id, new_epoch, &added_runs)?;
6596
6597            // Record the conflict + assign the epoch under the WAL lock so commit
6598            // order == WAL append order, but DO NOT fsync here (P3.2): the fsync
6599            // moves out of this critical section to the group-commit coordinator
6600            // so concurrent committers share a single leader fsync.
6601            self.conflicts.record(&write_keys, new_epoch);
6602            (
6603                new_epoch,
6604                _epoch_guard,
6605                applies,
6606                committed_materialized_views,
6607                commit_seq,
6608            )
6609        };
6610
6611        // ── 2b. Durability: one leader fsync serves this whole batch (P3.2). ──
6612        self.group
6613            .await_durable(&self.shared_wal, commit_seq)
6614            .inspect_err(|_| {
6615                self.poisoned.store(true, Ordering::Relaxed);
6616            })?;
6617
6618        // ── 3. Publish: apply non-spilled ops + link spilled runs ──
6619        {
6620            let tables = self.tables.read();
6621            // Apply truncates/deletes before linking spilled replacement rows.
6622            // This makes TRUNCATE + INSERT a single atomic replace even when
6623            // the insert side exceeds the spill threshold.
6624            for (table_id, ops) in applies {
6625                if let Some(handle) = tables.get(&table_id) {
6626                    let mut t = handle.lock();
6627                    for op in ops {
6628                        match op {
6629                            StagedOp::Put(row) => t.apply_put_rows(vec![row])?,
6630                            StagedOp::Delete(rid) => t.apply_delete(rid, new_epoch),
6631                            StagedOp::Truncate => t.apply_truncate(new_epoch)?,
6632                        }
6633                    }
6634                    t.invalidate_pending_cache();
6635                    t.persist_manifest(new_epoch)?;
6636                }
6637            }
6638            for s in &spilled {
6639                if let Some(handle) = tables.get(&s.table_id) {
6640                    let mut t = handle.lock();
6641                    let dest = t.run_path(s.run_id as u64);
6642                    std::fs::rename(&s.pending_path, &dest)?;
6643                    if let Some(parent) = s.pending_path.parent() {
6644                        let _ = std::fs::remove_dir_all(parent);
6645                    }
6646                    t.link_run(crate::manifest::RunRef {
6647                        run_id: s.run_id,
6648                        level: 0,
6649                        epoch_created: new_epoch.0,
6650                        row_count: s.row_count,
6651                    });
6652                    t.apply_run_metadata(&s.rows)?;
6653                    if truncated_tables.contains(&s.table_id) {
6654                        // TRUNCATE + spilled puts fully describe this table at
6655                        // the commit epoch. Endorse the epoch so clean-reopen
6656                        // recovery does not replay the truncate over the
6657                        // already-linked replacement run.
6658                        t.set_flushed_epoch(new_epoch);
6659                    }
6660                    t.invalidate_pending_cache();
6661                    t.persist_manifest(new_epoch)?;
6662                }
6663            }
6664        }
6665        for (name, _, pending) in &prepared_external {
6666            publish_external_state_file(&self.root, name, pending)?;
6667        }
6668        if !committed_materialized_views.is_empty() {
6669            {
6670                let mut catalog = self.catalog.write();
6671                for definition in committed_materialized_views {
6672                    if let Some(existing) = catalog
6673                        .materialized_views
6674                        .iter_mut()
6675                        .find(|existing| existing.name == definition.name)
6676                    {
6677                        *existing = definition;
6678                    } else {
6679                        catalog.materialized_views.push(definition);
6680                    }
6681                }
6682                catalog.db_epoch = catalog.db_epoch.max(new_epoch.0);
6683            }
6684            catalog::write_atomic(&self.root, &self.catalog.read(), self.meta_dek.as_ref())?;
6685        }
6686
6687        self.epoch.publish_in_order(new_epoch);
6688        if has_changes {
6689            let _ = self.change_wake.send(());
6690        }
6691        _epoch_guard.disarm();
6692        Ok((new_epoch, committed_row_ids))
6693    }
6694
6695    /// Register a read snapshot at the current visible epoch and return it with
6696    /// a guard that retains it for GC until dropped.
6697    pub fn snapshot(&self) -> (Snapshot, SnapshotGuard<'_>) {
6698        let e = self.epoch.visible();
6699        let g = self.snapshots.register(e);
6700        (Snapshot::at(e), g)
6701    }
6702
6703    /// Owned (clonable-handle) variant of [`Self::snapshot`] for cross-thread
6704    /// retention.
6705    pub fn snapshot_owned(&self) -> (Snapshot, OwnedSnapshotGuard) {
6706        let e = self.epoch.visible();
6707        let g = self.snapshots.register_owned(e);
6708        (Snapshot::at(e), g)
6709    }
6710
6711    /// Configure a rolling history window measured in prior commit epochs.
6712    /// The first enable starts at the current epoch because earlier versions
6713    /// may already have been compacted. Increasing the window likewise cannot
6714    /// recreate history that fell outside the previous guarantee.
6715    pub fn set_history_retention_epochs(&self, epochs: u64) -> Result<()> {
6716        let _guard = self.ddl_lock.lock();
6717        let current = self.epoch.visible();
6718        let (old_epochs, old_start) = self.snapshots.history_config();
6719        let earliest_already_guaranteed = if old_epochs == 0 {
6720            current
6721        } else {
6722            Epoch(old_start.0.max(current.0.saturating_sub(old_epochs)))
6723        };
6724        let start = if epochs == 0 {
6725            current
6726        } else {
6727            earliest_already_guaranteed
6728        };
6729        write_history_retention(&self.root, epochs, start)?;
6730        self.snapshots.configure_history(epochs, start);
6731        Ok(())
6732    }
6733
6734    pub fn history_retention_epochs(&self) -> u64 {
6735        self.snapshots.history_config().0
6736    }
6737
6738    pub fn earliest_retained_epoch(&self) -> Epoch {
6739        let current = self.epoch.visible();
6740        self.snapshots.history_floor(current).unwrap_or(current)
6741    }
6742
6743    /// Pin a guaranteed historical epoch for the lifetime of the returned
6744    /// guard. Rejects future epochs and epochs outside the configured window.
6745    pub fn snapshot_at_owned(&self, epoch: Epoch) -> Result<(Snapshot, OwnedSnapshotGuard)> {
6746        let current = self.epoch.visible();
6747        if epoch > current {
6748            return Err(MongrelError::InvalidArgument(format!(
6749                "epoch {} is in the future; current epoch is {}",
6750                epoch.0, current.0
6751            )));
6752        }
6753        let earliest = self.earliest_retained_epoch();
6754        if epoch < earliest {
6755            return Err(MongrelError::InvalidArgument(format!(
6756                "epoch {} is no longer retained; earliest available epoch is {}",
6757                epoch.0, earliest.0
6758            )));
6759        }
6760        let guard = self.snapshots.register_owned(epoch);
6761        Ok((Snapshot::at(epoch), guard))
6762    }
6763
6764    /// Names of all live tables.
6765    pub fn table_names(&self) -> Vec<String> {
6766        self.catalog
6767            .read()
6768            .tables
6769            .iter()
6770            .filter(|t| matches!(t.state, TableState::Live))
6771            .map(|t| t.name.clone())
6772            .collect()
6773    }
6774
6775    /// Best-effort flush-on-close (§4.4): force-flush every mounted table
6776    /// that has pending writes to a `.sr` sorted run, so WAL segments can be
6777    /// reaped on the next open. Call this as the last action before a
6778    /// short-lived process (CLI, one-shot script) exits. The daemon does not
6779    /// need this — its background auto-compactor handles run management.
6780    pub fn close(&self) -> Result<()> {
6781        for name in self.table_names() {
6782            if let Ok(handle) = self.table(&name) {
6783                if let Err(e) = handle.lock().close() {
6784                    eprintln!("[close] flush failed for {name}: {e}");
6785                }
6786            }
6787        }
6788        Ok(())
6789    }
6790
6791    /// Compact every mounted table: merge all sorted runs into one clean run
6792    /// so query cost stays flat (single-run fast path) instead of growing
6793    /// with run count. Tables with < 2 runs are skipped unless TTL has expired
6794    /// rows to reclaim. Each table
6795    /// is locked individually for its own compaction; snapshot retention is
6796    /// honored by `Table::compact`. Returns `(tables_compacted, tables_skipped)`.
6797    pub fn compact(&self) -> Result<(usize, usize)> {
6798        self.require(&crate::auth::Permission::Ddl)?;
6799        let mut compacted = 0;
6800        let mut skipped = 0;
6801        for name in self.table_names() {
6802            let Ok(handle) = self.table(&name) else {
6803                continue;
6804            };
6805            {
6806                let mut t = handle.lock();
6807                let before = t.run_count();
6808                if before < 2 && !t.should_compact() {
6809                    skipped += 1;
6810                    continue;
6811                }
6812                match t.compact() {
6813                    Ok(()) => {
6814                        let after = t.run_count();
6815                        compacted += 1;
6816                        eprintln!("[compact] {name}: {before} -> {after} runs");
6817                    }
6818                    Err(e) => {
6819                        eprintln!("[compact] {name}: compaction failed: {e}");
6820                        skipped += 1;
6821                    }
6822                }
6823            }
6824        }
6825        Ok((compacted, skipped))
6826    }
6827
6828    /// Compact a single table by name. Returns `Ok(true)` if it was
6829    /// compacted, `Ok(false)` if skipped (< 2 runs).
6830    pub fn compact_table(&self, name: &str) -> Result<bool> {
6831        self.require(&crate::auth::Permission::Ddl)?;
6832        let handle = self.table(name)?;
6833        let mut t = handle.lock();
6834        let before = t.run_count();
6835        if before < 2 {
6836            return Ok(false);
6837        }
6838        t.compact()?;
6839        Ok(t.run_count() < before)
6840    }
6841
6842    /// Look up a live table by name.
6843    pub fn table(&self, name: &str) -> Result<TableHandle> {
6844        let cat = self.catalog.read();
6845        let entry = cat
6846            .live(name)
6847            .ok_or_else(|| MongrelError::NotFound(format!("table {name:?} not found")))?;
6848        let id = entry.table_id;
6849        drop(cat);
6850        self.tables
6851            .read()
6852            .get(&id)
6853            .cloned()
6854            .ok_or_else(|| MongrelError::NotFound(format!("table {name:?} not mounted")))
6855    }
6856
6857    /// Whether any mounted table has wall-clock TTL retention. SQL sessions
6858    /// use this to avoid epoch-keyed result caches that can outlive a cutoff.
6859    pub fn has_ttl_tables(&self) -> bool {
6860        self.tables
6861            .read()
6862            .values()
6863            .any(|table| table.lock().ttl().is_some())
6864    }
6865
6866    /// Resolve a live table id → mounted handle (used by the constraint
6867    /// validation pass and other id-qualified internal paths).
6868    fn table_by_id(&self, id: u64) -> Result<TableHandle> {
6869        self.tables
6870            .read()
6871            .get(&id)
6872            .cloned()
6873            .ok_or_else(|| MongrelError::NotFound(format!("table id {id} not mounted")))
6874    }
6875
6876    /// Create a new table. The DDL is first logged to the shared WAL
6877    /// (`Op::Ddl(CreateTable)` + `TxnCommit`) and group-synced so it is durable
6878    /// BEFORE the in-memory catalog and table map are mutated; the catalog
6879    /// checkpoint is rewritten afterwards (spec §15, review fix #16). A reopen
6880    /// that sees a stale catalog still recovers the table by replaying the Ddl.
6881    pub fn create_table(&self, name: &str, schema: Schema) -> Result<u64> {
6882        use crate::wal::DdlOp;
6883        use std::sync::atomic::Ordering;
6884
6885        self.require(&crate::auth::Permission::Ddl)?;
6886        if self.poisoned.load(Ordering::Relaxed) {
6887            return Err(MongrelError::Other(
6888                "database poisoned by fsync error".into(),
6889            ));
6890        }
6891
6892        let _g = self.ddl_lock.lock();
6893        {
6894            let cat = self.catalog.read();
6895            if cat.live(name).is_some() {
6896                return Err(MongrelError::InvalidArgument(format!(
6897                    "table {name:?} already exists"
6898                )));
6899            }
6900        }
6901
6902        // Allocate id + epoch + txn id under the commit lock so the DDL commit
6903        // is serialized with data commits (in-order publish).
6904        let commit_lock = Arc::clone(&self.commit_lock);
6905        let _c = commit_lock.lock();
6906        let table_id = {
6907            let mut cat = self.catalog.write();
6908            let id = cat.next_table_id;
6909            cat.next_table_id += 1;
6910            id
6911        };
6912        let epoch = self.epoch.bump_assigned();
6913        let mut _epoch_guard = EpochGuard::new(self.epoch.as_ref(), epoch);
6914        let txn_id = self.alloc_txn_id();
6915
6916        // Stamp the schema_id with the unique table_id so every table in the
6917        // database has a distinct schema_id (caller-provided values are
6918        // ignored to prevent collisions).
6919        let mut schema = schema;
6920        schema.schema_id = table_id;
6921        // Defense in depth: reject an invalid schema BEFORE any durable
6922        // side-effect. `Table::create_in` re-validates, but by then the DDL has
6923        // already been appended to the shared WAL; a failing create_in would
6924        // leave a dangling entry that `recover_ddl_from_wal` replays without
6925        // re-validating, corrupting the catalog on reopen. Validating here
6926        // keeps the WAL free of schemas that can never be opened.
6927        schema.validate_auto_increment()?;
6928        schema.validate_defaults()?;
6929        schema.validate_ai()?;
6930        for index in &schema.indexes {
6931            index.validate_options()?;
6932        }
6933        for constraint in &schema.constraints.checks {
6934            constraint.expr.validate()?;
6935        }
6936
6937        // 1. Log the DDL + commit marker to the shared WAL, then make it durable
6938        //    via the group-commit coordinator (no fsync under the WAL lock — P3.2).
6939        let schema_json = DdlOp::encode_schema(&schema)?;
6940        let commit_seq = {
6941            let mut wal = self.shared_wal.lock();
6942            wal.append(
6943                txn_id,
6944                table_id,
6945                crate::wal::Op::Ddl(DdlOp::CreateTable {
6946                    table_id,
6947                    name: name.to_string(),
6948                    schema_json,
6949                }),
6950            )?;
6951            wal.append_commit(txn_id, epoch, &[])?
6952        };
6953        self.group
6954            .await_durable(&self.shared_wal, commit_seq)
6955            .inspect_err(|_| {
6956                self.poisoned.store(true, Ordering::Relaxed);
6957            })?;
6958
6959        // 2. Create the on-disk table dir + manifest.
6960        let tdir = self.root.join(TABLES_DIR).join(table_id.to_string());
6961        std::fs::create_dir_all(&tdir)?;
6962        let ctx = SharedCtx {
6963            epoch: Arc::clone(&self.epoch),
6964            page_cache: Arc::clone(&self.page_cache),
6965            decoded_cache: Arc::clone(&self.decoded_cache),
6966            snapshots: Arc::clone(&self.snapshots),
6967            kek: self.kek.clone(),
6968            commit_lock: Arc::clone(&self.commit_lock),
6969            shared: Some(crate::engine::SharedWalCtx {
6970                wal: Arc::clone(&self.shared_wal),
6971                group: Arc::clone(&self.group),
6972                poisoned: Arc::clone(&self.poisoned),
6973                txn_ids: Arc::clone(&self.next_txn_id),
6974                change_wake: self.change_wake.clone(),
6975            }),
6976            table_name: Some(name.to_string()),
6977            auth: self.table_auth_checker(),
6978            read_only: self.read_only,
6979        };
6980        let table = Table::create_in(&tdir, schema.clone(), table_id, ctx)?;
6981
6982        // 3. Mutate the in-memory catalog + mount the table, then rewrite the
6983        //    catalog checkpoint (lazy: outside the WAL critical section).
6984        {
6985            let mut cat = self.catalog.write();
6986            cat.tables.push(CatalogEntry {
6987                table_id,
6988                name: name.to_string(),
6989                schema,
6990                state: TableState::Live,
6991                created_epoch: epoch.0,
6992            });
6993        }
6994        catalog::write_atomic(&self.root, &self.catalog.read(), self.meta_dek.as_ref())?;
6995        self.tables
6996            .write()
6997            .insert(table_id, TableHandle::new(table));
6998
6999        self.epoch.publish_in_order(epoch);
7000        _epoch_guard.disarm();
7001        Ok(table_id)
7002    }
7003
7004    /// Logically drop a table, logging the DDL through the shared WAL first.
7005    pub fn drop_table(&self, name: &str) -> Result<()> {
7006        use crate::wal::DdlOp;
7007        use std::sync::atomic::Ordering;
7008
7009        self.require(&crate::auth::Permission::Ddl)?;
7010        if self.poisoned.load(Ordering::Relaxed) {
7011            return Err(MongrelError::Other(
7012                "database poisoned by fsync error".into(),
7013            ));
7014        }
7015
7016        let _g = self.ddl_lock.lock();
7017        let table_id = {
7018            let cat = self.catalog.read();
7019            cat.live(name)
7020                .ok_or_else(|| MongrelError::NotFound(format!("table {name:?} not found")))?
7021                .table_id
7022        };
7023
7024        let commit_lock = Arc::clone(&self.commit_lock);
7025        let _c = commit_lock.lock();
7026        let epoch = self.epoch.bump_assigned();
7027        let mut _epoch_guard = EpochGuard::new(self.epoch.as_ref(), epoch);
7028        let txn_id = self.alloc_txn_id();
7029        let commit_seq = {
7030            let mut wal = self.shared_wal.lock();
7031            wal.append(
7032                txn_id,
7033                table_id,
7034                crate::wal::Op::Ddl(DdlOp::DropTable { table_id }),
7035            )?;
7036            wal.append_commit(txn_id, epoch, &[])?
7037        };
7038        self.group
7039            .await_durable(&self.shared_wal, commit_seq)
7040            .inspect_err(|_| {
7041                self.poisoned.store(true, Ordering::Relaxed);
7042            })?;
7043
7044        {
7045            let mut cat = self.catalog.write();
7046            let entry = cat
7047                .tables
7048                .iter_mut()
7049                .find(|t| t.table_id == table_id)
7050                .ok_or_else(|| MongrelError::NotFound(format!("table {name:?} not found")))?;
7051            entry.state = TableState::Dropped { at_epoch: epoch.0 };
7052            cat.triggers.retain(|trigger| {
7053                !matches!(
7054                    &trigger.trigger.target,
7055                    TriggerTarget::Table(target) if target == name
7056                )
7057            });
7058            cat.materialized_views
7059                .retain(|definition| definition.name != name);
7060            cat.security.rls_tables.retain(|table| table != name);
7061            cat.security.policies.retain(|policy| policy.table != name);
7062            cat.security.masks.retain(|mask| mask.table != name);
7063            for role in &mut cat.roles {
7064                role.permissions
7065                    .retain(|permission| permission_table(permission) != Some(name));
7066            }
7067            cat.security_version = cat.security_version.wrapping_add(1);
7068        }
7069        catalog::write_atomic(&self.root, &self.catalog.read(), self.meta_dek.as_ref())?;
7070        self.tables.write().remove(&table_id);
7071
7072        self.epoch.publish_in_order(epoch);
7073        _epoch_guard.disarm();
7074        Ok(())
7075    }
7076
7077    /// Rename a live table. `name` must exist and `new_name` must not collide
7078    /// with any live table; both checks run under `ddl_lock` so they are atomic
7079    /// with the rename and with concurrent `create_table` existence checks (no
7080    /// TOCTOU window). A no-op rename (`name == new_name`) succeeds without
7081    /// side-effects. The rename is logged to the shared WAL as
7082    /// `DdlOp::RenameTable` and recovered on reopen; the `table_id`, schema,
7083    /// and on-disk layout are unchanged (the table is keyed by `table_id`, so
7084    /// the in-memory object does not move — only the catalog name changes).
7085    pub fn rename_table(&self, name: &str, new_name: &str) -> Result<()> {
7086        use crate::wal::DdlOp;
7087        use std::sync::atomic::Ordering;
7088
7089        self.require(&crate::auth::Permission::Ddl)?;
7090        if self.poisoned.load(Ordering::Relaxed) {
7091            return Err(MongrelError::Other(
7092                "database poisoned by fsync error".into(),
7093            ));
7094        }
7095
7096        // A no-op rename short-circuits before any locking, so it can never
7097        // trip the "target already exists" check (the source *is* that name).
7098        if name == new_name {
7099            return Ok(());
7100        }
7101        if new_name.is_empty() {
7102            return Err(MongrelError::InvalidArgument(
7103                "rename_table: new name must not be empty".into(),
7104            ));
7105        }
7106
7107        let _g = self.ddl_lock.lock();
7108        let table_id = {
7109            let cat = self.catalog.read();
7110            let src = cat
7111                .live(name)
7112                .ok_or_else(|| MongrelError::NotFound(format!("table {name:?} not found")))?;
7113            // Target must be free. Checked under ddl_lock, which every other
7114            // DDL (create/rename/drop) also holds, so a concurrent operation
7115            // cannot claim `new_name` between this check and the catalog write.
7116            if cat.live(new_name).is_some() {
7117                return Err(MongrelError::InvalidArgument(format!(
7118                    "rename_table: a table named {new_name:?} already exists"
7119                )));
7120            }
7121            src.table_id
7122        };
7123
7124        let commit_lock = Arc::clone(&self.commit_lock);
7125        let _c = commit_lock.lock();
7126        let epoch = self.epoch.bump_assigned();
7127        let mut _epoch_guard = EpochGuard::new(self.epoch.as_ref(), epoch);
7128        let txn_id = self.alloc_txn_id();
7129        let commit_seq = {
7130            let mut wal = self.shared_wal.lock();
7131            wal.append(
7132                txn_id,
7133                table_id,
7134                crate::wal::Op::Ddl(DdlOp::RenameTable {
7135                    table_id,
7136                    new_name: new_name.to_string(),
7137                }),
7138            )?;
7139            wal.append_commit(txn_id, epoch, &[])?
7140        };
7141        self.group
7142            .await_durable(&self.shared_wal, commit_seq)
7143            .inspect_err(|_| {
7144                self.poisoned.store(true, Ordering::Relaxed);
7145            })?;
7146
7147        {
7148            let mut cat = self.catalog.write();
7149            let entry = cat
7150                .tables
7151                .iter_mut()
7152                .find(|t| t.table_id == table_id)
7153                .ok_or_else(|| MongrelError::NotFound(format!("table {name:?} not found")))?;
7154            entry.name = new_name.to_string();
7155            for trigger in &mut cat.triggers {
7156                if matches!(
7157                    &trigger.trigger.target,
7158                    TriggerTarget::Table(target) if target == name
7159                ) {
7160                    trigger.trigger = trigger.trigger.retarget_table(new_name, epoch.0)?;
7161                }
7162            }
7163            if let Some(definition) = cat
7164                .materialized_views
7165                .iter_mut()
7166                .find(|definition| definition.name == name)
7167            {
7168                definition.name = new_name.to_string();
7169            }
7170            for table in &mut cat.security.rls_tables {
7171                if table == name {
7172                    *table = new_name.to_string();
7173                }
7174            }
7175            for policy in &mut cat.security.policies {
7176                if policy.table == name {
7177                    policy.table = new_name.to_string();
7178                }
7179            }
7180            for mask in &mut cat.security.masks {
7181                if mask.table == name {
7182                    mask.table = new_name.to_string();
7183                }
7184            }
7185            for role in &mut cat.roles {
7186                for permission in &mut role.permissions {
7187                    rename_permission_table(permission, name, new_name);
7188                }
7189            }
7190            cat.security_version = cat.security_version.wrapping_add(1);
7191        }
7192        catalog::write_atomic(&self.root, &self.catalog.read(), self.meta_dek.as_ref())?;
7193        // The in-memory table object is keyed by table_id, not name, so it does
7194        // not move and live TableHandles remain valid.
7195        if let Some(table) = self.tables.read().get(&table_id) {
7196            table.lock().set_catalog_name(new_name.to_string());
7197        }
7198        self.epoch.publish_in_order(epoch);
7199        _epoch_guard.disarm();
7200        Ok(())
7201    }
7202
7203    pub fn alter_column(
7204        &self,
7205        table_name: &str,
7206        column_name: &str,
7207        change: AlterColumn,
7208    ) -> Result<ColumnDef> {
7209        use crate::wal::DdlOp;
7210        use std::sync::atomic::Ordering;
7211
7212        self.require(&crate::auth::Permission::Ddl)?;
7213        if self.poisoned.load(Ordering::Relaxed) {
7214            return Err(MongrelError::Other(
7215                "database poisoned by fsync error".into(),
7216            ));
7217        }
7218
7219        let _g = self.ddl_lock.lock();
7220        let table_id = {
7221            let cat = self.catalog.read();
7222            cat.live(table_name)
7223                .ok_or_else(|| MongrelError::NotFound(format!("table {table_name:?} not found")))?
7224                .table_id
7225        };
7226        let handle =
7227            self.tables.read().get(&table_id).cloned().ok_or_else(|| {
7228                MongrelError::NotFound(format!("table {table_name:?} not mounted"))
7229            })?;
7230
7231        // Legitimate online-ALTER slice: when nullable -> NOT NULL has a
7232        // declared default, backfill existing NULL/absent cells as one durable
7233        // transaction before logging the metadata change. A crash between the
7234        // two commits leaves a harmless nullable-but-filled column; retry is
7235        // idempotent because only remaining NULLs are touched.
7236        let backfill = {
7237            let table = handle.lock();
7238            let old = table
7239                .schema()
7240                .column(column_name)
7241                .cloned()
7242                .ok_or_else(|| MongrelError::Schema(format!("unknown column {column_name}")))?;
7243            let next_flags = change.flags.unwrap_or(old.flags);
7244            if old.flags.contains(crate::schema::ColumnFlags::NULLABLE)
7245                && !next_flags.contains(crate::schema::ColumnFlags::NULLABLE)
7246                && old.default_value.is_some()
7247            {
7248                let snapshot = Snapshot::at(self.epoch.visible());
7249                let mut updates = Vec::new();
7250                for row in table.visible_rows(snapshot)? {
7251                    if row
7252                        .columns
7253                        .get(&old.id)
7254                        .is_some_and(|value| !matches!(value, Value::Null))
7255                    {
7256                        continue;
7257                    }
7258                    let mut cells: Vec<(u16, Value)> = row.columns.into_iter().collect();
7259                    table.apply_defaults(&mut cells)?;
7260                    updates.push((table_id, crate::txn::Staged::Update(row.row_id, cells)));
7261                }
7262                updates
7263            } else {
7264                Vec::new()
7265            }
7266        };
7267        if !backfill.is_empty() {
7268            self.commit_transaction_with_external_states(
7269                self.alloc_txn_id(),
7270                self.epoch.visible(),
7271                backfill,
7272                Vec::new(),
7273                Vec::new(),
7274                None,
7275                false,
7276                None,
7277            )?;
7278        }
7279        let mut table = handle.lock();
7280        let column = table.prepare_alter_column(column_name, &change)?;
7281        let renamed_column = (column.name != column_name).then(|| column.name.clone());
7282        if table
7283            .schema()
7284            .columns
7285            .iter()
7286            .find(|c| c.id == column.id)
7287            .is_some_and(|c| c == &column)
7288        {
7289            return Ok(column);
7290        }
7291
7292        let commit_lock = Arc::clone(&self.commit_lock);
7293        let _c = commit_lock.lock();
7294        let epoch = self.epoch.bump_assigned();
7295        let mut _epoch_guard = EpochGuard::new(self.epoch.as_ref(), epoch);
7296        let txn_id = self.alloc_txn_id();
7297        let column_json = DdlOp::encode_column(&column)?;
7298        let commit_seq = {
7299            let mut wal = self.shared_wal.lock();
7300            wal.append(
7301                txn_id,
7302                table_id,
7303                crate::wal::Op::Ddl(DdlOp::AlterTable {
7304                    table_id,
7305                    column_json,
7306                }),
7307            )?;
7308            wal.append_commit(txn_id, epoch, &[])?
7309        };
7310        self.group
7311            .await_durable(&self.shared_wal, commit_seq)
7312            .inspect_err(|_| {
7313                self.poisoned.store(true, Ordering::Relaxed);
7314            })?;
7315
7316        table.apply_altered_column(column.clone())?;
7317        let schema = table.schema().clone();
7318        drop(table);
7319
7320        {
7321            let mut cat = self.catalog.write();
7322            let entry = cat
7323                .tables
7324                .iter_mut()
7325                .find(|t| t.table_id == table_id)
7326                .ok_or_else(|| MongrelError::NotFound(format!("table {table_name:?} not found")))?;
7327            entry.schema = schema;
7328            if let Some(new_column_name) = renamed_column {
7329                for trigger in &mut cat.triggers {
7330                    if matches!(
7331                        &trigger.trigger.target,
7332                        TriggerTarget::Table(target) if target == table_name
7333                    ) {
7334                        trigger.trigger = trigger.trigger.renamed_update_column(
7335                            column_name,
7336                            new_column_name.clone(),
7337                            epoch.0,
7338                        )?;
7339                    }
7340                }
7341                for role in &mut cat.roles {
7342                    for permission in &mut role.permissions {
7343                        rename_permission_column(
7344                            permission,
7345                            table_name,
7346                            column_name,
7347                            &new_column_name,
7348                        );
7349                    }
7350                }
7351                cat.security_version = cat.security_version.wrapping_add(1);
7352            }
7353        }
7354        catalog::write_atomic(&self.root, &self.catalog.read(), self.meta_dek.as_ref())?;
7355
7356        self.epoch.publish_in_order(epoch);
7357        _epoch_guard.disarm();
7358        Ok(column)
7359    }
7360
7361    /// Set a timestamp-column TTL policy and WAL-log it for crash recovery and
7362    /// replication. Duration is in nanoseconds.
7363    pub fn set_table_ttl(
7364        &self,
7365        table_name: &str,
7366        column_name: &str,
7367        duration_nanos: u64,
7368    ) -> Result<crate::manifest::TtlPolicy> {
7369        let policy = self.replace_table_ttl(table_name, Some((column_name, duration_nanos)))?;
7370        Ok(policy.expect("set TTL produces a policy"))
7371    }
7372
7373    pub fn clear_table_ttl(&self, table_name: &str) -> Result<()> {
7374        self.replace_table_ttl(table_name, None)?;
7375        Ok(())
7376    }
7377
7378    fn replace_table_ttl(
7379        &self,
7380        table_name: &str,
7381        requested: Option<(&str, u64)>,
7382    ) -> Result<Option<crate::manifest::TtlPolicy>> {
7383        use crate::wal::DdlOp;
7384        use std::sync::atomic::Ordering;
7385
7386        self.require(&crate::auth::Permission::Ddl)?;
7387        if self.poisoned.load(Ordering::Relaxed) {
7388            return Err(MongrelError::Other(
7389                "database poisoned by fsync error".into(),
7390            ));
7391        }
7392
7393        let _g = self.ddl_lock.lock();
7394        let table_id = {
7395            let cat = self.catalog.read();
7396            cat.live(table_name)
7397                .ok_or_else(|| MongrelError::NotFound(format!("table {table_name:?} not found")))?
7398                .table_id
7399        };
7400        let handle =
7401            self.tables.read().get(&table_id).cloned().ok_or_else(|| {
7402                MongrelError::NotFound(format!("table {table_name:?} not mounted"))
7403            })?;
7404        let mut table = handle.lock();
7405        let policy = match requested {
7406            Some((column, duration)) => Some(table.prepare_ttl_policy(column, duration)?),
7407            None => None,
7408        };
7409        if table.ttl() == policy {
7410            return Ok(policy);
7411        }
7412
7413        let commit_lock = Arc::clone(&self.commit_lock);
7414        let _c = commit_lock.lock();
7415        let epoch = self.epoch.bump_assigned();
7416        let mut _epoch_guard = EpochGuard::new(self.epoch.as_ref(), epoch);
7417        let txn_id = self.alloc_txn_id();
7418        let policy_json = DdlOp::encode_ttl(policy)?;
7419        let commit_seq = {
7420            let mut wal = self.shared_wal.lock();
7421            wal.append(
7422                txn_id,
7423                table_id,
7424                crate::wal::Op::Ddl(DdlOp::SetTtl {
7425                    table_id,
7426                    policy_json,
7427                }),
7428            )?;
7429            wal.append_commit(txn_id, epoch, &[])?
7430        };
7431        self.group
7432            .await_durable(&self.shared_wal, commit_seq)
7433            .inspect_err(|_| {
7434                self.poisoned.store(true, Ordering::Relaxed);
7435            })?;
7436
7437        table.apply_ttl_policy_at(policy, epoch)?;
7438        self.epoch.publish_in_order(epoch);
7439        _epoch_guard.disarm();
7440        Ok(policy)
7441    }
7442
7443    /// Retention-gated garbage collection (spec §6.4, §7.4, §16). Deletes:
7444    /// - Dropped-table subdirs whose `at_epoch < min_active_snapshot`.
7445    /// - Stale `_txn/` dirs (aborted/crashed large-txn pending runs).
7446    ///
7447    /// Returns the number of items reclaimed.
7448    pub fn gc(&self) -> Result<usize> {
7449        self.require(&crate::auth::Permission::Ddl)?;
7450        let min_active = self.snapshots.min_active(self.epoch.visible()).0;
7451        let mut reclaimed = 0;
7452
7453        // Reclaim dropped-table dirs where no pinned snapshot still needs them.
7454        let cat = self.catalog.read();
7455        for entry in &cat.tables {
7456            if let TableState::Dropped { at_epoch } = entry.state {
7457                if at_epoch <= min_active {
7458                    let tdir = self.root.join(TABLES_DIR).join(entry.table_id.to_string());
7459                    if tdir.exists() {
7460                        std::fs::remove_dir_all(&tdir)?;
7461                        reclaimed += 1;
7462                    }
7463                }
7464            }
7465        }
7466        drop(cat);
7467
7468        // Sweep stale _txn/<id>/ dirs on remaining live tables — but NEVER an
7469        // in-flight spill's dir (deleting it would lose the pending run and fail
7470        // the commit, review fix #14). Each `_txn/` subdir is named by its txn id;
7471        // skip any id still registered in `active_spills`.
7472        let cat = self.catalog.read();
7473        for entry in &cat.tables {
7474            if !matches!(entry.state, TableState::Live) {
7475                continue;
7476            }
7477            let txn_dir = self
7478                .root
7479                .join(TABLES_DIR)
7480                .join(entry.table_id.to_string())
7481                .join("_txn");
7482            if !txn_dir.exists() {
7483                continue;
7484            }
7485            for sub in std::fs::read_dir(&txn_dir)? {
7486                let sub = sub?;
7487                let name = sub.file_name();
7488                let Some(name) = name.to_str() else { continue };
7489                // A non-numeric entry can't belong to a live txn — sweep it.
7490                let is_active = name
7491                    .parse::<u64>()
7492                    .map(|id| self.active_spills.is_active(id))
7493                    .unwrap_or(false);
7494                if is_active {
7495                    continue;
7496                }
7497                std::fs::remove_dir_all(sub.path())?;
7498                reclaimed += 1;
7499            }
7500        }
7501        drop(cat);
7502
7503        let external_names = {
7504            let cat = self.catalog.read();
7505            cat.external_tables
7506                .iter()
7507                .map(|entry| entry.name.clone())
7508                .collect::<std::collections::HashSet<_>>()
7509        };
7510        let vtab_dir = self.root.join(VTAB_DIR);
7511        if vtab_dir.exists() {
7512            for entry in std::fs::read_dir(&vtab_dir)? {
7513                let entry = entry?;
7514                let name = entry.file_name();
7515                let Some(name) = name.to_str() else { continue };
7516                if external_names.contains(name) {
7517                    continue;
7518                }
7519                let path = entry.path();
7520                if path.is_dir() {
7521                    std::fs::remove_dir_all(path)?;
7522                } else {
7523                    std::fs::remove_file(path)?;
7524                }
7525                reclaimed += 1;
7526            }
7527        }
7528
7529        // Reap compaction-superseded runs whose retire epoch no pinned snapshot
7530        // can still need (spec §6.4). Each table deletes its own retired files
7531        // gated on `min_active` and persists its manifest.
7532        let tables = self.tables.read();
7533        for (table_id, handle) in tables.iter() {
7534            let backup_pinned: HashSet<u128> = self
7535                .backup_pins
7536                .lock()
7537                .keys()
7538                .filter_map(|(pinned_table, run_id)| {
7539                    (*pinned_table == *table_id).then_some(*run_id)
7540                })
7541                .collect();
7542            reclaimed += handle
7543                .lock()
7544                .reap_retiring(Epoch(min_active), &backup_pinned)?;
7545        }
7546
7547        // WAL-segment GC (spec §6.4/§16). `SharedWal::open` mints a fresh active
7548        // segment on every reopen without truncating the prior ones, so rotated
7549        // segments accumulate. Once every live table's committed data is durable
7550        // in runs (no in-memory rows) and no in-flight spill is open, all rotated
7551        // (non-active) segments are redundant for recovery and safe to delete —
7552        // an in-flight txn only ever appends to the active segment, which is
7553        // never deleted.
7554        let all_durable = self.active_spills.is_idle()
7555            && tables.values().all(|h| {
7556                let g = h.lock();
7557                g.memtable_len() == 0 && g.mutable_run_len() == 0
7558            });
7559        drop(tables);
7560        if all_durable {
7561            let retain = self
7562                .replication_wal_retention_segments
7563                .load(std::sync::atomic::Ordering::Relaxed);
7564            reclaimed += self
7565                .shared_wal
7566                .lock()
7567                .gc_segments_retain_recent(u64::MAX, retain)?;
7568        }
7569
7570        Ok(reclaimed)
7571    }
7572
7573    /// Produce a deterministic-stable byte image of the database directory.
7574    ///
7575    /// After `checkpoint()`:
7576    ///   - All pending writes are flushed to sorted runs (no memtable data).
7577    ///   - Each table is compacted to a single sorted run (no run fragmentation).
7578    ///   - All non-active WAL segments are deleted (data is durable in runs).
7579    ///   - The active WAL segment is rotated to a fresh empty segment.
7580    ///   - Dropped-table directories are removed.
7581    ///   - All manifests + catalog are persisted.
7582    ///
7583    /// The resulting directory is byte-stable: `git add` captures a snapshot
7584    /// that `git checkout` restores deterministically. No stale WAL tail bytes,
7585    /// no unbounded segment growth, no mutable-run spill files.
7586    ///
7587    /// This is the engine primitive behind `mongreldb snapshot <dir>` (CLI).
7588    /// It does NOT clear the exclusive lock — the caller still owns the
7589    /// database handle.
7590    pub fn checkpoint(&self) -> Result<()> {
7591        // 1. Force-flush every table's pending writes to sorted runs.
7592        self.close()?;
7593
7594        // 2. Compact every table to a single run (merge all fragments).
7595        let _ = self.compact()?;
7596
7597        // 3. GC everything: dropped-table dirs, stale _txn dirs, retired runs,
7598        //    and all WAL segments whose data is now durable in runs.
7599        self.gc()?;
7600
7601        // 4. Reap ALL WAL segments (all data is durable in runs after flush +
7602        //    compact). Delete every segment file, then the reopen creates a
7603        //    fresh empty one via SharedWal::open. We can't use gc_segments alone
7604        //    because it skips the active segment — and leaving a stale active
7605        //    segment with pre-checkpoint tail bytes causes a magic-mismatch or
7606        //    truncated-read panic on reopen.
7607        {
7608            let wal = self.shared_wal.lock();
7609            let active = wal.active_segment_no();
7610            drop(wal);
7611            // Remove every segment file including the active one.
7612            let wal_dir = self.root.join("_wal");
7613            if wal_dir.exists() {
7614                for entry in std::fs::read_dir(&wal_dir)? {
7615                    let entry = entry?;
7616                    let path = entry.path();
7617                    if path.extension().is_some_and(|ext| ext == "wal") {
7618                        let _ = std::fs::remove_file(&path);
7619                    }
7620                }
7621            }
7622            let _ = active; // tracked for debugging
7623        }
7624
7625        // 5. Persist the catalog with the bumped next_segment_no.
7626        catalog::write_atomic(&self.root, &self.catalog.read(), self.meta_dek.as_ref())?;
7627
7628        // 6. Persist every table's manifest (force_flush/compact already did
7629        //    this, but a final pass ensures consistency after WAL rotation).
7630        let tables = self.tables.read();
7631        let visible = self.epoch.visible();
7632        for handle in tables.values() {
7633            handle.lock().persist_manifest(visible)?;
7634        }
7635
7636        Ok(())
7637    }
7638    fn alloc_txn_id(&self) -> u64 {
7639        let mut g = self.next_txn_id.lock();
7640        let id = *g;
7641        *g = g.wrapping_add(1);
7642        id
7643    }
7644
7645    /// Set the per-table spill threshold (bytes). When a transaction's staged
7646    /// bytes for a single table exceed this, the rows are written as a
7647    /// uniform-epoch pending run instead of streamed Put records (spec §8.5).
7648    pub fn set_spill_threshold(&self, bytes: u64) {
7649        self.spill_threshold
7650            .store(bytes, std::sync::atomic::Ordering::Relaxed);
7651    }
7652
7653    /// Test-only: install a hook invoked after a transaction writes its spill
7654    /// runs but before the sequencer, so a test can race `gc()` against an
7655    /// in-flight spill. Not part of the stable API.
7656    #[doc(hidden)]
7657    pub fn __set_spill_hook(&self, f: impl Fn() + Send + Sync + 'static) {
7658        *self.spill_hook.lock() = Some(Box::new(f));
7659    }
7660
7661    /// Test-only: pause an online backup after its consistent boundary is
7662    /// captured but before the pinned immutable runs are copied.
7663    #[doc(hidden)]
7664    pub fn __set_backup_hook(&self, f: impl Fn() + Send + Sync + 'static) {
7665        *self.backup_hook.lock() = Some(Box::new(f));
7666    }
7667
7668    /// Number of WAL fsyncs issued so far (test/diagnostic). With group commit
7669    /// this stays well below the number of committed transactions when commits
7670    /// are concurrent (one leader fsync covers a whole batch — spec §9.3).
7671    #[doc(hidden)]
7672    pub fn __wal_group_sync_count(&self) -> u64 {
7673        self.shared_wal.lock().group_sync_count()
7674    }
7675
7676    /// Force the poisoned state (test-only) to verify the §9.3e fail-fast
7677    /// contract that an fsync error would trigger in production.
7678    #[doc(hidden)]
7679    pub fn __poison(&self) {
7680        self.poisoned
7681            .store(true, std::sync::atomic::Ordering::Relaxed);
7682    }
7683
7684    /// Verify multi-table integrity (spec §16). For every live table this:
7685    /// authenticates the manifest; opens each `RunRef`'s file through
7686    /// [`RunReader`](crate::sorted_run::RunReader), which verifies the run footer
7687    /// checksum and — for encrypted DBs — the keyed run-metadata MAC; checks each
7688    /// run's physical row count against its `RunRef`; flags `RunRef`s whose file
7689    /// is missing (dangling) and `.sr` files on disk that no `RunRef` references
7690    /// (orphan); and verifies `flushed_epoch <= current_epoch`. Returns the list
7691    /// of issues found (empty = healthy). Orphans are `warning`-severity; all
7692    /// other findings are `error`-severity (so [`Self::doctor`] quarantines them).
7693    ///
7694    /// Cost: O(total run bytes) — the footer checksum is verified over each run's
7695    /// full body, so this is an integrity tool, not a hot path.
7696    pub fn check(&self) -> Vec<CheckIssue> {
7697        let mut issues = Vec::new();
7698        let cat = self.catalog.read();
7699        let manifest_meta_dek = crate::encryption::meta_dek_for(self.kek.as_deref());
7700        for entry in &cat.tables {
7701            if !matches!(entry.state, TableState::Live) {
7702                continue;
7703            }
7704            let tdir = self.root.join(TABLES_DIR).join(entry.table_id.to_string());
7705            let mut err = |sev: &str, desc: String| {
7706                issues.push(CheckIssue {
7707                    table_id: entry.table_id,
7708                    table_name: entry.name.clone(),
7709                    severity: sev.into(),
7710                    description: desc,
7711                });
7712            };
7713            let m = match crate::manifest::read(&tdir, manifest_meta_dek.as_ref()) {
7714                Ok(m) => m,
7715                Err(e) => {
7716                    err("error", format!("manifest read failed: {e}"));
7717                    continue;
7718                }
7719            };
7720            if m.flushed_epoch > m.current_epoch {
7721                err(
7722                    "error",
7723                    format!(
7724                        "flushed_epoch {} exceeds current_epoch {} (impossible)",
7725                        m.flushed_epoch, m.current_epoch
7726                    ),
7727                );
7728            }
7729
7730            let runs_dir = tdir.join(crate::engine::RUNS_DIR);
7731            let mut referenced: std::collections::HashSet<u128> = std::collections::HashSet::new();
7732            for rr in &m.runs {
7733                referenced.insert(rr.run_id);
7734                let run_path = runs_dir.join(format!("r-{}.sr", rr.run_id));
7735                if !run_path.exists() {
7736                    err("error", format!("missing run file: r-{}.sr", rr.run_id));
7737                    continue;
7738                }
7739                match crate::sorted_run::RunReader::open(
7740                    &run_path,
7741                    entry.schema.clone(),
7742                    self.kek.clone(),
7743                ) {
7744                    Ok(reader) => {
7745                        if reader.row_count() as u64 != rr.row_count {
7746                            err(
7747                                "error",
7748                                format!(
7749                                    "run r-{} row count mismatch: manifest {} vs run {}",
7750                                    rr.run_id,
7751                                    rr.row_count,
7752                                    reader.row_count()
7753                                ),
7754                            );
7755                        }
7756                    }
7757                    Err(e) => {
7758                        err(
7759                            "error",
7760                            format!("run r-{} integrity check failed: {e}", rr.run_id),
7761                        );
7762                    }
7763                }
7764            }
7765
7766            // Compaction-superseded runs awaiting retention-gated deletion are
7767            // tracked in `retiring`; their files are expected on disk, so they
7768            // are not orphans.
7769            for r in &m.retiring {
7770                referenced.insert(r.run_id);
7771            }
7772
7773            // Orphan `.sr` files present on disk but absent from the manifest.
7774            if let Ok(rd) = std::fs::read_dir(&runs_dir) {
7775                for ent in rd.flatten() {
7776                    let p = ent.path();
7777                    if p.extension().and_then(|s| s.to_str()) != Some("sr") {
7778                        continue;
7779                    }
7780                    let run_id = p
7781                        .file_stem()
7782                        .and_then(|s| s.to_str())
7783                        .and_then(|s| s.strip_prefix("r-"))
7784                        .and_then(|s| s.parse::<u128>().ok());
7785                    if let Some(id) = run_id {
7786                        if !referenced.contains(&id) {
7787                            err(
7788                                "warning",
7789                                format!("orphan run file r-{id}.sr not referenced by the manifest"),
7790                            );
7791                        }
7792                    }
7793                }
7794            }
7795        }
7796
7797        let external_names = cat
7798            .external_tables
7799            .iter()
7800            .map(|entry| entry.name.clone())
7801            .collect::<std::collections::HashSet<_>>();
7802        let vtab_dir = self.root.join(VTAB_DIR);
7803        if let Ok(entries) = std::fs::read_dir(&vtab_dir) {
7804            for entry in entries.flatten() {
7805                let name = entry.file_name();
7806                let Some(name) = name.to_str() else { continue };
7807                if !external_names.contains(name) {
7808                    issues.push(CheckIssue {
7809                        table_id: EXTERNAL_TABLE_ID,
7810                        table_name: name.to_string(),
7811                        severity: "warning".into(),
7812                        description: format!(
7813                            "orphan external table state entry {:?} not referenced by the catalog",
7814                            entry.path()
7815                        ),
7816                    });
7817                }
7818            }
7819        }
7820
7821        // WAL retention / integrity invariant (spec §16): every on-disk WAL
7822        // segment must open (header magic + version, and the frame cipher must
7823        // be derivable for an encrypted WAL). A segment that won't open is
7824        // corrupt or truncated and would break crash recovery. `table_id` is
7825        // the reserved `WAL_TABLE_ID` sentinel (u64::MAX) so [`Self::doctor`]
7826        // never confuses a WAL issue with a real table.
7827        for (seg, msg) in self.shared_wal.lock().verify_segments() {
7828            issues.push(CheckIssue {
7829                table_id: WAL_TABLE_ID,
7830                table_name: "<wal>".into(),
7831                severity: "error".into(),
7832                description: format!("WAL segment seg-{seg:06}.wal failed integrity check: {msg}"),
7833            });
7834        }
7835        issues
7836    }
7837
7838    /// Quarantine unreadable tables (spec §16). Moves corrupt table dirs to
7839    /// `_quarantine/<table_id>/`, marks them dropped in the catalog, and
7840    /// unmounts them from the live table map so the DB still opens.
7841    pub fn doctor(&self) -> Result<Vec<u64>> {
7842        // Hold the DDL lock for the whole operation to prevent concurrent
7843        // create_table/drop_table from racing the catalog/dir mutation.
7844        let _ddl = self.ddl_lock.lock();
7845        let issues = self.check();
7846        // A corrupt WAL segment is reported as an error but is NOT a table
7847        // problem — quarantining an innocent table cannot fix it (and the first
7848        // real table is id 0, so the WAL sentinel WAL_TABLE_ID = u64::MAX keeps
7849        // them disjoint). The admin must address WAL corruption manually.
7850        let bad_tables: std::collections::HashSet<u64> = issues
7851            .iter()
7852            .filter(|i| {
7853                i.severity == "error"
7854                    && i.table_id != WAL_TABLE_ID
7855                    && i.table_id != EXTERNAL_TABLE_ID
7856            })
7857            .map(|i| i.table_id)
7858            .collect();
7859        if bad_tables.is_empty() {
7860            return Ok(Vec::new());
7861        }
7862
7863        let qdir = self.root.join("_quarantine");
7864        std::fs::create_dir_all(&qdir)?;
7865        let mut quarantined = Vec::new();
7866        for &table_id in &bad_tables {
7867            let tdir = self.root.join(TABLES_DIR).join(table_id.to_string());
7868            if tdir.exists() {
7869                let dest = qdir.join(table_id.to_string());
7870                std::fs::rename(&tdir, &dest)?;
7871            }
7872            {
7873                let mut cat = self.catalog.write();
7874                if let Some(entry) = cat.tables.iter_mut().find(|t| t.table_id == table_id) {
7875                    entry.state = TableState::Dropped {
7876                        at_epoch: self.epoch.visible().0,
7877                    };
7878                }
7879            }
7880            // Unmount the live handle so no further access reaches the moved dir.
7881            self.tables.write().remove(&table_id);
7882            quarantined.push(table_id);
7883        }
7884        catalog::write_atomic(&self.root, &self.catalog.read(), self.meta_dek.as_ref())?;
7885        Ok(quarantined)
7886    }
7887
7888    /// The DB-wide KEK (if encrypted).
7889    #[allow(dead_code)]
7890    pub(crate) fn kek(&self) -> Option<&Arc<crate::encryption::Kek>> {
7891        self.kek.as_ref()
7892    }
7893
7894    /// Shared epoch authority (used by the transaction layer in P2).
7895    #[allow(dead_code)]
7896    pub(crate) fn epoch_authority(&self) -> &Arc<EpochAuthority> {
7897        &self.epoch
7898    }
7899
7900    /// Shared snapshot registry (used by GC in P3.6).
7901    #[allow(dead_code)]
7902    pub(crate) fn snapshots(&self) -> &Arc<SnapshotRegistry> {
7903        &self.snapshots
7904    }
7905}
7906
7907fn external_state_dir(root: &Path, name: &str) -> PathBuf {
7908    root.join(VTAB_DIR).join(name)
7909}
7910
7911fn filter_ignored_staging(
7912    staging: Vec<(u64, crate::txn::Staged)>,
7913    ignored_indices: &std::collections::BTreeSet<usize>,
7914) -> Vec<(u64, crate::txn::Staged)> {
7915    if ignored_indices.is_empty() {
7916        return staging;
7917    }
7918    staging
7919        .into_iter()
7920        .enumerate()
7921        .filter_map(|(idx, staged)| (!ignored_indices.contains(&idx)).then_some(staged))
7922        .collect()
7923}
7924
7925fn external_state_file(root: &Path, name: &str) -> PathBuf {
7926    external_state_dir(root, name).join("state.json")
7927}
7928
7929fn read_external_state_file(root: &Path, name: &str) -> Result<Vec<u8>> {
7930    let path = external_state_file(root, name);
7931    match std::fs::read(path) {
7932        Ok(bytes) => Ok(bytes),
7933        Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(Vec::new()),
7934        Err(e) => Err(e.into()),
7935    }
7936}
7937
7938fn current_external_state_bytes(
7939    root: &Path,
7940    external_states: &[(String, Vec<u8>)],
7941    name: &str,
7942) -> Result<Vec<u8>> {
7943    for (table, state) in external_states.iter().rev() {
7944        if table == name {
7945            return Ok(state.clone());
7946        }
7947    }
7948    read_external_state_file(root, name)
7949}
7950
7951fn dedup_external_states(external_states: Vec<(String, Vec<u8>)>) -> Vec<(String, Vec<u8>)> {
7952    let mut out = external_states;
7953    dedup_external_states_in_place(&mut out);
7954    out
7955}
7956
7957fn dedup_external_states_in_place(external_states: &mut Vec<(String, Vec<u8>)>) {
7958    let mut seen = std::collections::HashSet::new();
7959    let mut out = Vec::with_capacity(external_states.len());
7960    for (name, state) in std::mem::take(external_states).into_iter().rev() {
7961        if seen.insert(name.clone()) {
7962            out.push((name, state));
7963        }
7964    }
7965    out.reverse();
7966    *external_states = out;
7967}
7968
7969fn prepare_external_state_file(
7970    root: &Path,
7971    name: &str,
7972    state: &[u8],
7973    txn_id: u64,
7974) -> Result<PathBuf> {
7975    let dir = external_state_dir(root, name);
7976    std::fs::create_dir_all(&dir)?;
7977    let pending = dir.join(format!("state.json.{txn_id}.tmp"));
7978    {
7979        let mut file = std::fs::File::create(&pending)?;
7980        file.write_all(state)?;
7981        file.sync_all()?;
7982    }
7983    Ok(pending)
7984}
7985
7986fn publish_external_state_file(root: &Path, name: &str, pending: &Path) -> Result<()> {
7987    let path = external_state_file(root, name);
7988    std::fs::rename(pending, &path)?;
7989    if let Ok(dir) = std::fs::File::open(external_state_dir(root, name)) {
7990        let _ = dir.sync_all();
7991    }
7992    Ok(())
7993}
7994
7995fn write_external_state_file(root: &Path, name: &str, state: &[u8]) -> Result<()> {
7996    let pending = prepare_external_state_file(root, name, state, 0)?;
7997    publish_external_state_file(root, name, &pending)
7998}
7999
8000/// Two-pass, `flushed_epoch`-gated recovery of the shared WAL (spec §15).
8001///
8002/// Pass 1 scans every `TxnCommit` marker and records `txn_id → commit_epoch`
8003/// (the per-txn outcome; aborted / in-flight / torn-tail txns are absent). Pass
8004/// 2 applies each committed data record (Put/Delete) to its table at the commit
8005/// epoch, skipping records whose `commit_epoch <= table.flushed_epoch` (already
8006/// durable in a sorted run). Finally the shared epoch authority is raised to the
8007/// max committed epoch so the next commit continues monotonically.
8008fn recover_shared_wal(
8009    root: &Path,
8010    tables: &HashMap<u64, TableHandle>,
8011    epoch: &EpochAuthority,
8012    wal_dek: Option<&zeroize::Zeroizing<[u8; 32]>>,
8013) -> Result<()> {
8014    use crate::memtable::Row;
8015    use crate::rowid::RowId;
8016    use crate::wal::{Op, SharedWal};
8017
8018    let records = SharedWal::replay_with_dek(root, wal_dek)?;
8019
8020    // Pass 1: committed-txn outcomes + collect spilled-run info.
8021    let mut committed: HashMap<u64, u64> = HashMap::new();
8022    let mut spilled_to_link: Vec<(
8023        u64, /*txn_id*/
8024        u64, /*epoch*/
8025        Vec<crate::wal::AddedRun>,
8026    )> = Vec::new();
8027    for r in &records {
8028        if let Op::TxnCommit {
8029            epoch: ce,
8030            ref added_runs,
8031        } = r.op
8032        {
8033            committed.insert(r.txn_id, ce);
8034            if !added_runs.is_empty() {
8035                spilled_to_link.push((r.txn_id, ce, added_runs.clone()));
8036            }
8037        }
8038    }
8039    let truncated_transactions: HashSet<(u64, u64)> = records
8040        .iter()
8041        .filter_map(|record| {
8042            committed.get(&record.txn_id)?;
8043            match record.op {
8044                Op::TruncateTable { table_id } => Some((record.txn_id, table_id)),
8045                _ => None,
8046            }
8047        })
8048        .collect();
8049
8050    // Pass 2: stage data per table, gated by flushed_epoch.
8051    type TableStage = (Vec<Row>, Vec<(RowId, Epoch)>, Option<Epoch>, Epoch);
8052    let mut stage: HashMap<u64, TableStage> = HashMap::new();
8053    let mut max_epoch = epoch.visible().0;
8054    for r in records {
8055        let Some(&ce) = committed.get(&r.txn_id) else {
8056            continue; // aborted / in-flight — discard
8057        };
8058        let commit_epoch = Epoch(ce);
8059        max_epoch = max_epoch.max(ce);
8060        match r.op {
8061            Op::Put { table_id, rows } => {
8062                // Skip if this table already flushed past the commit epoch.
8063                let skip = tables
8064                    .get(&table_id)
8065                    .map(|h| h.lock().flushed_epoch() >= ce)
8066                    .unwrap_or(true);
8067                if skip {
8068                    continue;
8069                }
8070                let rows: Vec<Row> = match bincode::deserialize(&rows) {
8071                    Ok(v) => v,
8072                    Err(_) => continue,
8073                };
8074                // Re-stamp each row at the txn commit epoch (rows are pre-stamped
8075                // at pending_epoch which equals the commit epoch, but be robust).
8076                let rows: Vec<Row> = rows
8077                    .into_iter()
8078                    .map(|mut row| {
8079                        row.committed_epoch = commit_epoch;
8080                        row
8081                    })
8082                    .collect();
8083                let entry = stage
8084                    .entry(table_id)
8085                    .or_insert_with(|| (Vec::new(), Vec::new(), None, commit_epoch));
8086                entry.0.extend(rows);
8087                entry.3 = commit_epoch;
8088            }
8089            Op::Delete { table_id, row_ids } => {
8090                let skip = tables
8091                    .get(&table_id)
8092                    .map(|h| h.lock().flushed_epoch() >= ce)
8093                    .unwrap_or(true);
8094                if skip {
8095                    continue;
8096                }
8097                let dels = row_ids.into_iter().map(|rid| (rid, commit_epoch));
8098                let entry = stage
8099                    .entry(table_id)
8100                    .or_insert_with(|| (Vec::new(), Vec::new(), None, commit_epoch));
8101                entry.1.extend(dels);
8102                entry.3 = commit_epoch;
8103            }
8104            Op::TruncateTable { table_id } => {
8105                let skip = tables
8106                    .get(&table_id)
8107                    .map(|h| h.lock().flushed_epoch() >= ce)
8108                    .unwrap_or(true);
8109                if skip {
8110                    continue;
8111                }
8112                stage.insert(
8113                    table_id,
8114                    (Vec::new(), Vec::new(), Some(commit_epoch), commit_epoch),
8115                );
8116            }
8117            Op::ExternalTableState { name, state } => {
8118                write_external_state_file(root, &name, &state)?;
8119            }
8120            Op::Flush { .. }
8121            | Op::TxnCommit { .. }
8122            | Op::TxnAbort
8123            | Op::Ddl(_)
8124            | Op::BeforeImage { .. }
8125            | Op::CommitTimestamp { .. } => {}
8126        }
8127    }
8128    for (table_id, (rows, deletes, truncate_epoch, table_epoch)) in stage {
8129        let Some(handle) = tables.get(&table_id) else {
8130            continue;
8131        };
8132        let mut t = handle.lock();
8133        if let Some(epoch) = truncate_epoch {
8134            t.apply_truncate(epoch)?;
8135        }
8136        t.recover_apply(rows, deletes)?;
8137        // The WAL can be newer than the copied/persisted manifest after a
8138        // crash or replication apply. Rebuild O(1) count metadata from the
8139        // recovered state before endorsing the commit epoch in the manifest.
8140        let rows = t.visible_rows(Snapshot::at(Epoch(u64::MAX)))?;
8141        t.live_count = rows.len() as u64;
8142        t.persist_manifest(table_epoch)?;
8143    }
8144
8145    // Pass 3: link spilled runs from committed txns (spec §8.5). A crash
8146    // between TxnCommit sync and the publish phase leaves the run in
8147    // `_txn/<txn_id>/`. Move it to `_runs/` and add the RunRef.
8148    for (txn_id, ce, added_runs) in &spilled_to_link {
8149        for ar in added_runs {
8150            let Some(handle) = tables.get(&ar.table_id) else {
8151                continue;
8152            };
8153            let mut t = handle.lock();
8154            let dest = t.run_path(ar.run_id as u64);
8155            if !dest.exists() {
8156                let pending = root
8157                    .join(TABLES_DIR)
8158                    .join(ar.table_id.to_string())
8159                    .join("_txn")
8160                    .join(txn_id.to_string())
8161                    .join(format!("r-{}.sr", ar.run_id));
8162                if pending.exists() {
8163                    if let Some(parent) = pending.parent() {
8164                        std::fs::rename(&pending, &dest)?;
8165                        let _ = std::fs::remove_dir_all(parent);
8166                    }
8167                }
8168            }
8169            // Only link a run whose file is actually present, and never re-link
8170            // one the publish phase already persisted into the manifest (which is
8171            // the common clean-reopen case, since the `TxnCommit` lives in the WAL
8172            // until segment GC). `recover_spilled_run` is idempotent + reconciles
8173            // `live_count`/indexes only when the run is genuinely new.
8174            if t.run_path(ar.run_id as u64).exists() {
8175                let linked = t.recover_spilled_run(crate::manifest::RunRef {
8176                    run_id: ar.run_id,
8177                    level: ar.level,
8178                    epoch_created: *ce,
8179                    row_count: ar.row_count,
8180                });
8181                let replaced = truncated_transactions.contains(&(*txn_id, ar.table_id));
8182                if replaced {
8183                    t.set_flushed_epoch(Epoch(*ce));
8184                }
8185                if linked || replaced {
8186                    t.persist_manifest(Epoch(*ce))?;
8187                }
8188            }
8189        }
8190    }
8191
8192    epoch.advance_recovered(Epoch(max_epoch));
8193    Ok(())
8194}
8195
8196fn validate_condition_columns(condition: &ProcedureCondition, schema: &Schema) -> Result<()> {
8197    match condition {
8198        ProcedureCondition::Pk { .. } => {
8199            if schema.primary_key().is_none() {
8200                return Err(MongrelError::InvalidArgument(
8201                    "procedure condition Pk references a table without a primary key".into(),
8202                ));
8203            }
8204        }
8205        ProcedureCondition::BitmapEq { column_id, .. }
8206        | ProcedureCondition::BitmapIn { column_id, .. }
8207        | ProcedureCondition::Range { column_id, .. }
8208        | ProcedureCondition::RangeF64 { column_id, .. }
8209        | ProcedureCondition::IsNull { column_id }
8210        | ProcedureCondition::IsNotNull { column_id }
8211        | ProcedureCondition::FmContains { column_id, .. } => {
8212            validate_column_id(*column_id, schema)?;
8213        }
8214    }
8215    Ok(())
8216}
8217
8218fn bind_procedure_args(
8219    procedure: &StoredProcedure,
8220    mut args: HashMap<String, crate::Value>,
8221) -> Result<HashMap<String, crate::Value>> {
8222    let mut out = HashMap::new();
8223    for param in &procedure.params {
8224        let value = match args.remove(&param.name) {
8225            Some(value) => value,
8226            None => param.default.clone().ok_or_else(|| {
8227                MongrelError::InvalidArgument(format!(
8228                    "missing required procedure parameter {:?}",
8229                    param.name
8230                ))
8231            })?,
8232        };
8233        if !param.nullable && matches!(value, crate::Value::Null) {
8234            return Err(MongrelError::InvalidArgument(format!(
8235                "procedure parameter {:?} must not be NULL",
8236                param.name
8237            )));
8238        }
8239        if !matches!(value, crate::Value::Null) && !value_matches_type(&value, param.ty.clone()) {
8240            return Err(MongrelError::InvalidArgument(format!(
8241                "procedure parameter {:?} has wrong type",
8242                param.name
8243            )));
8244        }
8245        out.insert(param.name.clone(), value);
8246    }
8247    if let Some(extra) = args.keys().next() {
8248        return Err(MongrelError::InvalidArgument(format!(
8249            "unknown procedure parameter {extra:?}"
8250        )));
8251    }
8252    Ok(out)
8253}
8254
8255fn value_matches_type(value: &crate::Value, ty: crate::TypeId) -> bool {
8256    matches!(
8257        (value, ty),
8258        (crate::Value::Bool(_), crate::TypeId::Bool)
8259            | (crate::Value::Int64(_), crate::TypeId::Int8)
8260            | (crate::Value::Int64(_), crate::TypeId::Int16)
8261            | (crate::Value::Int64(_), crate::TypeId::Int32)
8262            | (crate::Value::Int64(_), crate::TypeId::Int64)
8263            | (crate::Value::Int64(_), crate::TypeId::UInt8)
8264            | (crate::Value::Int64(_), crate::TypeId::UInt16)
8265            | (crate::Value::Int64(_), crate::TypeId::UInt32)
8266            | (crate::Value::Int64(_), crate::TypeId::UInt64)
8267            | (crate::Value::Int64(_), crate::TypeId::TimestampNanos)
8268            | (crate::Value::Int64(_), crate::TypeId::Date32)
8269            | (crate::Value::Float64(_), crate::TypeId::Float32)
8270            | (crate::Value::Float64(_), crate::TypeId::Float64)
8271            | (crate::Value::Bytes(_), crate::TypeId::Bytes)
8272            | (crate::Value::Embedding(_), crate::TypeId::Embedding { .. })
8273    )
8274}
8275
8276fn eval_cells(
8277    cells: &[crate::procedure::ProcedureCell],
8278    args: &HashMap<String, crate::Value>,
8279    outputs: &HashMap<String, ProcedureCallOutput>,
8280) -> Result<Vec<(u16, crate::Value)>> {
8281    cells
8282        .iter()
8283        .map(|cell| Ok((cell.column_id, eval_value(&cell.value, args, outputs)?)))
8284        .collect()
8285}
8286
8287fn eval_condition(
8288    condition: &ProcedureCondition,
8289    args: &HashMap<String, crate::Value>,
8290    outputs: &HashMap<String, ProcedureCallOutput>,
8291) -> Result<crate::Condition> {
8292    Ok(match condition {
8293        ProcedureCondition::Pk { value } => {
8294            crate::Condition::Pk(eval_value(value, args, outputs)?.encode_key())
8295        }
8296        ProcedureCondition::BitmapEq { column_id, value } => crate::Condition::BitmapEq {
8297            column_id: *column_id,
8298            value: eval_value(value, args, outputs)?.encode_key(),
8299        },
8300        ProcedureCondition::BitmapIn { column_id, values } => crate::Condition::BitmapIn {
8301            column_id: *column_id,
8302            values: values
8303                .iter()
8304                .map(|value| Ok(eval_value(value, args, outputs)?.encode_key()))
8305                .collect::<Result<Vec<_>>>()?,
8306        },
8307        ProcedureCondition::Range { column_id, lo, hi } => crate::Condition::Range {
8308            column_id: *column_id,
8309            lo: expect_i64(eval_value(lo, args, outputs)?)?,
8310            hi: expect_i64(eval_value(hi, args, outputs)?)?,
8311        },
8312        ProcedureCondition::RangeF64 {
8313            column_id,
8314            lo,
8315            lo_inclusive,
8316            hi,
8317            hi_inclusive,
8318        } => crate::Condition::RangeF64 {
8319            column_id: *column_id,
8320            lo: expect_f64(eval_value(lo, args, outputs)?)?,
8321            lo_inclusive: *lo_inclusive,
8322            hi: expect_f64(eval_value(hi, args, outputs)?)?,
8323            hi_inclusive: *hi_inclusive,
8324        },
8325        ProcedureCondition::IsNull { column_id } => crate::Condition::IsNull {
8326            column_id: *column_id,
8327        },
8328        ProcedureCondition::IsNotNull { column_id } => crate::Condition::IsNotNull {
8329            column_id: *column_id,
8330        },
8331        ProcedureCondition::FmContains { column_id, pattern } => crate::Condition::FmContains {
8332            column_id: *column_id,
8333            pattern: expect_bytes(eval_value(pattern, args, outputs)?)?,
8334        },
8335    })
8336}
8337
8338fn eval_value(
8339    value: &ProcedureValue,
8340    args: &HashMap<String, crate::Value>,
8341    outputs: &HashMap<String, ProcedureCallOutput>,
8342) -> Result<crate::Value> {
8343    match value {
8344        ProcedureValue::Literal(value) => Ok(value.clone()),
8345        ProcedureValue::Param(name) => args.get(name).cloned().ok_or_else(|| {
8346            MongrelError::InvalidArgument(format!("unknown procedure parameter {name:?}"))
8347        }),
8348        ProcedureValue::StepScalar(id) => match outputs.get(id) {
8349            Some(ProcedureCallOutput::Scalar(value)) => Ok(value.clone()),
8350            _ => Err(MongrelError::InvalidArgument(format!(
8351                "procedure step {id:?} did not return a scalar"
8352            ))),
8353        },
8354        ProcedureValue::StepRows(_) | ProcedureValue::StepRow(_) => {
8355            Err(MongrelError::InvalidArgument(
8356                "row-valued procedure reference cannot be used as a scalar".into(),
8357            ))
8358        }
8359        ProcedureValue::Object(_) | ProcedureValue::Array(_) => Err(MongrelError::InvalidArgument(
8360            "structured procedure value cannot be used as a scalar cell".into(),
8361        )),
8362    }
8363}
8364
8365fn eval_return_output(
8366    value: &ProcedureValue,
8367    args: &HashMap<String, crate::Value>,
8368    outputs: &HashMap<String, ProcedureCallOutput>,
8369) -> Result<ProcedureCallOutput> {
8370    match value {
8371        ProcedureValue::Literal(value) => Ok(ProcedureCallOutput::Scalar(value.clone())),
8372        ProcedureValue::Param(name) => Ok(ProcedureCallOutput::Scalar(
8373            args.get(name).cloned().ok_or_else(|| {
8374                MongrelError::InvalidArgument(format!("unknown procedure parameter {name:?}"))
8375            })?,
8376        )),
8377        ProcedureValue::StepRows(id)
8378        | ProcedureValue::StepRow(id)
8379        | ProcedureValue::StepScalar(id) => outputs.get(id).cloned().ok_or_else(|| {
8380            MongrelError::InvalidArgument(format!("unknown procedure step output {id:?}"))
8381        }),
8382        ProcedureValue::Object(fields) => {
8383            let mut out = Vec::with_capacity(fields.len());
8384            for (name, value) in fields {
8385                out.push((name.clone(), eval_return_output(value, args, outputs)?));
8386            }
8387            Ok(ProcedureCallOutput::Object(out))
8388        }
8389        ProcedureValue::Array(values) => {
8390            let mut out = Vec::with_capacity(values.len());
8391            for value in values {
8392                out.push(eval_return_output(value, args, outputs)?);
8393            }
8394            Ok(ProcedureCallOutput::Array(out))
8395        }
8396    }
8397}
8398
8399fn expect_i64(value: crate::Value) -> Result<i64> {
8400    match value {
8401        crate::Value::Int64(value) => Ok(value),
8402        _ => Err(MongrelError::InvalidArgument(
8403            "procedure value must be Int64".into(),
8404        )),
8405    }
8406}
8407
8408fn expect_f64(value: crate::Value) -> Result<f64> {
8409    match value {
8410        crate::Value::Float64(value) => Ok(value),
8411        _ => Err(MongrelError::InvalidArgument(
8412            "procedure value must be Float64".into(),
8413        )),
8414    }
8415}
8416
8417fn expect_bytes(value: crate::Value) -> Result<Vec<u8>> {
8418    match value {
8419        crate::Value::Bytes(value) => Ok(value),
8420        _ => Err(MongrelError::InvalidArgument(
8421            "procedure value must be Bytes".into(),
8422        )),
8423    }
8424}
8425
8426fn validate_column_id(column_id: u16, schema: &Schema) -> Result<()> {
8427    if schema.columns.iter().any(|c| c.id == column_id) {
8428        Ok(())
8429    } else {
8430        Err(MongrelError::InvalidArgument(format!(
8431            "unknown column id {column_id}"
8432        )))
8433    }
8434}
8435
8436fn trigger_matches_event(
8437    trigger: &StoredTrigger,
8438    event: &WriteEvent,
8439    cat: &Catalog,
8440) -> Result<bool> {
8441    if trigger.event != event.kind {
8442        return Ok(false);
8443    }
8444    let TriggerTarget::Table(target) = &trigger.target else {
8445        return Ok(false);
8446    };
8447    if target != &event.table {
8448        return Ok(false);
8449    }
8450    if trigger.event == TriggerEvent::Update && !trigger.update_of.is_empty() {
8451        let schema = &cat
8452            .live(target)
8453            .ok_or_else(|| {
8454                MongrelError::InvalidArgument(format!(
8455                    "trigger {:?} references unknown table {target:?}",
8456                    trigger.name
8457                ))
8458            })?
8459            .schema;
8460        let mut watched = Vec::with_capacity(trigger.update_of.len());
8461        for name in &trigger.update_of {
8462            let col = schema.column(name).ok_or_else(|| {
8463                MongrelError::InvalidArgument(format!(
8464                    "trigger {:?} references unknown UPDATE OF column {name:?}",
8465                    trigger.name
8466                ))
8467            })?;
8468            watched.push(col.id);
8469        }
8470        if !event
8471            .changed_columns
8472            .iter()
8473            .any(|column_id| watched.contains(column_id))
8474        {
8475            return Ok(false);
8476        }
8477    }
8478    Ok(true)
8479}
8480
8481fn changed_columns(old: Option<&TriggerRowImage>, new: Option<&TriggerRowImage>) -> Vec<u16> {
8482    let mut ids = std::collections::BTreeSet::new();
8483    if let Some(old) = old {
8484        ids.extend(old.columns.keys().copied());
8485    }
8486    if let Some(new) = new {
8487        ids.extend(new.columns.keys().copied());
8488    }
8489    ids.into_iter()
8490        .filter(|id| {
8491            old.and_then(|row| row.columns.get(id)) != new.and_then(|row| row.columns.get(id))
8492        })
8493        .collect()
8494}
8495
8496fn eval_trigger_cells(
8497    cells: &[crate::trigger::TriggerCell],
8498    event: &WriteEvent,
8499    selected: Option<&TriggerRowImage>,
8500) -> Result<Vec<(u16, Value)>> {
8501    cells
8502        .iter()
8503        .map(|cell| {
8504            Ok((
8505                cell.column_id,
8506                eval_trigger_value(&cell.value, event, selected)?,
8507            ))
8508        })
8509        .collect()
8510}
8511
8512fn eval_trigger_expr(expr: &TriggerExpr, event: &WriteEvent) -> Result<bool> {
8513    match expr {
8514        TriggerExpr::Value(value) => match eval_trigger_value(value, event, None)? {
8515            Value::Bool(value) => Ok(value),
8516            Value::Null => Ok(false),
8517            other => Err(MongrelError::InvalidArgument(format!(
8518                "trigger WHEN value must be boolean, got {other:?}"
8519            ))),
8520        },
8521        TriggerExpr::Eq { left, right } => Ok(values_equal(
8522            &eval_trigger_value(left, event, None)?,
8523            &eval_trigger_value(right, event, None)?,
8524        )),
8525        TriggerExpr::NotEq { left, right } => Ok(!values_equal(
8526            &eval_trigger_value(left, event, None)?,
8527            &eval_trigger_value(right, event, None)?,
8528        )),
8529        TriggerExpr::Lt { left, right } => match value_order(
8530            &eval_trigger_value(left, event, None)?,
8531            &eval_trigger_value(right, event, None)?,
8532        ) {
8533            Some(ordering) => Ok(ordering == std::cmp::Ordering::Less),
8534            None => Ok(false),
8535        },
8536        TriggerExpr::Lte { left, right } => match value_order(
8537            &eval_trigger_value(left, event, None)?,
8538            &eval_trigger_value(right, event, None)?,
8539        ) {
8540            Some(ordering) => Ok(ordering != std::cmp::Ordering::Greater),
8541            None => Ok(false),
8542        },
8543        TriggerExpr::Gt { left, right } => match value_order(
8544            &eval_trigger_value(left, event, None)?,
8545            &eval_trigger_value(right, event, None)?,
8546        ) {
8547            Some(ordering) => Ok(ordering == std::cmp::Ordering::Greater),
8548            None => Ok(false),
8549        },
8550        TriggerExpr::Gte { left, right } => match value_order(
8551            &eval_trigger_value(left, event, None)?,
8552            &eval_trigger_value(right, event, None)?,
8553        ) {
8554            Some(ordering) => Ok(ordering != std::cmp::Ordering::Less),
8555            None => Ok(false),
8556        },
8557        TriggerExpr::IsNull(value) => Ok(matches!(
8558            eval_trigger_value(value, event, None)?,
8559            Value::Null
8560        )),
8561        TriggerExpr::IsNotNull(value) => Ok(!matches!(
8562            eval_trigger_value(value, event, None)?,
8563            Value::Null
8564        )),
8565        TriggerExpr::And { left, right } => {
8566            if !eval_trigger_expr(left, event)? {
8567                Ok(false)
8568            } else {
8569                Ok(eval_trigger_expr(right, event)?)
8570            }
8571        }
8572        TriggerExpr::Or { left, right } => {
8573            if eval_trigger_expr(left, event)? {
8574                Ok(true)
8575            } else {
8576                Ok(eval_trigger_expr(right, event)?)
8577            }
8578        }
8579        TriggerExpr::Not(expr) => Ok(!eval_trigger_expr(expr, event)?),
8580    }
8581}
8582
8583fn eval_trigger_condition(
8584    condition: &TriggerCondition,
8585    event: &WriteEvent,
8586    selected: &TriggerRowImage,
8587    schema: &Schema,
8588) -> Result<bool> {
8589    match condition {
8590        TriggerCondition::Pk { value } => {
8591            let pk = schema.primary_key().ok_or_else(|| {
8592                MongrelError::InvalidArgument(
8593                    "trigger condition Pk references a table without a primary key".into(),
8594                )
8595            })?;
8596            let lhs = eval_trigger_value(value, event, Some(selected))?;
8597            Ok(values_equal(
8598                &lhs,
8599                selected.columns.get(&pk.id).unwrap_or(&Value::Null),
8600            ))
8601        }
8602        TriggerCondition::Eq { column_id, value } => Ok(values_equal(
8603            selected.columns.get(column_id).unwrap_or(&Value::Null),
8604            &eval_trigger_value(value, event, Some(selected))?,
8605        )),
8606        TriggerCondition::NotEq { column_id, value } => Ok(!values_equal(
8607            selected.columns.get(column_id).unwrap_or(&Value::Null),
8608            &eval_trigger_value(value, event, Some(selected))?,
8609        )),
8610        TriggerCondition::Lt { column_id, value } => match value_order(
8611            selected.columns.get(column_id).unwrap_or(&Value::Null),
8612            &eval_trigger_value(value, event, Some(selected))?,
8613        ) {
8614            Some(ordering) => Ok(ordering == std::cmp::Ordering::Less),
8615            None => Ok(false),
8616        },
8617        TriggerCondition::Lte { column_id, value } => match value_order(
8618            selected.columns.get(column_id).unwrap_or(&Value::Null),
8619            &eval_trigger_value(value, event, Some(selected))?,
8620        ) {
8621            Some(ordering) => Ok(ordering != std::cmp::Ordering::Greater),
8622            None => Ok(false),
8623        },
8624        TriggerCondition::Gt { column_id, value } => match value_order(
8625            selected.columns.get(column_id).unwrap_or(&Value::Null),
8626            &eval_trigger_value(value, event, Some(selected))?,
8627        ) {
8628            Some(ordering) => Ok(ordering == std::cmp::Ordering::Greater),
8629            None => Ok(false),
8630        },
8631        TriggerCondition::Gte { column_id, value } => match value_order(
8632            selected.columns.get(column_id).unwrap_or(&Value::Null),
8633            &eval_trigger_value(value, event, Some(selected))?,
8634        ) {
8635            Some(ordering) => Ok(ordering != std::cmp::Ordering::Less),
8636            None => Ok(false),
8637        },
8638        TriggerCondition::IsNull { column_id } => Ok(matches!(
8639            selected.columns.get(column_id),
8640            None | Some(Value::Null)
8641        )),
8642        TriggerCondition::IsNotNull { column_id } => Ok(!matches!(
8643            selected.columns.get(column_id),
8644            None | Some(Value::Null)
8645        )),
8646        TriggerCondition::And { left, right } => {
8647            if !eval_trigger_condition(left, event, selected, schema)? {
8648                Ok(false)
8649            } else {
8650                Ok(eval_trigger_condition(right, event, selected, schema)?)
8651            }
8652        }
8653        TriggerCondition::Or { left, right } => {
8654            if eval_trigger_condition(left, event, selected, schema)? {
8655                Ok(true)
8656            } else {
8657                Ok(eval_trigger_condition(right, event, selected, schema)?)
8658            }
8659        }
8660        TriggerCondition::Not(condition) => {
8661            Ok(!eval_trigger_condition(condition, event, selected, schema)?)
8662        }
8663    }
8664}
8665
8666fn eval_trigger_value(
8667    value: &TriggerValue,
8668    event: &WriteEvent,
8669    selected: Option<&TriggerRowImage>,
8670) -> Result<Value> {
8671    match value {
8672        TriggerValue::Literal(value) => Ok(value.clone()),
8673        TriggerValue::NewColumn(column_id) => event
8674            .new
8675            .as_ref()
8676            .and_then(|row| row.columns.get(column_id))
8677            .cloned()
8678            .ok_or_else(|| MongrelError::InvalidArgument("NEW column is not available".into())),
8679        TriggerValue::OldColumn(column_id) => event
8680            .old
8681            .as_ref()
8682            .and_then(|row| row.columns.get(column_id))
8683            .cloned()
8684            .ok_or_else(|| MongrelError::InvalidArgument("OLD column is not available".into())),
8685        TriggerValue::SelectedColumn(column_id) => selected
8686            .and_then(|row| row.columns.get(column_id))
8687            .cloned()
8688            .ok_or_else(|| {
8689                MongrelError::InvalidArgument("SELECTED column is not available".into())
8690            }),
8691    }
8692}
8693
8694fn values_equal(left: &Value, right: &Value) -> bool {
8695    match (left, right) {
8696        (Value::Null, Value::Null) => true,
8697        (Value::Bool(a), Value::Bool(b)) => a == b,
8698        (Value::Int64(a), Value::Int64(b)) => a == b,
8699        (Value::Float64(a), Value::Float64(b)) => a.to_bits() == b.to_bits(),
8700        (Value::Bytes(a), Value::Bytes(b)) => a == b,
8701        (Value::Embedding(a), Value::Embedding(b)) => {
8702            a.len() == b.len()
8703                && a.iter()
8704                    .zip(b.iter())
8705                    .all(|(a, b)| a.to_bits() == b.to_bits())
8706        }
8707        _ => false,
8708    }
8709}
8710
8711fn value_order(left: &Value, right: &Value) -> Option<std::cmp::Ordering> {
8712    match (left, right) {
8713        (Value::Null, _) | (_, Value::Null) => None,
8714        (Value::Bool(a), Value::Bool(b)) => Some(a.cmp(b)),
8715        (Value::Int64(a), Value::Int64(b)) => Some(a.cmp(b)),
8716        // Cross-type Int64/Float64 comparison coerces the integer to f64.
8717        // This matches the spec but can lose precision for i64 values above 2^53.
8718        (Value::Int64(a), Value::Float64(b)) => {
8719            let af = *a as f64;
8720            Some(af.total_cmp(b))
8721        }
8722        // Cross-type Int64/Float64 comparison coerces the integer to f64.
8723        // This matches the spec but can lose precision for i64 values above 2^53.
8724        (Value::Float64(a), Value::Int64(b)) => {
8725            let bf = *b as f64;
8726            Some(a.total_cmp(&bf))
8727        }
8728        (Value::Float64(a), Value::Float64(b)) => Some(a.total_cmp(b)),
8729        (Value::Bytes(a), Value::Bytes(b)) => Some(a.cmp(b)),
8730        (Value::Embedding(_), Value::Embedding(_)) => None,
8731        _ => None,
8732    }
8733}
8734
8735fn trigger_message(value: Value) -> String {
8736    match value {
8737        Value::Null => "NULL".into(),
8738        Value::Bool(value) => value.to_string(),
8739        Value::Int64(value) => value.to_string(),
8740        Value::Float64(value) => value.to_string(),
8741        Value::Bytes(value) => String::from_utf8_lossy(&value).into_owned(),
8742        Value::Embedding(value) => format!("{value:?}"),
8743        Value::Decimal(value) => value.to_string(),
8744        Value::Interval {
8745            months,
8746            days,
8747            nanos,
8748        } => format!("{months}m {days}d {nanos}ns"),
8749        Value::Uuid(b) => b.iter().map(|x| format!("{x:02x}")).collect(),
8750        Value::Json(b) => String::from_utf8_lossy(&b).into_owned(),
8751    }
8752}
8753
8754fn validate_trigger_step<'a>(
8755    step: &TriggerStep,
8756    cat: &'a Catalog,
8757    target_schema: &Schema,
8758    event: TriggerEvent,
8759    select_schemas: &mut HashMap<String, &'a Schema>,
8760) -> Result<()> {
8761    match step {
8762        TriggerStep::SetNew { cells } => {
8763            if event == TriggerEvent::Delete {
8764                return Err(MongrelError::InvalidArgument(
8765                    "SetNew trigger step is not valid for DELETE triggers".into(),
8766                ));
8767            }
8768            for cell in cells {
8769                validate_column_id(cell.column_id, target_schema)?;
8770                validate_trigger_value(&cell.value, target_schema, event)?;
8771            }
8772        }
8773        TriggerStep::Insert { table, cells } => {
8774            let schema = trigger_write_schema(cat, table, "insert")?;
8775            for cell in cells {
8776                validate_column_id(cell.column_id, schema)?;
8777                validate_trigger_value(&cell.value, target_schema, event)?;
8778            }
8779        }
8780        TriggerStep::UpdateByPk { table, pk, cells } => {
8781            let schema = trigger_write_schema(cat, table, "update")?;
8782            if schema.primary_key().is_none() {
8783                return Err(MongrelError::InvalidArgument(format!(
8784                    "trigger update_by_pk references table {table:?} without a primary key"
8785                )));
8786            }
8787            validate_trigger_value(pk, target_schema, event)?;
8788            for cell in cells {
8789                validate_column_id(cell.column_id, schema)?;
8790                validate_trigger_value(&cell.value, target_schema, event)?;
8791            }
8792        }
8793        TriggerStep::DeleteByPk { table, pk } => {
8794            let schema = trigger_write_schema(cat, table, "delete")?;
8795            if schema.primary_key().is_none() {
8796                return Err(MongrelError::InvalidArgument(format!(
8797                    "trigger delete_by_pk references table {table:?} without a primary key"
8798                )));
8799            }
8800            validate_trigger_value(pk, target_schema, event)?;
8801        }
8802        TriggerStep::Select {
8803            id,
8804            table,
8805            conditions,
8806        } => {
8807            let schema = trigger_read_schema(cat, table)?;
8808            for condition in conditions {
8809                validate_trigger_condition(condition, schema, target_schema, event)?;
8810            }
8811            if select_schemas.contains_key(id) {
8812                return Err(MongrelError::InvalidArgument(format!(
8813                    "duplicate select id {id:?} in trigger program"
8814                )));
8815            }
8816            select_schemas.insert(id.clone(), schema);
8817        }
8818        TriggerStep::Foreach { id, steps } => {
8819            if !select_schemas.contains_key(id) {
8820                return Err(MongrelError::InvalidArgument(format!(
8821                    "foreach references unknown select id {id:?}"
8822                )));
8823            }
8824            let mut inner_select_schemas = select_schemas.clone();
8825            for step in steps {
8826                validate_trigger_step(step, cat, target_schema, event, &mut inner_select_schemas)?;
8827            }
8828        }
8829        TriggerStep::DeleteWhere { table, conditions } => {
8830            let schema = trigger_write_schema(cat, table, "delete")?;
8831            for condition in conditions {
8832                validate_trigger_condition(condition, schema, target_schema, event)?;
8833            }
8834        }
8835        TriggerStep::UpdateWhere {
8836            table,
8837            conditions,
8838            cells,
8839        } => {
8840            let schema = trigger_write_schema(cat, table, "update")?;
8841            for condition in conditions {
8842                validate_trigger_condition(condition, schema, target_schema, event)?;
8843            }
8844            for cell in cells {
8845                validate_column_id(cell.column_id, schema)?;
8846                validate_trigger_value(&cell.value, target_schema, event)?;
8847            }
8848        }
8849        TriggerStep::Raise { message, .. } => {
8850            validate_trigger_value(message, target_schema, event)?
8851        }
8852    }
8853    Ok(())
8854}
8855
8856fn trigger_write_schema<'a>(cat: &'a Catalog, table: &str, op: &str) -> Result<&'a Schema> {
8857    if let Some(entry) = cat.live(table) {
8858        return Ok(&entry.schema);
8859    }
8860    if let Some(entry) = cat.external_tables.iter().find(|entry| entry.name == table) {
8861        let allowed = match op {
8862            "insert" => entry.capabilities.writable || entry.capabilities.insert_only,
8863            "update" | "delete" => entry.capabilities.writable,
8864            _ => false,
8865        };
8866        if !allowed {
8867            return Err(MongrelError::InvalidArgument(format!(
8868                "trigger {op} references external table {table:?}, but module {:?} is not writable for that operation",
8869                entry.module
8870            )));
8871        }
8872        if !entry.capabilities.transaction_safe {
8873            return Err(MongrelError::InvalidArgument(format!(
8874                "trigger {op} references external table {table:?}, but module {:?} is not transaction-safe",
8875                entry.module
8876            )));
8877        }
8878        return Ok(&entry.declared_schema);
8879    }
8880    Err(MongrelError::InvalidArgument(format!(
8881        "trigger references unknown table {table:?}"
8882    )))
8883}
8884
8885fn trigger_read_schema<'a>(cat: &'a Catalog, table: &str) -> Result<&'a Schema> {
8886    if let Some(entry) = cat.live(table) {
8887        return Ok(&entry.schema);
8888    }
8889    if let Some(entry) = cat.external_tables.iter().find(|entry| entry.name == table) {
8890        if entry.capabilities.trigger_safe {
8891            return Ok(&entry.declared_schema);
8892        }
8893        return Err(MongrelError::InvalidArgument(format!(
8894            "trigger reads external table {table:?}, but module {:?} is not trigger-safe",
8895            entry.module
8896        )));
8897    }
8898    Err(MongrelError::InvalidArgument(format!(
8899        "trigger references unknown table {table:?}"
8900    )))
8901}
8902
8903fn validate_trigger_condition(
8904    condition: &TriggerCondition,
8905    schema: &Schema,
8906    target_schema: &Schema,
8907    event: TriggerEvent,
8908) -> Result<()> {
8909    match condition {
8910        TriggerCondition::Pk { value } => {
8911            if schema.primary_key().is_none() {
8912                return Err(MongrelError::InvalidArgument(
8913                    "trigger condition Pk references a table without a primary key".into(),
8914                ));
8915            }
8916            validate_trigger_value(value, target_schema, event)
8917        }
8918        TriggerCondition::Eq { column_id, value }
8919        | TriggerCondition::NotEq { column_id, value }
8920        | TriggerCondition::Lt { column_id, value }
8921        | TriggerCondition::Lte { column_id, value }
8922        | TriggerCondition::Gt { column_id, value }
8923        | TriggerCondition::Gte { column_id, value } => {
8924            validate_column_id(*column_id, schema)?;
8925            validate_trigger_value(value, target_schema, event)
8926        }
8927        TriggerCondition::IsNull { column_id } | TriggerCondition::IsNotNull { column_id } => {
8928            validate_column_id(*column_id, schema)
8929        }
8930        TriggerCondition::And { left, right } | TriggerCondition::Or { left, right } => {
8931            validate_trigger_condition(left, schema, target_schema, event)?;
8932            validate_trigger_condition(right, schema, target_schema, event)
8933        }
8934        TriggerCondition::Not(condition) => {
8935            validate_trigger_condition(condition, schema, target_schema, event)
8936        }
8937    }
8938}
8939
8940fn validate_trigger_expr(expr: &TriggerExpr, schema: &Schema, event: TriggerEvent) -> Result<()> {
8941    match expr {
8942        TriggerExpr::Value(value) | TriggerExpr::IsNull(value) | TriggerExpr::IsNotNull(value) => {
8943            validate_trigger_value(value, schema, event)
8944        }
8945        TriggerExpr::Eq { left, right }
8946        | TriggerExpr::NotEq { left, right }
8947        | TriggerExpr::Lt { left, right }
8948        | TriggerExpr::Lte { left, right }
8949        | TriggerExpr::Gt { left, right }
8950        | TriggerExpr::Gte { left, right } => {
8951            validate_trigger_value(left, schema, event)?;
8952            validate_trigger_value(right, schema, event)
8953        }
8954        TriggerExpr::And { left, right } | TriggerExpr::Or { left, right } => {
8955            validate_trigger_expr(left, schema, event)?;
8956            validate_trigger_expr(right, schema, event)
8957        }
8958        TriggerExpr::Not(expr) => validate_trigger_expr(expr, schema, event),
8959    }
8960}
8961
8962fn validate_trigger_value(
8963    value: &TriggerValue,
8964    schema: &Schema,
8965    event: TriggerEvent,
8966) -> Result<()> {
8967    match value {
8968        TriggerValue::Literal(_) => Ok(()),
8969        TriggerValue::NewColumn(id) => {
8970            if event == TriggerEvent::Delete {
8971                return Err(MongrelError::InvalidArgument(
8972                    "DELETE triggers cannot reference NEW".into(),
8973                ));
8974            }
8975            validate_column_id(*id, schema)
8976        }
8977        TriggerValue::OldColumn(id) => {
8978            if event == TriggerEvent::Insert {
8979                return Err(MongrelError::InvalidArgument(
8980                    "INSERT triggers cannot reference OLD".into(),
8981                ));
8982            }
8983            validate_column_id(*id, schema)
8984        }
8985        // SELECTED column references are only meaningful inside a foreach loop.
8986        // Strict loop-scope validation is deferred to runtime; the executor raises
8987        // an error if a selected row is not available.
8988        TriggerValue::SelectedColumn(_) => Ok(()),
8989    }
8990}
8991
8992/// Replay committed `Op::Ddl` records from the shared WAL into the catalog
8993/// (spec §15, review fix #16). A crash between WAL group-sync and the catalog
8994/// checkpoint leaves DDL durable in the WAL but absent from the on-disk
8995/// catalog. This pass closes that window by reconstructing missing entries
8996/// (and marking committed drops) before tables are mounted.
8997fn recover_ddl_from_wal(
8998    root: &Path,
8999    cat: &mut Catalog,
9000    meta_dek: Option<&[u8; META_DEK_LEN]>,
9001    wal_dek: Option<&zeroize::Zeroizing<[u8; 32]>>,
9002) -> Result<()> {
9003    use crate::wal::{DdlOp, Op, SharedWal};
9004
9005    let records = match SharedWal::replay_with_dek(root, wal_dek) {
9006        Ok(r) => r,
9007        Err(_) => return Ok(()),
9008    };
9009
9010    let mut committed: HashMap<u64, u64> = HashMap::new();
9011    for r in &records {
9012        if let Op::TxnCommit { epoch: ce, .. } = r.op {
9013            committed.insert(r.txn_id, ce);
9014        }
9015    }
9016
9017    let mut changed = false;
9018    for r in records {
9019        let Some(&ce) = committed.get(&r.txn_id) else {
9020            continue;
9021        };
9022        match r.op {
9023            Op::Ddl(DdlOp::CreateTable {
9024                table_id,
9025                ref name,
9026                ref schema_json,
9027            }) => {
9028                if cat.tables.iter().any(|t| t.table_id == table_id) {
9029                    continue;
9030                }
9031                let schema = DdlOp::decode_schema(schema_json)?;
9032                let tdir = root.join(TABLES_DIR).join(table_id.to_string());
9033                if !tdir.exists() {
9034                    std::fs::create_dir_all(tdir.join(crate::engine::WAL_DIR))?;
9035                    std::fs::create_dir_all(tdir.join(crate::engine::RUNS_DIR))?;
9036                    crate::engine::write_schema(&tdir, &schema)?;
9037                    // The DB-wide meta DEK is also the per-table manifest meta
9038                    // DEK (both derive from the KEK via `derive_meta_key`), so a
9039                    // reconstructed manifest must be sealed with it — otherwise
9040                    // the follow-up `Table::open_in` cannot authenticate it on an
9041                    // encrypted DB and the table becomes permanently unopenable.
9042                    let mut m = crate::manifest::Manifest::new(table_id, schema.schema_id);
9043                    crate::manifest::write_atomic(&tdir, &mut m, meta_dek)?;
9044                }
9045                cat.tables.push(CatalogEntry {
9046                    table_id,
9047                    name: name.clone(),
9048                    schema,
9049                    state: TableState::Live,
9050                    created_epoch: ce,
9051                });
9052                cat.next_table_id = cat.next_table_id.max(table_id + 1);
9053                changed = true;
9054            }
9055            Op::Ddl(DdlOp::DropTable { table_id }) => {
9056                let mut dropped_name = None;
9057                if let Some(entry) = cat.tables.iter_mut().find(|t| t.table_id == table_id) {
9058                    dropped_name = Some(entry.name.clone());
9059                    if matches!(entry.state, TableState::Live) {
9060                        entry.state = TableState::Dropped { at_epoch: ce };
9061                        changed = true;
9062                    }
9063                }
9064                if let Some(name) = dropped_name {
9065                    let before = cat.materialized_views.len();
9066                    cat.materialized_views
9067                        .retain(|definition| definition.name != name);
9068                    changed |= before != cat.materialized_views.len();
9069                    cat.security.rls_tables.retain(|table| table != &name);
9070                    cat.security.policies.retain(|policy| policy.table != name);
9071                    cat.security.masks.retain(|mask| mask.table != name);
9072                    for role in &mut cat.roles {
9073                        role.permissions
9074                            .retain(|permission| permission_table(permission) != Some(&name));
9075                    }
9076                    cat.security_version = cat.security_version.wrapping_add(1);
9077                }
9078            }
9079            Op::Ddl(DdlOp::RenameTable {
9080                table_id,
9081                ref new_name,
9082            }) => {
9083                let mut old_name = None;
9084                if let Some(entry) = cat.tables.iter_mut().find(|t| t.table_id == table_id) {
9085                    if entry.name != *new_name {
9086                        old_name = Some(entry.name.clone());
9087                        entry.name = new_name.clone();
9088                        changed = true;
9089                    }
9090                }
9091                if let Some(old_name) = old_name {
9092                    if let Some(definition) = cat
9093                        .materialized_views
9094                        .iter_mut()
9095                        .find(|definition| definition.name == old_name)
9096                    {
9097                        definition.name = new_name.clone();
9098                    }
9099                    for table in &mut cat.security.rls_tables {
9100                        if *table == old_name {
9101                            *table = new_name.clone();
9102                        }
9103                    }
9104                    for policy in &mut cat.security.policies {
9105                        if policy.table == old_name {
9106                            policy.table = new_name.clone();
9107                        }
9108                    }
9109                    for mask in &mut cat.security.masks {
9110                        if mask.table == old_name {
9111                            mask.table = new_name.clone();
9112                        }
9113                    }
9114                    for role in &mut cat.roles {
9115                        for permission in &mut role.permissions {
9116                            rename_permission_table(permission, &old_name, new_name);
9117                        }
9118                    }
9119                    cat.security_version = cat.security_version.wrapping_add(1);
9120                }
9121                // If the entry is absent, its CreateTable was already
9122                // checkpointed carrying the post-rename name, so there is
9123                // nothing to apply — a no-op, not an error.
9124            }
9125            Op::Ddl(DdlOp::AlterTable {
9126                table_id,
9127                ref column_json,
9128            }) => {
9129                let column = DdlOp::decode_column(column_json)?;
9130                let mut renamed = None;
9131                if let Some(entry) = cat.tables.iter_mut().find(|t| t.table_id == table_id) {
9132                    renamed = entry
9133                        .schema
9134                        .columns
9135                        .iter()
9136                        .find(|existing| existing.id == column.id && existing.name != column.name)
9137                        .map(|existing| {
9138                            (
9139                                entry.name.clone(),
9140                                existing.name.clone(),
9141                                column.name.clone(),
9142                            )
9143                        });
9144                    if apply_recovered_column_def(&mut entry.schema, column) {
9145                        let tdir = root.join(TABLES_DIR).join(table_id.to_string());
9146                        if tdir.exists() {
9147                            crate::engine::write_schema(&tdir, &entry.schema)?;
9148                        }
9149                        changed = true;
9150                    }
9151                }
9152                if let Some((table, old_name, new_name)) = renamed {
9153                    for role in &mut cat.roles {
9154                        for permission in &mut role.permissions {
9155                            rename_permission_column(permission, &table, &old_name, &new_name);
9156                        }
9157                    }
9158                    cat.security_version = cat.security_version.wrapping_add(1);
9159                }
9160            }
9161            Op::Ddl(DdlOp::SetTtl {
9162                table_id,
9163                ref policy_json,
9164            }) => {
9165                let policy = DdlOp::decode_ttl(policy_json)?;
9166                if let Some(policy) = policy {
9167                    let valid = cat
9168                        .tables
9169                        .iter()
9170                        .find(|entry| entry.table_id == table_id)
9171                        .and_then(|entry| {
9172                            entry
9173                                .schema
9174                                .columns
9175                                .iter()
9176                                .find(|column| column.id == policy.column_id)
9177                        })
9178                        .is_some_and(|column| {
9179                            column.ty == TypeId::TimestampNanos
9180                                && policy.duration_nanos > 0
9181                                && policy.duration_nanos <= i64::MAX as u64
9182                        });
9183                    if !valid {
9184                        return Err(MongrelError::Schema(format!(
9185                            "invalid recovered TTL policy for table id {table_id}"
9186                        )));
9187                    }
9188                }
9189                let tdir = root.join(TABLES_DIR).join(table_id.to_string());
9190                if tdir.exists() {
9191                    let mut manifest = crate::manifest::read(&tdir, meta_dek)?;
9192                    if manifest.ttl != policy || manifest.current_epoch < ce {
9193                        manifest.ttl = policy;
9194                        manifest.current_epoch = manifest.current_epoch.max(ce);
9195                        crate::manifest::write_atomic(&tdir, &mut manifest, meta_dek)?;
9196                    }
9197                }
9198            }
9199            Op::Ddl(DdlOp::SetMaterializedView {
9200                ref name,
9201                ref definition_json,
9202            }) => {
9203                let definition = DdlOp::decode_materialized_view(definition_json)?;
9204                if definition.name != *name {
9205                    return Err(MongrelError::Schema(format!(
9206                        "materialized view WAL name mismatch: {name:?}"
9207                    )));
9208                }
9209                if cat.live(name).is_some() {
9210                    if let Some(existing) = cat
9211                        .materialized_views
9212                        .iter_mut()
9213                        .find(|existing| existing.name == *name)
9214                    {
9215                        if *existing != definition {
9216                            *existing = definition;
9217                            changed = true;
9218                        }
9219                    } else {
9220                        cat.materialized_views.push(definition);
9221                        changed = true;
9222                    }
9223                }
9224            }
9225            Op::Ddl(DdlOp::SetSecurityCatalog { ref security_json }) => {
9226                let security = DdlOp::decode_security(security_json)?;
9227                validate_security_catalog(cat, &security)?;
9228                if cat.security != security {
9229                    cat.security = security;
9230                    cat.security_version = cat.security_version.wrapping_add(1);
9231                    changed = true;
9232                }
9233            }
9234            _ => {}
9235        }
9236    }
9237
9238    if changed {
9239        catalog::write_atomic(root, cat, meta_dek)?;
9240    }
9241    Ok(())
9242}
9243
9244fn apply_recovered_column_def(schema: &mut Schema, column: ColumnDef) -> bool {
9245    match schema.columns.iter_mut().find(|c| c.id == column.id) {
9246        Some(existing) if *existing == column => false,
9247        Some(existing) => {
9248            *existing = column;
9249            schema.schema_id = schema.schema_id.saturating_add(1);
9250            true
9251        }
9252        None => {
9253            schema.columns.push(column);
9254            schema.schema_id = schema.schema_id.saturating_add(1);
9255            true
9256        }
9257    }
9258}
9259
9260fn permission_table(permission: &crate::auth::Permission) -> Option<&str> {
9261    use crate::auth::Permission;
9262    match permission {
9263        Permission::Select { table }
9264        | Permission::Insert { table }
9265        | Permission::Update { table }
9266        | Permission::Delete { table }
9267        | Permission::SelectColumns { table, .. }
9268        | Permission::InsertColumns { table, .. }
9269        | Permission::UpdateColumns { table, .. } => Some(table),
9270        Permission::All | Permission::Ddl | Permission::Admin => None,
9271    }
9272}
9273
9274fn rename_permission_table(permission: &mut crate::auth::Permission, old: &str, new: &str) {
9275    use crate::auth::Permission;
9276    let table = match permission {
9277        Permission::Select { table }
9278        | Permission::Insert { table }
9279        | Permission::Update { table }
9280        | Permission::Delete { table }
9281        | Permission::SelectColumns { table, .. }
9282        | Permission::InsertColumns { table, .. }
9283        | Permission::UpdateColumns { table, .. } => Some(table),
9284        Permission::All | Permission::Ddl | Permission::Admin => None,
9285    };
9286    if let Some(table) = table.filter(|table| table.as_str() == old) {
9287        *table = new.to_string();
9288    }
9289}
9290
9291fn rename_permission_column(
9292    permission: &mut crate::auth::Permission,
9293    target_table: &str,
9294    old: &str,
9295    new: &str,
9296) {
9297    use crate::auth::Permission;
9298    let columns = match permission {
9299        Permission::SelectColumns { table, columns }
9300        | Permission::InsertColumns { table, columns }
9301        | Permission::UpdateColumns { table, columns }
9302            if table == target_table =>
9303        {
9304            Some(columns)
9305        }
9306        _ => None,
9307    };
9308    if let Some(column) = columns
9309        .into_iter()
9310        .flatten()
9311        .find(|column| column.as_str() == old)
9312    {
9313        *column = new.to_string();
9314    }
9315}
9316
9317fn merge_permission(
9318    permissions: &mut Vec<crate::auth::Permission>,
9319    permission: crate::auth::Permission,
9320) {
9321    use crate::auth::Permission;
9322    let (kind, table, mut columns) = match permission {
9323        Permission::SelectColumns { table, columns } => (0, table, columns),
9324        Permission::InsertColumns { table, columns } => (1, table, columns),
9325        Permission::UpdateColumns { table, columns } => (2, table, columns),
9326        permission if !permissions.contains(&permission) => {
9327            permissions.push(permission);
9328            return;
9329        }
9330        _ => return,
9331    };
9332    for permission in permissions.iter_mut() {
9333        let existing = match permission {
9334            Permission::SelectColumns {
9335                table: existing_table,
9336                columns,
9337            } if kind == 0 && existing_table == &table => Some(columns),
9338            Permission::InsertColumns {
9339                table: existing_table,
9340                columns,
9341            } if kind == 1 && existing_table == &table => Some(columns),
9342            Permission::UpdateColumns {
9343                table: existing_table,
9344                columns,
9345            } if kind == 2 && existing_table == &table => Some(columns),
9346            _ => None,
9347        };
9348        if let Some(existing) = existing {
9349            existing.append(&mut columns);
9350            existing.sort();
9351            existing.dedup();
9352            return;
9353        }
9354    }
9355    columns.sort();
9356    columns.dedup();
9357    permissions.push(match kind {
9358        0 => Permission::SelectColumns { table, columns },
9359        1 => Permission::InsertColumns { table, columns },
9360        2 => Permission::UpdateColumns { table, columns },
9361        _ => unreachable!(),
9362    });
9363}
9364
9365fn revoke_permission_from(
9366    permissions: &mut Vec<crate::auth::Permission>,
9367    revoked: &crate::auth::Permission,
9368) {
9369    use crate::auth::Permission;
9370    let revoked_columns = match revoked {
9371        Permission::SelectColumns { table, columns } => Some((0, table, columns)),
9372        Permission::InsertColumns { table, columns } => Some((1, table, columns)),
9373        Permission::UpdateColumns { table, columns } => Some((2, table, columns)),
9374        _ => None,
9375    };
9376    let Some((kind, table, columns)) = revoked_columns else {
9377        permissions.retain(|permission| permission != revoked);
9378        return;
9379    };
9380    for permission in permissions.iter_mut() {
9381        let current = match permission {
9382            Permission::SelectColumns {
9383                table: current_table,
9384                columns,
9385            } if kind == 0 && current_table == table => Some(columns),
9386            Permission::InsertColumns {
9387                table: current_table,
9388                columns,
9389            } if kind == 1 && current_table == table => Some(columns),
9390            Permission::UpdateColumns {
9391                table: current_table,
9392                columns,
9393            } if kind == 2 && current_table == table => Some(columns),
9394            _ => None,
9395        };
9396        if let Some(current) = current {
9397            current.retain(|column| !columns.contains(column));
9398        }
9399    }
9400    permissions.retain(|permission| match permission {
9401        Permission::SelectColumns { columns, .. }
9402        | Permission::InsertColumns { columns, .. }
9403        | Permission::UpdateColumns { columns, .. } => !columns.is_empty(),
9404        _ => true,
9405    });
9406}
9407
9408fn validate_security_catalog(
9409    catalog: &Catalog,
9410    security: &crate::security::SecurityCatalog,
9411) -> Result<()> {
9412    let mut policy_names = HashSet::new();
9413    for table in &security.rls_tables {
9414        if catalog.live(table).is_none() {
9415            return Err(MongrelError::NotFound(format!(
9416                "RLS table {table:?} not found"
9417            )));
9418        }
9419    }
9420    for policy in &security.policies {
9421        if !policy_names.insert((policy.table.clone(), policy.name.clone())) {
9422            return Err(MongrelError::InvalidArgument(format!(
9423                "duplicate policy {:?} on {:?}",
9424                policy.name, policy.table
9425            )));
9426        }
9427        let schema = &catalog
9428            .live(&policy.table)
9429            .ok_or_else(|| {
9430                MongrelError::NotFound(format!("policy table {:?} not found", policy.table))
9431            })?
9432            .schema;
9433        if let Some(expression) = &policy.using {
9434            validate_security_expression(expression, schema)?;
9435        }
9436        if let Some(expression) = &policy.with_check {
9437            validate_security_expression(expression, schema)?;
9438        }
9439    }
9440    let mut mask_names = HashSet::new();
9441    for mask in &security.masks {
9442        if !mask_names.insert((mask.table.clone(), mask.name.clone())) {
9443            return Err(MongrelError::InvalidArgument(format!(
9444                "duplicate mask {:?} on {:?}",
9445                mask.name, mask.table
9446            )));
9447        }
9448        let column = catalog
9449            .live(&mask.table)
9450            .and_then(|entry| {
9451                entry
9452                    .schema
9453                    .columns
9454                    .iter()
9455                    .find(|column| column.id == mask.column)
9456            })
9457            .ok_or_else(|| {
9458                MongrelError::NotFound(format!(
9459                    "mask column {} on {:?} not found",
9460                    mask.column, mask.table
9461                ))
9462            })?;
9463        if matches!(
9464            mask.strategy,
9465            crate::security::MaskStrategy::Redact { .. } | crate::security::MaskStrategy::Sha256
9466        ) && !matches!(column.ty, TypeId::Bytes | TypeId::Enum { .. })
9467        {
9468            return Err(MongrelError::InvalidArgument(format!(
9469                "mask {:?} requires a string/bytes column",
9470                mask.name
9471            )));
9472        }
9473    }
9474    Ok(())
9475}
9476
9477fn validate_security_expression(
9478    expression: &crate::security::SecurityExpr,
9479    schema: &Schema,
9480) -> Result<()> {
9481    use crate::security::SecurityExpr;
9482    match expression {
9483        SecurityExpr::True => Ok(()),
9484        SecurityExpr::ColumnEqCurrentUser { column }
9485        | SecurityExpr::ColumnEqValue { column, .. } => {
9486            if schema
9487                .columns
9488                .iter()
9489                .any(|candidate| candidate.id == *column)
9490            {
9491                Ok(())
9492            } else {
9493                Err(MongrelError::InvalidArgument(format!(
9494                    "security expression references unknown column id {column}"
9495                )))
9496            }
9497        }
9498        SecurityExpr::And { left, right } | SecurityExpr::Or { left, right } => {
9499            validate_security_expression(left, schema)?;
9500            validate_security_expression(right, schema)
9501        }
9502        SecurityExpr::Not { expression } => validate_security_expression(expression, schema),
9503    }
9504}
9505
9506/// Sweep stale `_txn/<txn_id>/` dirs from every table (spec §8.5, review fix
9507/// #14). These dirs hold pending uniform-epoch runs from large transactions
9508/// that were aborted or crashed before commit. On open, all such dirs are safe
9509/// to remove — committed txns moved their runs to `_runs/` at publish time.
9510fn sweep_pending_txn_dirs(root: &Path, cat: &Catalog) {
9511    for entry in &cat.tables {
9512        let txn_dir = root
9513            .join(TABLES_DIR)
9514            .join(entry.table_id.to_string())
9515            .join("_txn");
9516        if txn_dir.exists() {
9517            let _ = std::fs::remove_dir_all(&txn_dir);
9518        }
9519    }
9520}
9521
9522#[cfg(test)]
9523mod trigger_engine_tests {
9524    use super::*;
9525
9526    fn event_with(new_cells: &[(u16, Value)], old_cells: &[(u16, Value)]) -> WriteEvent {
9527        WriteEvent {
9528            table: "test".into(),
9529            kind: TriggerEvent::Insert,
9530            new: Some(TriggerRowImage {
9531                columns: new_cells.iter().cloned().collect(),
9532            }),
9533            old: Some(TriggerRowImage {
9534                columns: old_cells.iter().cloned().collect(),
9535            }),
9536            changed_columns: Vec::new(),
9537            op_indices: Vec::new(),
9538            put_idx: None,
9539            trigger_stack: Vec::new(),
9540        }
9541    }
9542
9543    fn event_insert(new_cells: &[(u16, Value)]) -> WriteEvent {
9544        WriteEvent {
9545            table: "test".into(),
9546            kind: TriggerEvent::Insert,
9547            new: Some(TriggerRowImage {
9548                columns: new_cells.iter().cloned().collect(),
9549            }),
9550            old: None,
9551            changed_columns: Vec::new(),
9552            op_indices: Vec::new(),
9553            put_idx: None,
9554            trigger_stack: Vec::new(),
9555        }
9556    }
9557
9558    #[test]
9559    fn value_order_int64_vs_float64() {
9560        assert_eq!(
9561            value_order(&Value::Int64(5), &Value::Float64(5.0)),
9562            Some(std::cmp::Ordering::Equal)
9563        );
9564        assert_eq!(
9565            value_order(&Value::Int64(5), &Value::Float64(3.0)),
9566            Some(std::cmp::Ordering::Greater)
9567        );
9568        assert_eq!(
9569            value_order(&Value::Int64(2), &Value::Float64(3.0)),
9570            Some(std::cmp::Ordering::Less)
9571        );
9572    }
9573
9574    #[test]
9575    fn value_order_null_returns_none() {
9576        assert_eq!(value_order(&Value::Int64(5), &Value::Null), None);
9577        assert_eq!(value_order(&Value::Null, &Value::Int64(5)), None);
9578        assert_eq!(value_order(&Value::Null, &Value::Null), None);
9579    }
9580
9581    #[test]
9582    fn value_order_cross_group_returns_none() {
9583        assert_eq!(
9584            value_order(&Value::Int64(5), &Value::Bytes(b"x".to_vec())),
9585            None
9586        );
9587        assert_eq!(value_order(&Value::Bool(true), &Value::Int64(1)), None);
9588        assert_eq!(
9589            value_order(
9590                &Value::Embedding(vec![1.0, 2.0]),
9591                &Value::Embedding(vec![1.0, 2.0])
9592            ),
9593            None
9594        );
9595    }
9596
9597    #[test]
9598    fn eval_trigger_expr_ranges_and_booleans() {
9599        let expr = TriggerExpr::And {
9600            left: Box::new(TriggerExpr::Gt {
9601                left: TriggerValue::NewColumn(1),
9602                right: TriggerValue::Literal(Value::Int64(0)),
9603            }),
9604            right: Box::new(TriggerExpr::Lte {
9605                left: TriggerValue::NewColumn(1),
9606                right: TriggerValue::Literal(Value::Int64(100)),
9607            }),
9608        };
9609        assert!(eval_trigger_expr(&expr, &event_insert(&[(1, Value::Int64(50))])).unwrap());
9610        assert!(!eval_trigger_expr(&expr, &event_insert(&[(1, Value::Int64(200))])).unwrap());
9611        assert!(!eval_trigger_expr(&expr, &event_insert(&[(1, Value::Null)])).unwrap());
9612
9613        let or_expr = TriggerExpr::Or {
9614            left: Box::new(TriggerExpr::Lt {
9615                left: TriggerValue::NewColumn(1),
9616                right: TriggerValue::Literal(Value::Int64(0)),
9617            }),
9618            right: Box::new(TriggerExpr::Not(Box::new(TriggerExpr::IsNull(
9619                TriggerValue::OldColumn(2),
9620            )))),
9621        };
9622        assert!(eval_trigger_expr(
9623            &or_expr,
9624            &event_with(&[(1, Value::Int64(5))], &[(2, Value::Int64(99))])
9625        )
9626        .unwrap());
9627        assert!(!eval_trigger_expr(
9628            &or_expr,
9629            &event_with(&[(1, Value::Int64(5))], &[(2, Value::Null)])
9630        )
9631        .unwrap());
9632
9633        assert!(eval_trigger_expr(
9634            &TriggerExpr::Value(TriggerValue::Literal(Value::Bool(true))),
9635            &event_insert(&[])
9636        )
9637        .unwrap());
9638        assert!(!eval_trigger_expr(
9639            &TriggerExpr::Value(TriggerValue::Literal(Value::Bool(false))),
9640            &event_insert(&[])
9641        )
9642        .unwrap());
9643        assert!(!eval_trigger_expr(
9644            &TriggerExpr::Value(TriggerValue::Literal(Value::Null)),
9645            &event_insert(&[])
9646        )
9647        .unwrap());
9648    }
9649}