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