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