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, EpochGuard, MaintenanceReceipt, 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::{Read, 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";
38pub const CTAS_BUILD_TABLE_PREFIX: &str = "__mongreldb_ctas_build_";
39
40/// Sentinel `table_id` for `CheckIssue`s that concern the shared WAL rather
41/// than any table. `u64::MAX` is never allocated to a real table (the catalog
42/// mints ids from 0 upward), so [`Database::doctor`] can safely skip them.
43pub const WAL_TABLE_ID: u64 = u64::MAX;
44/// Sentinel `table_id` for `CheckIssue`s that concern external-table module
45/// state instead of an ordinary table.
46pub const EXTERNAL_TABLE_ID: u64 = u64::MAX - 1;
47
48fn advance_security_version(catalog: &mut Catalog) -> Result<()> {
49    catalog.security_version = catalog.security_version.checked_add(1).ok_or_else(|| {
50        MongrelError::Conflict("security catalog version space is exhausted".into())
51    })?;
52    Ok(())
53}
54
55fn process_locked_paths() -> &'static Mutex<HashSet<PathBuf>> {
56    static LOCKED_PATHS: std::sync::OnceLock<Mutex<HashSet<PathBuf>>> = std::sync::OnceLock::new();
57    LOCKED_PATHS.get_or_init(|| Mutex::new(HashSet::new()))
58}
59
60struct DatabaseFileLock {
61    bootstrap_file: std::fs::File,
62    legacy_file: Option<std::fs::File>,
63    canonical_path: PathBuf,
64    durable_root: Option<Arc<crate::durable_file::DurableRoot>>,
65}
66
67impl Drop for DatabaseFileLock {
68    fn drop(&mut self) {
69        if let Some(file) = &self.legacy_file {
70            let _ = fs2::FileExt::unlock(file);
71        }
72        let _ = fs2::FileExt::unlock(&self.bootstrap_file);
73        process_locked_paths().lock().remove(&self.canonical_path);
74    }
75}
76
77fn commit_prepare_checkpoint(
78    control: Option<&crate::ExecutionControl>,
79    index: usize,
80) -> Result<()> {
81    if index.is_multiple_of(256) {
82        if let Some(control) = control {
83            control.checkpoint()?;
84        }
85    }
86    Ok(())
87}
88
89fn finish_controlled_commit_attempt(
90    result: Result<Epoch>,
91    after_commit: &mut Option<&mut dyn FnMut(Option<Epoch>) -> Result<()>>,
92) -> Result<Epoch> {
93    let Some(after_commit) = after_commit.as_mut() else {
94        return result;
95    };
96    match result {
97        Ok(epoch) => match (**after_commit)(Some(epoch)) {
98            Ok(()) => Ok(epoch),
99            Err(error) => Err(MongrelError::DurableCommit {
100                epoch: epoch.0,
101                message: error.to_string(),
102            }),
103        },
104        Err(MongrelError::DurableCommit { epoch, message }) => {
105            let callback_error = (**after_commit)(Some(Epoch(epoch))).err();
106            Err(MongrelError::DurableCommit {
107                epoch,
108                message: callback_error
109                    .map(|error| format!("{message}; commit callback: {error}"))
110                    .unwrap_or(message),
111            })
112        }
113        Err(error) => match (**after_commit)(None) {
114            Ok(()) => Err(error),
115            Err(callback_error) => Err(MongrelError::Other(format!(
116                "{error}; commit callback: {callback_error}"
117            ))),
118        },
119    }
120}
121
122fn current_unix_nanos() -> u64 {
123    std::time::SystemTime::now()
124        .duration_since(std::time::UNIX_EPOCH)
125        .unwrap_or_default()
126        .as_nanos() as u64
127}
128
129#[cfg(feature = "encryption")]
130fn read_encryption_salt(
131    root: &crate::durable_file::DurableRoot,
132) -> Result<[u8; crate::encryption::SALT_LEN]> {
133    let mut file = root
134        .open_regular(Path::new(META_DIR).join(KEYS_FILENAME))
135        .map_err(|error| MongrelError::NotFound(format!("encryption salt file: {error}")))?;
136    let length = file.metadata()?.len();
137    if length != crate::encryption::SALT_LEN as u64 {
138        return Err(MongrelError::Encryption(format!(
139            "invalid encryption salt length: got {length}, expected {}",
140            crate::encryption::SALT_LEN
141        )));
142    }
143    let mut salt = [0_u8; crate::encryption::SALT_LEN];
144    file.read_exact(&mut salt)?;
145    Ok(salt)
146}
147
148fn incremental_aggregate_cache_key(
149    table: &str,
150    conditions: &[crate::query::Condition],
151    column: Option<u16>,
152    agg: crate::engine::NativeAgg,
153    principal: Option<&crate::auth::Principal>,
154    security_version: u64,
155) -> u64 {
156    use std::hash::{Hash, Hasher};
157    let projection = column.as_ref().map(std::slice::from_ref);
158    let query_key = crate::query::canonical_query_key(conditions, projection, security_version);
159    let mut hasher = std::collections::hash_map::DefaultHasher::new();
160    table.hash(&mut hasher);
161    query_key.hash(&mut hasher);
162    match agg {
163        crate::engine::NativeAgg::Count => 0u8,
164        crate::engine::NativeAgg::Sum => 1,
165        crate::engine::NativeAgg::Min => 2,
166        crate::engine::NativeAgg::Max => 3,
167        crate::engine::NativeAgg::Avg => 4,
168    }
169    .hash(&mut hasher);
170    if let Some(principal) = principal {
171        principal.user_id.hash(&mut hasher);
172        principal.created_epoch.hash(&mut hasher);
173        principal.username.hash(&mut hasher);
174        principal.is_admin.hash(&mut hasher);
175        let mut roles = principal.roles.clone();
176        roles.sort_unstable();
177        roles.hash(&mut hasher);
178    }
179    hasher.finish()
180}
181
182fn read_history_retention(
183    root: &crate::durable_file::DurableRoot,
184    current_epoch: Epoch,
185) -> Result<(u64, Epoch)> {
186    const MAX_HISTORY_RETENTION_BYTES: u64 = 128;
187    let file = match root.open_regular(Path::new(META_DIR).join(HISTORY_RETENTION_FILENAME)) {
188        Ok(file) => file,
189        Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
190            return Ok((0, current_epoch));
191        }
192        Err(error) => return Err(error.into()),
193    };
194    let length = file.metadata()?.len();
195    if length > MAX_HISTORY_RETENTION_BYTES {
196        return Err(MongrelError::ResourceLimitExceeded {
197            resource: "history retention bytes",
198            requested: usize::try_from(length).unwrap_or(usize::MAX),
199            limit: MAX_HISTORY_RETENTION_BYTES as usize,
200        });
201    }
202    let mut bytes = Vec::with_capacity(length as usize);
203    file.take(MAX_HISTORY_RETENTION_BYTES + 1)
204        .read_to_end(&mut bytes)?;
205    if bytes.len() as u64 != length {
206        return Err(MongrelError::Other(
207            "history retention length changed while reading".into(),
208        ));
209    }
210    let text = std::str::from_utf8(&bytes)
211        .map_err(|error| MongrelError::Other(format!("history retention encoding: {error}")))?;
212    let mut fields = text.split_whitespace();
213    let epochs = fields
214        .next()
215        .ok_or_else(|| MongrelError::Other("history retention file is empty".into()))?
216        .parse::<u64>()
217        .map_err(|error| MongrelError::Other(format!("history retention epochs: {error}")))?;
218    let start = fields
219        .next()
220        .ok_or_else(|| MongrelError::Other("history retention start is missing".into()))?
221        .parse::<u64>()
222        .map_err(|error| MongrelError::Other(format!("history retention start: {error}")))?;
223    if fields.next().is_some() || start > current_epoch.0 {
224        return Err(MongrelError::Other(
225            "history retention file has trailing fields or a future start epoch".into(),
226        ));
227    }
228    Ok((epochs, Epoch(start)))
229}
230
231fn write_history_retention<F>(
232    root: &Path,
233    epochs: u64,
234    start: Epoch,
235    after_publish: F,
236) -> Result<()>
237where
238    F: FnOnce(),
239{
240    let meta = root.join(META_DIR);
241    let path = meta.join(HISTORY_RETENTION_FILENAME);
242    let bytes = format!("{epochs} {}\n", start.0);
243    crate::durable_file::write_atomic_with_after(&path, bytes.as_bytes(), after_publish)?;
244    Ok(())
245}
246
247struct PreparedBackupDestination {
248    parent: crate::durable_file::DurableRoot,
249    destination_name: std::ffi::OsString,
250    destination_path: PathBuf,
251    stage_name: std::ffi::OsString,
252    stage: Option<Box<crate::durable_file::DurableRoot>>,
253}
254
255fn prepare_backup_destination(
256    source: &Path,
257    destination: &Path,
258) -> Result<PreparedBackupDestination> {
259    let destination_name = destination
260        .file_name()
261        .ok_or_else(|| MongrelError::InvalidArgument("invalid backup destination".into()))?
262        .to_os_string();
263    let requested_parent = destination
264        .parent()
265        .filter(|path| !path.as_os_str().is_empty())
266        .unwrap_or_else(|| Path::new("."));
267    crate::durable_file::create_directory_all(requested_parent)?;
268    let parent = crate::durable_file::DurableRoot::open(requested_parent)?;
269    prepare_backup_destination_in(source, &parent, &destination_name)
270}
271
272fn prepare_backup_destination_in(
273    source: &Path,
274    parent: &crate::durable_file::DurableRoot,
275    destination_name: &std::ffi::OsStr,
276) -> Result<PreparedBackupDestination> {
277    let source = source.canonicalize()?;
278    if parent.canonical_path().starts_with(&source) {
279        return Err(MongrelError::InvalidArgument(
280            "backup destination must not be inside the source database".into(),
281        ));
282    }
283    if parent.entry_exists(Path::new(&destination_name))? {
284        return Err(MongrelError::Conflict(format!(
285            "backup destination already exists: {}",
286            parent.canonical_path().join(destination_name).display()
287        )));
288    }
289    let mut stage_name = None;
290    for _ in 0..128 {
291        let mut nonce = [0_u8; 8];
292        crate::encryption::fill_random(&mut nonce)?;
293        let suffix = nonce
294            .iter()
295            .map(|byte| format!("{byte:02x}"))
296            .collect::<String>();
297        let name = std::ffi::OsString::from(format!(
298            ".{}.backup-stage-{}-{suffix}",
299            destination_name.to_string_lossy(),
300            std::process::id()
301        ));
302        match parent.create_directory_new(Path::new(&name)) {
303            Ok(()) => {
304                stage_name = Some(name);
305                break;
306            }
307            Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => continue,
308            Err(error) => return Err(error.into()),
309        }
310    }
311    let stage_name = stage_name
312        .ok_or_else(|| MongrelError::Conflict("could not allocate backup staging path".into()))?;
313    let stage = parent.open_directory(Path::new(&stage_name))?;
314    Ok(PreparedBackupDestination {
315        destination_path: parent.canonical_path().join(destination_name),
316        destination_name: destination_name.to_os_string(),
317        stage_name,
318        stage: Some(Box::new(stage)),
319        parent: parent.try_clone()?,
320    })
321}
322
323fn copy_backup_boundary(
324    source_root: &Path,
325    destination_root: &crate::durable_file::DurableRoot,
326    deferred_runs: &HashSet<PathBuf>,
327    copied: &mut Vec<PathBuf>,
328    control: Option<&crate::ExecutionControl>,
329) -> Result<()> {
330    let mut visited = 0;
331    crate::durable_file::walk_regular_files_nofollow(
332        source_root,
333        |relative, is_directory| {
334            if visited % 256 == 0 {
335                if let Some(control) = control {
336                    control.checkpoint()?;
337                }
338            }
339            visited += 1;
340            if backup_path_excluded(relative) {
341                return Ok(false);
342            }
343            if is_directory {
344                return Ok(true);
345            }
346            if deferred_runs.contains(relative) {
347                return Ok(false);
348            }
349            Ok(!(relative
350                .parent()
351                .and_then(Path::file_name)
352                .is_some_and(|parent| parent == "_runs")
353                && relative
354                    .extension()
355                    .is_some_and(|extension| extension == "sr")))
356        },
357        |relative| {
358            destination_root.create_directory_all(relative)?;
359            Ok(())
360        },
361        |relative, source| {
362            destination_root.copy_new_from(relative, source)?;
363            copied.push(relative.to_path_buf());
364            Ok(())
365        },
366    )
367}
368
369fn backup_path_excluded(relative: &Path) -> bool {
370    relative == Path::new("_meta/.lock")
371        || relative == Path::new("_meta/replica")
372        || relative == Path::new("_meta/repl_epoch")
373        || relative == Path::new(crate::backup::BACKUP_MANIFEST_PATH)
374        || relative.components().any(|component| {
375            matches!(component, std::path::Component::Normal(name) if name == "_cache" || name == "_txn" || name == "backup-pins")
376        })
377}
378
379#[derive(Debug, Clone)]
380pub enum ExternalTriggerWrite {
381    Insert {
382        table: String,
383        cells: Vec<(u16, Value)>,
384    },
385    UpdateByPk {
386        table: String,
387        pk: Value,
388        cells: Vec<(u16, Value)>,
389    },
390    DeleteByPk {
391        table: String,
392        pk: Value,
393    },
394}
395
396impl ExternalTriggerWrite {
397    fn table(&self) -> &str {
398        match self {
399            Self::Insert { table, .. }
400            | Self::UpdateByPk { table, .. }
401            | Self::DeleteByPk { table, .. } => table,
402        }
403    }
404}
405
406#[derive(Debug, Clone, PartialEq)]
407pub enum ExternalTriggerBaseWrite {
408    Put {
409        table: String,
410        cells: Vec<(u16, Value)>,
411    },
412    Delete {
413        table: String,
414        row_id: RowId,
415    },
416}
417
418#[derive(Debug, Clone, PartialEq)]
419pub struct ExternalTriggerWriteResult {
420    pub state: Vec<u8>,
421    pub base_writes: Vec<ExternalTriggerBaseWrite>,
422}
423
424impl ExternalTriggerWriteResult {
425    pub fn new(state: Vec<u8>) -> Self {
426        Self {
427            state,
428            base_writes: Vec::new(),
429        }
430    }
431}
432
433pub trait ExternalTriggerBridge: Send + Sync {
434    fn apply_trigger_external_write(
435        &self,
436        entry: &ExternalTableEntry,
437        base_state: Vec<u8>,
438        op: ExternalTriggerWrite,
439    ) -> Result<ExternalTriggerWriteResult>;
440}
441
442/// A pending uniform-epoch run written during a large transaction (spec §8.5).
443struct SpilledRun {
444    table_id: u64,
445    run_id: u128,
446    pending_path: PathBuf,
447    final_path: PathBuf,
448    rows: Vec<crate::memtable::Row>,
449    row_count: u64,
450    min_rid: u64,
451    max_rid: u64,
452    content_hash: [u8; 32],
453}
454
455const SPILLED_WAL_PAYLOAD_MAX_BYTES: usize = 24 * 1024 * 1024;
456const SPILLED_WAL_TOTAL_MAX_BYTES: usize = 256 * 1024 * 1024;
457
458fn encode_spilled_row_chunks(
459    rows: &[crate::memtable::Row],
460    total_bytes: &mut usize,
461    total_limit: usize,
462    control: Option<&crate::ExecutionControl>,
463) -> Result<Vec<Vec<u8>>> {
464    let mut output = Vec::new();
465    let mut start = 0;
466    while start < rows.len() {
467        // Bincode's sequence length prefix is a u64 with the workspace's
468        // fixed-int options. `serialized_size` computes exact row sizes
469        // without first allocating one transaction-sized buffer.
470        let mut estimated_bytes = std::mem::size_of::<u64>();
471        let mut end = start;
472        while end < rows.len() {
473            if end % 256 == 0 {
474                if let Some(control) = control {
475                    control.checkpoint()?;
476                }
477            }
478            let row_bytes =
479                usize::try_from(bincode::serialized_size(&rows[end])?).map_err(|_| {
480                    MongrelError::ResourceLimitExceeded {
481                        resource: "spilled WAL row bytes",
482                        requested: usize::MAX,
483                        limit: SPILLED_WAL_PAYLOAD_MAX_BYTES,
484                    }
485                })?;
486            let next_bytes = estimated_bytes.checked_add(row_bytes).ok_or(
487                MongrelError::ResourceLimitExceeded {
488                    resource: "spilled WAL row bytes",
489                    requested: usize::MAX,
490                    limit: SPILLED_WAL_PAYLOAD_MAX_BYTES,
491                },
492            )?;
493            if next_bytes > SPILLED_WAL_PAYLOAD_MAX_BYTES {
494                break;
495            }
496            estimated_bytes = next_bytes;
497            end += 1;
498        }
499        if end == start {
500            return Err(MongrelError::ResourceLimitExceeded {
501                resource: "spilled WAL row bytes",
502                requested: estimated_bytes.saturating_add(1),
503                limit: SPILLED_WAL_PAYLOAD_MAX_BYTES,
504            });
505        }
506        let payload = bincode::serialize(&rows[start..end])?;
507        if payload.len() > SPILLED_WAL_PAYLOAD_MAX_BYTES {
508            return Err(MongrelError::ResourceLimitExceeded {
509                resource: "spilled WAL row bytes",
510                requested: payload.len(),
511                limit: SPILLED_WAL_PAYLOAD_MAX_BYTES,
512            });
513        }
514        let requested = total_bytes.checked_add(payload.len()).unwrap_or(usize::MAX);
515        if requested > total_limit {
516            return Err(MongrelError::ResourceLimitExceeded {
517                resource: "spilled WAL transaction bytes",
518                requested,
519                limit: total_limit,
520            });
521        }
522        *total_bytes = requested;
523        output.push(payload);
524        start = end;
525    }
526    Ok(output)
527}
528
529#[cfg(test)]
530mod spilled_wal_encoding_tests {
531    use super::*;
532
533    #[test]
534    fn logical_spill_payload_has_a_total_bound() {
535        let rows = (0..4)
536            .map(|row_id| crate::memtable::Row {
537                row_id: crate::rowid::RowId(row_id),
538                committed_epoch: Epoch::ZERO,
539                columns: [(1, Value::Bytes(vec![0; 64]))].into_iter().collect(),
540                deleted: false,
541            })
542            .collect::<Vec<_>>();
543        let mut total = 0;
544        let error = encode_spilled_row_chunks(&rows, &mut total, 32, None).unwrap_err();
545        assert!(matches!(
546            error,
547            MongrelError::ResourceLimitExceeded {
548                resource: "spilled WAL transaction bytes",
549                ..
550            }
551        ));
552    }
553}
554
555/// Move spill files to their final names before the WAL commit. Dropping this
556/// guard restores pending names while commit is still known not to have begun.
557/// It is disarmed immediately before the first WAL append, where the outcome
558/// can become ambiguous and recovery may need the final names.
559struct PreparedRunLinks {
560    links: Vec<(PathBuf, PathBuf)>,
561    armed: bool,
562}
563
564impl PreparedRunLinks {
565    fn prepare(spilled: &[SpilledRun]) -> Result<Self> {
566        let mut guard = Self {
567            links: Vec::with_capacity(spilled.len()),
568            armed: true,
569        };
570        for run in spilled {
571            crate::durable_file::rename(&run.pending_path, &run.final_path)?;
572            guard
573                .links
574                .push((run.pending_path.clone(), run.final_path.clone()));
575        }
576        Ok(guard)
577    }
578
579    fn disarm(&mut self) {
580        self.armed = false;
581        for (pending, _) in &self.links {
582            if let Some(parent) = pending.parent() {
583                let _ = std::fs::remove_dir_all(parent);
584            }
585        }
586    }
587}
588
589impl Drop for PreparedRunLinks {
590    fn drop(&mut self) {
591        if !self.armed {
592            return;
593        }
594        for (pending, final_path) in self.links.iter().rev() {
595            let _ = std::fs::rename(final_path, pending);
596        }
597    }
598}
599
600struct TableApplyBatch {
601    table_id: u64,
602    handle: TableHandle,
603    ops: Vec<crate::txn::StagedOp>,
604}
605
606#[derive(Debug, Clone)]
607struct TriggerRowImage {
608    columns: HashMap<u16, Value>,
609}
610
611impl TriggerRowImage {
612    fn from_row(row: crate::memtable::Row) -> Self {
613        Self {
614            columns: row.columns,
615        }
616    }
617
618    fn from_cells(cells: &[(u16, Value)]) -> Self {
619        Self {
620            columns: cells.iter().cloned().collect(),
621        }
622    }
623}
624
625#[derive(Debug, Clone)]
626struct WriteEvent {
627    table: String,
628    kind: TriggerEvent,
629    old: Option<TriggerRowImage>,
630    new: Option<TriggerRowImage>,
631    changed_columns: Vec<u16>,
632    op_indices: Vec<usize>,
633    put_idx: Option<usize>,
634    trigger_stack: Vec<String>,
635}
636
637#[derive(Default)]
638struct TriggerExpansion {
639    before: Vec<(u64, crate::txn::Staged)>,
640    before_stacks: Vec<Vec<String>>,
641    before_external: Vec<ExternalTriggerWrite>,
642    after: Vec<(u64, crate::txn::Staged)>,
643    after_stacks: Vec<Vec<String>>,
644    after_external: Vec<ExternalTriggerWrite>,
645    ignored_indices: std::collections::BTreeSet<usize>,
646}
647
648#[derive(Clone, PartialEq)]
649struct TriggerCatalogBinding {
650    triggers: Vec<TriggerEntry>,
651    tables: Vec<(String, u64, u64)>,
652    external_tables: Vec<(String, u64, u64)>,
653}
654
655fn trigger_catalog_binding(catalog: &Catalog) -> Option<TriggerCatalogBinding> {
656    let mut triggers = catalog
657        .triggers
658        .iter()
659        .filter(|entry| entry.trigger.enabled)
660        .cloned()
661        .collect::<Vec<_>>();
662    if triggers.is_empty() {
663        return None;
664    }
665    triggers.sort_by(|left, right| left.trigger.name.cmp(&right.trigger.name));
666    let mut tables = catalog
667        .tables
668        .iter()
669        .filter(|entry| matches!(entry.state, TableState::Live))
670        .map(|entry| (entry.name.clone(), entry.table_id, entry.schema.schema_id))
671        .collect::<Vec<_>>();
672    tables.sort_unstable();
673    let mut external_tables = catalog
674        .external_tables
675        .iter()
676        .map(|entry| {
677            (
678                entry.name.clone(),
679                entry.created_epoch,
680                entry.declared_schema.schema_id,
681            )
682        })
683        .collect::<Vec<_>>();
684    external_tables.sort_unstable();
685    Some(TriggerCatalogBinding {
686        triggers,
687        tables,
688        external_tables,
689    })
690}
691
692struct TriggerProgramOutput<'a> {
693    added: &'a mut Vec<(u64, crate::txn::Staged)>,
694    added_stacks: &'a mut Vec<Vec<String>>,
695    added_external: &'a mut Vec<ExternalTriggerWrite>,
696    ignored_indices: &'a mut std::collections::BTreeSet<usize>,
697}
698
699#[derive(Debug, Clone, Copy, PartialEq, Eq)]
700enum TriggerProgramOutcome {
701    Continue,
702    Ignore,
703}
704
705/// An integrity issue found by [`Database::check`] (spec §16).
706#[derive(Debug, Clone)]
707pub struct CheckIssue {
708    pub table_id: u64,
709    pub table_name: String,
710    pub severity: String,
711    pub description: String,
712}
713
714/// One optimistic authorization snapshot for a complete scored read.
715#[derive(Debug, Clone)]
716pub struct AuthorizedReadSnapshot {
717    pub table: String,
718    pub table_snapshot: Snapshot,
719    pub data_generation: u64,
720    pub security_version: u64,
721    pub allowed_row_ids: Option<HashSet<RowId>>,
722}
723
724/// Exact table/security generation used by one successful authorized read.
725#[derive(Debug, Clone, Copy, PartialEq, Eq)]
726pub struct AuthorizedReadStamp {
727    pub table_id: u64,
728    pub schema_id: u64,
729    pub data_generation: u64,
730    pub security_version: u64,
731    pub snapshot: Snapshot,
732}
733
734type RlsCacheKey = (String, u64, u64, String);
735
736/// Runtime statistics for the byte-bounded RLS candidate cache.
737#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
738pub struct RlsCacheStats {
739    pub entries: usize,
740    pub bytes: usize,
741    pub hits: u64,
742    pub misses: u64,
743    pub evictions: u64,
744    pub build_nanos: u64,
745    pub rows_evaluated: u64,
746}
747
748const RLS_CACHE_MAX_BYTES: usize = 64 * 1024 * 1024;
749const CDC_MAX_WAL_RECORDS: usize = 1_000_000;
750const CDC_MAX_WAL_REPLAY_BYTES: usize = 256 * 1024 * 1024;
751const CDC_MAX_EVENTS: usize = 100_000;
752const CDC_MAX_ROWS: usize = 1_000_000;
753const CDC_MAX_INLINE_PAYLOAD_BYTES: usize = 32 * 1024 * 1024;
754const CDC_MAX_RETAINED_BYTES: usize = 256 * 1024 * 1024;
755
756fn charge_cdc_bytes(total: &mut usize, amount: usize, resource: &'static str) -> Result<()> {
757    let requested = total.saturating_add(amount);
758    if requested > CDC_MAX_RETAINED_BYTES {
759        return Err(MongrelError::ResourceLimitExceeded {
760            resource,
761            requested,
762            limit: CDC_MAX_RETAINED_BYTES,
763        });
764    }
765    *total = requested;
766    Ok(())
767}
768
769fn cdc_row_storage_bytes(row: &crate::memtable::Row) -> usize {
770    usize::try_from(row.estimated_bytes())
771        .unwrap_or(usize::MAX)
772        .saturating_add(std::mem::size_of::<crate::memtable::Row>())
773}
774
775fn cdc_row_json_bytes(row: &crate::memtable::Row) -> usize {
776    let value_slot = std::mem::size_of::<serde_json::Value>();
777    row.columns.values().fold(512_usize, |bytes, value| {
778        let values = match value {
779            Value::Bytes(values) => values.len(),
780            Value::Json(values) => values.len(),
781            Value::Embedding(values) => values.len(),
782            _ => 1,
783        };
784        bytes.saturating_add(values.saturating_mul(value_slot))
785    })
786}
787
788fn cdc_rows_json_bytes(rows: &[crate::memtable::Row]) -> usize {
789    rows.iter().fold(0_usize, |bytes, row| {
790        bytes.saturating_add(cdc_row_json_bytes(row))
791    })
792}
793
794#[derive(Default)]
795struct RlsCache {
796    entries: HashMap<RlsCacheKey, (Arc<HashSet<RowId>>, usize)>,
797    lru: VecDeque<RlsCacheKey>,
798    bytes: usize,
799    hits: u64,
800    misses: u64,
801    evictions: u64,
802    build_nanos: u64,
803    rows_evaluated: u64,
804}
805
806impl RlsCache {
807    fn get(&mut self, key: &RlsCacheKey) -> Option<Arc<HashSet<RowId>>> {
808        let value = self.entries.get(key).map(|(value, _)| Arc::clone(value));
809        if value.is_some() {
810            self.hits = self.hits.saturating_add(1);
811            self.touch(key);
812        } else {
813            self.misses = self.misses.saturating_add(1);
814        }
815        value
816    }
817
818    fn insert(&mut self, key: RlsCacheKey, value: Arc<HashSet<RowId>>) {
819        let bytes = key
820            .0
821            .len()
822            .saturating_add(key.3.len())
823            .saturating_add(
824                value
825                    .capacity()
826                    .saturating_mul(std::mem::size_of::<RowId>() * 3),
827            )
828            .saturating_add(std::mem::size_of::<RlsCacheKey>());
829        if bytes > RLS_CACHE_MAX_BYTES {
830            return;
831        }
832        if let Some((_, old_bytes)) = self.entries.remove(&key) {
833            self.bytes = self.bytes.saturating_sub(old_bytes);
834        }
835        self.lru.retain(|candidate| candidate != &key);
836        while self.bytes.saturating_add(bytes) > RLS_CACHE_MAX_BYTES {
837            let Some(oldest) = self.lru.pop_front() else {
838                break;
839            };
840            if let Some((_, old_bytes)) = self.entries.remove(&oldest) {
841                self.bytes = self.bytes.saturating_sub(old_bytes);
842                self.evictions = self.evictions.saturating_add(1);
843            }
844        }
845        self.bytes = self.bytes.saturating_add(bytes);
846        self.lru.push_back(key.clone());
847        self.entries.insert(key, (value, bytes));
848    }
849
850    fn touch(&mut self, key: &RlsCacheKey) {
851        self.lru.retain(|candidate| candidate != key);
852        self.lru.push_back(key.clone());
853    }
854
855    fn stats(&self) -> RlsCacheStats {
856        RlsCacheStats {
857            entries: self.entries.len(),
858            bytes: self.bytes,
859            hits: self.hits,
860            misses: self.misses,
861            evictions: self.evictions,
862            build_nanos: self.build_nanos,
863            rows_evaluated: self.rows_evaluated,
864        }
865    }
866}
867
868/// Mounted table with immutable, structurally shared scored-read generations.
869#[derive(Clone)]
870pub struct TableHandle {
871    inner: TableHandleInner,
872    generation_metrics: Arc<TableGenerationMetrics>,
873}
874
875#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
876pub struct TableGenerationStats {
877    pub active_read_generations: usize,
878    pub max_live_read_generations: usize,
879    pub cow_clone_count: u64,
880    pub cow_clone_nanos: u64,
881    pub estimated_cow_clone_bytes: u64,
882    pub writer_wait_nanos: u64,
883}
884
885#[derive(Default)]
886#[doc(hidden)]
887pub struct TableGenerationMetrics {
888    active_read_generations: AtomicUsize,
889    max_live_read_generations: AtomicUsize,
890    cow_clone_count: AtomicU64,
891    cow_clone_nanos: AtomicU64,
892    estimated_cow_clone_bytes: AtomicU64,
893    writer_wait_nanos: AtomicU64,
894}
895
896impl TableGenerationMetrics {
897    fn activate(self: &Arc<Self>, table: Table) -> Arc<TableReadGeneration> {
898        let active = self.active_read_generations.fetch_add(1, Ordering::Relaxed) + 1;
899        self.max_live_read_generations
900            .fetch_max(active, Ordering::Relaxed);
901        Arc::new(TableReadGeneration {
902            table,
903            metrics: Arc::clone(self),
904        })
905    }
906
907    fn stats(&self) -> TableGenerationStats {
908        TableGenerationStats {
909            active_read_generations: self.active_read_generations.load(Ordering::Relaxed),
910            max_live_read_generations: self.max_live_read_generations.load(Ordering::Relaxed),
911            cow_clone_count: self.cow_clone_count.load(Ordering::Relaxed),
912            cow_clone_nanos: self.cow_clone_nanos.load(Ordering::Relaxed),
913            estimated_cow_clone_bytes: self.estimated_cow_clone_bytes.load(Ordering::Relaxed),
914            writer_wait_nanos: self.writer_wait_nanos.load(Ordering::Relaxed),
915        }
916    }
917}
918
919/// Immutable, structurally shared snapshot used by scored readers.
920pub struct TableReadGeneration {
921    table: Table,
922    metrics: Arc<TableGenerationMetrics>,
923}
924
925impl std::ops::Deref for TableReadGeneration {
926    type Target = Table;
927
928    fn deref(&self) -> &Self::Target {
929        &self.table
930    }
931}
932
933impl Drop for TableReadGeneration {
934    fn drop(&mut self) {
935        self.metrics
936            .active_read_generations
937            .fetch_sub(1, Ordering::Relaxed);
938    }
939}
940
941#[derive(Clone)]
942enum TableHandleInner {
943    CopyOnWrite(Arc<RwLock<Arc<Table>>>),
944    Direct(Arc<Mutex<Table>>),
945}
946
947pub enum TableGuard<'a> {
948    CopyOnWrite {
949        table: parking_lot::RwLockWriteGuard<'a, Arc<Table>>,
950        metrics: Arc<TableGenerationMetrics>,
951    },
952    Direct {
953        table: parking_lot::MutexGuard<'a, Table>,
954    },
955}
956
957impl TableHandle {
958    fn new(table: Table) -> Self {
959        Self {
960            inner: TableHandleInner::CopyOnWrite(Arc::new(RwLock::new(Arc::new(table)))),
961            generation_metrics: Arc::new(TableGenerationMetrics::default()),
962        }
963    }
964
965    pub fn from_table(table: Table) -> Self {
966        Self::new(table)
967    }
968
969    pub fn lock(&self) -> TableGuard<'_> {
970        let started = std::time::Instant::now();
971        let guard = match &self.inner {
972            TableHandleInner::CopyOnWrite(table) => TableGuard::CopyOnWrite {
973                table: table.write(),
974                metrics: Arc::clone(&self.generation_metrics),
975            },
976            TableHandleInner::Direct(table) => TableGuard::Direct {
977                table: table.lock(),
978            },
979        };
980        self.generation_metrics.writer_wait_nanos.fetch_add(
981            started.elapsed().as_nanos().min(u128::from(u64::MAX)) as u64,
982            Ordering::Relaxed,
983        );
984        guard
985    }
986
987    fn try_lock_for(&self, timeout: std::time::Duration) -> Option<TableGuard<'_>> {
988        let started = std::time::Instant::now();
989        let guard = match &self.inner {
990            TableHandleInner::CopyOnWrite(table) => {
991                table
992                    .try_write_for(timeout)
993                    .map(|table| TableGuard::CopyOnWrite {
994                        table,
995                        metrics: Arc::clone(&self.generation_metrics),
996                    })
997            }
998            TableHandleInner::Direct(table) => table
999                .try_lock_for(timeout)
1000                .map(|table| TableGuard::Direct { table }),
1001        };
1002        self.generation_metrics.writer_wait_nanos.fetch_add(
1003            started.elapsed().as_nanos().min(u128::from(u64::MAX)) as u64,
1004            Ordering::Relaxed,
1005        );
1006        guard
1007    }
1008
1009    pub fn ptr_eq(&self, other: &Self) -> bool {
1010        match (&self.inner, &other.inner) {
1011            (TableHandleInner::CopyOnWrite(left), TableHandleInner::CopyOnWrite(right)) => {
1012                Arc::ptr_eq(left, right)
1013            }
1014            (TableHandleInner::Direct(left), TableHandleInner::Direct(right)) => {
1015                Arc::ptr_eq(left, right)
1016            }
1017            _ => false,
1018        }
1019    }
1020
1021    pub fn read_generation_with_context(
1022        &self,
1023        context: Option<&crate::query::AiExecutionContext>,
1024    ) -> Result<(Arc<TableReadGeneration>, Snapshot)> {
1025        let mut table = if let Some(context) = context {
1026            loop {
1027                context.checkpoint()?;
1028                let wait = context
1029                    .remaining_duration()
1030                    .unwrap_or(std::time::Duration::from_millis(5))
1031                    .min(std::time::Duration::from_millis(5));
1032                if let Some(table) = self.try_lock_for(wait) {
1033                    break table;
1034                }
1035            }
1036        } else {
1037            self.lock()
1038        };
1039        let snapshot = table.snapshot();
1040        let generation = table.clone_read_generation()?;
1041        Ok((self.generation_metrics.activate(generation), snapshot))
1042    }
1043
1044    pub fn generation_stats(&self) -> TableGenerationStats {
1045        self.generation_metrics.stats()
1046    }
1047}
1048
1049impl From<Arc<Mutex<Table>>> for TableHandle {
1050    fn from(table: Arc<Mutex<Table>>) -> Self {
1051        Self {
1052            inner: TableHandleInner::Direct(table),
1053            generation_metrics: Arc::new(TableGenerationMetrics::default()),
1054        }
1055    }
1056}
1057
1058impl std::ops::Deref for TableGuard<'_> {
1059    type Target = Table;
1060
1061    fn deref(&self) -> &Self::Target {
1062        match self {
1063            Self::CopyOnWrite { table, .. } => table.as_ref(),
1064            Self::Direct { table } => table,
1065        }
1066    }
1067}
1068
1069impl std::ops::DerefMut for TableGuard<'_> {
1070    fn deref_mut(&mut self) -> &mut Self::Target {
1071        match self {
1072            Self::CopyOnWrite { table, metrics } => {
1073                if Arc::strong_count(table) > 1 || Arc::weak_count(table) > 0 {
1074                    let estimated_bytes = table.estimated_clone_bytes();
1075                    let started = std::time::Instant::now();
1076                    let table = Arc::make_mut(table);
1077                    metrics.cow_clone_count.fetch_add(1, Ordering::Relaxed);
1078                    metrics.cow_clone_nanos.fetch_add(
1079                        started.elapsed().as_nanos().min(u128::from(u64::MAX)) as u64,
1080                        Ordering::Relaxed,
1081                    );
1082                    metrics
1083                        .estimated_cow_clone_bytes
1084                        .fetch_add(estimated_bytes, Ordering::Relaxed);
1085                    table
1086                } else {
1087                    Arc::make_mut(table)
1088                }
1089            }
1090            Self::Direct { table } => table,
1091        }
1092    }
1093}
1094
1095#[derive(Clone, Debug)]
1096pub struct ReadAuthorization {
1097    pub operation: crate::auth::ColumnOperation,
1098    pub columns: Vec<u16>,
1099    pub permissions: Vec<crate::auth::Permission>,
1100}
1101
1102#[derive(Default, Debug)]
1103struct TableWritePermissionNeeds {
1104    insert: bool,
1105    insert_columns: Vec<u16>,
1106    update: bool,
1107    update_columns: Vec<u16>,
1108    delete: bool,
1109    truncate: bool,
1110}
1111
1112#[cfg(test)]
1113thread_local! {
1114    static WRITE_PERMISSION_DECISIONS: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
1115    static AUTO_INCREMENT_TABLE_LOCKS: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
1116    static PREBUILD_TABLE_LOCKS: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
1117    static PUBLISH_TABLE_LOCKS: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
1118    static COMMIT_MANIFEST_WRITES: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
1119    static TABLE_PERMISSION_DECISIONS: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
1120}
1121
1122fn summarize_write_permissions(
1123    staging: &[(u64, crate::txn::Staged)],
1124) -> HashMap<u64, TableWritePermissionNeeds> {
1125    use crate::txn::Staged;
1126
1127    let mut needs = HashMap::<u64, TableWritePermissionNeeds>::new();
1128    for (table_id, operation) in staging {
1129        let table = needs.entry(*table_id).or_default();
1130        match operation {
1131            Staged::Put(cells) => {
1132                table.insert = true;
1133                table
1134                    .insert_columns
1135                    .extend(cells.iter().map(|(column, _)| *column));
1136            }
1137            Staged::Update {
1138                changed_columns, ..
1139            } => {
1140                table.update = true;
1141                table.update_columns.extend(changed_columns);
1142            }
1143            Staged::Delete(_) => table.delete = true,
1144            Staged::Truncate => table.truncate = true,
1145        }
1146    }
1147    for table in needs.values_mut() {
1148        table.insert_columns.sort_unstable();
1149        table.insert_columns.dedup();
1150        table.update_columns.sort_unstable();
1151        table.update_columns.dedup();
1152    }
1153    needs
1154}
1155
1156struct SecurityCoordinator {
1157    /// Lock order: security gate -> commit lock -> shared WAL -> table locks.
1158    gate: RwLock<()>,
1159    version: AtomicU64,
1160}
1161
1162fn security_coordinator(root: &Path, version: u64) -> Arc<SecurityCoordinator> {
1163    static COORDINATORS: std::sync::OnceLock<
1164        Mutex<HashMap<PathBuf, std::sync::Weak<SecurityCoordinator>>>,
1165    > = std::sync::OnceLock::new();
1166
1167    let root = root.canonicalize().unwrap_or_else(|_| root.to_path_buf());
1168    let mut coordinators = COORDINATORS
1169        .get_or_init(|| Mutex::new(HashMap::new()))
1170        .lock();
1171    coordinators.retain(|_, coordinator| coordinator.strong_count() > 0);
1172    if let Some(coordinator) = coordinators.get(&root).and_then(std::sync::Weak::upgrade) {
1173        return coordinator;
1174    }
1175    let coordinator = Arc::new(SecurityCoordinator {
1176        gate: RwLock::new(()),
1177        version: AtomicU64::new(version),
1178    });
1179    coordinators.insert(root, Arc::downgrade(&coordinator));
1180    coordinator
1181}
1182
1183pub fn lock_table_with_context<'a>(
1184    handle: &'a TableHandle,
1185    context: Option<&crate::query::AiExecutionContext>,
1186) -> Result<TableGuard<'a>> {
1187    let Some(context) = context else {
1188        return Ok(handle.lock());
1189    };
1190    loop {
1191        context.checkpoint()?;
1192        let wait = context
1193            .remaining_duration()
1194            .unwrap_or(std::time::Duration::from_millis(5))
1195            .min(std::time::Duration::from_millis(5));
1196        if let Some(guard) = handle.try_lock_for(wait) {
1197            return Ok(guard);
1198        }
1199    }
1200}
1201
1202/// Knobs for [`Database::open_with_options`].
1203///
1204/// All fields default to the same values the convenience
1205/// [`Database::open`] / [`Database::open_encrypted`] / etc. constructors use,
1206/// so `OpenOptions::default()` round-trips the historical behavior exactly.
1207#[derive(Clone, Debug, Default)]
1208pub struct OpenOptions {
1209    /// Maximum time, in milliseconds, to wait for the cross-process database
1210    /// lock (`_meta/.lock`) before failing the open with `MongrelError::Io`.
1211    ///
1212    /// `0` (the default) preserves the historical fail-fast semantics: a
1213    /// single `try_lock_exclusive` call, no retry, no sleep. SQLite-style
1214    /// `busy_timeout` semantics kick in once this is non-zero — the open
1215    /// sleeps with progressively wider backoff (1ms → 10ms → 50ms) until
1216    /// either the lock is acquired or `lock_timeout_ms` elapses, at which
1217    /// point the open returns the same `Io(WouldBlock)` error the fail-fast
1218    /// path would.
1219    ///
1220    /// Only the cross-process lock is affected. Mounted tables, page-cache
1221    /// misses, and WAL appends already serialize through in-process locks
1222    /// that handle their own contention.
1223    pub lock_timeout_ms: u32,
1224}
1225
1226impl OpenOptions {
1227    /// Set [`OpenOptions::lock_timeout_ms`]. `0` keeps the fail-fast default;
1228    /// SQLite-style applications typically pick 1_000 – 5_000ms.
1229    pub fn with_lock_timeout_ms(mut self, ms: u32) -> Self {
1230        self.lock_timeout_ms = ms;
1231        self
1232    }
1233}
1234
1235/// A multi-table database: one catalog, one epoch clock, shared caches, a
1236/// shared WAL, and a live map of name → `Arc<Table>`.
1237pub struct Database {
1238    root: PathBuf,
1239    durable_root: Arc<crate::durable_file::DurableRoot>,
1240    /// Set by `_meta/replica`; user writes are rejected on follower copies.
1241    read_only: bool,
1242    catalog: RwLock<Catalog>,
1243    security_coordinator: Arc<SecurityCoordinator>,
1244    security_catalog_disk_reads: AtomicU64,
1245    rls_cache: Mutex<RlsCache>,
1246    epoch: Arc<EpochAuthority>,
1247    snapshots: Arc<SnapshotRegistry>,
1248    page_cache: Arc<crate::cache::Sharded<crate::cache::PageCache>>,
1249    decoded_cache: Arc<crate::cache::Sharded<crate::cache::DecodedPageCache>>,
1250    commit_lock: Arc<Mutex<()>>,
1251    /// One shared WAL multiplexing every table's records (spec §7.2). Owned
1252    /// behind a `Mutex` so the transaction layer can append + group-sync. Shared
1253    /// (via `Arc`) with every mounted `Table` so single-table `put`/`commit`
1254    /// writes also land in this one WAL (B1 — one WAL per database).
1255    shared_wal: Arc<Mutex<crate::wal::SharedWal>>,
1256    /// Monotonic per-open transaction-id counter. Scoped by `open_generation`
1257    /// in P2.7; here it just needs to be unique within an open. Shared with
1258    /// mounted tables so their auto-commit txn ids never alias cross-table ones.
1259    next_txn_id: Arc<Mutex<u64>>,
1260    tables: RwLock<HashMap<u64, TableHandle>>,
1261    kek: Option<Arc<crate::encryption::Kek>>,
1262    /// Serializes DDL (create/drop table); data commits serialize through
1263    /// `commit_lock` shared via `SharedCtx`.
1264    ddl_lock: Mutex<()>,
1265    meta_dek: Option<[u8; META_DEK_LEN]>,
1266    /// P3.4: when staged bytes per table exceed this, write a uniform-epoch
1267    /// pending run to `_txn/<txn_id>/` instead of streaming Put records (§8.5).
1268    spill_threshold: std::sync::atomic::AtomicU64,
1269    /// P3.1: write-key → commit_epoch for first-committer-wins conflict
1270    /// detection (spec §9.2).
1271    conflicts: crate::txn::ConflictIndex,
1272    /// P3.1: min read_epoch of all in-flight txns, drives conflict-index
1273    /// pruning (spec §9.2, review fix #12).
1274    active_txns: crate::txn::ActiveTxns,
1275    /// P3.2: set on fsync error — all subsequent writes fail fast (spec §9.3e).
1276    /// Shared with mounted tables so a single-table commit also honors poison.
1277    poisoned: Arc<std::sync::atomic::AtomicBool>,
1278    /// P3.2: group-commit coordinator. The sequencer appends under the WAL lock
1279    /// but defers the fsync to one leader here, so concurrent commits share a
1280    /// single fsync (spec §9.3). Shared with mounted tables.
1281    group: Arc<crate::txn::GroupCommit>,
1282    /// P3.6: txn ids currently spilling into `_txn/<id>/`. GC never deletes a
1283    /// live spill's pending dir (review fix #14, spec §6.4).
1284    active_spills: Arc<crate::retention::ActiveSpills>,
1285    /// A write lock captures a consistent bootstrap image; transaction commits
1286    /// hold a read lock across spill preparation, WAL append, and publish.
1287    replication_barrier: parking_lot::RwLock<()>,
1288    /// Number of rotated WAL segments retained for lagging followers.
1289    replication_wal_retention_segments: AtomicUsize,
1290    /// Live immutable run files used by online backups or scored read
1291    /// generations. GC cannot unlink them until every owning guard drops.
1292    backup_pins: Arc<Mutex<HashMap<(u64, u128), usize>>>,
1293    /// Test-only barrier invoked after a transaction writes its spill runs but
1294    /// before the sequencer/publish, so tests can race `gc()` against an
1295    /// in-flight spill. `None` in production.
1296    #[doc(hidden)]
1297    spill_hook: Mutex<Option<Box<dyn Fn() + Send + Sync>>>,
1298    /// Test seam after the security read gate is held and before WAL append.
1299    #[doc(hidden)]
1300    security_commit_hook: Mutex<Option<Box<dyn Fn() + Send + Sync>>>,
1301    /// Test seam after transaction preparation and before catalog generation
1302    /// validation under the commit sequencer.
1303    #[doc(hidden)]
1304    catalog_commit_hook: Mutex<Option<Box<dyn Fn() + Send + Sync>>>,
1305    /// Test seam after a backup boundary is captured and before pinned runs are
1306    /// copied. Lets tests compact+GC the source at the worst possible moment.
1307    #[doc(hidden)]
1308    backup_hook: Mutex<Option<Box<dyn Fn() + Send + Sync>>>,
1309    replication_hook: Mutex<Option<Box<dyn Fn() + Send + Sync>>>,
1310    trigger_recursive: AtomicBool,
1311    trigger_max_depth: AtomicU32,
1312    trigger_max_loop_iterations: AtomicU32,
1313    /// Exclusive cross-process lock held for the database's lifetime to prevent
1314    /// two processes from opening the same directory concurrently.
1315    _lock: Option<DatabaseFileLock>,
1316    /// Lightweight channel for ephemeral SQL NOTIFY messages. Durable row CDC
1317    /// is reconstructed from the WAL by [`Database::change_events_since`].
1318    notify: tokio::sync::broadcast::Sender<ChangeEvent>,
1319    /// Commit-time wake-up for durable CDC consumers. Payloads are reconstructed
1320    /// from the WAL, so lagged receivers lose only a wake-up, never data.
1321    change_wake: tokio::sync::broadcast::Sender<()>,
1322    /// The authenticated principal for this handle. `None` on databases
1323    /// opened without credentials (the default — `require_auth = false`),
1324    /// `Some` on credentialed opens. Consulted by every enforcement point
1325    /// when the catalog's `require_auth` flag is set. Behind an `RwLock`
1326    /// because the access pattern is read-heavy: every `require()` call
1327    /// reads the principal, while writes happen only at open, `enable_auth`,
1328    /// and `refresh_principal`. This matches the engine's existing use of
1329    /// `RwLock` for `catalog` and `tables`.
1330    /// See `docs/15-credential-enforcement.md`.
1331    principal: RwLock<Option<crate::auth::Principal>>,
1332    /// Shared, cloneable handle to the auth state (require_auth flag from the
1333    /// catalog + the principal). Cloned into every mounted `Table` so the
1334    /// Table layer can enforce permissions without holding a reference back
1335    /// to `Database` (which would create a cycle). `AuthState` is already
1336    /// cheaply cloneable (inner `Arc`), so no outer `Arc` is needed.
1337    auth_state: crate::auth_state::AuthState,
1338}
1339
1340struct RunPins {
1341    pins: Arc<Mutex<HashMap<(u64, u128), usize>>>,
1342    runs: Vec<(u64, u128)>,
1343}
1344
1345struct BackupFilePins {
1346    root: PathBuf,
1347}
1348
1349struct PendingTableDir {
1350    path: PathBuf,
1351    armed: bool,
1352}
1353
1354impl PendingTableDir {
1355    fn new(path: PathBuf) -> Self {
1356        Self { path, armed: true }
1357    }
1358
1359    fn disarm(&mut self) {
1360        self.armed = false;
1361    }
1362}
1363
1364impl Drop for PendingTableDir {
1365    fn drop(&mut self) {
1366        if self.armed {
1367            let _ = std::fs::remove_dir_all(&self.path);
1368        }
1369    }
1370}
1371
1372impl Drop for BackupFilePins {
1373    fn drop(&mut self) {
1374        let _ = std::fs::remove_dir_all(&self.root);
1375    }
1376}
1377
1378impl Drop for RunPins {
1379    fn drop(&mut self) {
1380        let mut pins = self.pins.lock();
1381        for run in &self.runs {
1382            if let Some(count) = pins.get_mut(run) {
1383                *count -= 1;
1384                if *count == 0 {
1385                    pins.remove(run);
1386                }
1387            }
1388        }
1389    }
1390}
1391
1392/// A durable data-change event reconstructed from committed WAL records, or an
1393/// ephemeral SQL `NOTIFY` event when `id` is `None`.
1394#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
1395pub struct ChangeEvent {
1396    pub id: Option<String>,
1397    pub channel: String,
1398    pub table_id: Option<u64>,
1399    pub table: String,
1400    pub op: String,
1401    pub epoch: u64,
1402    pub txn_id: Option<u64>,
1403    pub message: Option<String>,
1404    pub data: Option<serde_json::Value>,
1405}
1406
1407#[derive(Debug, Clone)]
1408pub struct CdcBatch {
1409    pub events: Vec<ChangeEvent>,
1410    pub current_epoch: u64,
1411    pub earliest_epoch: Option<u64>,
1412    pub gap: bool,
1413}
1414
1415/// Manual `Debug` for `Database` — surfaces the diagnostics-relevant fields
1416/// (root, epoch, table count, encryption/auth state) without requiring every
1417/// internal type (Table, GroupCommit, broadcast sender, etc.) to impl Debug.
1418/// The raw field types carry locks, trait objects, and channels that have no
1419/// useful `Debug` output, so a hand-written impl is clearer than peppering
1420/// `#[allow(dead_code)]` skip attributes across two dozen fields.
1421impl std::fmt::Debug for Database {
1422    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1423        let cat = self.catalog.read();
1424        let principal_guard = self.principal.read();
1425        let principal: &str = principal_guard
1426            .as_ref()
1427            .map(|p| p.username.as_str())
1428            .unwrap_or("<none>");
1429        f.debug_struct("Database")
1430            .field("root", &self.root)
1431            .field("db_epoch", &cat.db_epoch)
1432            .field("open_generation", &"sidecar")
1433            .field("tables", &cat.tables.len())
1434            .field("visible_epoch", &self.epoch.visible().0)
1435            .field("encrypted", &self.kek.is_some())
1436            .field("require_auth", &cat.require_auth)
1437            .field("principal", &principal)
1438            .finish()
1439    }
1440}
1441
1442impl Database {
1443    fn canonical_lock_target(root: &Path) -> std::io::Result<(PathBuf, PathBuf)> {
1444        if let Ok(canonical) = root.canonicalize() {
1445            let lock_dir = canonical.parent().ok_or_else(|| {
1446                std::io::Error::new(
1447                    std::io::ErrorKind::InvalidInput,
1448                    "database root must have a parent directory",
1449                )
1450            })?;
1451            return Ok((canonical.clone(), lock_dir.to_path_buf()));
1452        }
1453
1454        let absolute = if root.is_absolute() {
1455            root.to_path_buf()
1456        } else {
1457            std::env::current_dir()?.join(root)
1458        };
1459        let mut cursor = absolute.as_path();
1460        let mut suffix = Vec::new();
1461        while !cursor.exists() {
1462            let name = cursor.file_name().ok_or_else(|| {
1463                std::io::Error::new(
1464                    std::io::ErrorKind::NotFound,
1465                    format!("no existing ancestor for database root {}", root.display()),
1466                )
1467            })?;
1468            suffix.push(name.to_os_string());
1469            cursor = cursor.parent().ok_or_else(|| {
1470                std::io::Error::new(
1471                    std::io::ErrorKind::NotFound,
1472                    format!("no existing ancestor for database root {}", root.display()),
1473                )
1474            })?;
1475        }
1476        let lock_dir = cursor.canonicalize()?;
1477        let mut canonical = lock_dir.clone();
1478        for component in suffix.iter().rev() {
1479            canonical.push(component);
1480        }
1481        Ok((canonical, lock_dir))
1482    }
1483
1484    fn acquire_database_lock(root: &Path, timeout_ms: u32) -> Result<DatabaseFileLock> {
1485        use std::hash::{Hash, Hasher};
1486
1487        let (canonical_path, lock_dir) = Self::canonical_lock_target(root)?;
1488        {
1489            let mut locked = process_locked_paths().lock();
1490            if !locked.insert(canonical_path.clone()) {
1491                return Err(MongrelError::DatabaseLocked {
1492                    path: root.to_path_buf(),
1493                    message: "already open in this process".into(),
1494                });
1495            }
1496        }
1497
1498        let mut hasher = std::collections::hash_map::DefaultHasher::new();
1499        canonical_path.hash(&mut hasher);
1500        let lock_path = lock_dir.join(format!(".mongreldb-{:016x}.lock", hasher.finish()));
1501        let file = match std::fs::OpenOptions::new()
1502            .create(true)
1503            .truncate(false)
1504            .write(true)
1505            .open(lock_path)
1506        {
1507            Ok(file) => file,
1508            Err(error) => {
1509                process_locked_paths().lock().remove(&canonical_path);
1510                return Err(error.into());
1511            }
1512        };
1513        if let Err(error) = Self::fs_lock_exclusive(&file, timeout_ms) {
1514            process_locked_paths().lock().remove(&canonical_path);
1515            return Err(MongrelError::DatabaseLocked {
1516                path: root.to_path_buf(),
1517                message: error.to_string(),
1518            });
1519        }
1520        Ok(DatabaseFileLock {
1521            bootstrap_file: file,
1522            legacy_file: None,
1523            canonical_path,
1524            durable_root: None,
1525        })
1526    }
1527
1528    fn acquire_legacy_database_lock(
1529        lock: &mut DatabaseFileLock,
1530        root: &Path,
1531        timeout_ms: u32,
1532    ) -> Result<()> {
1533        let durable_root = lock
1534            .durable_root
1535            .as_ref()
1536            .ok_or_else(|| MongrelError::Other("database root descriptor was not pinned".into()))?;
1537        let file = durable_root.open_lock_file(Path::new(META_DIR).join(".lock"))?;
1538        if let Err(error) = Self::fs_lock_exclusive(&file, timeout_ms) {
1539            return Err(MongrelError::DatabaseLocked {
1540                path: root.to_path_buf(),
1541                message: error.to_string(),
1542            });
1543        }
1544        lock.legacy_file = Some(file);
1545        Ok(())
1546    }
1547
1548    fn pin_or_create_database_root(path: &Path) -> Result<crate::durable_file::DurableRoot> {
1549        if path.exists() {
1550            return crate::durable_file::DurableRoot::open(path).map_err(Into::into);
1551        }
1552        let mut ancestor = path;
1553        while !ancestor.exists() {
1554            ancestor = ancestor.parent().ok_or_else(|| {
1555                MongrelError::NotFound(format!(
1556                    "no existing ancestor for database root {}",
1557                    path.display()
1558                ))
1559            })?;
1560        }
1561        let relative = path.strip_prefix(ancestor).map_err(|error| {
1562            MongrelError::InvalidArgument(format!("invalid database root: {error}"))
1563        })?;
1564        crate::durable_file::DurableRoot::open(ancestor)?
1565            .create_directory_all_pinned(relative)
1566            .map_err(Into::into)
1567    }
1568
1569    fn begin_create(root: impl AsRef<Path>) -> Result<(PathBuf, DatabaseFileLock)> {
1570        let requested_root = root.as_ref();
1571        let mut lock = Self::acquire_database_lock(requested_root, 0)?;
1572        let root = lock.canonical_path.clone();
1573        Self::reject_existing_database(&root)?;
1574        let durable_root = Arc::new(Self::pin_or_create_database_root(&root)?);
1575        if durable_root.canonical_path() != lock.canonical_path {
1576            return Err(MongrelError::Conflict(
1577                "database root changed while it was being created".into(),
1578            ));
1579        }
1580        durable_root.create_directory_all(META_DIR)?;
1581        lock.durable_root = Some(durable_root);
1582        let io_root = lock
1583            .durable_root
1584            .as_ref()
1585            .ok_or_else(|| MongrelError::Other("database root descriptor was not pinned".into()))?
1586            .io_path()?;
1587        Self::acquire_legacy_database_lock(&mut lock, &io_root, 0)?;
1588        Self::reject_existing_database(&io_root)?;
1589        Ok((io_root, lock))
1590    }
1591
1592    fn begin_open(
1593        root: impl AsRef<Path>,
1594        lock_timeout_ms: u32,
1595    ) -> Result<(PathBuf, DatabaseFileLock)> {
1596        let root = root.as_ref();
1597        let durable_root = crate::durable_file::DurableRoot::open(root).map_err(|error| {
1598            if error.kind() == std::io::ErrorKind::NotFound {
1599                MongrelError::NotFound(format!("database root {}: {error}", root.display()))
1600            } else {
1601                error.into()
1602            }
1603        })?;
1604        Self::begin_open_durable(durable_root, lock_timeout_ms)
1605    }
1606
1607    fn begin_open_durable(
1608        durable_root: crate::durable_file::DurableRoot,
1609        lock_timeout_ms: u32,
1610    ) -> Result<(PathBuf, DatabaseFileLock)> {
1611        let io_root = durable_root.io_path()?;
1612        let current_root = io_root.canonicalize()?;
1613        let mut lock = Self::acquire_database_lock(&current_root, lock_timeout_ms)?;
1614        lock.durable_root = Some(Arc::new(durable_root));
1615        let io_root = lock
1616            .durable_root
1617            .as_ref()
1618            .ok_or_else(|| MongrelError::Other("database root descriptor was not pinned".into()))?
1619            .io_path()?;
1620        if lock
1621            .durable_root
1622            .as_ref()
1623            .ok_or_else(|| MongrelError::Other("database root descriptor was not pinned".into()))?
1624            .open_directory(META_DIR)
1625            .is_err()
1626        {
1627            return Err(MongrelError::NotFound(format!(
1628                "no database metadata found at {:?}",
1629                current_root
1630            )));
1631        }
1632        Self::acquire_legacy_database_lock(&mut lock, &io_root, lock_timeout_ms)?;
1633        Ok((io_root, lock))
1634    }
1635
1636    /// Create a fresh plaintext database at `root`.
1637    pub fn create(root: impl AsRef<Path>) -> Result<Self> {
1638        let (root, lock) = Self::begin_create(root)?;
1639        Self::create_inner(root, None, lock)
1640    }
1641
1642    /// Create a fresh encrypted database, deriving the DB-wide KEK from a
1643    /// passphrase (Argon2id + HKDF). The salt is persisted at `_meta/keys`.
1644    #[cfg(feature = "encryption")]
1645    pub fn create_encrypted(root: impl AsRef<Path>, passphrase: &str) -> Result<Self> {
1646        let (root, lock) = Self::begin_create(root)?;
1647        let salt = crate::encryption::random_salt()?;
1648        crate::durable_file::write_atomic(&root.join(META_DIR).join(KEYS_FILENAME), &salt)?;
1649        let kek = Arc::new(crate::encryption::Kek::derive(passphrase, &salt)?);
1650        Self::create_inner(root, Some(kek), lock)
1651    }
1652
1653    /// Create a fresh encrypted database, deriving the DB-wide KEK from a raw
1654    /// high-entropy key via HKDF. The salt is persisted at `_meta/keys`.
1655    #[cfg(feature = "encryption")]
1656    pub fn create_with_key(root: impl AsRef<Path>, key: &[u8]) -> Result<Self> {
1657        let (root, lock) = Self::begin_create(root)?;
1658        let salt = crate::encryption::random_salt()?;
1659        crate::durable_file::write_atomic(&root.join(META_DIR).join(KEYS_FILENAME), &salt)?;
1660        let kek = Arc::new(crate::encryption::Kek::from_raw_key(key, &salt)?);
1661        Self::create_inner(root, Some(kek), lock)
1662    }
1663
1664    fn create_inner(
1665        root: PathBuf,
1666        kek: Option<Arc<crate::encryption::Kek>>,
1667        lock: DatabaseFileLock,
1668    ) -> Result<Self> {
1669        crate::durable_file::create_directory_all(&root.join(TABLES_DIR))?;
1670        let meta_dek = crate::encryption::meta_dek_for(kek.as_deref());
1671        let cat = Catalog::empty();
1672        catalog::write_atomic(&root, &cat, meta_dek.as_ref())?;
1673        Self::finish_open(root, cat, kek, meta_dek, false, None, None, None, lock)
1674    }
1675
1676    /// Open an existing plaintext database.
1677    pub fn open(root: impl AsRef<Path>) -> Result<Self> {
1678        Self::open_inner(root, None, None)
1679    }
1680
1681    /// Open an existing encrypted database with a passphrase.
1682    #[cfg(feature = "encryption")]
1683    pub fn open_encrypted(root: impl AsRef<Path>, passphrase: &str) -> Result<Self> {
1684        let (root, lock) = Self::begin_open(root, 0)?;
1685        let salt = read_encryption_salt(lock.durable_root.as_deref().ok_or_else(|| {
1686            MongrelError::Other("database root descriptor was not pinned".into())
1687        })?)?;
1688        let kek = Arc::new(crate::encryption::Kek::derive(passphrase, &salt)?);
1689        Self::open_inner_locked(root, Some(kek), lock)
1690    }
1691
1692    /// Open an existing encrypted database with a configurable cross-process
1693    /// lock timeout. Mirrors [`open_with_options`](Self::open_with_options).
1694    #[cfg(feature = "encryption")]
1695    pub fn open_encrypted_with_options(
1696        root: impl AsRef<Path>,
1697        passphrase: &str,
1698        options: OpenOptions,
1699    ) -> Result<Self> {
1700        let (root, lock) = Self::begin_open(root, options.lock_timeout_ms)?;
1701        let salt = read_encryption_salt(lock.durable_root.as_deref().ok_or_else(|| {
1702            MongrelError::Other("database root descriptor was not pinned".into())
1703        })?)?;
1704        let kek = Arc::new(crate::encryption::Kek::derive(passphrase, &salt)?);
1705        Self::open_inner_locked(root, Some(kek), lock)
1706    }
1707
1708    /// Open an existing encrypted database using a raw high-entropy key.
1709    #[cfg(feature = "encryption")]
1710    pub fn open_with_key(root: impl AsRef<Path>, key: &[u8]) -> Result<Self> {
1711        let (root, lock) = Self::begin_open(root, 0)?;
1712        let salt = read_encryption_salt(lock.durable_root.as_deref().ok_or_else(|| {
1713            MongrelError::Other("database root descriptor was not pinned".into())
1714        })?)?;
1715        let kek = Arc::new(crate::encryption::Kek::from_raw_key(key, &salt)?);
1716        Self::open_inner_locked(root, Some(kek), lock)
1717    }
1718
1719    /// Open an existing plaintext database that has `require_auth = true`,
1720    /// verifying the supplied credentials up front and caching the resolved
1721    /// [`Principal`] on the returned handle. Every subsequent operation will
1722    /// be checked against that principal.
1723    ///
1724    /// Returns [`MongrelError::AuthNotRequired`] if the database does not have
1725    /// `require_auth` enabled — callers must pick the matching constructor for
1726    /// the database's mode. Returns [`MongrelError::InvalidCredentials`] on a
1727    /// bad username/password.
1728    ///
1729    /// See `docs/15-credential-enforcement.md`.
1730    pub fn open_with_credentials(
1731        root: impl AsRef<Path>,
1732        username: &str,
1733        password: &str,
1734    ) -> Result<Self> {
1735        Self::open_inner_with_credentials(root, None, username, password)
1736    }
1737
1738    /// Open with credentials and a configurable cross-process lock timeout.
1739    /// Mirrors [`open_with_options`](Self::open_with_options) for the
1740    /// credentialed path.
1741    pub fn open_with_credentials_and_options(
1742        root: impl AsRef<Path>,
1743        username: &str,
1744        password: &str,
1745        options: OpenOptions,
1746    ) -> Result<Self> {
1747        Self::open_inner_with_credentials_and_lock_timeout(
1748            root,
1749            None,
1750            username,
1751            password,
1752            options.lock_timeout_ms,
1753        )
1754    }
1755
1756    /// Open an existing encrypted database that has `require_auth = true`,
1757    /// combining the encryption passphrase flow with credential verification.
1758    #[cfg(feature = "encryption")]
1759    pub fn open_encrypted_with_credentials(
1760        root: impl AsRef<Path>,
1761        passphrase: &str,
1762        username: &str,
1763        password: &str,
1764    ) -> Result<Self> {
1765        let (root, lock) = Self::begin_open(root, 0)?;
1766        let salt = read_encryption_salt(lock.durable_root.as_deref().ok_or_else(|| {
1767            MongrelError::Other("database root descriptor was not pinned".into())
1768        })?)?;
1769        let kek = Arc::new(crate::encryption::Kek::derive(passphrase, &salt)?);
1770        Self::open_inner_with_credentials_locked(root, Some(kek), username, password, lock)
1771    }
1772
1773    /// Open an encrypted + credentialed database with a configurable
1774    /// cross-process lock timeout. Mirrors
1775    /// [`open_encrypted_with_options`](Self::open_encrypted_with_options).
1776    #[cfg(feature = "encryption")]
1777    pub fn open_encrypted_with_credentials_and_options(
1778        root: impl AsRef<Path>,
1779        passphrase: &str,
1780        username: &str,
1781        password: &str,
1782        options: OpenOptions,
1783    ) -> Result<Self> {
1784        let (root, lock) = Self::begin_open(root, options.lock_timeout_ms)?;
1785        let salt = read_encryption_salt(lock.durable_root.as_deref().ok_or_else(|| {
1786            MongrelError::Other("database root descriptor was not pinned".into())
1787        })?)?;
1788        let kek = Arc::new(crate::encryption::Kek::derive(passphrase, &salt)?);
1789        Self::open_inner_with_credentials_locked(root, Some(kek), username, password, lock)
1790    }
1791
1792    /// Open an existing database with non-default [`OpenOptions`].
1793    ///
1794    /// Use this when you need cross-process lock retries (`lock_timeout_ms`)
1795    /// rather than the fail-fast default. The other open constructors keep
1796    /// their previous defaults; use their `*_with_options` variants when they
1797    /// need the same timeout behavior.
1798    pub fn open_with_options(root: impl AsRef<Path>, options: OpenOptions) -> Result<Self> {
1799        // No encryption, no auth; encrypted and credentialed paths have their
1800        // own `*_with_options` constructors.
1801        Self::open_inner_with_lock_timeout(root, None, None, options.lock_timeout_ms)
1802    }
1803
1804    fn open_inner_with_lock_timeout(
1805        root: impl AsRef<Path>,
1806        kek: Option<Arc<crate::encryption::Kek>>,
1807        _meta_dek_override: Option<[u8; META_DEK_LEN]>,
1808        lock_timeout_ms: u32,
1809    ) -> Result<Self> {
1810        let (root, lock) = Self::begin_open(root, lock_timeout_ms)?;
1811        Self::open_inner_locked(root, kek, lock)
1812    }
1813
1814    fn open_inner_locked(
1815        root: PathBuf,
1816        kek: Option<Arc<crate::encryption::Kek>>,
1817        lock: DatabaseFileLock,
1818    ) -> Result<Self> {
1819        let meta_dek = crate::encryption::meta_dek_for(kek.as_deref());
1820        let mut cat = catalog::read_durable(
1821            lock.durable_root.as_deref().ok_or_else(|| {
1822                MongrelError::Other("database root descriptor was not pinned".into())
1823            })?,
1824            meta_dek.as_ref(),
1825        )?
1826        .ok_or_else(|| MongrelError::NotFound(format!("no catalog found at {:?}", root)))?;
1827        let recovery_checkpoint = cat.clone();
1828
1829        // CATALOG is only a checkpoint. Authentication must use the
1830        // authoritative catalog after committed WAL DDL/security replay.
1831        let wal_dek = crate::encryption::wal_dek_for(kek.as_deref());
1832        let recovery_records = crate::wal::SharedWal::replay_durable_with_dek(
1833            lock.durable_root.as_deref().ok_or_else(|| {
1834                MongrelError::Other("database root descriptor was not pinned".into())
1835            })?,
1836            wal_dek.as_ref(),
1837        )?;
1838        recover_ddl_from_records(
1839            &root,
1840            Some(lock.durable_root.as_deref().ok_or_else(|| {
1841                MongrelError::Other("database root descriptor was not pinned".into())
1842            })?),
1843            &mut cat,
1844            meta_dek.as_ref(),
1845            false,
1846            None,
1847            &recovery_records,
1848        )?;
1849        Self::finish_open(
1850            root,
1851            cat,
1852            kek,
1853            meta_dek,
1854            true,
1855            Some(recovery_checkpoint),
1856            Some(recovery_records),
1857            None,
1858            lock,
1859        )
1860    }
1861
1862    /// Shared credentialed-open inner: read the catalog, verify the database
1863    /// requires auth, verify the password, resolve the principal, and pass
1864    /// everything to `finish_open` in one shot. This avoids the chicken-and-egg
1865    /// problem where `finish_open`'s fail-closed check (`require_auth &&
1866    /// principal.is_none()`) would fire before a post-open `authenticate()`
1867    /// could supply the principal.
1868    fn open_inner_with_credentials(
1869        root: impl AsRef<Path>,
1870        kek: Option<Arc<crate::encryption::Kek>>,
1871        username: &str,
1872        password: &str,
1873    ) -> Result<Self> {
1874        Self::open_inner_with_credentials_and_lock_timeout(root, kek, username, password, 0)
1875    }
1876
1877    /// Credentialed-open with an explicit cross-process lock timeout. The
1878    /// timeout is opt-in: callers that don't pass `OpenOptions` keep the
1879    /// historical fail-fast behavior via the wrapper above.
1880    fn open_inner_with_credentials_and_lock_timeout(
1881        root: impl AsRef<Path>,
1882        kek: Option<Arc<crate::encryption::Kek>>,
1883        username: &str,
1884        password: &str,
1885        lock_timeout_ms: u32,
1886    ) -> Result<Self> {
1887        let (root, lock) = Self::begin_open(root, lock_timeout_ms)?;
1888        Self::open_inner_with_credentials_locked(root, kek, username, password, lock)
1889    }
1890
1891    fn open_inner_with_credentials_locked(
1892        root: PathBuf,
1893        kek: Option<Arc<crate::encryption::Kek>>,
1894        username: &str,
1895        password: &str,
1896        lock: DatabaseFileLock,
1897    ) -> Result<Self> {
1898        let meta_dek = crate::encryption::meta_dek_for(kek.as_deref());
1899        let mut cat = catalog::read_durable(
1900            lock.durable_root.as_deref().ok_or_else(|| {
1901                MongrelError::Other("database root descriptor was not pinned".into())
1902            })?,
1903            meta_dek.as_ref(),
1904        )?
1905        .ok_or_else(|| MongrelError::NotFound(format!("no catalog found at {:?}", root)))?;
1906        let recovery_checkpoint = cat.clone();
1907
1908        // Never verify against a stale checkpoint. A committed password,
1909        // user, role, or auth-mode change in WAL is authoritative.
1910        let wal_dek = crate::encryption::wal_dek_for(kek.as_deref());
1911        let recovery_records = crate::wal::SharedWal::replay_durable_with_dek(
1912            lock.durable_root.as_deref().ok_or_else(|| {
1913                MongrelError::Other("database root descriptor was not pinned".into())
1914            })?,
1915            wal_dek.as_ref(),
1916        )?;
1917        recover_ddl_from_records(
1918            &root,
1919            Some(lock.durable_root.as_deref().ok_or_else(|| {
1920                MongrelError::Other("database root descriptor was not pinned".into())
1921            })?),
1922            &mut cat,
1923            meta_dek.as_ref(),
1924            false,
1925            None,
1926            &recovery_records,
1927        )?;
1928
1929        // Fail early if the database is not in require_auth mode — the caller
1930        // picked the wrong constructor.
1931        if !cat.require_auth {
1932            return Err(MongrelError::AuthNotRequired);
1933        }
1934
1935        // Verify credentials against the on-disk catalog before constructing
1936        // the full Database handle. This reads users/hashes directly from the
1937        // loaded catalog rather than going through the Database::verify_user
1938        // method (which requires a constructed Database).
1939        let user = cat
1940            .users
1941            .iter()
1942            .find(|u| u.username == username)
1943            .filter(|u| !u.password_hash.is_empty())
1944            .ok_or_else(|| MongrelError::InvalidCredentials {
1945                username: username.to_string(),
1946            })?;
1947        let password_ok = crate::auth::verify_password(password, &user.password_hash)
1948            .map_err(MongrelError::Other)?;
1949        if !password_ok {
1950            return Err(MongrelError::InvalidCredentials {
1951                username: username.to_string(),
1952            });
1953        }
1954
1955        // Resolve the principal from the catalog (roles + permissions).
1956        let principal =
1957            Self::resolve_principal_from_catalog(&cat, &user.username).ok_or_else(|| {
1958                MongrelError::InvalidCredentials {
1959                    username: username.to_string(),
1960                }
1961            })?;
1962
1963        Self::finish_open(
1964            root,
1965            cat,
1966            kek,
1967            meta_dek,
1968            true,
1969            Some(recovery_checkpoint),
1970            Some(recovery_records),
1971            Some(principal),
1972            lock,
1973        )
1974    }
1975
1976    /// Create a fresh plaintext database with `require_auth = true` and a
1977    /// single admin user. The returned handle is already authenticated as
1978    /// that admin — every subsequent operation is checked against the admin
1979    /// principal (which bypasses all permission checks via `is_admin`).
1980    ///
1981    /// This is the bootstrap path: there is no window where the database
1982    /// requires auth but has no users.
1983    ///
1984    /// See `docs/15-credential-enforcement.md`.
1985    pub fn create_with_credentials(
1986        root: impl AsRef<Path>,
1987        admin_username: &str,
1988        admin_password: &str,
1989    ) -> Result<Self> {
1990        let (root, lock) = Self::begin_create(root)?;
1991        Self::create_inner_with_credentials(root, None, admin_username, admin_password, lock)
1992    }
1993
1994    /// Create a fresh encrypted database with `require_auth = true` and a
1995    /// single admin user. Composes encryption-at-rest with credential
1996    /// enforcement.
1997    #[cfg(feature = "encryption")]
1998    pub fn create_encrypted_with_credentials(
1999        root: impl AsRef<Path>,
2000        passphrase: &str,
2001        admin_username: &str,
2002        admin_password: &str,
2003    ) -> Result<Self> {
2004        let (root, lock) = Self::begin_create(root)?;
2005        let salt = crate::encryption::random_salt()?;
2006        crate::durable_file::write_atomic(&root.join(META_DIR).join(KEYS_FILENAME), &salt)?;
2007        let kek = Arc::new(crate::encryption::Kek::derive(passphrase, &salt)?);
2008        Self::create_inner_with_credentials(root, Some(kek), admin_username, admin_password, lock)
2009    }
2010
2011    fn create_inner_with_credentials(
2012        root: PathBuf,
2013        kek: Option<Arc<crate::encryption::Kek>>,
2014        admin_username: &str,
2015        admin_password: &str,
2016        lock: DatabaseFileLock,
2017    ) -> Result<Self> {
2018        crate::durable_file::create_directory_all(&root.join(TABLES_DIR))?;
2019        let meta_dek = crate::encryption::meta_dek_for(kek.as_deref());
2020
2021        // Build the initial catalog with require_auth = true and one admin user.
2022        let password_hash =
2023            crate::auth::hash_password(admin_password).map_err(MongrelError::Other)?;
2024        let mut cat = Catalog::empty();
2025        cat.require_auth = true;
2026        cat.next_user_id = 2;
2027        cat.users.push(crate::auth::UserEntry {
2028            id: 1,
2029            username: admin_username.to_string(),
2030            password_hash,
2031            roles: Vec::new(),
2032            is_admin: true,
2033            created_epoch: 0,
2034        });
2035        catalog::write_atomic(&root, &cat, meta_dek.as_ref())?;
2036
2037        // The handle is constructed already authenticated as the admin user
2038        // it just created — no separate verify step needed.
2039        let admin_principal = crate::auth::Principal {
2040            user_id: 1,
2041            created_epoch: 0,
2042            username: admin_username.to_string(),
2043            is_admin: true,
2044            roles: Vec::new(),
2045            permissions: Vec::new(),
2046        };
2047        Self::finish_open(
2048            root,
2049            cat,
2050            kek,
2051            meta_dek,
2052            false,
2053            None,
2054            None,
2055            Some(admin_principal),
2056            lock,
2057        )
2058    }
2059
2060    fn reject_existing_database(root: &Path) -> Result<()> {
2061        // Refuse to overwrite an existing database. If CATALOG exists, the
2062        // directory already contains a real database; replacing it destroys data.
2063        if root.join(catalog::CATALOG_FILENAME).exists() {
2064            return Err(MongrelError::InvalidArgument(format!(
2065                "database already exists at {}; use Database::open() to open it, \
2066                 or remove the directory first",
2067                root.display()
2068            )));
2069        }
2070        Ok(())
2071    }
2072
2073    fn open_inner(
2074        root: impl AsRef<Path>,
2075        kek: Option<Arc<crate::encryption::Kek>>,
2076        _meta_dek_override: Option<[u8; META_DEK_LEN]>,
2077    ) -> Result<Self> {
2078        Self::open_inner_with_lock_timeout(root, kek, None, 0)
2079    }
2080
2081    /// Internal recovery open for a staging directory explicitly marked as a
2082    /// read-only replica. It bypasses user authentication only so PITR can
2083    /// replay auth-mode and password transitions; it is not public API.
2084    pub(crate) fn open_replica_recovery_durable(
2085        root: &crate::durable_file::DurableRoot,
2086    ) -> Result<Self> {
2087        let (root, lock) = Self::begin_open_durable(root.try_clone()?, 0)?;
2088        Self::open_replica_recovery_inner(root, None, lock)
2089    }
2090
2091    #[cfg(feature = "encryption")]
2092    pub(crate) fn open_encrypted_replica_recovery_durable(
2093        root: &crate::durable_file::DurableRoot,
2094        passphrase: &str,
2095    ) -> Result<Self> {
2096        let (root_path, lock) = Self::begin_open_durable(root.try_clone()?, 0)?;
2097        let salt = read_encryption_salt(root)?;
2098        let kek = Arc::new(crate::encryption::Kek::derive(passphrase, &salt)?);
2099        Self::open_replica_recovery_inner(root_path, Some(kek), lock)
2100    }
2101
2102    fn open_replica_recovery_inner(
2103        root: PathBuf,
2104        kek: Option<Arc<crate::encryption::Kek>>,
2105        lock: DatabaseFileLock,
2106    ) -> Result<Self> {
2107        if !root.join(META_DIR).join("replica").is_file() {
2108            return Err(MongrelError::InvalidArgument(
2109                "recovery auth bypass requires a marked replica staging directory".into(),
2110            ));
2111        }
2112        let meta_dek = crate::encryption::meta_dek_for(kek.as_deref());
2113        let mut cat = catalog::read_durable(
2114            lock.durable_root.as_deref().ok_or_else(|| {
2115                MongrelError::Other("database root descriptor was not pinned".into())
2116            })?,
2117            meta_dek.as_ref(),
2118        )?
2119        .ok_or_else(|| MongrelError::NotFound(format!("no catalog found at {:?}", root)))?;
2120        let recovery_checkpoint = cat.clone();
2121        let wal_dek = crate::encryption::wal_dek_for(kek.as_deref());
2122        let recovery_records = crate::wal::SharedWal::replay_durable_with_dek(
2123            lock.durable_root.as_deref().ok_or_else(|| {
2124                MongrelError::Other("database root descriptor was not pinned".into())
2125            })?,
2126            wal_dek.as_ref(),
2127        )?;
2128        recover_ddl_from_records(
2129            &root,
2130            Some(lock.durable_root.as_deref().ok_or_else(|| {
2131                MongrelError::Other("database root descriptor was not pinned".into())
2132            })?),
2133            &mut cat,
2134            meta_dek.as_ref(),
2135            false,
2136            None,
2137            &recovery_records,
2138        )?;
2139        let principal = if cat.require_auth {
2140            cat.users
2141                .iter()
2142                .find(|user| user.is_admin)
2143                .and_then(|user| Self::resolve_principal_from_catalog(&cat, &user.username))
2144                .ok_or_else(|| {
2145                    MongrelError::Schema(
2146                        "authenticated replica catalog has no recoverable admin".into(),
2147                    )
2148                })?
2149                .into()
2150        } else {
2151            None
2152        };
2153        Self::finish_open(
2154            root,
2155            cat,
2156            kek,
2157            meta_dek,
2158            true,
2159            Some(recovery_checkpoint),
2160            Some(recovery_records),
2161            principal,
2162            lock,
2163        )
2164    }
2165
2166    /// Acquire an exclusive advisory lock on `f`, retrying on `EAGAIN`/`EWOULDBLOCK`
2167    /// until `timeout_ms` elapses, mirroring SQLite's `busy_timeout` semantics.
2168    ///
2169    /// `timeout_ms == 0` is the fail-fast path: a single `try_lock_exclusive` call,
2170    /// no retry, no sleep. Existing open paths rely on that fail-fast default for
2171    /// backwards compatibility — opt in with `OpenOptions::lock_timeout_ms`.
2172    ///
2173    /// Backoff schedule: 1ms → 10ms → 50ms → 50ms → ... until `timeout_ms`.
2174    /// Total elapsed (not just sleep time) is bounded by `timeout_ms`, so the
2175    /// caller never blocks past its budget even at the tail of a busy lock
2176    /// holder's lock-window.
2177    fn fs_lock_exclusive(f: &std::fs::File, timeout_ms: u32) -> std::io::Result<()> {
2178        use fs2::FileExt;
2179        if timeout_ms == 0 {
2180            return f.try_lock_exclusive();
2181        }
2182        // Per-call deadline so a single stray 50ms sleep can't overshoot the budget.
2183        let deadline =
2184            std::time::Instant::now() + std::time::Duration::from_millis(timeout_ms as u64);
2185        let mut next_sleep = std::time::Duration::from_millis(1);
2186        loop {
2187            match f.try_lock_exclusive() {
2188                Ok(()) => return Ok(()),
2189                Err(e) if e.kind() == std::io::ErrorKind::WouldBlock => {
2190                    let now = std::time::Instant::now();
2191                    if now >= deadline {
2192                        return Err(std::io::Error::new(
2193                            std::io::ErrorKind::WouldBlock,
2194                            format!("could not acquire database lock within {timeout_ms}ms"),
2195                        ));
2196                    }
2197                    let remaining = deadline - now;
2198                    let sleep = next_sleep.min(remaining);
2199                    std::thread::sleep(sleep);
2200                    // Cap the per-iteration sleep so a single back-off step
2201                    // never overshoots the remaining budget.
2202                    next_sleep = next_sleep
2203                        .saturating_mul(10)
2204                        .min(std::time::Duration::from_millis(50));
2205                }
2206                Err(e) => return Err(e),
2207            }
2208        }
2209    }
2210
2211    #[allow(clippy::too_many_arguments)]
2212    fn finish_open(
2213        root: PathBuf,
2214        cat: Catalog,
2215        kek: Option<Arc<crate::encryption::Kek>>,
2216        meta_dek: Option<[u8; META_DEK_LEN]>,
2217        existing: bool,
2218        recovery_checkpoint: Option<Catalog>,
2219        recovery_records: Option<Vec<crate::wal::Record>>,
2220        principal: Option<crate::auth::Principal>,
2221        lock: DatabaseFileLock,
2222    ) -> Result<Self> {
2223        let durable_root = Arc::clone(lock.durable_root.as_ref().ok_or_else(|| {
2224            MongrelError::Other("database root descriptor was not pinned".into())
2225        })?);
2226        let read_only = if existing {
2227            match durable_root.open_regular(Path::new(META_DIR).join("replica")) {
2228                Ok(_) => true,
2229                Err(error) if error.kind() == std::io::ErrorKind::NotFound => false,
2230                Err(error) => return Err(error.into()),
2231            }
2232        } else {
2233            false
2234        };
2235        let recovered_catalog = cat;
2236        let mut cat = recovered_catalog.clone();
2237        let abandoned = if existing && !read_only {
2238            let abandoned = cat
2239                .tables
2240                .iter()
2241                .filter(|entry| matches!(entry.state, TableState::Building { .. }))
2242                .map(|entry| entry.table_id)
2243                .collect::<Vec<_>>();
2244            for entry in &mut cat.tables {
2245                if abandoned.contains(&entry.table_id) {
2246                    entry.state = TableState::Dropped {
2247                        at_epoch: cat.db_epoch,
2248                    };
2249                }
2250            }
2251            abandoned
2252        } else {
2253            Vec::new()
2254        };
2255        let wal_dek = crate::encryption::wal_dek_for(kek.as_deref());
2256        let recovery_records = match (existing, recovery_records) {
2257            (true, Some(records)) => records,
2258            (true, None) => {
2259                return Err(MongrelError::Other(
2260                    "existing open has no validated WAL recovery plan".into(),
2261                ))
2262            }
2263            (false, _) => Vec::new(),
2264        };
2265        let (history_epochs, history_start) =
2266            read_history_retention(&durable_root, Epoch(cat.db_epoch))?;
2267        let open_generation = if existing {
2268            let checkpoint = recovery_checkpoint.as_ref().ok_or_else(|| {
2269                MongrelError::Other("existing open has no catalog recovery checkpoint".into())
2270            })?;
2271            let recovered_table_ids = cat
2272                .tables
2273                .iter()
2274                .filter(|entry| {
2275                    checkpoint
2276                        .tables
2277                        .iter()
2278                        .all(|checkpoint| checkpoint.table_id != entry.table_id)
2279                })
2280                .map(|entry| entry.table_id)
2281                .collect::<HashSet<_>>();
2282            let reconciled_table_ids = cat
2283                .tables
2284                .iter()
2285                .filter(|entry| {
2286                    checkpoint
2287                        .tables
2288                        .iter()
2289                        .find(|checkpoint| checkpoint.table_id == entry.table_id)
2290                        .is_some_and(|checkpoint| {
2291                            crate::wal::DdlOp::encode_schema(&checkpoint.schema).ok()
2292                                != crate::wal::DdlOp::encode_schema(&entry.schema).ok()
2293                        })
2294                })
2295                .map(|entry| entry.table_id)
2296                .collect::<HashSet<_>>();
2297            validate_shared_wal_recovery_plan(
2298                &durable_root,
2299                &cat,
2300                &recovered_table_ids,
2301                &reconciled_table_ids,
2302                meta_dek.as_ref(),
2303                kek.clone(),
2304                &recovery_records,
2305            )?;
2306            let retained_generation = recovery_records
2307                .iter()
2308                .filter(|record| record.txn_id != crate::wal::SYSTEM_TXN_ID)
2309                .map(|record| record.txn_id >> 32)
2310                .max()
2311                .unwrap_or(0);
2312            let head_generation =
2313                crate::wal::SharedWal::durable_open_generation(&durable_root, wal_dek.as_ref())?;
2314            let durable_floor = match head_generation {
2315                Some(head) if retained_generation > head => {
2316                    return Err(MongrelError::CorruptWal {
2317                        offset: retained_generation,
2318                        reason: format!(
2319                            "retained transaction generation {retained_generation} exceeds WAL head generation {head}"
2320                        ),
2321                    })
2322                }
2323                Some(head) => head,
2324                None => retained_generation,
2325            };
2326            let stored = catalog::read_generation(&durable_root)?;
2327            if stored.is_some_and(|generation| generation < durable_floor) {
2328                return Err(MongrelError::Other(format!(
2329                    "open-generation {stored:?} precedes durable WAL generation {durable_floor}"
2330                )));
2331            }
2332            let bumped = stored
2333                .unwrap_or(durable_floor)
2334                .max(durable_floor)
2335                .checked_add(1)
2336                .ok_or_else(|| MongrelError::Full("open-generation namespace exhausted".into()))?;
2337            if bumped > u32::MAX as u64 {
2338                return Err(MongrelError::Full(
2339                    "open-generation namespace exhausted".into(),
2340                ));
2341            }
2342            bumped
2343        } else {
2344            0
2345        };
2346        let principal = if cat.require_auth {
2347            let supplied = principal.as_ref().ok_or(MongrelError::AuthRequired)?;
2348            Some(
2349                Self::resolve_bound_principal_from_catalog(&cat, supplied)
2350                    .ok_or(MongrelError::AuthRequired)?,
2351            )
2352        } else {
2353            principal
2354        };
2355        let mut table_roots = HashMap::<u64, Arc<crate::durable_file::DurableRoot>>::new();
2356        if existing {
2357            for entry in &cat.tables {
2358                if !matches!(entry.state, TableState::Live) {
2359                    continue;
2360                }
2361                match durable_root
2362                    .open_directory(Path::new(TABLES_DIR).join(entry.table_id.to_string()))
2363                {
2364                    Ok(root) => {
2365                        table_roots.insert(entry.table_id, Arc::new(root));
2366                    }
2367                    Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
2368                    Err(error) => return Err(error.into()),
2369                }
2370            }
2371        }
2372
2373        // No database-tree mutation occurs above this point. DDL, row payloads,
2374        // immutable runs, auth state, retention, and generation state have all
2375        // been validated against the authoritative recovered catalog.
2376        if existing {
2377            let mut applied = recovery_checkpoint.ok_or_else(|| {
2378                MongrelError::Other("existing open has no catalog recovery checkpoint".into())
2379            })?;
2380            recover_ddl_from_records(
2381                &root,
2382                Some(&durable_root),
2383                &mut applied,
2384                meta_dek.as_ref(),
2385                true,
2386                Some(&table_roots),
2387                &recovery_records,
2388            )?;
2389            let catalog_value = |catalog: &Catalog| {
2390                serde_json::to_value(catalog)
2391                    .map_err(|error| MongrelError::Other(format!("catalog compare: {error}")))
2392            };
2393            if catalog_value(&applied)? != catalog_value(&recovered_catalog)? {
2394                return Err(MongrelError::CorruptWal {
2395                    offset: 0,
2396                    reason: "validated and applied DDL recovery plans differ".into(),
2397                });
2398            }
2399            if catalog_value(&cat)? != catalog_value(&applied)? {
2400                catalog::write_atomic(&root, &cat, meta_dek.as_ref())?;
2401            }
2402            validate_catalog_table_storage(&durable_root, &cat, meta_dek.as_ref())?;
2403            if !read_only {
2404                sweep_unreferenced_table_dirs(&root, &cat)?;
2405            }
2406            match durable_root.remove_directory_all(Path::new(META_DIR).join("backup-pins")) {
2407                Ok(()) => {}
2408                Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
2409                Err(error) => return Err(error.into()),
2410            }
2411        }
2412
2413        let epoch = Arc::new(EpochAuthority::new(cat.db_epoch));
2414        let snapshots = Arc::new(SnapshotRegistry::new());
2415        snapshots.configure_history(history_epochs, history_start);
2416        let page_cache = Arc::new(crate::cache::Sharded::new(
2417            crate::cache::CACHE_SHARDS,
2418            || {
2419                crate::cache::PageCache::new(
2420                    crate::engine::PAGE_CACHE_CAPACITY / crate::cache::CACHE_SHARDS as u64,
2421                )
2422            },
2423        ));
2424        let decoded_cache = Arc::new(crate::cache::Sharded::new(
2425            crate::cache::CACHE_SHARDS,
2426            || {
2427                crate::cache::DecodedPageCache::new(
2428                    crate::engine::DECODED_CACHE_CAPACITY / crate::cache::CACHE_SHARDS as u64,
2429                )
2430            },
2431        ));
2432        let commit_lock = Arc::new(Mutex::new(()));
2433        let shared_wal = Arc::new(Mutex::new(if existing {
2434            crate::wal::SharedWal::open_durable_root_validated(
2435                Arc::clone(&durable_root),
2436                Epoch(cat.db_epoch),
2437                wal_dek.clone(),
2438                Some(&recovery_records),
2439            )?
2440        } else {
2441            crate::wal::SharedWal::create_with_durable_root(
2442                Arc::clone(&durable_root),
2443                Epoch(cat.db_epoch),
2444                wal_dek.clone(),
2445            )?
2446        }));
2447        // Shared write-path state handed to every mounted table so single-table
2448        // `put`/`commit` writes route through the one shared WAL, the one group-
2449        // commit coordinator, and the one poison flag (B1).
2450        let poisoned = Arc::new(std::sync::atomic::AtomicBool::new(false));
2451        let group = Arc::new(crate::txn::GroupCommit::new(
2452            shared_wal.lock().durable_seq(),
2453        ));
2454        let (change_wake, _change_rx) = tokio::sync::broadcast::channel(256);
2455        // Final base value is set after the open-generation bump below; tables
2456        // only draw ids once the user issues a write (post-open), so the
2457        // placeholder is never observed.
2458        let txn_ids = Arc::new(Mutex::new(1u64));
2459        let _ = abandoned;
2460
2461        // Build the shared auth state early — it's cloned into every mounted
2462        // Table's SharedCtx so the Table layer can enforce permissions without
2463        // a reference back to Database. The `require_auth` flag is mirrored
2464        // from the catalog; `enable_auth` / `refresh_principal` update it live.
2465        let auth_state = crate::auth_state::AuthState::new(cat.require_auth, principal.clone());
2466        let security_coordinator = security_coordinator(&root, cat.security_version);
2467        let auth_checker: Option<Arc<dyn crate::auth_state::TableAuthChecker>> = Some(Arc::new(
2468            crate::auth_state::DefaultTableAuthChecker::new(auth_state.clone()),
2469        ));
2470
2471        // Open every live table against the shared context. Mounted tables have
2472        // no private WAL (B1) — `open_in` just loads the manifest/runs and
2473        // advances the shared epoch authority to its manifest epoch, so the
2474        // final shared watermark is the max across all tables. All of a mounted
2475        // table's committed records are replayed below from the shared WAL.
2476        let mut tables: HashMap<u64, TableHandle> = HashMap::new();
2477        for entry in &cat.tables {
2478            if !matches!(entry.state, TableState::Live) {
2479                continue;
2480            }
2481            let table_root = match table_roots.remove(&entry.table_id) {
2482                Some(root) => root,
2483                None => Arc::new(
2484                    durable_root
2485                        .open_directory(Path::new(TABLES_DIR).join(entry.table_id.to_string()))?,
2486                ),
2487            };
2488            let tdir = table_root.io_path()?;
2489            let ctx = SharedCtx {
2490                root_guard: Some(table_root),
2491                epoch: Arc::clone(&epoch),
2492                page_cache: Arc::clone(&page_cache),
2493                decoded_cache: Arc::clone(&decoded_cache),
2494                snapshots: Arc::clone(&snapshots),
2495                kek: kek.clone(),
2496                commit_lock: Arc::clone(&commit_lock),
2497                shared: Some(crate::engine::SharedWalCtx {
2498                    wal: Arc::clone(&shared_wal),
2499                    group: Arc::clone(&group),
2500                    poisoned: Arc::clone(&poisoned),
2501                    txn_ids: Arc::clone(&txn_ids),
2502                    change_wake: change_wake.clone(),
2503                }),
2504                table_name: Some(entry.name.clone()),
2505                auth: auth_checker.clone(),
2506                read_only,
2507            };
2508            let t = Table::open_in(&tdir, ctx)?;
2509            tables.insert(entry.table_id, TableHandle::new(t));
2510        }
2511
2512        // Recover transaction writes from the shared WAL (spec §15). This is the
2513        // single durability source for mounted tables: it applies every committed
2514        // record — both single-table `Table::commit` writes and cross-table
2515        // transactions — gated by each table's `flushed_epoch` (records already
2516        // durable in a run are not re-applied).
2517        if existing {
2518            recover_shared_wal(&durable_root, &tables, &cat, &epoch, &recovery_records)?;
2519            reconcile_recovered_table_metadata(&tables, epoch.visible())?;
2520            if read_only {
2521                crate::replication::reconcile_replica_epoch_durable(
2522                    &durable_root,
2523                    epoch.visible().0,
2524                )?;
2525            }
2526            // P3.4: sweep stale `_txn/<txn_id>/` dirs left by aborted/crashed
2527            // large transactions (spec §8.5, review fix #14).
2528            sweep_pending_txn_dirs(&root, &cat);
2529        }
2530
2531        // Persist only after all semantic recovery and table mounting succeeds.
2532        catalog::write_generation(&durable_root, open_generation)?;
2533        shared_wal.lock().seal_open_generation(open_generation)?;
2534        crate::replication::replication_identity_durable(&durable_root)?;
2535        let next_txn_id = (open_generation << 32) | 1;
2536        // Seed the shared txn-id allocator now that the generation is final.
2537        *txn_ids.lock() = next_txn_id;
2538
2539        Ok(Self {
2540            root,
2541            durable_root,
2542            read_only,
2543            catalog: RwLock::new(cat),
2544            security_coordinator,
2545            security_catalog_disk_reads: AtomicU64::new(0),
2546            rls_cache: Mutex::new(RlsCache::default()),
2547            epoch,
2548            snapshots,
2549            page_cache,
2550            decoded_cache,
2551            commit_lock,
2552            shared_wal,
2553            next_txn_id: txn_ids,
2554            tables: RwLock::new(tables),
2555            kek,
2556            ddl_lock: Mutex::new(()),
2557            meta_dek,
2558            conflicts: crate::txn::ConflictIndex::new(),
2559            active_txns: crate::txn::ActiveTxns::new(),
2560            poisoned,
2561            group,
2562            spill_threshold: std::sync::atomic::AtomicU64::new(64 * 1024 * 1024),
2563            active_spills: Arc::new(crate::retention::ActiveSpills::new()),
2564            replication_barrier: parking_lot::RwLock::new(()),
2565            replication_wal_retention_segments: AtomicUsize::new(0),
2566            backup_pins: Arc::new(Mutex::new(HashMap::new())),
2567            spill_hook: Mutex::new(None),
2568            security_commit_hook: Mutex::new(None),
2569            catalog_commit_hook: Mutex::new(None),
2570            backup_hook: Mutex::new(None),
2571            replication_hook: Mutex::new(None),
2572            trigger_recursive: AtomicBool::new(TriggerConfig::default().recursive_triggers),
2573            trigger_max_depth: AtomicU32::new(TriggerConfig::default().max_depth),
2574            trigger_max_loop_iterations: AtomicU32::new(
2575                TriggerConfig::default().max_loop_iterations,
2576            ),
2577            _lock: Some(lock),
2578            notify: {
2579                let (tx, _rx) = tokio::sync::broadcast::channel(256);
2580                tx
2581            },
2582            change_wake,
2583            principal: RwLock::new(principal),
2584            auth_state,
2585        })
2586    }
2587
2588    /// The current reader-visible epoch.
2589    pub fn visible_epoch(&self) -> Epoch {
2590        self.epoch.visible()
2591    }
2592
2593    /// Clone the in-memory catalog (for diagnostics / tests).
2594    pub fn catalog_snapshot(&self) -> Catalog {
2595        self.catalog.read().clone()
2596    }
2597
2598    /// Read SQLite-compatible application metadata persisted in the catalog.
2599    pub fn sql_pragma_i64(&self, key: &str) -> Result<Option<i64>> {
2600        let catalog = self.catalog.read();
2601        match key {
2602            "user_version" => Ok(catalog.user_version),
2603            "application_id" => Ok(catalog.application_id),
2604            _ => Err(MongrelError::InvalidArgument(format!(
2605                "unsupported persistent SQL pragma {key:?}"
2606            ))),
2607        }
2608    }
2609
2610    /// Persist SQLite-compatible application metadata and return its exact
2611    /// publication epoch. An unchanged value performs no durable write.
2612    pub fn set_sql_pragma_i64_with_epoch(&self, key: &str, value: i64) -> Result<Option<Epoch>> {
2613        self.set_sql_pragma_i64_with_epoch_inner(key, value, None)
2614    }
2615
2616    pub fn set_sql_pragma_i64_with_epoch_controlled<F>(
2617        &self,
2618        key: &str,
2619        value: i64,
2620        mut before_commit: F,
2621    ) -> Result<Option<Epoch>>
2622    where
2623        F: FnMut() -> Result<()>,
2624    {
2625        self.set_sql_pragma_i64_with_epoch_inner(key, value, Some(&mut before_commit))
2626    }
2627
2628    fn set_sql_pragma_i64_with_epoch_inner(
2629        &self,
2630        key: &str,
2631        value: i64,
2632        before_commit: Option<&mut dyn FnMut() -> Result<()>>,
2633    ) -> Result<Option<Epoch>> {
2634        use crate::wal::DdlOp;
2635
2636        self.require(&crate::auth::Permission::Ddl)?;
2637        if self.read_only {
2638            return Err(MongrelError::ReadOnlyReplica);
2639        }
2640        if self.poisoned.load(Ordering::Relaxed) {
2641            return Err(MongrelError::Other(
2642                "database poisoned by fsync error".into(),
2643            ));
2644        }
2645        let _ddl = self.ddl_lock.lock();
2646        let _security_write = self.security_write()?;
2647        self.require(&crate::auth::Permission::Ddl)?;
2648        let mut next_catalog = self.catalog.read().clone();
2649        let target = match key {
2650            "user_version" => &mut next_catalog.user_version,
2651            "application_id" => &mut next_catalog.application_id,
2652            _ => {
2653                return Err(MongrelError::InvalidArgument(format!(
2654                    "unsupported persistent SQL pragma {key:?}"
2655                )))
2656            }
2657        };
2658        if *target == Some(value) {
2659            return Ok(None);
2660        }
2661        *target = Some(value);
2662
2663        let _commit = self.commit_lock.lock();
2664        let epoch = self.epoch.bump_assigned();
2665        let mut epoch_guard = EpochGuard::new(self.epoch.as_ref(), epoch);
2666        let txn_id = self.alloc_txn_id()?;
2667        next_catalog.db_epoch = next_catalog.db_epoch.max(epoch.0);
2668        let commit_seq = {
2669            let mut wal = self.shared_wal.lock();
2670            if let Some(before_commit) = before_commit {
2671                before_commit()?;
2672            }
2673            let append: Result<u64> = (|| {
2674                wal.append(
2675                    txn_id,
2676                    WAL_TABLE_ID,
2677                    crate::wal::Op::Ddl(DdlOp::SetSqlPragma {
2678                        key: key.to_string(),
2679                        value,
2680                    }),
2681                )?;
2682                append_catalog_snapshot(&mut wal, txn_id, &next_catalog)?;
2683                wal.append_commit(txn_id, epoch, &[])
2684            })();
2685            append.map_err(|error| self.commit_outcome_unknown(epoch, error))?
2686        };
2687        self.await_durable_commit(commit_seq, epoch)?;
2688        let checkpoint = self.checkpoint_catalog_after_durable(next_catalog);
2689        self.finish_durable_publish(epoch, &mut epoch_guard, checkpoint)?;
2690        Ok(Some(epoch))
2691    }
2692
2693    pub fn materialized_view(&self, name: &str) -> Option<crate::catalog::MaterializedViewEntry> {
2694        self.catalog
2695            .read()
2696            .materialized_views
2697            .iter()
2698            .find(|definition| definition.name == name)
2699            .cloned()
2700    }
2701
2702    pub fn materialized_views(&self) -> Vec<crate::catalog::MaterializedViewEntry> {
2703        self.catalog.read().materialized_views.clone()
2704    }
2705
2706    pub fn security_catalog(&self) -> crate::security::SecurityCatalog {
2707        self.catalog.read().security.clone()
2708    }
2709
2710    pub fn security_active_for(&self, table: &str) -> bool {
2711        self.catalog.read().security.table_has_security(table)
2712    }
2713
2714    fn refresh_security_catalog_if_stale(&self, expected_version: u64) -> Result<()> {
2715        if self.catalog.read().security_version == expected_version {
2716            return Ok(());
2717        }
2718        self.security_catalog_disk_reads
2719            .fetch_add(1, Ordering::Relaxed);
2720        let fresh = catalog::read_durable(&self.durable_root, self.meta_dek.as_ref())?
2721            .ok_or_else(|| MongrelError::NotFound("catalog vanished during write".into()))?;
2722        let principal = self.principal.read().clone();
2723        let principal = if fresh.require_auth {
2724            principal
2725                .as_ref()
2726                .and_then(|principal| Self::resolve_bound_principal_from_catalog(&fresh, principal))
2727        } else {
2728            principal
2729        };
2730        self.auth_state.set_require_auth(fresh.require_auth);
2731        *self.catalog.write() = fresh;
2732        *self.principal.write() = principal.clone();
2733        self.auth_state.set_principal(principal);
2734        Ok(())
2735    }
2736
2737    fn security_write(&self) -> Result<parking_lot::RwLockWriteGuard<'_, ()>> {
2738        let guard = self.security_coordinator.gate.write();
2739        let version = self.security_coordinator.version.load(Ordering::Acquire);
2740        self.refresh_security_catalog_if_stale(version)?;
2741        Ok(guard)
2742    }
2743
2744    /// Commit an exact catalog image through the shared WAL, then checkpoint it.
2745    /// The WAL image is the authoritative PITR and replication delta; CATALOG is
2746    /// only its restart checkpoint.
2747    fn publish_catalog_candidate(
2748        &self,
2749        catalog: Catalog,
2750        epoch: Epoch,
2751        epoch_guard: &mut EpochGuard<'_>,
2752        before_publish: Option<&mut dyn FnMut() -> Result<()>>,
2753    ) -> Result<()> {
2754        self.publish_catalog_candidate_with_prelude(
2755            catalog,
2756            epoch,
2757            epoch_guard,
2758            before_publish,
2759            Vec::new(),
2760        )
2761    }
2762
2763    fn publish_catalog_candidate_with_prelude(
2764        &self,
2765        catalog: Catalog,
2766        epoch: Epoch,
2767        epoch_guard: &mut EpochGuard<'_>,
2768        mut before_publish: Option<&mut dyn FnMut() -> Result<()>>,
2769        prelude: Vec<(u64, crate::wal::Op)>,
2770    ) -> Result<()> {
2771        use crate::wal::DdlOp;
2772
2773        if self.read_only {
2774            return Err(MongrelError::ReadOnlyReplica);
2775        }
2776        if self.poisoned.load(Ordering::Relaxed) {
2777            return Err(MongrelError::Other(
2778                "database poisoned by fsync error".into(),
2779            ));
2780        }
2781        if let Some(before_publish) = before_publish.as_mut() {
2782            (**before_publish)()?;
2783        }
2784        if catalog.db_epoch != epoch.0 {
2785            return Err(MongrelError::InvalidArgument(format!(
2786                "catalog epoch {} does not match commit epoch {}",
2787                catalog.db_epoch, epoch.0
2788            )));
2789        }
2790        {
2791            let current = self.catalog.read();
2792            validate_catalog_transition(&current, &catalog)?;
2793        }
2794        validate_recovered_catalog(&catalog)?;
2795        let catalog_json = DdlOp::encode_catalog(&catalog)?;
2796        let txn_id = self.alloc_txn_id()?;
2797        let commit_seq = {
2798            let mut wal = self.shared_wal.lock();
2799            let append: Result<u64> = (|| {
2800                for (table_id, op) in prelude {
2801                    wal.append(txn_id, table_id, op)?;
2802                }
2803                wal.append(
2804                    txn_id,
2805                    WAL_TABLE_ID,
2806                    crate::wal::Op::Ddl(DdlOp::CatalogSnapshot { catalog_json }),
2807                )?;
2808                wal.append_commit(txn_id, epoch, &[])
2809            })();
2810            append.map_err(|error| self.commit_outcome_unknown(epoch, error))?
2811        };
2812        self.await_durable_commit(commit_seq, epoch)?;
2813        let checkpoint = self.checkpoint_catalog_after_durable(catalog);
2814        self.finish_durable_publish(epoch, epoch_guard, checkpoint)
2815    }
2816
2817    /// A WAL commit is already durable. Publish the matching catalog in memory
2818    /// even when its checkpoint rewrite fails; recovery can rebuild the file,
2819    /// while the live handle must never continue with pre-commit metadata.
2820    fn checkpoint_catalog_after_durable(&self, catalog: Catalog) -> Result<()> {
2821        let checkpoint = catalog::write_atomic(&self.root, &catalog, self.meta_dek.as_ref());
2822        let version = catalog.security_version;
2823        let principal = self.principal.read().clone();
2824        let principal = if catalog.require_auth {
2825            principal.as_ref().and_then(|principal| {
2826                Self::resolve_bound_principal_from_catalog(&catalog, principal)
2827            })
2828        } else {
2829            principal
2830        };
2831        *self.catalog.write() = catalog;
2832        self.security_coordinator
2833            .version
2834            .store(version, Ordering::Release);
2835        self.auth_state
2836            .set_require_auth(self.catalog.read().require_auth);
2837        *self.principal.write() = principal.clone();
2838        self.auth_state.set_principal(principal);
2839        checkpoint
2840    }
2841
2842    fn finish_durable_publish(
2843        &self,
2844        epoch: Epoch,
2845        epoch_guard: &mut EpochGuard<'_>,
2846        post_step: Result<()>,
2847    ) -> Result<()> {
2848        self.epoch.publish_in_order(epoch);
2849        epoch_guard.disarm();
2850        match post_step {
2851            Ok(()) => Ok(()),
2852            Err(error) => {
2853                self.poisoned.store(true, Ordering::Relaxed);
2854                Err(MongrelError::DurableCommit {
2855                    epoch: epoch.0,
2856                    message: error.to_string(),
2857                })
2858            }
2859        }
2860    }
2861
2862    /// Wait for a commit marker to reach stable storage. A failed append/fsync
2863    /// acknowledgement is ambiguous, so poison the live handle and preserve
2864    /// the assigned epoch in a structured unknown-outcome error.
2865    fn await_durable_commit(&self, commit_seq: u64, epoch: Epoch) -> Result<()> {
2866        match self.group.await_durable(&self.shared_wal, commit_seq) {
2867            Ok(()) => Ok(()),
2868            Err(error) => {
2869                self.poisoned.store(true, Ordering::Relaxed);
2870                Err(MongrelError::CommitOutcomeUnknown {
2871                    epoch: epoch.0,
2872                    message: error.to_string(),
2873                })
2874            }
2875        }
2876    }
2877
2878    fn commit_outcome_unknown(&self, epoch: Epoch, error: impl std::fmt::Display) -> MongrelError {
2879        self.poisoned.store(true, Ordering::Relaxed);
2880        MongrelError::CommitOutcomeUnknown {
2881            epoch: epoch.0,
2882            message: error.to_string(),
2883        }
2884    }
2885
2886    /// Persist a complete validated RLS/masking catalog through the WAL.
2887    pub fn set_security_catalog(&self, security: crate::security::SecurityCatalog) -> Result<()> {
2888        self.set_security_catalog_as_with_epoch(security, None)
2889            .map(|_| ())
2890    }
2891
2892    /// Persist security policy changes on behalf of an explicit request principal.
2893    pub fn set_security_catalog_as(
2894        &self,
2895        security: crate::security::SecurityCatalog,
2896        principal: Option<&crate::auth::Principal>,
2897    ) -> Result<()> {
2898        self.set_security_catalog_as_with_epoch(security, principal)
2899            .map(|_| ())
2900    }
2901
2902    /// Persist security policy changes and return the exact publication epoch.
2903    pub fn set_security_catalog_as_with_epoch(
2904        &self,
2905        security: crate::security::SecurityCatalog,
2906        principal: Option<&crate::auth::Principal>,
2907    ) -> Result<Epoch> {
2908        self.set_security_catalog_as_with_epoch_inner(security, principal, None)
2909    }
2910
2911    /// Persist security policy changes, entering the commit fence immediately
2912    /// before the first WAL record can become visible to recovery.
2913    pub fn set_security_catalog_as_with_epoch_controlled<F>(
2914        &self,
2915        security: crate::security::SecurityCatalog,
2916        principal: Option<&crate::auth::Principal>,
2917        mut before_commit: F,
2918    ) -> Result<Epoch>
2919    where
2920        F: FnMut() -> Result<()>,
2921    {
2922        self.set_security_catalog_as_with_epoch_inner(security, principal, Some(&mut before_commit))
2923    }
2924
2925    fn set_security_catalog_as_with_epoch_inner(
2926        &self,
2927        security: crate::security::SecurityCatalog,
2928        principal: Option<&crate::auth::Principal>,
2929        before_commit: Option<&mut dyn FnMut() -> Result<()>>,
2930    ) -> Result<Epoch> {
2931        use crate::wal::DdlOp;
2932        use std::sync::atomic::Ordering;
2933
2934        self.require_for(principal, &crate::auth::Permission::Admin)?;
2935        if self.poisoned.load(Ordering::Relaxed) {
2936            return Err(MongrelError::Other(
2937                "database poisoned by fsync error".into(),
2938            ));
2939        }
2940        let _ddl = self.ddl_lock.lock();
2941        // DDL serializes first; write-path order after that is security gate ->
2942        // commit lock -> shared WAL.
2943        let _security_write = self.security_write()?;
2944        self.require_for(principal, &crate::auth::Permission::Admin)?;
2945        let mut next_catalog = self.catalog.read().clone();
2946        validate_security_catalog(&next_catalog, &security)?;
2947        let payload = DdlOp::encode_security(&security)?;
2948        let _commit = self.commit_lock.lock();
2949        let epoch = self.epoch.bump_assigned();
2950        let mut epoch_guard = EpochGuard::new(self.epoch.as_ref(), epoch);
2951        let txn_id = self.alloc_txn_id()?;
2952        next_catalog.security = security;
2953        advance_security_version(&mut next_catalog)?;
2954        next_catalog.db_epoch = next_catalog.db_epoch.max(epoch.0);
2955        let commit_seq = {
2956            let mut wal = self.shared_wal.lock();
2957            if let Some(before_commit) = before_commit {
2958                before_commit()?;
2959            }
2960            let append: Result<u64> = (|| {
2961                wal.append(
2962                    txn_id,
2963                    WAL_TABLE_ID,
2964                    crate::wal::Op::Ddl(DdlOp::SetSecurityCatalog {
2965                        security_json: payload,
2966                    }),
2967                )?;
2968                append_catalog_snapshot(&mut wal, txn_id, &next_catalog)?;
2969                wal.append_commit(txn_id, epoch, &[])
2970            })();
2971            append.map_err(|error| self.commit_outcome_unknown(epoch, error))?
2972        };
2973        self.await_durable_commit(commit_seq, epoch)?;
2974        let checkpoint = self.checkpoint_catalog_after_durable(next_catalog);
2975        self.finish_durable_publish(epoch, &mut epoch_guard, checkpoint)?;
2976        Ok(epoch)
2977    }
2978
2979    pub fn require_for(
2980        &self,
2981        principal: Option<&crate::auth::Principal>,
2982        permission: &crate::auth::Permission,
2983    ) -> Result<()> {
2984        let Some(principal) = principal else {
2985            return self.require(permission);
2986        };
2987        let resolved;
2988        let principal = if self.auth_state.require_auth() || principal.user_id != 0 {
2989            resolved = Self::resolve_bound_principal_from_catalog(&self.catalog.read(), principal)
2990                .ok_or(MongrelError::AuthRequired)?;
2991            &resolved
2992        } else {
2993            principal
2994        };
2995        #[cfg(test)]
2996        TABLE_PERMISSION_DECISIONS.with(|decisions| decisions.set(decisions.get() + 1));
2997        if principal.has_permission(permission) {
2998            Ok(())
2999        } else {
3000            Err(MongrelError::PermissionDenied {
3001                required: permission.clone(),
3002                principal: principal.username.clone(),
3003            })
3004        }
3005    }
3006
3007    /// Recheck the exact operation principal while the caller holds the
3008    /// security gate. This deliberately performs no refresh or nested gate
3009    /// acquisition.
3010    fn require_exact_principal_current(
3011        &self,
3012        principal: Option<&crate::auth::Principal>,
3013        permission: &crate::auth::Permission,
3014    ) -> Result<()> {
3015        let catalog = self.catalog.read();
3016        if !catalog.require_auth {
3017            return Ok(());
3018        }
3019        let supplied = principal.ok_or(MongrelError::AuthRequired)?;
3020        let current = Self::resolve_bound_principal_from_catalog(&catalog, supplied)
3021            .ok_or(MongrelError::AuthRequired)?;
3022        if current.has_permission(permission) {
3023            Ok(())
3024        } else {
3025            Err(MongrelError::PermissionDenied {
3026                required: permission.clone(),
3027                principal: current.username,
3028            })
3029        }
3030    }
3031
3032    pub(crate) fn with_exact_principal_current<T, F>(
3033        &self,
3034        principal: Option<&crate::auth::Principal>,
3035        permission: &crate::auth::Permission,
3036        operation: F,
3037    ) -> Result<T>
3038    where
3039        F: FnOnce() -> Result<T>,
3040    {
3041        let _security = self.security_coordinator.gate.read();
3042        self.require_exact_principal_current(principal, permission)?;
3043        operation()
3044    }
3045
3046    pub fn principal_snapshot(&self) -> Option<crate::auth::Principal> {
3047        self.principal.read().clone()
3048    }
3049
3050    #[cfg(test)]
3051    pub(crate) fn set_cached_principal_for_test(&self, principal: Option<crate::auth::Principal>) {
3052        *self.principal.write() = principal.clone();
3053        self.auth_state.set_principal(principal);
3054    }
3055
3056    pub fn require_columns_for(
3057        &self,
3058        table: &str,
3059        operation: crate::auth::ColumnOperation,
3060        column_ids: &[u16],
3061        principal: Option<&crate::auth::Principal>,
3062    ) -> Result<()> {
3063        if principal.is_none() && !self.auth_state.require_auth() {
3064            return Ok(());
3065        }
3066        let cached = self.principal.read().clone();
3067        let principal = principal.or(cached.as_ref());
3068        let Some(principal) = principal else {
3069            let permission = match operation {
3070                crate::auth::ColumnOperation::Select => crate::auth::Permission::Select {
3071                    table: table.to_string(),
3072                },
3073                crate::auth::ColumnOperation::Insert => crate::auth::Permission::Insert {
3074                    table: table.to_string(),
3075                },
3076                crate::auth::ColumnOperation::Update => crate::auth::Permission::Update {
3077                    table: table.to_string(),
3078                },
3079            };
3080            return self.require(&permission);
3081        };
3082        let catalog = self.catalog.read();
3083        let resolved;
3084        let principal = if catalog.require_auth || principal.user_id != 0 {
3085            resolved = Self::resolve_bound_principal_from_catalog(&catalog, principal)
3086                .ok_or(MongrelError::AuthRequired)?;
3087            &resolved
3088        } else {
3089            principal
3090        };
3091        let schema = &catalog
3092            .live(table)
3093            .ok_or_else(|| MongrelError::NotFound(format!("table {table:?} not found")))?
3094            .schema;
3095        Self::require_columns_for_principal(table, schema, operation, column_ids, principal)
3096    }
3097
3098    fn require_columns_for_principal(
3099        table: &str,
3100        schema: &Schema,
3101        operation: crate::auth::ColumnOperation,
3102        column_ids: &[u16],
3103        principal: &crate::auth::Principal,
3104    ) -> Result<()> {
3105        #[cfg(test)]
3106        WRITE_PERMISSION_DECISIONS.with(|decisions| decisions.set(decisions.get() + 1));
3107        match principal.column_access(table, operation) {
3108            crate::auth::ColumnAccess::All => Ok(()),
3109            crate::auth::ColumnAccess::Columns(allowed) => {
3110                let denied = column_ids.iter().find_map(|column_id| {
3111                    schema
3112                        .columns
3113                        .iter()
3114                        .find(|column| column.id == *column_id)
3115                        .filter(|column| !allowed.contains(&column.name))
3116                });
3117                if denied.is_none() {
3118                    Ok(())
3119                } else {
3120                    Err(MongrelError::PermissionDenied {
3121                        required: match operation {
3122                            crate::auth::ColumnOperation::Select => {
3123                                crate::auth::Permission::SelectColumns {
3124                                    table: table.to_string(),
3125                                    columns: denied
3126                                        .into_iter()
3127                                        .map(|column| column.name.clone())
3128                                        .collect(),
3129                                }
3130                            }
3131                            crate::auth::ColumnOperation::Insert => {
3132                                crate::auth::Permission::InsertColumns {
3133                                    table: table.to_string(),
3134                                    columns: denied
3135                                        .into_iter()
3136                                        .map(|column| column.name.clone())
3137                                        .collect(),
3138                                }
3139                            }
3140                            crate::auth::ColumnOperation::Update => {
3141                                crate::auth::Permission::UpdateColumns {
3142                                    table: table.to_string(),
3143                                    columns: denied
3144                                        .into_iter()
3145                                        .map(|column| column.name.clone())
3146                                        .collect(),
3147                                }
3148                            }
3149                        },
3150                        principal: principal.username.clone(),
3151                    })
3152                }
3153            }
3154            crate::auth::ColumnAccess::Denied => Err(MongrelError::PermissionDenied {
3155                required: match operation {
3156                    crate::auth::ColumnOperation::Select => crate::auth::Permission::Select {
3157                        table: table.to_string(),
3158                    },
3159                    crate::auth::ColumnOperation::Insert => crate::auth::Permission::Insert {
3160                        table: table.to_string(),
3161                    },
3162                    crate::auth::ColumnOperation::Update => crate::auth::Permission::Update {
3163                        table: table.to_string(),
3164                    },
3165                },
3166                principal: principal.username.clone(),
3167            }),
3168        }
3169    }
3170
3171    pub fn select_column_ids_for(
3172        &self,
3173        table: &str,
3174        principal: Option<&crate::auth::Principal>,
3175    ) -> Result<Vec<u16>> {
3176        let catalog = self.catalog.read();
3177        let columns = catalog
3178            .live(table)
3179            .ok_or_else(|| MongrelError::NotFound(format!("table {table:?} not found")))?
3180            .schema
3181            .columns
3182            .iter()
3183            .map(|column| (column.id, column.name.clone()))
3184            .collect::<Vec<_>>();
3185        let principal = self.principal_for_authorized_read(&catalog, principal, false)?;
3186        drop(catalog);
3187        let Some(principal) = principal.as_ref() else {
3188            self.require(&crate::auth::Permission::Select {
3189                table: table.to_string(),
3190            })?;
3191            return Ok(columns.iter().map(|(id, _)| *id).collect());
3192        };
3193        match principal.column_access(table, crate::auth::ColumnOperation::Select) {
3194            crate::auth::ColumnAccess::All => Ok(columns.iter().map(|(id, _)| *id).collect()),
3195            crate::auth::ColumnAccess::Columns(allowed) => Ok(columns
3196                .iter()
3197                .filter(|(_, name)| allowed.contains(name))
3198                .map(|(id, _)| *id)
3199                .collect()),
3200            crate::auth::ColumnAccess::Denied => Err(MongrelError::PermissionDenied {
3201                required: crate::auth::Permission::Select {
3202                    table: table.to_string(),
3203                },
3204                principal: principal.username.clone(),
3205            }),
3206        }
3207    }
3208
3209    pub fn secure_rows_for(
3210        &self,
3211        table: &str,
3212        rows: Vec<crate::memtable::Row>,
3213        principal: Option<&crate::auth::Principal>,
3214    ) -> Result<Vec<crate::memtable::Row>> {
3215        self.secure_rows_for_with_context(table, rows, principal, None)
3216    }
3217
3218    pub fn secure_rows_for_with_context(
3219        &self,
3220        table: &str,
3221        rows: Vec<crate::memtable::Row>,
3222        principal: Option<&crate::auth::Principal>,
3223        context: Option<&crate::query::AiExecutionContext>,
3224    ) -> Result<Vec<crate::memtable::Row>> {
3225        let (security, principal) = {
3226            let catalog = self.catalog.read();
3227            (
3228                catalog.security.clone(),
3229                self.principal_for_authorized_read(&catalog, principal, false)?,
3230            )
3231        };
3232        if !security.table_has_security(table) {
3233            return Ok(rows);
3234        }
3235        let principal = principal.as_ref().ok_or(MongrelError::AuthRequired)?;
3236        let mut output = Vec::new();
3237        for mut row in rows {
3238            if let Some(context) = context {
3239                context.consume(1)?;
3240            }
3241            if security.row_allowed(
3242                table,
3243                crate::security::PolicyCommand::Select,
3244                &row,
3245                principal,
3246                false,
3247            ) {
3248                security.apply_masks(table, &mut row, principal);
3249                output.push(row);
3250            }
3251        }
3252        Ok(output)
3253    }
3254
3255    /// Apply column masks to already RLS-authorized scored hits without a
3256    /// second row gather or policy evaluation.
3257    pub fn mask_search_hits_for(
3258        &self,
3259        table: &str,
3260        hits: &mut [crate::query::SearchHit],
3261        principal: Option<&crate::auth::Principal>,
3262    ) -> Result<()> {
3263        let (security, principal) = {
3264            let catalog = self.catalog.read();
3265            (
3266                catalog.security.clone(),
3267                self.principal_for_authorized_read(&catalog, principal, false)?,
3268            )
3269        };
3270        if !security.table_has_security(table) {
3271            return Ok(());
3272        }
3273        let principal = principal.as_ref().ok_or(MongrelError::AuthRequired)?;
3274        for hit in hits {
3275            security.apply_masks_to_cells(table, &mut hit.cells, principal);
3276        }
3277        Ok(())
3278    }
3279
3280    /// Apply masks to rows already admitted by candidate-aware RLS.
3281    pub fn mask_rows_for(
3282        &self,
3283        table: &str,
3284        rows: &mut [crate::memtable::Row],
3285        principal: Option<&crate::auth::Principal>,
3286    ) -> Result<()> {
3287        let (security, principal) = {
3288            let catalog = self.catalog.read();
3289            (
3290                catalog.security.clone(),
3291                self.principal_for_authorized_read(&catalog, principal, false)?,
3292            )
3293        };
3294        if !security.table_has_security(table) {
3295            return Ok(());
3296        }
3297        let principal = principal.as_ref().ok_or(MongrelError::AuthRequired)?;
3298        for row in rows {
3299            security.apply_masks(table, row, principal);
3300        }
3301        Ok(())
3302    }
3303
3304    /// Row IDs allowed to enter scored ranking. `None` means no RLS filter.
3305    pub fn authorized_candidate_ids_for(
3306        &self,
3307        table: &str,
3308        principal: Option<&crate::auth::Principal>,
3309    ) -> Result<Option<std::collections::HashSet<RowId>>> {
3310        Ok(self
3311            .authorized_read_snapshot(table, principal)?
3312            .allowed_row_ids)
3313    }
3314
3315    fn allowed_row_ids_locked(
3316        &self,
3317        table_name: &str,
3318        table: &Table,
3319        table_snapshot: Snapshot,
3320        security_state: (&crate::security::SecurityCatalog, u64),
3321        principal: Option<&crate::auth::Principal>,
3322        context: Option<&crate::query::AiExecutionContext>,
3323    ) -> Result<Option<Arc<HashSet<RowId>>>> {
3324        let (security, security_version) = security_state;
3325        if !security.rls_enabled(table_name) {
3326            return Ok(None);
3327        }
3328        let authorization_started = std::time::Instant::now();
3329        let principal = principal.ok_or(MongrelError::AuthRequired)?;
3330        let mut roles = principal.roles.clone();
3331        roles.sort_unstable();
3332        let principal_key = format!(
3333            "{}:{}:{}:{}:{roles:?}",
3334            principal.user_id, principal.created_epoch, principal.username, principal.is_admin
3335        );
3336        let cache_key = (
3337            table_name.to_string(),
3338            table.data_generation(),
3339            security_version,
3340            principal_key,
3341        );
3342        if let Some(allowed) = self.rls_cache.lock().get(&cache_key) {
3343            crate::trace::QueryTrace::record(|trace| {
3344                trace.rls_cache_hit = true;
3345                trace.authorization_nanos = trace
3346                    .authorization_nanos
3347                    .saturating_add(authorization_started.elapsed().as_nanos() as u64);
3348            });
3349            return Ok(Some(allowed));
3350        }
3351        if let Some(context) = context {
3352            context.checkpoint()?;
3353        }
3354        // ponytail: full RLS universe scan; replace with policy-column candidate checks if RLS search throughput matters.
3355        let started = std::time::Instant::now();
3356        let rows = table.visible_rows(table_snapshot)?;
3357        let rows_evaluated = rows.len() as u64;
3358        let mut allowed = HashSet::new();
3359        for chunk in rows.chunks(256) {
3360            if let Some(context) = context {
3361                context.consume(chunk.len())?;
3362            }
3363            allowed.extend(chunk.iter().filter_map(|row| {
3364                security
3365                    .row_allowed(
3366                        table_name,
3367                        crate::security::PolicyCommand::Select,
3368                        row,
3369                        principal,
3370                        false,
3371                    )
3372                    .then_some(row.row_id)
3373            }));
3374        }
3375        let allowed = Arc::new(allowed);
3376        let mut cache = self.rls_cache.lock();
3377        cache.build_nanos = cache
3378            .build_nanos
3379            .saturating_add(started.elapsed().as_nanos() as u64);
3380        cache.rows_evaluated = cache.rows_evaluated.saturating_add(rows_evaluated);
3381        cache.insert(cache_key, Arc::clone(&allowed));
3382        crate::trace::QueryTrace::record(|trace| {
3383            trace.rls_rows_evaluated = trace
3384                .rls_rows_evaluated
3385                .saturating_add(rows_evaluated as usize);
3386            trace.authorization_nanos = trace
3387                .authorization_nanos
3388                .saturating_add(authorization_started.elapsed().as_nanos() as u64);
3389        });
3390        Ok(Some(allowed))
3391    }
3392
3393    fn principal_for_authorized_read(
3394        &self,
3395        catalog: &Catalog,
3396        principal: Option<&crate::auth::Principal>,
3397        catalog_bound: bool,
3398    ) -> Result<Option<crate::auth::Principal>> {
3399        let principal = principal.cloned().or_else(|| self.principal.read().clone());
3400        let Some(principal) = principal else {
3401            return Ok(None);
3402        };
3403        if catalog.require_auth || catalog_bound || principal.user_id != 0 {
3404            return Self::resolve_bound_principal_from_catalog(catalog, &principal)
3405                .map(Some)
3406                .ok_or(MongrelError::AuthRequired);
3407        }
3408        Ok(Some(principal))
3409    }
3410
3411    /// Run authorization, candidate generation, ranking, and materialization
3412    /// while holding one table generation. Security changes cause a bounded
3413    /// retry before any result is published.
3414    pub fn with_authorized_read<T, F>(
3415        &self,
3416        table_name: &str,
3417        principal: Option<&crate::auth::Principal>,
3418        catalog_bound: bool,
3419        read: F,
3420    ) -> Result<T>
3421    where
3422        F: FnMut(
3423            &mut Table,
3424            Snapshot,
3425            Option<&HashSet<RowId>>,
3426            Option<&crate::auth::Principal>,
3427        ) -> Result<T>,
3428    {
3429        self.with_authorized_read_context(
3430            table_name,
3431            principal,
3432            catalog_bound,
3433            None,
3434            None,
3435            None,
3436            read,
3437        )
3438    }
3439
3440    #[allow(clippy::too_many_arguments)]
3441    pub fn with_authorized_read_context<T, F>(
3442        &self,
3443        table_name: &str,
3444        principal: Option<&crate::auth::Principal>,
3445        catalog_bound: bool,
3446        authorization: Option<&ReadAuthorization>,
3447        context: Option<&crate::query::AiExecutionContext>,
3448        snapshot_override: Option<Snapshot>,
3449        read: F,
3450    ) -> Result<T>
3451    where
3452        F: FnMut(
3453            &mut Table,
3454            Snapshot,
3455            Option<&HashSet<RowId>>,
3456            Option<&crate::auth::Principal>,
3457        ) -> Result<T>,
3458    {
3459        self.with_authorized_read_context_stamped(
3460            table_name,
3461            principal,
3462            catalog_bound,
3463            authorization,
3464            context,
3465            snapshot_override,
3466            read,
3467        )
3468        .map(|(result, _)| result)
3469    }
3470
3471    #[allow(clippy::too_many_arguments)]
3472    pub fn with_authorized_read_context_stamped<T, F>(
3473        &self,
3474        table_name: &str,
3475        principal: Option<&crate::auth::Principal>,
3476        catalog_bound: bool,
3477        authorization: Option<&ReadAuthorization>,
3478        context: Option<&crate::query::AiExecutionContext>,
3479        snapshot_override: Option<Snapshot>,
3480        mut read: F,
3481    ) -> Result<(T, AuthorizedReadStamp)>
3482    where
3483        F: FnMut(
3484            &mut Table,
3485            Snapshot,
3486            Option<&HashSet<RowId>>,
3487            Option<&crate::auth::Principal>,
3488        ) -> Result<T>,
3489    {
3490        if principal.is_none() && self.principal.read().is_some() {
3491            self.refresh_principal()?;
3492        }
3493        const RETRIES: usize = 3;
3494        let handle = self.table(table_name)?;
3495        for attempt in 0..RETRIES {
3496            crate::trace::QueryTrace::record(|trace| {
3497                trace.authorization_retries = attempt;
3498            });
3499            let (security, security_version, effective_principal) = {
3500                let catalog = self.catalog.read();
3501                (
3502                    catalog.security.clone(),
3503                    catalog.security_version,
3504                    self.principal_for_authorized_read(&catalog, principal, catalog_bound)?,
3505                )
3506            };
3507            if let Some(authorization) = authorization {
3508                for permission in &authorization.permissions {
3509                    self.require_for(effective_principal.as_ref(), permission)?;
3510                }
3511                self.require_columns_for(
3512                    table_name,
3513                    authorization.operation,
3514                    &authorization.columns,
3515                    effective_principal.as_ref(),
3516                )?;
3517            }
3518            let result = {
3519                let mut table = lock_table_with_context(&handle, context)?;
3520                let snapshot = snapshot_override.unwrap_or_else(|| table.snapshot());
3521                let allowed = self.allowed_row_ids_locked(
3522                    table_name,
3523                    &table,
3524                    snapshot,
3525                    (&security, security_version),
3526                    effective_principal.as_ref(),
3527                    context,
3528                )?;
3529                let stamp = AuthorizedReadStamp {
3530                    table_id: table.table_id(),
3531                    schema_id: table.schema().schema_id,
3532                    data_generation: table.data_generation(),
3533                    security_version,
3534                    snapshot,
3535                };
3536                let result = read(
3537                    &mut table,
3538                    snapshot,
3539                    allowed.as_deref(),
3540                    effective_principal.as_ref(),
3541                )?;
3542                (result, stamp)
3543            };
3544            if let Some(context) = context {
3545                context.checkpoint()?;
3546            }
3547            if self.catalog.read().security_version == security_version {
3548                return Ok(result);
3549            }
3550            if attempt + 1 == RETRIES {
3551                return Err(MongrelError::Conflict(
3552                    "security policy changed during scored read".into(),
3553                ));
3554            }
3555        }
3556        Err(MongrelError::Conflict(
3557            "authorization retry loop exhausted".into(),
3558        ))
3559    }
3560
3561    fn with_authorized_aggregate_table<T, F>(
3562        &self,
3563        table_name: &str,
3564        columns: &[u16],
3565        principal: Option<&crate::auth::Principal>,
3566        catalog_bound: bool,
3567        allow_table_security: bool,
3568        mut aggregate: F,
3569    ) -> Result<T>
3570    where
3571        F: FnMut(
3572            &mut Table,
3573            Option<&crate::security::CandidateAuthorization<'_>>,
3574            Option<&crate::auth::Principal>,
3575            u64,
3576        ) -> Result<T>,
3577    {
3578        if principal.is_none() && self.principal.read().is_some() {
3579            self.refresh_principal()?;
3580        }
3581        const RETRIES: usize = 3;
3582        let handle = self.table(table_name)?;
3583        for attempt in 0..RETRIES {
3584            let (security, security_version, effective_principal) = {
3585                let catalog = self.catalog.read();
3586                (
3587                    catalog.security.clone(),
3588                    catalog.security_version,
3589                    self.principal_for_authorized_read(&catalog, principal, catalog_bound)?,
3590                )
3591            };
3592            self.require_columns_for(
3593                table_name,
3594                crate::auth::ColumnOperation::Select,
3595                columns,
3596                effective_principal.as_ref(),
3597            )?;
3598            if !allow_table_security && security.table_has_security(table_name) {
3599                return Err(MongrelError::InvalidArgument(
3600                    "incremental aggregate is unsupported while RLS or column masks are active"
3601                        .into(),
3602                ));
3603            }
3604            let result = {
3605                let mut table = handle.lock();
3606                let authorization = if security.rls_enabled(table_name) {
3607                    Some(crate::security::CandidateAuthorization {
3608                        table: table_name,
3609                        security: &security,
3610                        principal: effective_principal
3611                            .as_ref()
3612                            .ok_or(MongrelError::AuthRequired)?,
3613                    })
3614                } else {
3615                    None
3616                };
3617                aggregate(
3618                    &mut table,
3619                    authorization.as_ref(),
3620                    effective_principal.as_ref(),
3621                    security_version,
3622                )?
3623            };
3624            if self.catalog.read().security_version == security_version {
3625                return Ok(result);
3626            }
3627            if attempt + 1 == RETRIES {
3628                return Err(MongrelError::Conflict(
3629                    "security policy changed during aggregate read".into(),
3630                ));
3631            }
3632        }
3633        Err(MongrelError::Conflict(
3634            "aggregate authorization retry loop exhausted".into(),
3635        ))
3636    }
3637
3638    /// Scored-read authorization that evaluates RLS only for approximate
3639    /// candidates. This avoids a full-table policy scan on cache misses while
3640    /// preserving one table generation and security-version retry.
3641    pub fn with_authorized_scored_read_context<T, F>(
3642        &self,
3643        table_name: &str,
3644        principal: Option<&crate::auth::Principal>,
3645        catalog_bound: bool,
3646        authorization: Option<&ReadAuthorization>,
3647        context: Option<&crate::query::AiExecutionContext>,
3648        mut read: F,
3649    ) -> Result<T>
3650    where
3651        F: FnMut(
3652            &mut Table,
3653            Snapshot,
3654            Option<&crate::security::CandidateAuthorization<'_>>,
3655            Option<&crate::auth::Principal>,
3656        ) -> Result<T>,
3657    {
3658        self.with_authorized_scored_read_context_at(
3659            table_name,
3660            principal,
3661            catalog_bound,
3662            authorization,
3663            context,
3664            None,
3665            |table, snapshot, authorization, principal| {
3666                let mut table = table.clone();
3667                read(&mut table, snapshot, authorization, principal)
3668            },
3669        )
3670    }
3671
3672    #[allow(clippy::too_many_arguments)]
3673    pub fn with_authorized_scored_read_context_at<T, F>(
3674        &self,
3675        table_name: &str,
3676        principal: Option<&crate::auth::Principal>,
3677        catalog_bound: bool,
3678        authorization: Option<&ReadAuthorization>,
3679        context: Option<&crate::query::AiExecutionContext>,
3680        snapshot_override: Option<Snapshot>,
3681        read: F,
3682    ) -> Result<T>
3683    where
3684        F: FnMut(
3685            &Table,
3686            Snapshot,
3687            Option<&crate::security::CandidateAuthorization<'_>>,
3688            Option<&crate::auth::Principal>,
3689        ) -> Result<T>,
3690    {
3691        self.with_authorized_scored_read_context_at_stamped(
3692            table_name,
3693            principal,
3694            catalog_bound,
3695            authorization,
3696            context,
3697            snapshot_override,
3698            read,
3699        )
3700        .map(|(result, _)| result)
3701    }
3702
3703    #[allow(clippy::too_many_arguments)]
3704    pub fn with_authorized_scored_read_context_at_stamped<T, F>(
3705        &self,
3706        table_name: &str,
3707        principal: Option<&crate::auth::Principal>,
3708        catalog_bound: bool,
3709        authorization: Option<&ReadAuthorization>,
3710        context: Option<&crate::query::AiExecutionContext>,
3711        snapshot_override: Option<Snapshot>,
3712        mut read: F,
3713    ) -> Result<(T, AuthorizedReadStamp)>
3714    where
3715        F: FnMut(
3716            &Table,
3717            Snapshot,
3718            Option<&crate::security::CandidateAuthorization<'_>>,
3719            Option<&crate::auth::Principal>,
3720        ) -> Result<T>,
3721    {
3722        if principal.is_none() && self.principal.read().is_some() {
3723            self.refresh_principal()?;
3724        }
3725        const RETRIES: usize = 3;
3726        let handle = self.table(table_name)?;
3727        for attempt in 0..RETRIES {
3728            if let Some(context) = context {
3729                context.checkpoint()?;
3730            }
3731            crate::trace::QueryTrace::record(|trace| {
3732                trace.authorization_retries = attempt;
3733            });
3734            let (security, security_version, effective_principal) = {
3735                let catalog = self.catalog.read();
3736                (
3737                    catalog.security.clone(),
3738                    catalog.security_version,
3739                    self.principal_for_authorized_read(&catalog, principal, catalog_bound)?,
3740                )
3741            };
3742            if let Some(authorization) = authorization {
3743                for permission in &authorization.permissions {
3744                    self.require_for(effective_principal.as_ref(), permission)?;
3745                }
3746                self.require_columns_for(
3747                    table_name,
3748                    authorization.operation,
3749                    &authorization.columns,
3750                    effective_principal.as_ref(),
3751                )?;
3752            }
3753            let result = {
3754                let (table, snapshot, _snapshot_guard, _run_pins) =
3755                    self.scored_read_generation(&handle, context, snapshot_override)?;
3756                let candidate_authorization = if security.rls_enabled(table_name) {
3757                    Some(crate::security::CandidateAuthorization {
3758                        table: table_name,
3759                        security: &security,
3760                        principal: effective_principal
3761                            .as_ref()
3762                            .ok_or(MongrelError::AuthRequired)?,
3763                    })
3764                } else {
3765                    None
3766                };
3767                let stamp = AuthorizedReadStamp {
3768                    table_id: table.table_id(),
3769                    schema_id: table.schema().schema_id,
3770                    data_generation: table.data_generation(),
3771                    security_version,
3772                    snapshot,
3773                };
3774                let result = read(
3775                    table.as_ref(),
3776                    snapshot,
3777                    candidate_authorization.as_ref(),
3778                    effective_principal.as_ref(),
3779                )?;
3780                (result, stamp)
3781            };
3782            if let Some(context) = context {
3783                context.checkpoint()?;
3784            }
3785            if self.catalog.read().security_version == security_version {
3786                return Ok(result);
3787            }
3788            if attempt + 1 == RETRIES {
3789                return Err(MongrelError::Conflict(
3790                    "security policy changed during scored read".into(),
3791                ));
3792            }
3793        }
3794        Err(MongrelError::Conflict(
3795            "scored-read authorization retry loop exhausted".into(),
3796        ))
3797    }
3798
3799    fn scored_read_generation(
3800        &self,
3801        handle: &TableHandle,
3802        context: Option<&crate::query::AiExecutionContext>,
3803        snapshot_override: Option<Snapshot>,
3804    ) -> Result<(
3805        Arc<TableReadGeneration>,
3806        Snapshot,
3807        crate::retention::OwnedSnapshotGuard,
3808        RunPins,
3809    )> {
3810        let mut table = if let Some(context) = context {
3811            loop {
3812                context.checkpoint()?;
3813                let wait = context
3814                    .remaining_duration()
3815                    .unwrap_or(std::time::Duration::from_millis(5))
3816                    .min(std::time::Duration::from_millis(5));
3817                if let Some(table) = handle.try_lock_for(wait) {
3818                    break table;
3819                }
3820            }
3821        } else {
3822            handle.lock()
3823        };
3824        let (snapshot, snapshot_guard) = if let Some(snapshot) = snapshot_override {
3825            self.snapshot_at_owned(snapshot.epoch)?
3826        } else {
3827            let snapshot = table.snapshot();
3828            let guard = self.snapshots.register_owned(snapshot.epoch);
3829            (snapshot, guard)
3830        };
3831        let table_id = table.table_id();
3832        let run_keys: Vec<_> = table
3833            .active_run_ids()
3834            .map(|run_id| (table_id, run_id))
3835            .collect();
3836        let generation = handle
3837            .generation_metrics
3838            .activate(table.clone_read_generation()?);
3839        let run_pins = self.pin_runs(&run_keys);
3840        Ok((generation, snapshot, snapshot_guard, run_pins))
3841    }
3842
3843    fn pin_runs(&self, runs: &[(u64, u128)]) -> RunPins {
3844        let mut pins = self.backup_pins.lock();
3845        for run in runs {
3846            *pins.entry(*run).or_insert(0) += 1;
3847        }
3848        drop(pins);
3849        RunPins {
3850            pins: Arc::clone(&self.backup_pins),
3851            runs: runs.to_vec(),
3852        }
3853    }
3854
3855    /// Execute a native conjunctive read with the database principal's row
3856    /// policy, column grants, and masks applied. Raw [`Table`] methods remain
3857    /// policy-unaware; language bindings must use this boundary for reads.
3858    pub fn query_for_current_principal(
3859        &self,
3860        table_name: &str,
3861        query: &crate::query::Query,
3862        projection: Option<&[u16]>,
3863    ) -> Result<Vec<crate::memtable::Row>> {
3864        let condition_columns = crate::query::condition_columns(&query.conditions);
3865        self.with_authorized_read(
3866            table_name,
3867            None,
3868            true,
3869            |table, snapshot, allowed, principal| {
3870                let allowed_columns = self.select_column_ids_for(table_name, principal)?;
3871                self.require_columns_for(
3872                    table_name,
3873                    crate::auth::ColumnOperation::Select,
3874                    &condition_columns,
3875                    principal,
3876                )?;
3877                if let Some(projection) = projection {
3878                    self.require_columns_for(
3879                        table_name,
3880                        crate::auth::ColumnOperation::Select,
3881                        projection,
3882                        principal,
3883                    )?;
3884                }
3885                let mut rows = table.query_at_with_allowed(query, snapshot, allowed)?;
3886                let projection =
3887                    projection.map(|columns| columns.iter().copied().collect::<HashSet<_>>());
3888                for row in &mut rows {
3889                    row.columns.retain(|column, _| {
3890                        allowed_columns.contains(column)
3891                            && projection
3892                                .as_ref()
3893                                .is_none_or(|projection| projection.contains(column))
3894                    });
3895                }
3896                self.secure_rows_for(table_name, rows, principal)
3897            },
3898        )
3899    }
3900
3901    /// Reservoir aggregate with column grants, RLS, masks, and security-version
3902    /// retry applied at the database boundary.
3903    pub fn approx_aggregate_for_current_principal(
3904        &self,
3905        table_name: &str,
3906        conditions: &[crate::query::Condition],
3907        column: Option<u16>,
3908        agg: crate::engine::ApproxAgg,
3909        z: f64,
3910    ) -> Result<Option<crate::engine::ApproxResult>> {
3911        if !z.is_finite() || z <= 0.0 {
3912            return Err(MongrelError::InvalidArgument(
3913                "z must be finite and > 0".into(),
3914            ));
3915        }
3916        let mut columns = crate::query::condition_columns(conditions);
3917        columns.extend(column);
3918        columns.sort_unstable();
3919        columns.dedup();
3920        self.with_authorized_aggregate_table(
3921            table_name,
3922            &columns,
3923            None,
3924            true,
3925            true,
3926            |table, authorization, _, _| {
3927                table.approx_aggregate_with_candidate_authorization(
3928                    conditions,
3929                    column,
3930                    agg,
3931                    z,
3932                    authorization,
3933                )
3934            },
3935        )
3936    }
3937
3938    /// Incremental aggregate over an append-only table. Active RLS or masks are
3939    /// rejected because the table-global delta cache cannot safely represent a
3940    /// secured row universe.
3941    pub fn incremental_aggregate_for_current_principal(
3942        &self,
3943        table_name: &str,
3944        conditions: &[crate::query::Condition],
3945        column: Option<u16>,
3946        agg: crate::engine::NativeAgg,
3947    ) -> Result<crate::engine::IncrementalAggResult> {
3948        self.incremental_aggregate_for_principal(table_name, conditions, column, agg, None, true)
3949    }
3950
3951    /// Incremental aggregate using an explicit request principal. A
3952    /// catalog-bound principal is re-resolved on every retry so live grants,
3953    /// revocations, RLS, and masks cannot reuse a stale cache entry.
3954    pub fn incremental_aggregate_for_principal(
3955        &self,
3956        table_name: &str,
3957        conditions: &[crate::query::Condition],
3958        column: Option<u16>,
3959        agg: crate::engine::NativeAgg,
3960        principal: Option<&crate::auth::Principal>,
3961        catalog_bound: bool,
3962    ) -> Result<crate::engine::IncrementalAggResult> {
3963        let mut columns = crate::query::condition_columns(conditions);
3964        columns.extend(column);
3965        columns.sort_unstable();
3966        columns.dedup();
3967        self.with_authorized_aggregate_table(
3968            table_name,
3969            &columns,
3970            principal,
3971            catalog_bound,
3972            false,
3973            |table, _, principal, security_version| {
3974                let cache_key = incremental_aggregate_cache_key(
3975                    table_name,
3976                    conditions,
3977                    column,
3978                    agg,
3979                    principal,
3980                    security_version,
3981                );
3982                table.aggregate_incremental(cache_key, conditions, column, agg)
3983            },
3984        )
3985    }
3986
3987    /// Read one row with the database principal's row policy, column grants,
3988    /// and masks applied.
3989    pub fn get_for_current_principal(
3990        &self,
3991        table_name: &str,
3992        row_id: RowId,
3993    ) -> Result<Option<crate::memtable::Row>> {
3994        self.with_authorized_read(
3995            table_name,
3996            None,
3997            true,
3998            |table, snapshot, allowed, principal| {
3999                let allowed_columns = self.select_column_ids_for(table_name, principal)?;
4000                let Some(row) = table.get(row_id, snapshot) else {
4001                    return Ok(None);
4002                };
4003                if allowed.is_some_and(|allowed| !allowed.contains(&row.row_id)) {
4004                    return Ok(None);
4005                }
4006                let mut rows = self.secure_rows_for(table_name, vec![row], principal)?;
4007                if let Some(row) = rows.first_mut() {
4008                    row.columns
4009                        .retain(|column, _| allowed_columns.contains(column));
4010                }
4011                Ok(rows.pop())
4012            },
4013        )
4014    }
4015
4016    /// Run exact ANN reranking over only rows authorized for this database
4017    /// handle. The embedding column still requires normal column access.
4018    pub fn ann_rerank_for_current_principal(
4019        &self,
4020        table_name: &str,
4021        request: &crate::query::AnnRerankRequest,
4022    ) -> Result<Vec<crate::query::AnnRerankHit>> {
4023        self.with_authorized_scored_read_context_at(
4024            table_name,
4025            None,
4026            true,
4027            Some(&ReadAuthorization {
4028                operation: crate::auth::ColumnOperation::Select,
4029                columns: vec![request.column_id],
4030                permissions: Vec::new(),
4031            }),
4032            None,
4033            None,
4034            |table, snapshot, authorization, principal| {
4035                self.require_columns_for(
4036                    table_name,
4037                    crate::auth::ColumnOperation::Select,
4038                    &[request.column_id],
4039                    principal,
4040                )?;
4041                table.ann_rerank_at_with_candidate_authorization_on_generation(
4042                    request,
4043                    snapshot,
4044                    authorization,
4045                    None,
4046                )
4047            },
4048        )
4049    }
4050
4051    /// Capture one table snapshot and the security version used to authorize it.
4052    /// The caller must validate the returned version before publishing results.
4053    pub fn authorized_read_snapshot(
4054        &self,
4055        table: &str,
4056        principal: Option<&crate::auth::Principal>,
4057    ) -> Result<AuthorizedReadSnapshot> {
4058        let (security, security_version, effective_principal) = {
4059            let catalog = self.catalog.read();
4060            (
4061                catalog.security.clone(),
4062                catalog.security_version,
4063                self.principal_for_authorized_read(&catalog, principal, false)?,
4064            )
4065        };
4066        let handle = self.table(table)?;
4067        let (table_snapshot, data_generation, allowed_row_ids) = {
4068            let table_handle = handle.lock();
4069            let table_snapshot = table_handle.snapshot();
4070            let data_generation = table_handle.data_generation();
4071            let allowed = self.allowed_row_ids_locked(
4072                table,
4073                &table_handle,
4074                table_snapshot,
4075                (&security, security_version),
4076                effective_principal.as_ref(),
4077                None,
4078            )?;
4079            (
4080                table_snapshot,
4081                data_generation,
4082                allowed.map(|allowed| (*allowed).clone()),
4083            )
4084        };
4085        Ok(AuthorizedReadSnapshot {
4086            table: table.to_string(),
4087            table_snapshot,
4088            data_generation,
4089            security_version,
4090            allowed_row_ids,
4091        })
4092    }
4093
4094    pub fn authorized_read_snapshot_valid(&self, snapshot: &AuthorizedReadSnapshot) -> bool {
4095        if self.catalog.read().security_version != snapshot.security_version {
4096            return false;
4097        }
4098        self.table(&snapshot.table)
4099            .ok()
4100            .is_some_and(|table| table.lock().data_generation() == snapshot.data_generation)
4101    }
4102
4103    pub fn rls_cache_stats(&self) -> RlsCacheStats {
4104        self.rls_cache.lock().stats()
4105    }
4106
4107    /// Read visible rows with column authorization, RLS, and masks applied.
4108    pub fn rows_for(
4109        &self,
4110        table: &str,
4111        principal: Option<&crate::auth::Principal>,
4112    ) -> Result<Vec<crate::memtable::Row>> {
4113        if principal.is_none() && self.principal.read().is_some() {
4114            self.refresh_principal()?;
4115        }
4116        let allowed = self.select_column_ids_for(table, principal)?;
4117        let handle = self.table(table)?;
4118        let rows = {
4119            let table = handle.lock();
4120            table.visible_rows(table.snapshot())?
4121        };
4122        let mut rows = self.secure_rows_for(table, rows, principal)?;
4123        for row in &mut rows {
4124            row.columns.retain(|column, _| allowed.contains(column));
4125        }
4126        Ok(rows)
4127    }
4128
4129    /// Historical rows use the current principal and security catalog against
4130    /// the row values visible at the requested snapshot.
4131    pub fn rows_at_epoch_for_current_principal(
4132        &self,
4133        table_name: &str,
4134        snapshot: Snapshot,
4135    ) -> Result<Vec<crate::memtable::Row>> {
4136        self.with_authorized_read_context(
4137            table_name,
4138            None,
4139            true,
4140            Some(&ReadAuthorization {
4141                operation: crate::auth::ColumnOperation::Select,
4142                columns: Vec::new(),
4143                permissions: Vec::new(),
4144            }),
4145            None,
4146            Some(snapshot),
4147            |table, snapshot, allowed, principal| {
4148                let allowed_columns = self.select_column_ids_for(table_name, principal)?;
4149                let mut rows = table.visible_rows(snapshot)?;
4150                if let Some(allowed) = allowed {
4151                    rows.retain(|row| allowed.contains(&row.row_id));
4152                }
4153                rows = self.secure_rows_for(table_name, rows, principal)?;
4154                for row in &mut rows {
4155                    row.columns
4156                        .retain(|column, _| allowed_columns.contains(column));
4157                }
4158                Ok(rows)
4159            },
4160        )
4161    }
4162
4163    /// Count rows visible to a principal without bypassing RLS.
4164    pub fn count_for(
4165        &self,
4166        table: &str,
4167        principal: Option<&crate::auth::Principal>,
4168    ) -> Result<u64> {
4169        if principal.is_none() && self.principal.read().is_some() {
4170            self.refresh_principal()?;
4171        }
4172        self.select_column_ids_for(table, principal)?;
4173        if self.security_active_for(table) {
4174            Ok(self.rows_for(table, principal)?.len() as u64)
4175        } else {
4176            Ok(self.table(table)?.lock().count())
4177        }
4178    }
4179
4180    /// Authorize and write one native-API row for an explicit principal.
4181    pub fn put_for(
4182        &self,
4183        table: &str,
4184        mut cells: Vec<(u16, crate::memtable::Value)>,
4185        principal: Option<&crate::auth::Principal>,
4186    ) -> Result<RowId> {
4187        let columns = cells.iter().map(|(column, _)| *column).collect::<Vec<_>>();
4188        self.require_columns_for(
4189            table,
4190            crate::auth::ColumnOperation::Insert,
4191            &columns,
4192            principal,
4193        )?;
4194        let handle = self.table(table)?;
4195        let mut table_handle = handle.lock();
4196        table_handle.fill_auto_inc(&mut cells)?;
4197        table_handle.apply_defaults(&mut cells)?;
4198        let mut row = crate::memtable::Row::new(RowId(0), self.epoch.visible());
4199        row.columns.extend(cells.iter().cloned());
4200        self.check_row_policy_for(
4201            table,
4202            crate::security::PolicyCommand::Insert,
4203            &row,
4204            true,
4205            principal,
4206        )?;
4207        table_handle.put(cells)
4208    }
4209
4210    pub fn check_row_policy_for(
4211        &self,
4212        table: &str,
4213        command: crate::security::PolicyCommand,
4214        row: &crate::memtable::Row,
4215        check_new: bool,
4216        principal: Option<&crate::auth::Principal>,
4217    ) -> Result<()> {
4218        let security = self.catalog.read().security.clone();
4219        if !security.rls_enabled(table) {
4220            return Ok(());
4221        }
4222        let cached = self.principal.read().clone();
4223        let principal = principal
4224            .or(cached.as_ref())
4225            .ok_or(MongrelError::AuthRequired)?;
4226        if security.row_allowed(table, command, row, principal, check_new) {
4227            return Ok(());
4228        }
4229        let required = match command {
4230            crate::security::PolicyCommand::Insert => crate::auth::Permission::Insert {
4231                table: table.to_string(),
4232            },
4233            crate::security::PolicyCommand::Update => crate::auth::Permission::Update {
4234                table: table.to_string(),
4235            },
4236            crate::security::PolicyCommand::Select => crate::auth::Permission::Select {
4237                table: table.to_string(),
4238            },
4239            crate::security::PolicyCommand::Delete | crate::security::PolicyCommand::All => {
4240                crate::auth::Permission::Delete {
4241                    table: table.to_string(),
4242                }
4243            }
4244        };
4245        Err(MongrelError::PermissionDenied {
4246            required,
4247            principal: principal.username.clone(),
4248        })
4249    }
4250
4251    /// Durably create or replace a materialized-view definition after its
4252    /// physical table has been populated.
4253    pub fn set_materialized_view(
4254        &self,
4255        definition: crate::catalog::MaterializedViewEntry,
4256    ) -> Result<()> {
4257        self.set_materialized_view_with_epoch(definition)
4258            .map(|_| ())
4259    }
4260
4261    /// Durably create or replace a materialized-view definition and return its epoch.
4262    pub fn set_materialized_view_with_epoch(
4263        &self,
4264        definition: crate::catalog::MaterializedViewEntry,
4265    ) -> Result<Epoch> {
4266        use crate::wal::DdlOp;
4267        use std::sync::atomic::Ordering;
4268
4269        self.require(&crate::auth::Permission::Ddl)?;
4270        if self.poisoned.load(Ordering::Relaxed) {
4271            return Err(MongrelError::Other(
4272                "database poisoned by fsync error".into(),
4273            ));
4274        }
4275        if definition.name.is_empty() || definition.query.trim().is_empty() {
4276            return Err(MongrelError::InvalidArgument(
4277                "materialized view name and query must not be empty".into(),
4278            ));
4279        }
4280
4281        let _ddl = self.ddl_lock.lock();
4282        let _security_write = self.security_write()?;
4283        self.require(&crate::auth::Permission::Ddl)?;
4284        let table_id = self
4285            .catalog
4286            .read()
4287            .live(&definition.name)
4288            .ok_or_else(|| {
4289                MongrelError::NotFound(format!(
4290                    "materialized view table {:?} not found",
4291                    definition.name
4292                ))
4293            })?
4294            .table_id;
4295        let definition_json = DdlOp::encode_materialized_view(&definition)?;
4296        let _commit = self.commit_lock.lock();
4297        let epoch = self.epoch.bump_assigned();
4298        let mut epoch_guard = EpochGuard::new(self.epoch.as_ref(), epoch);
4299        let txn_id = self.alloc_txn_id()?;
4300        let mut next_catalog = self.catalog.read().clone();
4301        if let Some(existing) = next_catalog
4302            .materialized_views
4303            .iter_mut()
4304            .find(|existing| existing.name == definition.name)
4305        {
4306            *existing = definition.clone();
4307        } else {
4308            next_catalog.materialized_views.push(definition.clone());
4309        }
4310        next_catalog.db_epoch = next_catalog.db_epoch.max(epoch.0);
4311        let commit_seq = {
4312            let mut wal = self.shared_wal.lock();
4313            let append: Result<u64> = (|| {
4314                wal.append(
4315                    txn_id,
4316                    table_id,
4317                    crate::wal::Op::Ddl(DdlOp::SetMaterializedView {
4318                        name: definition.name.clone(),
4319                        definition_json,
4320                    }),
4321                )?;
4322                append_catalog_snapshot(&mut wal, txn_id, &next_catalog)?;
4323                wal.append_commit(txn_id, epoch, &[])
4324            })();
4325            append.map_err(|error| self.commit_outcome_unknown(epoch, error))?
4326        };
4327        self.await_durable_commit(commit_seq, epoch)?;
4328
4329        let checkpoint = self.checkpoint_catalog_after_durable(next_catalog);
4330        self.finish_durable_publish(epoch, &mut epoch_guard, checkpoint)?;
4331        Ok(epoch)
4332    }
4333
4334    /// The filesystem root this database was opened/created at.
4335    pub fn root(&self) -> &Path {
4336        self.durable_root.canonical_path()
4337    }
4338
4339    /// Open a descriptor-pinned view of this database root for durable
4340    /// extension state such as server idempotency receipts.
4341    pub fn durable_root(&self) -> Arc<crate::durable_file::DurableRoot> {
4342        Arc::clone(&self.durable_root)
4343    }
4344
4345    /// Domain-separated authentication key for server idempotency state.
4346    /// Encrypted databases derive it from the in-memory KEK. Plain databases
4347    /// return `None`; their server persists a random key under the pinned root.
4348    #[cfg(feature = "encryption")]
4349    pub fn derive_server_idempotency_key(&self) -> Option<zeroize::Zeroizing<[u8; 32]>> {
4350        self.kek
4351            .as_deref()
4352            .map(|kek| kek.derive_subkey(b"mongreldb/server/idempotency/v1"))
4353    }
4354
4355    #[cfg(not(feature = "encryption"))]
4356    pub fn derive_server_idempotency_key(&self) -> Option<zeroize::Zeroizing<[u8; 32]>> {
4357        None
4358    }
4359
4360    pub fn is_read_only_replica(&self) -> bool {
4361        self.read_only
4362    }
4363
4364    /// Reject reads whose backing state may require WAL recovery after a
4365    /// post-commit publication failure. Ordinary table/catalog state is made
4366    /// coherent before poison; file-backed external modules use this gate.
4367    pub fn ensure_consistent_read(&self) -> Result<()> {
4368        if self.poisoned.load(Ordering::Relaxed) {
4369            return Err(MongrelError::Other(
4370                "database poisoned by post-commit failure; reopen required".into(),
4371            ));
4372        }
4373        Ok(())
4374    }
4375
4376    pub fn set_replication_wal_retention_segments(&self, segments: usize) {
4377        self.replication_wal_retention_segments
4378            .store(segments, std::sync::atomic::Ordering::Relaxed);
4379    }
4380
4381    /// Capture a consistent bootstrap image. DDL, transaction spill/publish,
4382    /// direct table commits, compaction, and WAL append are quiesced while the
4383    /// file image is read. WAL records newer than manifests remain sufficient
4384    /// for recovery, so no flush or compaction is required.
4385    pub fn replication_snapshot(&self) -> Result<crate::replication::ReplicationSnapshot> {
4386        let admin = crate::auth::Permission::Admin;
4387        self.require(&admin)?;
4388        let operation_principal = self.principal_snapshot();
4389        let _barrier = self.replication_barrier.write();
4390        let _ddl = self.ddl_lock.lock();
4391        let _security = self.security_coordinator.gate.read();
4392        self.require_exact_principal_current(operation_principal.as_ref(), &admin)?;
4393        let mut handles: Vec<_> = self
4394            .tables
4395            .read()
4396            .iter()
4397            .map(|(id, handle)| (*id, handle.clone()))
4398            .collect();
4399        handles.sort_by_key(|(id, _)| *id);
4400        let _table_guards: Vec<_> = handles.iter().map(|(_, handle)| handle.lock()).collect();
4401        let _commit = self.commit_lock.lock();
4402        let mut wal = self.shared_wal.lock();
4403        wal.group_sync()?;
4404        let wal_dek = crate::encryption::wal_dek_for(self.kek.as_deref());
4405        let records = crate::wal::SharedWal::replay_with_dek(&self.root, wal_dek.as_ref())?;
4406        let epoch = records
4407            .iter()
4408            .filter_map(|record| match &record.op {
4409                crate::wal::Op::TxnCommit { epoch, .. } => Some(*epoch),
4410                _ => None,
4411            })
4412            .max()
4413            .unwrap_or(0)
4414            .max(self.epoch.committed().0);
4415        let files = crate::replication::capture_files(&self.root)?;
4416        let source_id = crate::replication::replication_identity_durable(&self.durable_root)?;
4417        drop(wal);
4418        Ok(crate::replication::ReplicationSnapshot::new(
4419            source_id, epoch, files,
4420        ))
4421    }
4422
4423    /// Create an online, directly-openable backup at `destination`.
4424    ///
4425    /// The short boundary phase quiesces commits/DDL, syncs the WAL, copies
4426    /// mutable metadata, and pins the exact immutable runs named by the copied
4427    /// manifests. Writers resume while those runs stream into a sibling staging
4428    /// directory. A checksummed backup manifest is written last, then the stage
4429    /// is atomically renamed into place.
4430    pub fn hot_backup(&self, destination: impl AsRef<Path>) -> Result<crate::backup::BackupReport> {
4431        let control = crate::ExecutionControl::new(None);
4432        self.hot_backup_controlled(destination, &control, || true)
4433    }
4434
4435    pub(crate) fn hot_backup_to_durable_child(
4436        &self,
4437        parent: &crate::durable_file::DurableRoot,
4438        child: &Path,
4439        control: &crate::ExecutionControl,
4440    ) -> Result<crate::backup::BackupReport> {
4441        let mut components = child.components();
4442        if !matches!(components.next(), Some(std::path::Component::Normal(_)))
4443            || components.next().is_some()
4444        {
4445            return Err(MongrelError::InvalidArgument(
4446                "durable backup child must be one relative path component".into(),
4447            ));
4448        }
4449        let destination_name = child.file_name().ok_or_else(|| {
4450            MongrelError::InvalidArgument("durable backup child has no filename".into())
4451        })?;
4452        let prepared = prepare_backup_destination_in(&self.root, parent, destination_name)?;
4453        self.hot_backup_prepared(prepared, control, || true)
4454    }
4455
4456    /// Build a backup cooperatively, then invoke `before_publish` immediately
4457    /// before the staging directory is atomically renamed into place.
4458    #[doc(hidden)]
4459    pub fn hot_backup_controlled<F>(
4460        &self,
4461        destination: impl AsRef<Path>,
4462        control: &crate::ExecutionControl,
4463        before_publish: F,
4464    ) -> Result<crate::backup::BackupReport>
4465    where
4466        F: FnOnce() -> bool,
4467    {
4468        let prepared = prepare_backup_destination(&self.root, destination.as_ref())?;
4469        self.hot_backup_prepared(prepared, control, before_publish)
4470    }
4471
4472    fn hot_backup_prepared<F>(
4473        &self,
4474        mut prepared: PreparedBackupDestination,
4475        control: &crate::ExecutionControl,
4476        before_publish: F,
4477    ) -> Result<crate::backup::BackupReport>
4478    where
4479        F: FnOnce() -> bool,
4480    {
4481        let admin = crate::auth::Permission::Admin;
4482        self.require(&admin)?;
4483        let operation_principal = self.principal_snapshot();
4484        control.checkpoint()?;
4485        let destination = prepared.destination_path.clone();
4486        let mut before_publish = Some(before_publish);
4487
4488        let outcome = (|| {
4489            control.checkpoint()?;
4490            let barrier = self.replication_barrier.write();
4491            let ddl = self.ddl_lock.lock();
4492            let security = self.security_coordinator.gate.read();
4493            self.require_exact_principal_current(operation_principal.as_ref(), &admin)?;
4494            let mut handles: Vec<_> = self
4495                .tables
4496                .read()
4497                .iter()
4498                .map(|(id, handle)| (*id, handle.clone()))
4499                .collect();
4500            handles.sort_by_key(|(id, _)| *id);
4501            let table_guards: Vec<_> = handles.iter().map(|(_, handle)| handle.lock()).collect();
4502            let commit = self.commit_lock.lock();
4503            let mut wal = self.shared_wal.lock();
4504            wal.group_sync()?;
4505            let epoch = self.epoch.committed().0;
4506            let boundary_unix_nanos = current_unix_nanos();
4507
4508            let pin_nonce = std::time::SystemTime::now()
4509                .duration_since(std::time::UNIX_EPOCH)
4510                .unwrap_or_default()
4511                .as_nanos();
4512            let file_pin_root = self
4513                .root
4514                .join(META_DIR)
4515                .join("backup-pins")
4516                .join(format!("{}-{pin_nonce}", std::process::id()));
4517            std::fs::create_dir_all(&file_pin_root)?;
4518            let _file_pins = BackupFilePins {
4519                root: file_pin_root.clone(),
4520            };
4521            let mut run_files = Vec::new();
4522            for (index, (table_id, _)) in handles.iter().enumerate() {
4523                if index % 256 == 0 {
4524                    control.checkpoint()?;
4525                }
4526                let table = &table_guards[index];
4527                for (run_index, run) in table.run_refs().iter().enumerate() {
4528                    if run_index % 256 == 0 {
4529                        control.checkpoint()?;
4530                    }
4531                    let source = table.run_path(run.run_id as u64);
4532                    let relative = Path::new(TABLES_DIR)
4533                        .join(table_id.to_string())
4534                        .join(crate::engine::RUNS_DIR)
4535                        .join(format!("r-{}.sr", run.run_id));
4536                    let pinned = file_pin_root.join(format!("{table_id}-{}.sr", run.run_id));
4537                    if std::fs::hard_link(&source, &pinned).is_err() {
4538                        crate::backup::copy_file_synced(&source, &pinned)?;
4539                    }
4540                    run_files.push(((*table_id, run.run_id), pinned, relative));
4541                }
4542            }
4543            crate::durable_file::sync_directory(&file_pin_root)?;
4544            let run_keys: Vec<_> = run_files.iter().map(|(key, _, _)| *key).collect();
4545            {
4546                let mut pins = self.backup_pins.lock();
4547                for key in &run_keys {
4548                    *pins.entry(*key).or_insert(0) += 1;
4549                }
4550            }
4551            let _run_pins = RunPins {
4552                pins: Arc::clone(&self.backup_pins),
4553                runs: run_keys,
4554            };
4555            let deferred: HashSet<_> = run_files
4556                .iter()
4557                .map(|(_, _, relative)| relative.clone())
4558                .collect();
4559            let mut copied = Vec::new();
4560            copy_backup_boundary(
4561                &self.root,
4562                prepared.stage.as_deref().ok_or_else(|| {
4563                    MongrelError::Other("backup staging root was already released".into())
4564                })?,
4565                &deferred,
4566                &mut copied,
4567                Some(control),
4568            )?;
4569
4570            drop(wal);
4571            drop(commit);
4572            drop(table_guards);
4573            drop(security);
4574            drop(ddl);
4575            drop(barrier);
4576
4577            if let Some(hook) = self.backup_hook.lock().as_ref() {
4578                hook();
4579            }
4580            for (index, (_, source, relative)) in run_files.into_iter().enumerate() {
4581                if index % 256 == 0 {
4582                    control.checkpoint()?;
4583                }
4584                let mut source = crate::durable_file::open_regular_nofollow(&source)?;
4585                prepared
4586                    .stage
4587                    .as_deref()
4588                    .ok_or_else(|| {
4589                        MongrelError::Other("backup staging root was already released".into())
4590                    })?
4591                    .copy_new_from(&relative, &mut source)?;
4592                copied.push(relative);
4593            }
4594
4595            let manifest = crate::backup::BackupManifest::create_controlled_durable(
4596                prepared.stage.as_deref().ok_or_else(|| {
4597                    MongrelError::Other("backup staging root was already released".into())
4598                })?,
4599                epoch,
4600                &copied,
4601                control,
4602            )?;
4603            manifest.write_to_durable(prepared.stage.as_deref().ok_or_else(|| {
4604                MongrelError::Other("backup staging root was already released".into())
4605            })?)?;
4606            control.checkpoint()?;
4607            let publish = before_publish.take().ok_or_else(|| {
4608                MongrelError::Other("backup publication callback already consumed".into())
4609            })?;
4610            if !publish() {
4611                return Err(MongrelError::Cancelled);
4612            }
4613            let final_security = self.security_coordinator.gate.read();
4614            self.require_exact_principal_current(operation_principal.as_ref(), &admin)?;
4615            // Windows pins directories without delete sharing. Release the
4616            // stage handle before renaming that directory, while the parent
4617            // remains descriptor-pinned for the no-replace publication.
4618            drop(prepared.stage.take().ok_or_else(|| {
4619                MongrelError::Other("backup staging root was already released".into())
4620            })?);
4621            let published = std::cell::Cell::new(false);
4622            if let Err(error) = prepared.parent.rename_directory_new_with_after(
4623                Path::new(&prepared.stage_name),
4624                &prepared.parent,
4625                Path::new(&prepared.destination_name),
4626                || published.set(true),
4627            ) {
4628                if published.get() {
4629                    return Err(MongrelError::CommitOutcomeUnknown {
4630                        epoch,
4631                        message: format!("backup publication was not durable: {error}"),
4632                    });
4633                }
4634                return Err(error.into());
4635            }
4636            drop(final_security);
4637            Ok(crate::backup::BackupReport {
4638                destination,
4639                epoch,
4640                boundary_unix_nanos,
4641                files: manifest.files.len(),
4642                bytes: manifest.total_bytes(),
4643            })
4644        })();
4645
4646        if outcome.is_err() {
4647            drop(prepared.stage.take());
4648            let _ = prepared
4649                .parent
4650                .remove_directory_all(Path::new(&prepared.stage_name));
4651        }
4652        outcome
4653    }
4654
4655    /// Return complete committed transactions after `since_epoch`. A gap or a
4656    /// transaction backed by a spilled run requires a fresh bootstrap image.
4657    pub fn replication_batch_since(
4658        &self,
4659        since_epoch: u64,
4660    ) -> Result<crate::replication::ReplicationBatch> {
4661        use crate::wal::Op;
4662
4663        let admin = crate::auth::Permission::Admin;
4664        self.require(&admin)?;
4665        let operation_principal = self.principal_snapshot();
4666
4667        let mut wal = self.shared_wal.lock();
4668        wal.group_sync()?;
4669        let wal_dek = crate::encryption::wal_dek_for(self.kek.as_deref());
4670        let records = crate::wal::SharedWal::replay_with_dek(&self.root, wal_dek.as_ref())?;
4671        drop(wal);
4672
4673        let commits: HashMap<u64, u64> = records
4674            .iter()
4675            .filter_map(|record| match &record.op {
4676                Op::TxnCommit { epoch, .. } => Some((record.txn_id, *epoch)),
4677                _ => None,
4678            })
4679            .collect();
4680        let earliest_epoch = commits.values().copied().min();
4681        let current_epoch = commits
4682            .values()
4683            .copied()
4684            .max()
4685            .unwrap_or(0)
4686            .max(self.epoch.committed().0);
4687        let selected: HashSet<u64> = commits
4688            .iter()
4689            .filter_map(|(txn_id, epoch)| (*epoch > since_epoch).then_some(*txn_id))
4690            .collect();
4691        let retention_gap = since_epoch < current_epoch
4692            && since_epoch < crate::replication::replication_wal_floor(&self.root)?;
4693        let spilled = records.iter().any(|record| {
4694            selected.contains(&record.txn_id)
4695                && matches!(
4696                    &record.op,
4697                    Op::TxnCommit { added_runs, .. } if !added_runs.is_empty()
4698                )
4699        });
4700        let records = records
4701            .into_iter()
4702            .filter(|record| record.txn_id != crate::wal::SYSTEM_TXN_ID)
4703            .filter(|record| selected.contains(&record.txn_id))
4704            .collect();
4705        let source_id = crate::replication::replication_identity_durable(&self.durable_root)?;
4706        let batch = crate::replication::ReplicationBatch::complete_for_source(
4707            source_id,
4708            since_epoch,
4709            current_epoch,
4710            earliest_epoch,
4711            retention_gap,
4712            spilled,
4713            records,
4714        )?;
4715        if let Some(hook) = self.replication_hook.lock().as_ref() {
4716            hook();
4717        }
4718        let _security = self.security_coordinator.gate.read();
4719        self.require_exact_principal_current(operation_principal.as_ref(), &admin)?;
4720        Ok(batch)
4721    }
4722
4723    /// Durably append a leader batch to a follower's local WAL and checkpoint
4724    /// its catalog metadata. Security changes apply to this live handle before
4725    /// success returns. The caller must reopen to mount new table state.
4726    pub fn append_replication_batch(
4727        &self,
4728        batch: &crate::replication::ReplicationBatch,
4729    ) -> Result<u64> {
4730        use crate::wal::Op;
4731
4732        if !self.read_only {
4733            return Err(MongrelError::InvalidArgument(
4734                "replication batches may only target a marked replica".into(),
4735            ));
4736        }
4737        let current = crate::replication::replica_epoch(&self.root)?;
4738        if batch.is_source_bound() {
4739            let source_id = crate::replication::replica_source_id_durable(&self.durable_root)?;
4740            if batch.source_id != source_id {
4741                return Err(MongrelError::Conflict(
4742                    "replication batch source does not match follower binding".into(),
4743                ));
4744            }
4745        }
4746        if batch.requires_snapshot {
4747            return Err(MongrelError::Conflict(
4748                "replication snapshot required for this batch".into(),
4749            ));
4750        }
4751        batch.validate_proof()?;
4752        if batch.from_epoch != current {
4753            if batch.from_epoch < current && batch.current_epoch == current {
4754                let wal_dek = crate::encryption::wal_dek_for(self.kek.as_deref());
4755                let _wal = self.shared_wal.lock();
4756                let existing: HashSet<(u64, u64)> =
4757                    crate::wal::SharedWal::replay_with_dek(&self.root, wal_dek.as_ref())?
4758                        .into_iter()
4759                        .filter_map(|record| match record.op {
4760                            Op::TxnCommit { epoch, .. } => Some((record.txn_id, epoch)),
4761                            _ => None,
4762                        })
4763                        .collect();
4764                let already_applied = batch.records.iter().all(|record| match &record.op {
4765                    Op::TxnCommit { epoch, .. } => existing.contains(&(record.txn_id, *epoch)),
4766                    _ => true,
4767                });
4768                if already_applied {
4769                    return Ok(current);
4770                }
4771            }
4772            return Err(MongrelError::Conflict(format!(
4773                "replication batch starts at epoch {}, follower is at epoch {current}",
4774                batch.from_epoch
4775            )));
4776        }
4777        if batch.current_epoch < current {
4778            return Err(MongrelError::InvalidArgument(format!(
4779                "replication batch current epoch {} precedes follower epoch {current}",
4780                batch.current_epoch
4781            )));
4782        }
4783        let records = &batch.records;
4784        let mut commits = HashMap::new();
4785        let mut commit_epochs = HashSet::new();
4786        let mut commit_timestamps = HashMap::new();
4787        for record in records {
4788            match &record.op {
4789                Op::TxnCommit { epoch, added_runs } => {
4790                    if !added_runs.is_empty() {
4791                        return Err(MongrelError::Conflict(
4792                            "replication snapshot required for spilled-run transaction".into(),
4793                        ));
4794                    }
4795                    if commits.insert(record.txn_id, *epoch).is_some() {
4796                        return Err(MongrelError::InvalidArgument(format!(
4797                            "duplicate commit for replication transaction {}",
4798                            record.txn_id
4799                        )));
4800                    }
4801                    if *epoch <= current || *epoch > batch.current_epoch {
4802                        return Err(MongrelError::InvalidArgument(format!(
4803                            "replication commit epoch {epoch} is outside ({current}, {}]",
4804                            batch.current_epoch
4805                        )));
4806                    }
4807                    if !commit_epochs.insert(*epoch) {
4808                        return Err(MongrelError::InvalidArgument(format!(
4809                            "duplicate replication commit epoch {epoch}"
4810                        )));
4811                    }
4812                }
4813                Op::CommitTimestamp { unix_nanos } => {
4814                    commit_timestamps.insert(record.txn_id, *unix_nanos);
4815                }
4816                _ => {}
4817            }
4818        }
4819        for record in records {
4820            if record.txn_id == crate::wal::SYSTEM_TXN_ID
4821                || matches!(&record.op, Op::TxnAbort | Op::Flush { .. })
4822            {
4823                return Err(MongrelError::InvalidArgument(
4824                    "replication batch contains a non-committed record".into(),
4825                ));
4826            }
4827            if !commits.contains_key(&record.txn_id) {
4828                return Err(MongrelError::InvalidArgument(format!(
4829                    "incomplete replication transaction {}",
4830                    record.txn_id
4831                )));
4832            }
4833        }
4834        let target_epoch = commits
4835            .values()
4836            .copied()
4837            .filter(|epoch| *epoch > current)
4838            .max()
4839            .unwrap_or(current);
4840        if target_epoch != batch.current_epoch {
4841            return Err(MongrelError::InvalidArgument(format!(
4842                "replication batch ends at epoch {target_epoch}, expected {}",
4843                batch.current_epoch
4844            )));
4845        }
4846        let mut selected: HashSet<u64> = commits
4847            .iter()
4848            .filter_map(|(txn_id, epoch)| (*epoch > current).then_some(*txn_id))
4849            .collect();
4850        if selected.is_empty() {
4851            return Ok(current);
4852        }
4853        let mut wal = self.shared_wal.lock();
4854        wal.group_sync()?;
4855        let wal_dek = crate::encryption::wal_dek_for(self.kek.as_deref());
4856        let existing: HashSet<(u64, u64)> =
4857            crate::wal::SharedWal::replay_with_dek(&self.root, wal_dek.as_ref())?
4858                .into_iter()
4859                .filter_map(|record| match record.op {
4860                    Op::TxnCommit { epoch, .. } => Some((record.txn_id, epoch)),
4861                    _ => None,
4862                })
4863                .collect();
4864        selected.retain(|txn_id| {
4865            commits
4866                .get(txn_id)
4867                .is_some_and(|epoch| !existing.contains(&(*txn_id, *epoch)))
4868        });
4869        for record in records {
4870            if !selected.contains(&record.txn_id) {
4871                continue;
4872            }
4873            match &record.op {
4874                Op::TxnCommit { epoch, added_runs } => {
4875                    let timestamp = commit_timestamps
4876                        .get(&record.txn_id)
4877                        .copied()
4878                        .unwrap_or_else(current_unix_nanos);
4879                    wal.append_commit_at(record.txn_id, Epoch(*epoch), added_runs, timestamp)?;
4880                }
4881                Op::TxnAbort | Op::Flush { .. } | Op::CommitTimestamp { .. } => {}
4882                op => {
4883                    wal.append(record.txn_id, 0, op.clone())?;
4884                }
4885            }
4886        }
4887        if !selected.is_empty() {
4888            wal.group_sync()?;
4889        }
4890        drop(wal);
4891
4892        // Auth mode is selected before `finish_open` replays the WAL. Make the
4893        // catalog transition durable and publish security state to this live
4894        // handle before reporting success.
4895        let mut recovered_catalog = self.catalog.read().clone();
4896        if let Err(error) = recover_ddl_from_wal(
4897            &self.root,
4898            Some(&self.durable_root),
4899            &mut recovered_catalog,
4900            self.meta_dek.as_ref(),
4901            wal_dek.as_ref(),
4902            true,
4903            None,
4904        ) {
4905            return Err(MongrelError::DurableCommit {
4906                epoch: target_epoch,
4907                message: format!(
4908                    "replication WAL is durable but catalog checkpoint failed: {error}"
4909                ),
4910            });
4911        }
4912        let _security = self.security_coordinator.gate.write();
4913        let old_security_version = self.catalog.read().security_version;
4914        let security_changed = old_security_version != recovered_catalog.security_version
4915            || self.catalog.read().require_auth != recovered_catalog.require_auth;
4916        let require_auth = recovered_catalog.require_auth;
4917        let principal = if security_changed {
4918            None
4919        } else {
4920            self.principal.read().as_ref().and_then(|principal| {
4921                Self::resolve_bound_principal_from_catalog(&recovered_catalog, principal)
4922            })
4923        };
4924        if require_auth {
4925            self.auth_state.set_require_auth(true);
4926        }
4927        self.auth_state.set_principal(principal.clone());
4928        *self.principal.write() = principal;
4929        let security_version = recovered_catalog.security_version;
4930        *self.catalog.write() = recovered_catalog;
4931        self.security_coordinator
4932            .version
4933            .store(security_version, Ordering::Release);
4934        if !require_auth {
4935            self.auth_state.set_require_auth(false);
4936        }
4937        if let Err(error) =
4938            crate::replication::reconcile_replica_epoch_durable(&self.durable_root, target_epoch)
4939        {
4940            return Err(MongrelError::DurableCommit {
4941                epoch: target_epoch,
4942                message: format!(
4943                    "replication WAL and catalog are durable but follower watermark failed: {error}"
4944                ),
4945            });
4946        }
4947        Ok(target_epoch)
4948    }
4949
4950    /// Resolve a table name → id (live tables only). pub(crate) so the
4951    /// transaction layer can stage by name.
4952    pub fn table_id(&self, name: &str) -> Result<u64> {
4953        let cat = self.catalog.read();
4954        cat.live(name)
4955            .map(|e| e.table_id)
4956            .ok_or_else(|| MongrelError::NotFound(format!("table {name:?} not found")))
4957    }
4958
4959    /// Return the stable table id and current schema generation from one
4960    /// catalog snapshot. Callers can bind retries to this identity so a table
4961    /// dropped and recreated under the same name is never mistaken for the
4962    /// original resource.
4963    pub fn table_identity(&self, name: &str) -> Result<(u64, u64)> {
4964        let catalog = self.catalog.read();
4965        catalog
4966            .live(name)
4967            .map(|entry| (entry.table_id, entry.schema.schema_id))
4968            .ok_or_else(|| MongrelError::NotFound(format!("table {name:?} not found")))
4969    }
4970
4971    pub(crate) fn building_table_id(&self, name: &str) -> Result<u64> {
4972        self.catalog
4973            .read()
4974            .building(name)
4975            .map(|entry| entry.table_id)
4976            .ok_or_else(|| MongrelError::NotFound(format!("building table {name:?} not found")))
4977    }
4978
4979    pub fn procedures(&self) -> Vec<StoredProcedure> {
4980        self.catalog
4981            .read()
4982            .procedures
4983            .iter()
4984            .map(|p| p.procedure.clone())
4985            .collect()
4986    }
4987
4988    pub fn procedure(&self, name: &str) -> Option<StoredProcedure> {
4989        self.catalog
4990            .read()
4991            .procedures
4992            .iter()
4993            .find(|p| p.procedure.name == name)
4994            .map(|p| p.procedure.clone())
4995    }
4996
4997    pub fn create_procedure(&self, procedure: StoredProcedure) -> Result<StoredProcedure> {
4998        self.create_procedure_inner(procedure, None)
4999    }
5000
5001    pub fn create_procedure_controlled<F>(
5002        &self,
5003        procedure: StoredProcedure,
5004        mut before_publish: F,
5005    ) -> Result<StoredProcedure>
5006    where
5007        F: FnMut() -> Result<()>,
5008    {
5009        self.create_procedure_inner(procedure, Some(&mut before_publish))
5010    }
5011
5012    fn create_procedure_inner(
5013        &self,
5014        mut procedure: StoredProcedure,
5015        before_publish: Option<&mut dyn FnMut() -> Result<()>>,
5016    ) -> Result<StoredProcedure> {
5017        self.require(&crate::auth::Permission::Ddl)?;
5018        let _g = self.ddl_lock.lock();
5019        let _security_write = self.security_write()?;
5020        self.require(&crate::auth::Permission::Ddl)?;
5021        procedure.validate()?;
5022        self.validate_procedure_references(&procedure)?;
5023        {
5024            let cat = self.catalog.read();
5025            if cat
5026                .procedures
5027                .iter()
5028                .any(|p| p.procedure.name == procedure.name)
5029            {
5030                return Err(MongrelError::InvalidArgument(format!(
5031                    "procedure {:?} already exists",
5032                    procedure.name
5033                )));
5034            }
5035        }
5036        let commit_lock = Arc::clone(&self.commit_lock);
5037        let _c = commit_lock.lock();
5038        let epoch = self.epoch.bump_assigned();
5039        let mut _epoch_guard = EpochGuard::new(self.epoch.as_ref(), epoch);
5040        procedure.created_epoch = epoch.0;
5041        procedure.updated_epoch = epoch.0;
5042        let mut next_catalog = self.catalog.read().clone();
5043        next_catalog
5044            .procedures
5045            .push(ProcedureEntry::from(procedure.clone()));
5046        next_catalog.db_epoch = epoch.0;
5047        self.publish_catalog_candidate(next_catalog, epoch, &mut _epoch_guard, before_publish)?;
5048        Ok(procedure)
5049    }
5050
5051    pub fn create_or_replace_procedure(
5052        &self,
5053        procedure: StoredProcedure,
5054    ) -> Result<StoredProcedure> {
5055        self.create_or_replace_procedure_inner(procedure, None)
5056    }
5057
5058    pub fn create_or_replace_procedure_controlled<F>(
5059        &self,
5060        procedure: StoredProcedure,
5061        mut before_publish: F,
5062    ) -> Result<StoredProcedure>
5063    where
5064        F: FnMut() -> Result<()>,
5065    {
5066        self.create_or_replace_procedure_inner(procedure, Some(&mut before_publish))
5067    }
5068
5069    fn create_or_replace_procedure_inner(
5070        &self,
5071        procedure: StoredProcedure,
5072        before_publish: Option<&mut dyn FnMut() -> Result<()>>,
5073    ) -> Result<StoredProcedure> {
5074        self.require(&crate::auth::Permission::Ddl)?;
5075        let _g = self.ddl_lock.lock();
5076        let _security_write = self.security_write()?;
5077        self.require(&crate::auth::Permission::Ddl)?;
5078        procedure.validate()?;
5079        self.validate_procedure_references(&procedure)?;
5080        let commit_lock = Arc::clone(&self.commit_lock);
5081        let _c = commit_lock.lock();
5082        let epoch = self.epoch.bump_assigned();
5083        let mut _epoch_guard = EpochGuard::new(self.epoch.as_ref(), epoch);
5084        let mut next_catalog = self.catalog.read().clone();
5085        let replaced = {
5086            let next = match next_catalog
5087                .procedures
5088                .iter()
5089                .position(|p| p.procedure.name == procedure.name)
5090            {
5091                Some(idx) => {
5092                    let next = next_catalog.procedures[idx]
5093                        .procedure
5094                        .replaced(procedure.clone(), epoch.0)?;
5095                    next_catalog.procedures[idx] = ProcedureEntry::from(next.clone());
5096                    next
5097                }
5098                None => {
5099                    let mut next = procedure;
5100                    next.created_epoch = epoch.0;
5101                    next.updated_epoch = epoch.0;
5102                    next_catalog
5103                        .procedures
5104                        .push(ProcedureEntry::from(next.clone()));
5105                    next
5106                }
5107            };
5108            next_catalog.db_epoch = epoch.0;
5109            next
5110        };
5111        self.publish_catalog_candidate(next_catalog, epoch, &mut _epoch_guard, before_publish)?;
5112        Ok(replaced)
5113    }
5114
5115    pub fn drop_procedure(&self, name: &str) -> Result<()> {
5116        self.drop_procedure_with_epoch(name).map(|_| ())
5117    }
5118
5119    pub fn drop_procedure_with_epoch(&self, name: &str) -> Result<Epoch> {
5120        self.drop_procedure_with_epoch_inner(name, None)
5121    }
5122
5123    pub fn drop_procedure_with_epoch_controlled<F>(
5124        &self,
5125        name: &str,
5126        mut before_publish: F,
5127    ) -> Result<Epoch>
5128    where
5129        F: FnMut() -> Result<()>,
5130    {
5131        self.drop_procedure_with_epoch_inner(name, Some(&mut before_publish))
5132    }
5133
5134    fn drop_procedure_with_epoch_inner(
5135        &self,
5136        name: &str,
5137        before_publish: Option<&mut dyn FnMut() -> Result<()>>,
5138    ) -> Result<Epoch> {
5139        self.require(&crate::auth::Permission::Ddl)?;
5140        let _g = self.ddl_lock.lock();
5141        let _security_write = self.security_write()?;
5142        self.require(&crate::auth::Permission::Ddl)?;
5143        let commit_lock = Arc::clone(&self.commit_lock);
5144        let _c = commit_lock.lock();
5145        let epoch = self.epoch.bump_assigned();
5146        let mut _epoch_guard = EpochGuard::new(self.epoch.as_ref(), epoch);
5147        let mut next_catalog = self.catalog.read().clone();
5148        let before = next_catalog.procedures.len();
5149        next_catalog.procedures.retain(|p| p.procedure.name != name);
5150        if next_catalog.procedures.len() == before {
5151            return Err(MongrelError::NotFound(format!(
5152                "procedure {name:?} not found"
5153            )));
5154        }
5155        next_catalog.db_epoch = epoch.0;
5156        self.publish_catalog_candidate(next_catalog, epoch, &mut _epoch_guard, before_publish)?;
5157        Ok(epoch)
5158    }
5159
5160    // ── User / role / credentials management ─────────────────────────────
5161
5162    /// List all catalog users (password hashes included — callers should not
5163    /// serialize them externally).
5164    pub fn users(&self) -> Vec<crate::auth::UserEntry> {
5165        self.catalog.read().users.clone()
5166    }
5167
5168    /// Resolve only the stable, non-secret identity fields needed to scope
5169    /// request receipts. Password hashes never leave the catalog lock.
5170    pub fn user_identity(&self, username: &str) -> Option<(u64, u64)> {
5171        self.catalog
5172            .read()
5173            .users
5174            .iter()
5175            .find(|user| user.username == username)
5176            .map(|user| (user.id, user.created_epoch))
5177    }
5178
5179    /// Current catalog authorization generation. Retry bindings can include
5180    /// this value to fail closed after roles, grants, or row policies change.
5181    pub fn security_version(&self) -> u64 {
5182        self.catalog.read().security_version
5183    }
5184
5185    /// List all catalog roles.
5186    pub fn roles(&self) -> Vec<crate::auth::RoleEntry> {
5187        self.catalog.read().roles.clone()
5188    }
5189
5190    /// Create a new user with an Argon2id-hashed password.
5191    pub fn create_user(&self, username: &str, password: &str) -> Result<crate::auth::UserEntry> {
5192        self.require(&crate::auth::Permission::Admin)?;
5193        let hash = crate::auth::hash_password(password).map_err(MongrelError::Other)?;
5194        self.create_user_with_password_hash(username, hash)
5195    }
5196
5197    /// Create a user from a password hash prepared before a commit fence.
5198    pub fn create_user_with_password_hash(
5199        &self,
5200        username: &str,
5201        hash: String,
5202    ) -> Result<crate::auth::UserEntry> {
5203        self.create_user_with_password_hash_inner(username, hash, None)
5204    }
5205
5206    pub fn create_user_with_password_hash_controlled<F>(
5207        &self,
5208        username: &str,
5209        hash: String,
5210        mut before_publish: F,
5211    ) -> Result<crate::auth::UserEntry>
5212    where
5213        F: FnMut() -> Result<()>,
5214    {
5215        self.create_user_with_password_hash_inner(username, hash, Some(&mut before_publish))
5216    }
5217
5218    fn create_user_with_password_hash_inner(
5219        &self,
5220        username: &str,
5221        hash: String,
5222        before_publish: Option<&mut dyn FnMut() -> Result<()>>,
5223    ) -> Result<crate::auth::UserEntry> {
5224        self.require(&crate::auth::Permission::Admin)?;
5225        let _ddl = self.ddl_lock.lock();
5226        let _security_write = self.security_write()?;
5227        self.require(&crate::auth::Permission::Admin)?;
5228        let _commit = self.commit_lock.lock();
5229        let epoch = self.epoch.bump_assigned();
5230        let mut _epoch_guard = EpochGuard::new(self.epoch.as_ref(), epoch);
5231        let mut next_catalog = self.catalog.read().clone();
5232        if next_catalog.users.iter().any(|u| u.username == username) {
5233            return Err(MongrelError::InvalidArgument(format!(
5234                "user {username:?} already exists"
5235            )));
5236        }
5237        next_catalog.next_user_id = next_catalog.next_user_id.max(1);
5238        let id = next_catalog.next_user_id;
5239        next_catalog.next_user_id = id
5240            .checked_add(1)
5241            .ok_or_else(|| MongrelError::Full("user-id namespace exhausted".into()))?;
5242        let entry = crate::auth::UserEntry {
5243            id,
5244            username: username.into(),
5245            password_hash: hash,
5246            roles: Vec::new(),
5247            is_admin: false,
5248            created_epoch: epoch.0,
5249        };
5250        next_catalog.users.push(entry.clone());
5251        advance_security_version(&mut next_catalog)?;
5252        next_catalog.db_epoch = epoch.0;
5253        self.publish_catalog_candidate(next_catalog, epoch, &mut _epoch_guard, before_publish)?;
5254        Ok(entry)
5255    }
5256
5257    /// Drop a user by username.
5258    pub fn drop_user(&self, username: &str) -> Result<()> {
5259        self.drop_user_with_epoch(username).map(|_| ())
5260    }
5261
5262    pub fn drop_user_with_epoch(&self, username: &str) -> Result<Epoch> {
5263        self.drop_user_with_epoch_inner(username, None)
5264    }
5265
5266    pub fn drop_user_with_epoch_controlled<F>(
5267        &self,
5268        username: &str,
5269        mut before_publish: F,
5270    ) -> Result<Epoch>
5271    where
5272        F: FnMut() -> Result<()>,
5273    {
5274        self.drop_user_with_epoch_inner(username, Some(&mut before_publish))
5275    }
5276
5277    fn drop_user_with_epoch_inner(
5278        &self,
5279        username: &str,
5280        before_publish: Option<&mut dyn FnMut() -> Result<()>>,
5281    ) -> Result<Epoch> {
5282        self.require(&crate::auth::Permission::Admin)?;
5283        let _ddl = self.ddl_lock.lock();
5284        let _security_write = self.security_write()?;
5285        self.require(&crate::auth::Permission::Admin)?;
5286        let _commit = self.commit_lock.lock();
5287        let epoch = self.epoch.bump_assigned();
5288        let mut _epoch_guard = EpochGuard::new(self.epoch.as_ref(), epoch);
5289        let mut next_catalog = self.catalog.read().clone();
5290        let before = next_catalog.users.len();
5291        next_catalog.users.retain(|u| u.username != username);
5292        if next_catalog.users.len() == before {
5293            return Err(MongrelError::NotFound(format!(
5294                "user {username:?} not found"
5295            )));
5296        }
5297        advance_security_version(&mut next_catalog)?;
5298        next_catalog.db_epoch = epoch.0;
5299        self.publish_catalog_candidate(next_catalog, epoch, &mut _epoch_guard, before_publish)?;
5300        Ok(epoch)
5301    }
5302
5303    /// Change a user's password.
5304    pub fn alter_user_password(&self, username: &str, new_password: &str) -> Result<()> {
5305        self.alter_user_password_with_epoch(username, new_password)
5306            .map(|_| ())
5307    }
5308
5309    pub fn alter_user_password_with_epoch(
5310        &self,
5311        username: &str,
5312        new_password: &str,
5313    ) -> Result<Epoch> {
5314        self.require(&crate::auth::Permission::Admin)?;
5315        let hash = crate::auth::hash_password(new_password).map_err(MongrelError::Other)?;
5316        self.alter_user_password_hash_with_epoch(username, hash)
5317    }
5318
5319    pub fn alter_user_password_hash_with_epoch(
5320        &self,
5321        username: &str,
5322        hash: String,
5323    ) -> Result<Epoch> {
5324        self.alter_user_password_hash_with_epoch_inner(username, hash, None)
5325    }
5326
5327    pub fn alter_user_password_hash_with_epoch_controlled<F>(
5328        &self,
5329        username: &str,
5330        hash: String,
5331        mut before_publish: F,
5332    ) -> Result<Epoch>
5333    where
5334        F: FnMut() -> Result<()>,
5335    {
5336        self.alter_user_password_hash_with_epoch_inner(username, hash, Some(&mut before_publish))
5337    }
5338
5339    fn alter_user_password_hash_with_epoch_inner(
5340        &self,
5341        username: &str,
5342        hash: String,
5343        before_publish: Option<&mut dyn FnMut() -> Result<()>>,
5344    ) -> Result<Epoch> {
5345        self.require(&crate::auth::Permission::Admin)?;
5346        let _ddl = self.ddl_lock.lock();
5347        let _security_write = self.security_write()?;
5348        self.require(&crate::auth::Permission::Admin)?;
5349        let _commit = self.commit_lock.lock();
5350        let epoch = self.epoch.bump_assigned();
5351        let mut _epoch_guard = EpochGuard::new(self.epoch.as_ref(), epoch);
5352        let mut next_catalog = self.catalog.read().clone();
5353        let user = next_catalog
5354            .users
5355            .iter_mut()
5356            .find(|u| u.username == username)
5357            .ok_or_else(|| MongrelError::NotFound(format!("user {username:?} not found")))?;
5358        user.password_hash = hash;
5359        advance_security_version(&mut next_catalog)?;
5360        next_catalog.db_epoch = epoch.0;
5361        self.publish_catalog_candidate(next_catalog, epoch, &mut _epoch_guard, before_publish)?;
5362        Ok(epoch)
5363    }
5364
5365    /// Verify credentials. Returns `Some(entry)` on success, `None` on
5366    /// mismatch, `Err` on engine error.
5367    pub fn verify_user(
5368        &self,
5369        username: &str,
5370        password: &str,
5371    ) -> Result<Option<crate::auth::UserEntry>> {
5372        let cat = self.catalog.read();
5373        let Some(user) = cat.users.iter().find(|u| u.username == username) else {
5374            return Ok(None);
5375        };
5376        if user.password_hash.is_empty() {
5377            return Ok(None);
5378        }
5379        let ok = crate::auth::verify_password(password, &user.password_hash)
5380            .map_err(MongrelError::Other)?;
5381        if ok {
5382            Ok(Some(user.clone()))
5383        } else {
5384            Ok(None)
5385        }
5386    }
5387
5388    /// Authenticate and resolve one immutable principal from the same catalog
5389    /// snapshot. Username reuse cannot bridge the password check and principal
5390    /// resolution.
5391    pub fn authenticate_principal(
5392        &self,
5393        username: &str,
5394        password: &str,
5395    ) -> Result<Option<crate::auth::Principal>> {
5396        self.authenticate_principal_inner(username, password, || {})
5397    }
5398
5399    fn authenticate_principal_inner<F>(
5400        &self,
5401        username: &str,
5402        password: &str,
5403        after_verify: F,
5404    ) -> Result<Option<crate::auth::Principal>>
5405    where
5406        F: FnOnce(),
5407    {
5408        let catalog = self.catalog.read();
5409        let Some(user) = catalog.users.iter().find(|user| user.username == username) else {
5410            return Ok(None);
5411        };
5412        if user.password_hash.is_empty()
5413            || !crate::auth::verify_password(password, &user.password_hash)
5414                .map_err(MongrelError::Other)?
5415        {
5416            return Ok(None);
5417        }
5418        after_verify();
5419        Ok(Self::resolve_user_principal_from_catalog(&catalog, user))
5420    }
5421
5422    /// Grant admin privileges to a user (bypasses all permission checks).
5423    pub fn set_user_admin(&self, username: &str, is_admin: bool) -> Result<()> {
5424        self.set_user_admin_with_epoch(username, is_admin)
5425            .map(|_| ())
5426    }
5427
5428    pub fn set_user_admin_with_epoch(
5429        &self,
5430        username: &str,
5431        is_admin: bool,
5432    ) -> Result<Option<Epoch>> {
5433        self.set_user_admin_with_epoch_inner(username, is_admin, None)
5434    }
5435
5436    pub fn set_user_admin_with_epoch_controlled<F>(
5437        &self,
5438        username: &str,
5439        is_admin: bool,
5440        mut before_publish: F,
5441    ) -> Result<Option<Epoch>>
5442    where
5443        F: FnMut() -> Result<()>,
5444    {
5445        self.set_user_admin_with_epoch_inner(username, is_admin, Some(&mut before_publish))
5446    }
5447
5448    fn set_user_admin_with_epoch_inner(
5449        &self,
5450        username: &str,
5451        is_admin: bool,
5452        before_publish: Option<&mut dyn FnMut() -> Result<()>>,
5453    ) -> Result<Option<Epoch>> {
5454        self.require(&crate::auth::Permission::Admin)?;
5455        let _ddl = self.ddl_lock.lock();
5456        let _security_write = self.security_write()?;
5457        self.require(&crate::auth::Permission::Admin)?;
5458        let _commit = self.commit_lock.lock();
5459        let mut next_catalog = self.catalog.read().clone();
5460        let user = next_catalog
5461            .users
5462            .iter_mut()
5463            .find(|u| u.username == username)
5464            .ok_or_else(|| MongrelError::NotFound(format!("user {username:?} not found")))?;
5465        if user.is_admin == is_admin {
5466            return Ok(None);
5467        }
5468        user.is_admin = is_admin;
5469        let epoch = self.epoch.bump_assigned();
5470        let mut _epoch_guard = EpochGuard::new(self.epoch.as_ref(), epoch);
5471        advance_security_version(&mut next_catalog)?;
5472        next_catalog.db_epoch = epoch.0;
5473        self.publish_catalog_candidate(next_catalog, epoch, &mut _epoch_guard, before_publish)?;
5474        Ok(Some(epoch))
5475    }
5476
5477    /// Create a new role.
5478    pub fn create_role(&self, name: &str) -> Result<crate::auth::RoleEntry> {
5479        self.create_role_inner(name, None)
5480    }
5481
5482    pub fn create_role_controlled<F>(
5483        &self,
5484        name: &str,
5485        mut before_publish: F,
5486    ) -> Result<crate::auth::RoleEntry>
5487    where
5488        F: FnMut() -> Result<()>,
5489    {
5490        self.create_role_inner(name, Some(&mut before_publish))
5491    }
5492
5493    fn create_role_inner(
5494        &self,
5495        name: &str,
5496        before_publish: Option<&mut dyn FnMut() -> Result<()>>,
5497    ) -> Result<crate::auth::RoleEntry> {
5498        self.require(&crate::auth::Permission::Admin)?;
5499        let _ddl = self.ddl_lock.lock();
5500        let _security_write = self.security_write()?;
5501        self.require(&crate::auth::Permission::Admin)?;
5502        let _commit = self.commit_lock.lock();
5503        let epoch = self.epoch.bump_assigned();
5504        let mut _epoch_guard = EpochGuard::new(self.epoch.as_ref(), epoch);
5505        let mut next_catalog = self.catalog.read().clone();
5506        if next_catalog.roles.iter().any(|r| r.name == name) {
5507            return Err(MongrelError::InvalidArgument(format!(
5508                "role {name:?} already exists"
5509            )));
5510        }
5511        let entry = crate::auth::RoleEntry {
5512            name: name.into(),
5513            permissions: Vec::new(),
5514            created_epoch: epoch.0,
5515        };
5516        next_catalog.roles.push(entry.clone());
5517        advance_security_version(&mut next_catalog)?;
5518        next_catalog.db_epoch = epoch.0;
5519        self.publish_catalog_candidate(next_catalog, epoch, &mut _epoch_guard, before_publish)?;
5520        Ok(entry)
5521    }
5522
5523    /// Drop a role by name.
5524    pub fn drop_role(&self, name: &str) -> Result<()> {
5525        self.drop_role_with_epoch(name).map(|_| ())
5526    }
5527
5528    pub fn drop_role_with_epoch(&self, name: &str) -> Result<Epoch> {
5529        self.drop_role_with_epoch_inner(name, None)
5530    }
5531
5532    pub fn drop_role_with_epoch_controlled<F>(
5533        &self,
5534        name: &str,
5535        mut before_publish: F,
5536    ) -> Result<Epoch>
5537    where
5538        F: FnMut() -> Result<()>,
5539    {
5540        self.drop_role_with_epoch_inner(name, Some(&mut before_publish))
5541    }
5542
5543    fn drop_role_with_epoch_inner(
5544        &self,
5545        name: &str,
5546        before_publish: Option<&mut dyn FnMut() -> Result<()>>,
5547    ) -> Result<Epoch> {
5548        self.require(&crate::auth::Permission::Admin)?;
5549        let _ddl = self.ddl_lock.lock();
5550        let _security_write = self.security_write()?;
5551        self.require(&crate::auth::Permission::Admin)?;
5552        let _commit = self.commit_lock.lock();
5553        let epoch = self.epoch.bump_assigned();
5554        let mut _epoch_guard = EpochGuard::new(self.epoch.as_ref(), epoch);
5555        let mut next_catalog = self.catalog.read().clone();
5556        let before = next_catalog.roles.len();
5557        next_catalog.roles.retain(|r| r.name != name);
5558        if next_catalog.roles.len() == before {
5559            return Err(MongrelError::NotFound(format!("role {name:?} not found")));
5560        }
5561        for user in &mut next_catalog.users {
5562            user.roles.retain(|r| r != name);
5563        }
5564        advance_security_version(&mut next_catalog)?;
5565        next_catalog.db_epoch = epoch.0;
5566        self.publish_catalog_candidate(next_catalog, epoch, &mut _epoch_guard, before_publish)?;
5567        Ok(epoch)
5568    }
5569
5570    /// Grant a role to a user.
5571    pub fn grant_role(&self, username: &str, role_name: &str) -> Result<()> {
5572        self.grant_role_with_epoch(username, role_name).map(|_| ())
5573    }
5574
5575    pub fn grant_role_with_epoch(&self, username: &str, role_name: &str) -> Result<Option<Epoch>> {
5576        self.grant_role_with_epoch_inner(username, role_name, None)
5577    }
5578
5579    pub fn grant_role_with_epoch_controlled<F>(
5580        &self,
5581        username: &str,
5582        role_name: &str,
5583        mut before_publish: F,
5584    ) -> Result<Option<Epoch>>
5585    where
5586        F: FnMut() -> Result<()>,
5587    {
5588        self.grant_role_with_epoch_inner(username, role_name, Some(&mut before_publish))
5589    }
5590
5591    fn grant_role_with_epoch_inner(
5592        &self,
5593        username: &str,
5594        role_name: &str,
5595        before_publish: Option<&mut dyn FnMut() -> Result<()>>,
5596    ) -> Result<Option<Epoch>> {
5597        self.require(&crate::auth::Permission::Admin)?;
5598        let _ddl = self.ddl_lock.lock();
5599        let _security_write = self.security_write()?;
5600        self.require(&crate::auth::Permission::Admin)?;
5601        let _commit = self.commit_lock.lock();
5602        let mut next_catalog = self.catalog.read().clone();
5603        if !next_catalog.roles.iter().any(|r| r.name == role_name) {
5604            return Err(MongrelError::NotFound(format!(
5605                "role {role_name:?} not found"
5606            )));
5607        }
5608        let user = next_catalog
5609            .users
5610            .iter_mut()
5611            .find(|u| u.username == username)
5612            .ok_or_else(|| MongrelError::NotFound(format!("user {username:?} not found")))?;
5613        if user.roles.iter().any(|role| role == role_name) {
5614            return Ok(None);
5615        }
5616        user.roles.push(role_name.into());
5617        let epoch = self.epoch.bump_assigned();
5618        let mut _epoch_guard = EpochGuard::new(self.epoch.as_ref(), epoch);
5619        advance_security_version(&mut next_catalog)?;
5620        next_catalog.db_epoch = epoch.0;
5621        self.publish_catalog_candidate(next_catalog, epoch, &mut _epoch_guard, before_publish)?;
5622        Ok(Some(epoch))
5623    }
5624
5625    /// Revoke a role from a user.
5626    pub fn revoke_role(&self, username: &str, role_name: &str) -> Result<()> {
5627        self.revoke_role_with_epoch(username, role_name).map(|_| ())
5628    }
5629
5630    pub fn revoke_role_with_epoch(&self, username: &str, role_name: &str) -> Result<Option<Epoch>> {
5631        self.revoke_role_with_epoch_inner(username, role_name, None)
5632    }
5633
5634    pub fn revoke_role_with_epoch_controlled<F>(
5635        &self,
5636        username: &str,
5637        role_name: &str,
5638        mut before_publish: F,
5639    ) -> Result<Option<Epoch>>
5640    where
5641        F: FnMut() -> Result<()>,
5642    {
5643        self.revoke_role_with_epoch_inner(username, role_name, Some(&mut before_publish))
5644    }
5645
5646    fn revoke_role_with_epoch_inner(
5647        &self,
5648        username: &str,
5649        role_name: &str,
5650        before_publish: Option<&mut dyn FnMut() -> Result<()>>,
5651    ) -> Result<Option<Epoch>> {
5652        self.require(&crate::auth::Permission::Admin)?;
5653        let _ddl = self.ddl_lock.lock();
5654        let _security_write = self.security_write()?;
5655        self.require(&crate::auth::Permission::Admin)?;
5656        let _commit = self.commit_lock.lock();
5657        let mut next_catalog = self.catalog.read().clone();
5658        let user = next_catalog
5659            .users
5660            .iter_mut()
5661            .find(|u| u.username == username)
5662            .ok_or_else(|| MongrelError::NotFound(format!("user {username:?} not found")))?;
5663        let before = user.roles.len();
5664        user.roles.retain(|r| r != role_name);
5665        if user.roles.len() == before {
5666            return Ok(None);
5667        }
5668        let epoch = self.epoch.bump_assigned();
5669        let mut _epoch_guard = EpochGuard::new(self.epoch.as_ref(), epoch);
5670        advance_security_version(&mut next_catalog)?;
5671        next_catalog.db_epoch = epoch.0;
5672        self.publish_catalog_candidate(next_catalog, epoch, &mut _epoch_guard, before_publish)?;
5673        Ok(Some(epoch))
5674    }
5675
5676    /// Grant a permission to a role.
5677    pub fn grant_permission(
5678        &self,
5679        role_name: &str,
5680        permission: crate::auth::Permission,
5681    ) -> Result<()> {
5682        self.grant_permission_with_epoch(role_name, permission)
5683            .map(|_| ())
5684    }
5685
5686    pub fn grant_permission_with_epoch(
5687        &self,
5688        role_name: &str,
5689        permission: crate::auth::Permission,
5690    ) -> Result<Option<Epoch>> {
5691        self.grant_permission_with_epoch_inner(role_name, permission, None)
5692    }
5693
5694    pub fn grant_permission_with_epoch_controlled<F>(
5695        &self,
5696        role_name: &str,
5697        permission: crate::auth::Permission,
5698        mut before_publish: F,
5699    ) -> Result<Option<Epoch>>
5700    where
5701        F: FnMut() -> Result<()>,
5702    {
5703        self.grant_permission_with_epoch_inner(role_name, permission, Some(&mut before_publish))
5704    }
5705
5706    fn grant_permission_with_epoch_inner(
5707        &self,
5708        role_name: &str,
5709        permission: crate::auth::Permission,
5710        before_publish: Option<&mut dyn FnMut() -> Result<()>>,
5711    ) -> Result<Option<Epoch>> {
5712        self.require(&crate::auth::Permission::Admin)?;
5713        let _ddl = self.ddl_lock.lock();
5714        let _security_write = self.security_write()?;
5715        self.require(&crate::auth::Permission::Admin)?;
5716        let _commit = self.commit_lock.lock();
5717        let mut next_catalog = self.catalog.read().clone();
5718        let role = next_catalog
5719            .roles
5720            .iter_mut()
5721            .find(|r| r.name == role_name)
5722            .ok_or_else(|| MongrelError::NotFound(format!("role {role_name:?} not found")))?;
5723        let before = role.permissions.clone();
5724        merge_permission(&mut role.permissions, permission);
5725        if role.permissions == before {
5726            return Ok(None);
5727        }
5728        let epoch = self.epoch.bump_assigned();
5729        let mut _epoch_guard = EpochGuard::new(self.epoch.as_ref(), epoch);
5730        advance_security_version(&mut next_catalog)?;
5731        next_catalog.db_epoch = epoch.0;
5732        self.publish_catalog_candidate(next_catalog, epoch, &mut _epoch_guard, before_publish)?;
5733        Ok(Some(epoch))
5734    }
5735
5736    /// Revoke a permission from a role.
5737    pub fn revoke_permission(
5738        &self,
5739        role_name: &str,
5740        permission: crate::auth::Permission,
5741    ) -> Result<()> {
5742        self.revoke_permission_with_epoch(role_name, permission)
5743            .map(|_| ())
5744    }
5745
5746    pub fn revoke_permission_with_epoch(
5747        &self,
5748        role_name: &str,
5749        permission: crate::auth::Permission,
5750    ) -> Result<Option<Epoch>> {
5751        self.revoke_permission_with_epoch_inner(role_name, permission, None)
5752    }
5753
5754    pub fn revoke_permission_with_epoch_controlled<F>(
5755        &self,
5756        role_name: &str,
5757        permission: crate::auth::Permission,
5758        mut before_publish: F,
5759    ) -> Result<Option<Epoch>>
5760    where
5761        F: FnMut() -> Result<()>,
5762    {
5763        self.revoke_permission_with_epoch_inner(role_name, permission, Some(&mut before_publish))
5764    }
5765
5766    fn revoke_permission_with_epoch_inner(
5767        &self,
5768        role_name: &str,
5769        permission: crate::auth::Permission,
5770        before_publish: Option<&mut dyn FnMut() -> Result<()>>,
5771    ) -> Result<Option<Epoch>> {
5772        self.require(&crate::auth::Permission::Admin)?;
5773        let _ddl = self.ddl_lock.lock();
5774        let _security_write = self.security_write()?;
5775        self.require(&crate::auth::Permission::Admin)?;
5776        let _commit = self.commit_lock.lock();
5777        let mut next_catalog = self.catalog.read().clone();
5778        let role = next_catalog
5779            .roles
5780            .iter_mut()
5781            .find(|r| r.name == role_name)
5782            .ok_or_else(|| MongrelError::NotFound(format!("role {role_name:?} not found")))?;
5783        let before = role.permissions.clone();
5784        revoke_permission_from(&mut role.permissions, &permission);
5785        if role.permissions == before {
5786            return Ok(None);
5787        }
5788        let epoch = self.epoch.bump_assigned();
5789        let mut _epoch_guard = EpochGuard::new(self.epoch.as_ref(), epoch);
5790        advance_security_version(&mut next_catalog)?;
5791        next_catalog.db_epoch = epoch.0;
5792        self.publish_catalog_candidate(next_catalog, epoch, &mut _epoch_guard, before_publish)?;
5793        Ok(Some(epoch))
5794    }
5795
5796    /// Resolve a user into a [`crate::auth::Principal`] by collecting all
5797    /// permissions from their roles. Returns `None` if the user doesn't exist.
5798    pub fn resolve_principal(&self, username: &str) -> Option<crate::auth::Principal> {
5799        let cat = self.catalog.read();
5800        Self::resolve_principal_from_catalog(&cat, username)
5801    }
5802
5803    /// Re-resolve only when the immutable user identity still exists. This is
5804    /// the server/session validation path; username reuse never matches.
5805    pub fn resolve_current_principal(
5806        &self,
5807        principal: &crate::auth::Principal,
5808    ) -> Option<crate::auth::Principal> {
5809        Self::resolve_bound_principal_from_catalog(&self.catalog.read(), principal)
5810    }
5811
5812    /// Resolve a username to a [`Principal`] directly from a catalog snapshot,
5813    /// without needing a constructed `Database`. Used by the credentialed open
5814    /// path (which must verify credentials before the `Database` exists) and
5815    /// by [`resolve_principal`](Self::resolve_principal).
5816    fn resolve_principal_from_catalog(
5817        cat: &Catalog,
5818        username: &str,
5819    ) -> Option<crate::auth::Principal> {
5820        let user = cat.users.iter().find(|u| u.username == username)?;
5821        Self::resolve_user_principal_from_catalog(cat, user)
5822    }
5823
5824    fn resolve_bound_principal_from_catalog(
5825        cat: &Catalog,
5826        principal: &crate::auth::Principal,
5827    ) -> Option<crate::auth::Principal> {
5828        let user = cat.users.iter().find(|user| {
5829            user.id == principal.user_id
5830                && user.created_epoch == principal.created_epoch
5831                && user.username == principal.username
5832        })?;
5833        Self::resolve_user_principal_from_catalog(cat, user)
5834    }
5835
5836    fn resolve_user_principal_from_catalog(
5837        cat: &Catalog,
5838        user: &crate::auth::UserEntry,
5839    ) -> Option<crate::auth::Principal> {
5840        let mut permissions = Vec::new();
5841        for role_name in &user.roles {
5842            if let Some(role) = cat.roles.iter().find(|r| &r.name == role_name) {
5843                permissions.extend(role.permissions.iter().cloned());
5844            }
5845        }
5846        Some(crate::auth::Principal {
5847            user_id: user.id,
5848            created_epoch: user.created_epoch,
5849            username: user.username.clone(),
5850            is_admin: user.is_admin,
5851            roles: user.roles.clone(),
5852            permissions,
5853        })
5854    }
5855
5856    /// Check whether a user has a specific permission (via their roles).
5857    pub fn check_permission(&self, username: &str, permission: &crate::auth::Permission) -> bool {
5858        match self.resolve_principal(username) {
5859            Some(p) => p.has_permission(permission),
5860            None => false,
5861        }
5862    }
5863
5864    /// Returns `true` if this database's catalog has `require_auth = true`.
5865    /// When true, every operation consults the cached [`Principal`] via
5866    /// [`require`](Self::require).
5867    pub fn require_auth_enabled(&self) -> bool {
5868        self.catalog.read().require_auth
5869    }
5870
5871    /// A snapshot of the cached principal for this handle, if any. `None` for
5872    /// databases opened without credentials (the default). Returns a clone
5873    /// because the principal lives behind an `RwLock`.
5874    pub fn principal(&self) -> Option<crate::auth::Principal> {
5875        self.principal.read().clone()
5876    }
5877
5878    /// Build a `TableAuthChecker` from the current auth state. Used when
5879    /// mounting a new table (`create_table`) so the table inherits the
5880    /// database's enforcement configuration. The checker reads the live
5881    /// `require_auth` flag and cached principal, so changes via `enable_auth`
5882    /// / `refresh_principal` propagate to already-mounted tables.
5883    fn table_auth_checker(&self) -> Option<Arc<dyn crate::auth_state::TableAuthChecker>> {
5884        Some(Arc::new(crate::auth_state::DefaultTableAuthChecker::new(
5885            self.auth_state.clone(),
5886        )))
5887    }
5888
5889    /// Re-resolve the cached principal from the shared current catalog.
5890    /// Long-lived
5891    /// handles (e.g. a daemon) call this after a `REVOKE` or role change —
5892    /// possibly made by a different handle to the same database — to pick up
5893    /// the new effective permissions without re-verifying the password.
5894    ///
5895    /// The process-wide security version reloads from disk only when another
5896    /// handle published a newer catalog. The username is taken from
5897    /// the existing cached principal; if the user has since been dropped,
5898    /// returns [`MongrelError::InvalidCredentials`].
5899    ///
5900    /// No-op (returns `Ok(())`) on a credentialless database, or on a
5901    /// credentialed database whose cached principal is `None`.
5902    pub fn refresh_principal(&self) -> Result<()> {
5903        let previous = match self.principal.read().clone() {
5904            Some(principal) => principal,
5905            None => return Ok(()),
5906        };
5907        let observed_version = self.security_coordinator.version.load(Ordering::Acquire);
5908        self.refresh_security_catalog_if_stale(observed_version)?;
5909        let cat = self.catalog.read();
5910        match Self::resolve_bound_principal_from_catalog(&cat, &previous) {
5911            Some(p) => {
5912                *self.principal.write() = Some(p.clone());
5913                // Update the shared auth state so mounted Tables see the new
5914                // permissions immediately (Tables read from AuthState, not from
5915                // self.principal).
5916                self.auth_state.set_principal(Some(p));
5917                Ok(())
5918            }
5919            None => Err(MongrelError::InvalidCredentials {
5920                username: previous.username,
5921            }),
5922        }
5923    }
5924
5925    /// Number of security-catalog disk reloads performed by this open handle.
5926    /// Initial open reads are excluded.
5927    pub fn security_catalog_disk_read_count(&self) -> u64 {
5928        self.security_catalog_disk_reads.load(Ordering::Relaxed)
5929    }
5930
5931    /// Convert a credentialless database to a credentialed one: create the
5932    /// first admin user, set `require_auth = true`, and cache the admin
5933    /// principal on this handle so subsequent operations on the same handle
5934    /// continue to work. After this call, the database can only be reopened
5935    /// via `open_with_credentials` / `open_encrypted_with_credentials`.
5936    ///
5937    /// Refuses if the database already has `require_auth = true`. This is
5938    /// the conversion path for existing databases; for fresh databases,
5939    /// `create_with_credentials` sets everything up atomically.
5940    ///
5941    /// See `docs/15-credential-enforcement.md`.
5942    pub fn enable_auth(&self, admin_username: &str, admin_password: &str) -> Result<()> {
5943        let password_hash =
5944            crate::auth::hash_password(admin_password).map_err(MongrelError::Other)?;
5945        let _ddl = self.ddl_lock.lock();
5946        let _security_write = self.security_write()?;
5947        let _commit = self.commit_lock.lock();
5948        let epoch = self.epoch.bump_assigned();
5949        let mut _epoch_guard = EpochGuard::new(self.epoch.as_ref(), epoch);
5950        let mut next_catalog = self.catalog.read().clone();
5951        if next_catalog.require_auth {
5952            return Err(MongrelError::InvalidArgument(
5953                "database already has require_auth enabled".into(),
5954            ));
5955        }
5956        if next_catalog
5957            .users
5958            .iter()
5959            .any(|u| u.username == admin_username)
5960        {
5961            return Err(MongrelError::InvalidArgument(format!(
5962                "user {admin_username:?} already exists"
5963            )));
5964        }
5965        next_catalog.next_user_id = next_catalog.next_user_id.max(1);
5966        let id = next_catalog.next_user_id;
5967        next_catalog.next_user_id = id
5968            .checked_add(1)
5969            .ok_or_else(|| MongrelError::Full("user-id namespace exhausted".into()))?;
5970        next_catalog.users.push(crate::auth::UserEntry {
5971            id,
5972            username: admin_username.to_string(),
5973            password_hash,
5974            roles: Vec::new(),
5975            is_admin: true,
5976            created_epoch: epoch.0,
5977        });
5978        next_catalog.require_auth = true;
5979        advance_security_version(&mut next_catalog)?;
5980        next_catalog.db_epoch = epoch.0;
5981        let publish = self.publish_catalog_candidate(next_catalog, epoch, &mut _epoch_guard, None);
5982        // Cache the admin principal on this handle + update the shared auth
5983        // state whenever rename published, even if directory fsync was
5984        // inconclusive.
5985        if publish.is_ok() || matches!(&publish, Err(MongrelError::CommitOutcomeUnknown { .. })) {
5986            let principal = crate::auth::Principal {
5987                user_id: id,
5988                created_epoch: epoch.0,
5989                username: admin_username.to_string(),
5990                is_admin: true,
5991                roles: Vec::new(),
5992                permissions: Vec::new(),
5993            };
5994            *self.principal.write() = Some(principal.clone());
5995            self.auth_state.set_principal(Some(principal));
5996        }
5997        publish
5998    }
5999
6000    /// Disable `require_auth` on this database, reverting it to credentialless
6001    /// mode. This is the **recovery** path — it requires the handle to already
6002    /// be open (and therefore already authenticated if `require_auth` was on).
6003    ///
6004    /// After this call, the database can be reopened with plain
6005    /// [`open`](Self::open) / [`open_encrypted`](Self::open_encrypted) without
6006    /// credentials. All existing users and roles are preserved in the catalog
6007    /// (so `require_auth` can be re-enabled without recreating them), but they
6008    /// are no longer consulted for enforcement.
6009    ///
6010    /// For true **offline** recovery (when credentials are lost and no
6011    /// authenticated handle is available), the caller opens the database
6012    /// directly via the catalog file (filesystem access required) and calls
6013    /// this method — see the CLI's `auth disable-offline` command.
6014    ///
6015    /// See `docs/15-credential-enforcement.md` §4.7.
6016    pub fn disable_auth(&self) -> Result<()> {
6017        let _ddl = self.ddl_lock.lock();
6018        let _security_write = self.security_write()?;
6019        let _commit = self.commit_lock.lock();
6020        let epoch = self.epoch.bump_assigned();
6021        let mut _epoch_guard = EpochGuard::new(self.epoch.as_ref(), epoch);
6022        let mut next_catalog = self.catalog.read().clone();
6023        if !next_catalog.require_auth {
6024            return Err(MongrelError::InvalidArgument(
6025                "database does not have require_auth enabled".into(),
6026            ));
6027        }
6028        next_catalog.require_auth = false;
6029        advance_security_version(&mut next_catalog)?;
6030        next_catalog.db_epoch = epoch.0;
6031        let publish = self.publish_catalog_candidate(next_catalog, epoch, &mut _epoch_guard, None);
6032        // Clear the cached principal — enforcement is now off.
6033        if publish.is_ok() || matches!(&publish, Err(MongrelError::CommitOutcomeUnknown { .. })) {
6034            *self.principal.write() = None;
6035        }
6036        publish
6037    }
6038
6039    /// Enforcement check: if the catalog has `require_auth = true`, verify
6040    /// that the cached principal satisfies `perm`. Called by every
6041    /// enforcement point (DDL, admin, maintenance, and — in Phase 2 —
6042    /// Table/Transaction/MongrelSession operations).
6043    ///
6044    /// On a credentialless database this is a no-op (`Ok(())`).
6045    pub fn require(&self, perm: &crate::auth::Permission) -> Result<()> {
6046        if self.read_only && !matches!(perm, crate::auth::Permission::Select { .. }) {
6047            return Err(MongrelError::ReadOnlyReplica);
6048        }
6049        if self.principal.read().is_some() {
6050            self.refresh_principal().map_err(|error| match error {
6051                MongrelError::InvalidCredentials { .. } => MongrelError::AuthRequired,
6052                error => error,
6053            })?;
6054        }
6055        if !self.catalog.read().require_auth {
6056            return Ok(());
6057        }
6058        let guard = self.principal.read();
6059        let p = guard.as_ref().ok_or(MongrelError::AuthRequired)?;
6060        if p.has_permission(perm) {
6061            Ok(())
6062        } else {
6063            Err(MongrelError::PermissionDenied {
6064                required: perm.clone(),
6065                principal: p.username.clone(),
6066            })
6067        }
6068    }
6069
6070    /// Convenience: enforce a table-level permission (`Select`/`Insert`/
6071    /// `Update`/`Delete`) by table name. Used by the Transaction layer and
6072    /// other callers that know the operation kind + table name but don't want
6073    /// to construct the full `Permission` enum value themselves.
6074    pub fn require_table(
6075        &self,
6076        table: &str,
6077        perm: crate::auth_state::RequiredPermission,
6078    ) -> Result<()> {
6079        self.require(&perm.into_permission(table))
6080    }
6081
6082    pub fn triggers(&self) -> Vec<StoredTrigger> {
6083        self.catalog
6084            .read()
6085            .triggers
6086            .iter()
6087            .map(|t| t.trigger.clone())
6088            .collect()
6089    }
6090
6091    pub fn trigger(&self, name: &str) -> Option<StoredTrigger> {
6092        self.catalog
6093            .read()
6094            .triggers
6095            .iter()
6096            .find(|t| t.trigger.name == name)
6097            .map(|t| t.trigger.clone())
6098    }
6099
6100    pub fn create_trigger(&self, trigger: StoredTrigger) -> Result<StoredTrigger> {
6101        self.create_trigger_inner(trigger, None, None)
6102    }
6103
6104    pub fn create_trigger_controlled<F>(
6105        &self,
6106        trigger: StoredTrigger,
6107        mut before_publish: F,
6108    ) -> Result<StoredTrigger>
6109    where
6110        F: FnMut() -> Result<()>,
6111    {
6112        self.create_trigger_inner(trigger, None, Some(&mut before_publish))
6113    }
6114
6115    pub fn create_trigger_as_controlled<F>(
6116        &self,
6117        trigger: StoredTrigger,
6118        principal: Option<&crate::auth::Principal>,
6119        mut before_publish: F,
6120    ) -> Result<StoredTrigger>
6121    where
6122        F: FnMut() -> Result<()>,
6123    {
6124        self.create_trigger_inner(trigger, principal, Some(&mut before_publish))
6125    }
6126
6127    fn create_trigger_inner(
6128        &self,
6129        mut trigger: StoredTrigger,
6130        principal: Option<&crate::auth::Principal>,
6131        before_publish: Option<&mut dyn FnMut() -> Result<()>>,
6132    ) -> Result<StoredTrigger> {
6133        self.require_for(principal, &crate::auth::Permission::Ddl)?;
6134        let _g = self.ddl_lock.lock();
6135        let _security_write = self.security_write()?;
6136        self.require_for(principal, &crate::auth::Permission::Ddl)?;
6137        trigger.validate()?;
6138        self.validate_trigger_references(&trigger)
6139            .map_err(trigger_validation_error)?;
6140        {
6141            let cat = self.catalog.read();
6142            if cat.triggers.iter().any(|t| t.trigger.name == trigger.name) {
6143                return Err(MongrelError::TriggerValidation(format!(
6144                    "trigger {:?} already exists",
6145                    trigger.name
6146                )));
6147            }
6148        }
6149        let commit_lock = Arc::clone(&self.commit_lock);
6150        let _c = commit_lock.lock();
6151        let epoch = self.epoch.bump_assigned();
6152        let mut _epoch_guard = EpochGuard::new(self.epoch.as_ref(), epoch);
6153        trigger.created_epoch = epoch.0;
6154        trigger.updated_epoch = epoch.0;
6155        let mut next_catalog = self.catalog.read().clone();
6156        next_catalog
6157            .triggers
6158            .push(TriggerEntry::from(trigger.clone()));
6159        next_catalog.db_epoch = epoch.0;
6160        self.publish_catalog_candidate(next_catalog, epoch, &mut _epoch_guard, before_publish)?;
6161        Ok(trigger)
6162    }
6163
6164    pub fn create_or_replace_trigger(&self, trigger: StoredTrigger) -> Result<StoredTrigger> {
6165        self.create_or_replace_trigger_inner(trigger, None, None)
6166    }
6167
6168    pub fn create_or_replace_trigger_controlled<F>(
6169        &self,
6170        trigger: StoredTrigger,
6171        mut before_publish: F,
6172    ) -> Result<StoredTrigger>
6173    where
6174        F: FnMut() -> Result<()>,
6175    {
6176        self.create_or_replace_trigger_inner(trigger, None, Some(&mut before_publish))
6177    }
6178
6179    pub fn create_or_replace_trigger_as_controlled<F>(
6180        &self,
6181        trigger: StoredTrigger,
6182        principal: Option<&crate::auth::Principal>,
6183        mut before_publish: F,
6184    ) -> Result<StoredTrigger>
6185    where
6186        F: FnMut() -> Result<()>,
6187    {
6188        self.create_or_replace_trigger_inner(trigger, principal, Some(&mut before_publish))
6189    }
6190
6191    fn create_or_replace_trigger_inner(
6192        &self,
6193        trigger: StoredTrigger,
6194        principal: Option<&crate::auth::Principal>,
6195        before_publish: Option<&mut dyn FnMut() -> Result<()>>,
6196    ) -> Result<StoredTrigger> {
6197        self.require_for(principal, &crate::auth::Permission::Ddl)?;
6198        let _g = self.ddl_lock.lock();
6199        let _security_write = self.security_write()?;
6200        self.require_for(principal, &crate::auth::Permission::Ddl)?;
6201        trigger.validate()?;
6202        self.validate_trigger_references(&trigger)
6203            .map_err(trigger_validation_error)?;
6204        let commit_lock = Arc::clone(&self.commit_lock);
6205        let _c = commit_lock.lock();
6206        let epoch = self.epoch.bump_assigned();
6207        let mut _epoch_guard = EpochGuard::new(self.epoch.as_ref(), epoch);
6208        let mut next_catalog = self.catalog.read().clone();
6209        let replaced = {
6210            let next = match next_catalog
6211                .triggers
6212                .iter()
6213                .position(|t| t.trigger.name == trigger.name)
6214            {
6215                Some(idx) => {
6216                    let next = next_catalog.triggers[idx]
6217                        .trigger
6218                        .replaced(trigger.clone(), epoch.0)?;
6219                    next_catalog.triggers[idx] = TriggerEntry::from(next.clone());
6220                    next
6221                }
6222                None => {
6223                    let mut next = trigger;
6224                    next.created_epoch = epoch.0;
6225                    next.updated_epoch = epoch.0;
6226                    next_catalog.triggers.push(TriggerEntry::from(next.clone()));
6227                    next
6228                }
6229            };
6230            next_catalog.db_epoch = epoch.0;
6231            next
6232        };
6233        self.publish_catalog_candidate(next_catalog, epoch, &mut _epoch_guard, before_publish)?;
6234        Ok(replaced)
6235    }
6236
6237    pub fn drop_trigger(&self, name: &str) -> Result<()> {
6238        self.drop_trigger_with_epoch(name).map(|_| ())
6239    }
6240
6241    /// Drop one trigger and return the exact catalog publication epoch.
6242    pub fn drop_trigger_with_epoch(&self, name: &str) -> Result<Epoch> {
6243        self.drop_triggers_with_epoch(&[name.to_string()])
6244    }
6245
6246    pub fn drop_trigger_with_epoch_controlled<F>(
6247        &self,
6248        name: &str,
6249        before_publish: F,
6250    ) -> Result<Epoch>
6251    where
6252        F: FnMut() -> Result<()>,
6253    {
6254        self.drop_triggers_with_epoch_controlled(&[name.to_string()], before_publish)
6255    }
6256
6257    /// Atomically drop several triggers in one catalog publication.
6258    pub fn drop_triggers_with_epoch(&self, names: &[String]) -> Result<Epoch> {
6259        self.drop_triggers_with_epoch_inner(names, None, None)
6260    }
6261
6262    pub fn drop_triggers_with_epoch_controlled<F>(
6263        &self,
6264        names: &[String],
6265        mut before_publish: F,
6266    ) -> Result<Epoch>
6267    where
6268        F: FnMut() -> Result<()>,
6269    {
6270        self.drop_triggers_with_epoch_inner(names, None, Some(&mut before_publish))
6271    }
6272
6273    pub fn drop_triggers_with_epoch_as_controlled<F>(
6274        &self,
6275        names: &[String],
6276        principal: Option<&crate::auth::Principal>,
6277        mut before_publish: F,
6278    ) -> Result<Epoch>
6279    where
6280        F: FnMut() -> Result<()>,
6281    {
6282        self.drop_triggers_with_epoch_inner(names, principal, Some(&mut before_publish))
6283    }
6284
6285    fn drop_triggers_with_epoch_inner(
6286        &self,
6287        names: &[String],
6288        principal: Option<&crate::auth::Principal>,
6289        before_publish: Option<&mut dyn FnMut() -> Result<()>>,
6290    ) -> Result<Epoch> {
6291        self.require_for(principal, &crate::auth::Permission::Ddl)?;
6292        if names.is_empty() {
6293            return Err(MongrelError::InvalidArgument(
6294                "at least one trigger name is required".into(),
6295            ));
6296        }
6297        let _g = self.ddl_lock.lock();
6298        let _security_write = self.security_write()?;
6299        self.require_for(principal, &crate::auth::Permission::Ddl)?;
6300        {
6301            let cat = self.catalog.read();
6302            for name in names {
6303                if !cat.triggers.iter().any(|t| t.trigger.name == *name) {
6304                    return Err(MongrelError::NotFound(format!(
6305                        "trigger {name:?} not found"
6306                    )));
6307                }
6308            }
6309        }
6310        let commit_lock = Arc::clone(&self.commit_lock);
6311        let _c = commit_lock.lock();
6312        let epoch = self.epoch.bump_assigned();
6313        let mut _epoch_guard = EpochGuard::new(self.epoch.as_ref(), epoch);
6314        let mut next_catalog = self.catalog.read().clone();
6315        next_catalog
6316            .triggers
6317            .retain(|trigger| !names.contains(&trigger.trigger.name));
6318        next_catalog.db_epoch = epoch.0;
6319        self.publish_catalog_candidate(next_catalog, epoch, &mut _epoch_guard, before_publish)?;
6320        Ok(epoch)
6321    }
6322
6323    pub fn external_tables(&self) -> Vec<ExternalTableEntry> {
6324        self.catalog.read().external_tables.clone()
6325    }
6326
6327    pub fn external_table(&self, name: &str) -> Option<ExternalTableEntry> {
6328        self.catalog
6329            .read()
6330            .external_tables
6331            .iter()
6332            .find(|t| t.name == name)
6333            .cloned()
6334    }
6335
6336    pub fn create_external_table(&self, entry: ExternalTableEntry) -> Result<ExternalTableEntry> {
6337        self.create_external_table_inner(entry, None)
6338    }
6339
6340    pub fn create_external_table_controlled<F>(
6341        &self,
6342        entry: ExternalTableEntry,
6343        mut before_publish: F,
6344    ) -> Result<ExternalTableEntry>
6345    where
6346        F: FnMut() -> Result<()>,
6347    {
6348        self.create_external_table_inner(entry, Some(&mut before_publish))
6349    }
6350
6351    fn create_external_table_inner(
6352        &self,
6353        mut entry: ExternalTableEntry,
6354        before_publish: Option<&mut dyn FnMut() -> Result<()>>,
6355    ) -> Result<ExternalTableEntry> {
6356        self.require(&crate::auth::Permission::Ddl)?;
6357        let _g = self.ddl_lock.lock();
6358        let _security_write = self.security_write()?;
6359        self.require(&crate::auth::Permission::Ddl)?;
6360        entry.validate()?;
6361        {
6362            let cat = self.catalog.read();
6363            if cat.live(&entry.name).is_some()
6364                || cat.external_tables.iter().any(|t| t.name == entry.name)
6365            {
6366                return Err(MongrelError::InvalidArgument(format!(
6367                    "table {:?} already exists",
6368                    entry.name
6369                )));
6370            }
6371        }
6372        let commit_lock = Arc::clone(&self.commit_lock);
6373        let _c = commit_lock.lock();
6374        // A prior durable drop may have left connector state behind if its
6375        // cleanup failed or the process crashed. Never let a new table with
6376        // the same name inherit that stale state.
6377        crate::durable_file::create_directory(&self.root.join(VTAB_DIR))?;
6378        crate::durable_file::remove_directory_all(&self.root.join(VTAB_DIR).join(&entry.name))?;
6379        let epoch = self.epoch.bump_assigned();
6380        let mut _epoch_guard = EpochGuard::new(self.epoch.as_ref(), epoch);
6381        entry.created_epoch = epoch.0;
6382        let mut next_catalog = self.catalog.read().clone();
6383        next_catalog.external_tables.push(entry.clone());
6384        next_catalog.db_epoch = epoch.0;
6385        self.publish_catalog_candidate_with_prelude(
6386            next_catalog,
6387            epoch,
6388            &mut _epoch_guard,
6389            before_publish,
6390            vec![(
6391                EXTERNAL_TABLE_ID,
6392                crate::wal::Op::Ddl(crate::wal::DdlOp::ResetExternalTableState {
6393                    name: entry.name.clone(),
6394                    generation_epoch: epoch.0,
6395                }),
6396            )],
6397        )?;
6398        Ok(entry)
6399    }
6400
6401    pub fn drop_external_table(&self, name: &str) -> Result<()> {
6402        self.drop_external_table_with_epoch(name).map(|_| ())
6403    }
6404
6405    /// Drop an external table and return the exact catalog publication epoch.
6406    pub fn drop_external_table_with_epoch(&self, name: &str) -> Result<Epoch> {
6407        self.drop_external_table_with_epoch_inner(name, None)
6408    }
6409
6410    pub fn drop_external_table_with_epoch_controlled<F>(
6411        &self,
6412        name: &str,
6413        mut before_publish: F,
6414    ) -> Result<Epoch>
6415    where
6416        F: FnMut() -> Result<()>,
6417    {
6418        self.drop_external_table_with_epoch_inner(name, Some(&mut before_publish))
6419    }
6420
6421    fn drop_external_table_with_epoch_inner(
6422        &self,
6423        name: &str,
6424        before_publish: Option<&mut dyn FnMut() -> Result<()>>,
6425    ) -> Result<Epoch> {
6426        self.require(&crate::auth::Permission::Ddl)?;
6427        let _g = self.ddl_lock.lock();
6428        let _security_write = self.security_write()?;
6429        self.require(&crate::auth::Permission::Ddl)?;
6430        let commit_lock = Arc::clone(&self.commit_lock);
6431        let _c = commit_lock.lock();
6432        let epoch = self.epoch.bump_assigned();
6433        let mut _epoch_guard = EpochGuard::new(self.epoch.as_ref(), epoch);
6434        let mut next_catalog = self.catalog.read().clone();
6435        let before = next_catalog.external_tables.len();
6436        next_catalog.external_tables.retain(|t| t.name != name);
6437        if next_catalog.external_tables.len() == before {
6438            return Err(MongrelError::NotFound(format!(
6439                "external table {name:?} not found"
6440            )));
6441        }
6442        next_catalog.db_epoch = epoch.0;
6443        self.publish_catalog_candidate_with_prelude(
6444            next_catalog,
6445            epoch,
6446            &mut _epoch_guard,
6447            before_publish,
6448            vec![(
6449                EXTERNAL_TABLE_ID,
6450                crate::wal::Op::Ddl(crate::wal::DdlOp::ResetExternalTableState {
6451                    name: name.to_string(),
6452                    generation_epoch: epoch.0,
6453                }),
6454            )],
6455        )?;
6456        let state_dir = self.root.join(VTAB_DIR).join(name);
6457        if let Err(error) = crate::durable_file::remove_directory_all(&state_dir) {
6458            return Err(MongrelError::DurableCommit {
6459                epoch: epoch.0,
6460                message: format!(
6461                    "external table was dropped but connector-state cleanup failed: {error}"
6462                ),
6463            });
6464        }
6465        Ok(epoch)
6466    }
6467
6468    pub fn commit_external_table_state(&self, name: &str, state: &[u8]) -> Result<Epoch> {
6469        let txn_id = self.alloc_txn_id()?;
6470        let (principal, catalog_bound) = self.transaction_principal_snapshot();
6471        self.commit_transaction_with_external_states(
6472            txn_id,
6473            self.epoch.visible(),
6474            Vec::new(),
6475            vec![(name.to_string(), state.to_vec())],
6476            Vec::new(),
6477            principal,
6478            catalog_bound,
6479            None,
6480        )
6481        .map(|(epoch, _)| epoch)
6482    }
6483
6484    pub fn trigger_config(&self) -> TriggerConfig {
6485        use std::sync::atomic::Ordering;
6486        TriggerConfig {
6487            recursive_triggers: self.trigger_recursive.load(Ordering::Relaxed),
6488            max_depth: self.trigger_max_depth.load(Ordering::Relaxed),
6489            max_loop_iterations: self.trigger_max_loop_iterations.load(Ordering::Relaxed),
6490        }
6491    }
6492
6493    pub fn set_trigger_config(&self, config: TriggerConfig) -> Result<()> {
6494        use std::sync::atomic::Ordering;
6495        if config.max_depth == 0 {
6496            return Err(MongrelError::InvalidArgument(
6497                "trigger max_depth must be greater than 0".into(),
6498            ));
6499        }
6500        self.trigger_recursive
6501            .store(config.recursive_triggers, Ordering::Relaxed);
6502        self.trigger_max_depth
6503            .store(config.max_depth, Ordering::Relaxed);
6504        self.trigger_max_loop_iterations
6505            .store(config.max_loop_iterations, Ordering::Relaxed);
6506        Ok(())
6507    }
6508
6509    pub fn set_recursive_triggers(&self, recursive: bool) {
6510        use std::sync::atomic::Ordering;
6511        self.trigger_recursive.store(recursive, Ordering::Relaxed);
6512    }
6513
6514    /// Subscribe to ephemeral SQL NOTIFY messages. Durable row changes use
6515    /// [`Self::change_events_since`], with [`Self::subscribe_change_commits`]
6516    /// as a low-latency wake-up.
6517    pub fn subscribe_changes(&self) -> tokio::sync::broadcast::Receiver<ChangeEvent> {
6518        self.notify.subscribe()
6519    }
6520
6521    pub fn subscribe_change_commits(&self) -> tokio::sync::broadcast::Receiver<()> {
6522        self.change_wake.subscribe()
6523    }
6524
6525    /// Reconstruct committed row changes from the retained shared WAL. Event
6526    /// ids are stable `<commit_epoch>:<operation_index>` pairs. A caller that
6527    /// resumes before the oldest retained commit receives `gap = true` and
6528    /// must rebootstrap instead of silently skipping changes.
6529    pub fn change_events_since(&self, last_event_id: Option<&str>) -> Result<CdcBatch> {
6530        let control = crate::ExecutionControl::new(None);
6531        self.change_events_since_controlled(last_event_id, &control)
6532    }
6533
6534    /// Reconstruct committed changes with cooperative cancellation and bounds.
6535    pub fn change_events_since_controlled(
6536        &self,
6537        last_event_id: Option<&str>,
6538        control: &crate::ExecutionControl,
6539    ) -> Result<CdcBatch> {
6540        use crate::wal::Op;
6541
6542        control.checkpoint()?;
6543        let resume = match last_event_id {
6544            Some(id) => {
6545                let (epoch, index) = id.split_once(':').ok_or_else(|| {
6546                    MongrelError::InvalidArgument(format!(
6547                        "invalid CDC event id {id:?}; expected <epoch>:<index>"
6548                    ))
6549                })?;
6550                Some((
6551                    epoch.parse::<u64>().map_err(|error| {
6552                        MongrelError::InvalidArgument(format!("invalid CDC epoch: {error}"))
6553                    })?,
6554                    index.parse::<u32>().map_err(|error| {
6555                        MongrelError::InvalidArgument(format!("invalid CDC index: {error}"))
6556                    })?,
6557                ))
6558            }
6559            None => None,
6560        };
6561
6562        let mut wal = self.shared_wal.lock();
6563        wal.group_sync()?;
6564        let wal_dek = crate::encryption::wal_dek_for(self.kek.as_deref());
6565        let records = crate::wal::SharedWal::replay_with_dek_controlled(
6566            &self.root,
6567            wal_dek.as_ref(),
6568            control,
6569            CDC_MAX_WAL_RECORDS,
6570            CDC_MAX_WAL_REPLAY_BYTES,
6571        )?;
6572        drop(wal);
6573        control.checkpoint()?;
6574
6575        let mut commits: HashMap<u64, (u64, Vec<crate::wal::AddedRun>)> = HashMap::new();
6576        let mut spilled_payloads: HashMap<(u64, u64), Vec<&[u8]>> = HashMap::new();
6577        for (index, record) in records.iter().enumerate() {
6578            if index % 256 == 0 {
6579                control.checkpoint()?;
6580            }
6581            if let Op::TxnCommit { epoch, added_runs } = &record.op {
6582                commits.insert(record.txn_id, (*epoch, added_runs.clone()));
6583            }
6584            if let Op::SpilledRows { table_id, rows } = &record.op {
6585                spilled_payloads
6586                    .entry((record.txn_id, *table_id))
6587                    .or_default()
6588                    .push(rows);
6589            }
6590        }
6591        let earliest_epoch = commits.values().map(|(epoch, _)| *epoch).min();
6592        let current_epoch = self.epoch.committed().0;
6593        let retention_floor = crate::replication::replication_wal_floor(&self.root)?;
6594        let gap = resume.is_some_and(|(epoch, _)| {
6595            retention_floor != 0 && epoch <= retention_floor && epoch <= current_epoch
6596        });
6597        if gap {
6598            return Ok(CdcBatch {
6599                events: Vec::new(),
6600                current_epoch,
6601                earliest_epoch,
6602                gap: true,
6603            });
6604        }
6605
6606        let table_names: HashMap<u64, String> = self
6607            .catalog
6608            .read()
6609            .tables
6610            .iter()
6611            .map(|entry| (entry.table_id, entry.name.clone()))
6612            .collect();
6613        let mut before_images: HashMap<(u64, u64, u64), crate::memtable::Row> = HashMap::new();
6614        let mut retained_bytes = 0_usize;
6615        for (index, record) in records.iter().enumerate() {
6616            if index % 256 == 0 {
6617                control.checkpoint()?;
6618            }
6619            if !commits.contains_key(&record.txn_id) {
6620                continue;
6621            }
6622            let Op::BeforeImage {
6623                table_id,
6624                row_id,
6625                row,
6626            } = &record.op
6627            else {
6628                continue;
6629            };
6630            if row.len() > CDC_MAX_INLINE_PAYLOAD_BYTES {
6631                return Err(MongrelError::ResourceLimitExceeded {
6632                    resource: "CDC before-image bytes",
6633                    requested: row.len(),
6634                    limit: CDC_MAX_INLINE_PAYLOAD_BYTES,
6635                });
6636            }
6637            let before: crate::memtable::Row = bincode::deserialize(row)?;
6638            if before_images.len() >= CDC_MAX_ROWS {
6639                return Err(MongrelError::ResourceLimitExceeded {
6640                    resource: "CDC before-image rows",
6641                    requested: before_images.len().saturating_add(1),
6642                    limit: CDC_MAX_ROWS,
6643                });
6644            }
6645            charge_cdc_bytes(
6646                &mut retained_bytes,
6647                cdc_row_storage_bytes(&before),
6648                "CDC retained bytes",
6649            )?;
6650            before_images.insert((record.txn_id, *table_id, row_id.0), before);
6651        }
6652        let mut operation_indices: HashMap<u64, u32> = HashMap::new();
6653        let mut events = Vec::new();
6654        let mut decoded_rows = before_images.len();
6655        for (record_index, record) in records.iter().enumerate() {
6656            if record_index % 256 == 0 {
6657                control.checkpoint()?;
6658            }
6659            let Some((commit_epoch, _)) = commits.get(&record.txn_id) else {
6660                continue;
6661            };
6662            let event = match &record.op {
6663                Op::Put { table_id, rows } => {
6664                    if rows.len() > CDC_MAX_INLINE_PAYLOAD_BYTES {
6665                        return Err(MongrelError::ResourceLimitExceeded {
6666                            resource: "CDC inline row bytes",
6667                            requested: rows.len(),
6668                            limit: CDC_MAX_INLINE_PAYLOAD_BYTES,
6669                        });
6670                    }
6671                    let rows: Vec<crate::memtable::Row> = bincode::deserialize(rows)?;
6672                    decoded_rows = decoded_rows.saturating_add(rows.len());
6673                    if decoded_rows > CDC_MAX_ROWS {
6674                        return Err(MongrelError::ResourceLimitExceeded {
6675                            resource: "CDC decoded rows",
6676                            requested: decoded_rows,
6677                            limit: CDC_MAX_ROWS,
6678                        });
6679                    }
6680                    let event_bytes = cdc_rows_json_bytes(&rows).saturating_add(512);
6681                    let mut peak_bytes = retained_bytes;
6682                    charge_cdc_bytes(&mut peak_bytes, event_bytes, "CDC retained event bytes")?;
6683                    let data = serde_json::to_value(rows)
6684                        .map_err(|error| MongrelError::Other(format!("CDC JSON: {error}")))?;
6685                    Some((*table_id, "put", data, event_bytes))
6686                }
6687                Op::Delete { table_id, row_ids } => {
6688                    let before = row_ids
6689                        .iter()
6690                        .filter_map(|row_id| {
6691                            before_images
6692                                .get(&(record.txn_id, *table_id, row_id.0))
6693                                .cloned()
6694                        })
6695                        .collect::<Vec<_>>();
6696                    let event_bytes = cdc_rows_json_bytes(&before)
6697                        .saturating_add(
6698                            row_ids
6699                                .len()
6700                                .saturating_mul(std::mem::size_of::<serde_json::Value>()),
6701                        )
6702                        .saturating_add(512);
6703                    let mut peak_bytes = retained_bytes;
6704                    charge_cdc_bytes(&mut peak_bytes, event_bytes, "CDC retained event bytes")?;
6705                    Some((
6706                        *table_id,
6707                        "delete",
6708                        serde_json::json!({
6709                            "row_ids": row_ids.iter().map(|row_id| row_id.0).collect::<Vec<_>>(),
6710                            "before": before,
6711                        }),
6712                        event_bytes,
6713                    ))
6714                }
6715                Op::TruncateTable { table_id } => {
6716                    Some((*table_id, "truncate", serde_json::Value::Null, 512))
6717                }
6718                _ => None,
6719            };
6720            if let Some((table_id, op, data, event_bytes)) = event {
6721                let index = operation_indices.entry(record.txn_id).or_insert(0);
6722                let event_position = (*commit_epoch, *index);
6723                *index = index.saturating_add(1);
6724                if resume.is_some_and(|position| event_position <= position) {
6725                    continue;
6726                }
6727                if events.len() >= CDC_MAX_EVENTS {
6728                    return Err(MongrelError::ResourceLimitExceeded {
6729                        resource: "CDC events",
6730                        requested: events.len().saturating_add(1),
6731                        limit: CDC_MAX_EVENTS,
6732                    });
6733                }
6734                charge_cdc_bytes(&mut retained_bytes, event_bytes, "CDC retained event bytes")?;
6735                events.push(ChangeEvent {
6736                    id: Some(format!("{}:{}", event_position.0, event_position.1)),
6737                    channel: "changes".into(),
6738                    table_id: Some(table_id),
6739                    table: table_names.get(&table_id).cloned().unwrap_or_default(),
6740                    op: op.into(),
6741                    epoch: *commit_epoch,
6742                    txn_id: Some(record.txn_id),
6743                    message: None,
6744                    data: Some(data),
6745                });
6746            }
6747            if let Op::TxnCommit { added_runs, .. } = &record.op {
6748                for run in added_runs {
6749                    control.checkpoint()?;
6750                    let index = operation_indices.entry(record.txn_id).or_insert(0);
6751                    let event_position = (*commit_epoch, *index);
6752                    *index = index.saturating_add(1);
6753                    if resume.is_some_and(|position| event_position <= position) {
6754                        continue;
6755                    }
6756                    let mut rows = if let Some(payloads) =
6757                        spilled_payloads.get(&(record.txn_id, run.table_id))
6758                    {
6759                        let mut rows = Vec::new();
6760                        for payload in payloads {
6761                            control.checkpoint()?;
6762                            if payload.len() > CDC_MAX_INLINE_PAYLOAD_BYTES {
6763                                return Err(MongrelError::ResourceLimitExceeded {
6764                                    resource: "CDC spilled row bytes",
6765                                    requested: payload.len(),
6766                                    limit: CDC_MAX_INLINE_PAYLOAD_BYTES,
6767                                });
6768                            }
6769                            let chunk: Vec<crate::memtable::Row> = bincode::deserialize(payload)?;
6770                            if decoded_rows
6771                                .saturating_add(rows.len())
6772                                .saturating_add(chunk.len())
6773                                > CDC_MAX_ROWS
6774                            {
6775                                return Err(MongrelError::ResourceLimitExceeded {
6776                                    resource: "CDC decoded rows",
6777                                    requested: decoded_rows
6778                                        .saturating_add(rows.len())
6779                                        .saturating_add(chunk.len()),
6780                                    limit: CDC_MAX_ROWS,
6781                                });
6782                            }
6783                            rows.extend(chunk);
6784                        }
6785                        rows
6786                    } else {
6787                        let Some(handle) = self.tables.read().get(&run.table_id).cloned() else {
6788                            return Ok(CdcBatch {
6789                                events: Vec::new(),
6790                                current_epoch,
6791                                earliest_epoch,
6792                                gap: true,
6793                            });
6794                        };
6795                        let table = handle.lock();
6796                        let mut reader = match table.open_reader(run.run_id) {
6797                            Ok(reader) => reader,
6798                            Err(_) => {
6799                                return Ok(CdcBatch {
6800                                    events: Vec::new(),
6801                                    current_epoch,
6802                                    earliest_epoch,
6803                                    gap: true,
6804                                })
6805                            }
6806                        };
6807                        let remaining = CDC_MAX_ROWS.saturating_sub(decoded_rows);
6808                        let rows = reader.all_rows_controlled(control, remaining)?;
6809                        drop(reader);
6810                        drop(table);
6811                        rows
6812                    };
6813                    for row in &mut rows {
6814                        row.committed_epoch = Epoch(*commit_epoch);
6815                    }
6816                    decoded_rows = decoded_rows.saturating_add(rows.len());
6817                    let event_bytes = cdc_rows_json_bytes(&rows).saturating_add(768);
6818                    charge_cdc_bytes(&mut retained_bytes, event_bytes, "CDC retained event bytes")?;
6819                    if events.len() >= CDC_MAX_EVENTS {
6820                        return Err(MongrelError::ResourceLimitExceeded {
6821                            resource: "CDC events",
6822                            requested: events.len().saturating_add(1),
6823                            limit: CDC_MAX_EVENTS,
6824                        });
6825                    }
6826                    events.push(ChangeEvent {
6827                        id: Some(format!("{}:{}", event_position.0, event_position.1)),
6828                        channel: "changes".into(),
6829                        table_id: Some(run.table_id),
6830                        table: table_names.get(&run.table_id).cloned().unwrap_or_default(),
6831                        op: "put_run".into(),
6832                        epoch: *commit_epoch,
6833                        txn_id: Some(record.txn_id),
6834                        message: None,
6835                        data: Some(serde_json::json!({
6836                            "run_id": run.run_id.to_string(),
6837                            "row_count": run.row_count,
6838                            "min_row_id": run.min_row_id,
6839                            "max_row_id": run.max_row_id,
6840                            "rows": rows,
6841                        })),
6842                    });
6843                }
6844            }
6845        }
6846        control.checkpoint()?;
6847        Ok(CdcBatch {
6848            events,
6849            current_epoch,
6850            earliest_epoch,
6851            gap: false,
6852        })
6853    }
6854
6855    /// Publish a notification message on a named channel. Reaches all active
6856    /// subscribers (daemon `/events`, application listeners).
6857    pub fn notify(&self, channel: &str, message: Option<String>) {
6858        let _ = self.notify.send(ChangeEvent {
6859            id: None,
6860            channel: channel.to_string(),
6861            table_id: None,
6862            table: String::new(),
6863            op: "notify".into(),
6864            epoch: self.epoch.visible().0,
6865            txn_id: None,
6866            message,
6867            data: None,
6868        });
6869    }
6870
6871    pub fn call_procedure(
6872        &self,
6873        name: &str,
6874        args: HashMap<String, crate::Value>,
6875    ) -> Result<ProcedureCallResult> {
6876        self.call_procedure_as(name, args, None)
6877    }
6878
6879    pub fn call_procedure_as(
6880        &self,
6881        name: &str,
6882        args: HashMap<String, crate::Value>,
6883        principal: Option<&crate::auth::Principal>,
6884    ) -> Result<ProcedureCallResult> {
6885        let control = crate::ExecutionControl::new(None);
6886        self.call_procedure_as_controlled(name, args, principal, &control, || true)
6887    }
6888
6889    /// Execute only the exact procedure revision previously authorized by the
6890    /// caller. A dropped or replaced definition fails closed.
6891    #[doc(hidden)]
6892    pub fn call_procedure_as_bound(
6893        &self,
6894        expected: &StoredProcedure,
6895        args: HashMap<String, crate::Value>,
6896        principal: Option<&crate::auth::Principal>,
6897    ) -> Result<ProcedureCallResult> {
6898        self.require_for(principal, &crate::auth::Permission::All)?;
6899        let procedure = self.procedure(&expected.name).ok_or_else(|| {
6900            MongrelError::NotFound(format!("procedure {:?} not found", expected.name))
6901        })?;
6902        if &procedure != expected {
6903            return Err(MongrelError::Conflict(format!(
6904                "procedure {:?} changed after request authorization",
6905                expected.name
6906            )));
6907        }
6908        let control = crate::ExecutionControl::new(None);
6909        self.execute_procedure_as_controlled(procedure, args, principal, &control, || true)
6910    }
6911
6912    /// Execute a procedure with cooperative cancellation during preparation.
6913    /// `before_commit` runs after every procedure step has succeeded and
6914    /// immediately before a write procedure commits. Returning `false` aborts
6915    /// the transaction without publishing it.
6916    #[doc(hidden)]
6917    pub fn call_procedure_as_controlled<F>(
6918        &self,
6919        name: &str,
6920        args: HashMap<String, crate::Value>,
6921        principal: Option<&crate::auth::Principal>,
6922        control: &crate::ExecutionControl,
6923        before_commit: F,
6924    ) -> Result<ProcedureCallResult>
6925    where
6926        F: FnOnce() -> bool,
6927    {
6928        // v1 requires ALL to call procedures on a require_auth database; a
6929        // finer SECURITY DEFINER-style marker is a future extension (spec §9
6930        // decision 1).
6931        self.require_for(principal, &crate::auth::Permission::All)?;
6932        let procedure = self
6933            .procedure(name)
6934            .ok_or_else(|| MongrelError::NotFound(format!("procedure {name:?} not found")))?;
6935        self.execute_procedure_as_controlled(procedure, args, principal, control, before_commit)
6936    }
6937
6938    fn execute_procedure_as_controlled<F>(
6939        &self,
6940        procedure: StoredProcedure,
6941        args: HashMap<String, crate::Value>,
6942        principal: Option<&crate::auth::Principal>,
6943        control: &crate::ExecutionControl,
6944        before_commit: F,
6945    ) -> Result<ProcedureCallResult>
6946    where
6947        F: FnOnce() -> bool,
6948    {
6949        let args = bind_procedure_args(&procedure, args)?;
6950        let has_writes = procedure.body.steps.iter().any(ProcedureStep::is_write);
6951        let mut outputs: HashMap<String, ProcedureCallOutput> = HashMap::new();
6952        if has_writes {
6953            let mut tx = self.begin_as(principal.cloned());
6954            let run = (|| {
6955                for (step_index, step) in procedure.body.steps.iter().enumerate() {
6956                    if step_index % 256 == 0 {
6957                        control.checkpoint()?;
6958                    }
6959                    let output = self.execute_procedure_step(
6960                        step,
6961                        &args,
6962                        &outputs,
6963                        Some(&mut tx),
6964                        principal,
6965                        Some(control),
6966                    )?;
6967                    outputs.insert(step.id().to_string(), output);
6968                }
6969                control.checkpoint()?;
6970                eval_return_output(&procedure.body.return_value, &args, &outputs)
6971            })();
6972            match run {
6973                Ok(output) => {
6974                    control.checkpoint()?;
6975                    if !before_commit() {
6976                        tx.rollback();
6977                        return Err(MongrelError::Cancelled);
6978                    }
6979                    let epoch = tx.commit()?.0;
6980                    Ok(ProcedureCallResult {
6981                        epoch: Some(epoch),
6982                        output,
6983                    })
6984                }
6985                Err(e) => {
6986                    tx.rollback();
6987                    Err(e)
6988                }
6989            }
6990        } else {
6991            for (step_index, step) in procedure.body.steps.iter().enumerate() {
6992                if step_index % 256 == 0 {
6993                    control.checkpoint()?;
6994                }
6995                let output = self.execute_procedure_step(
6996                    step,
6997                    &args,
6998                    &outputs,
6999                    None,
7000                    principal,
7001                    Some(control),
7002                )?;
7003                outputs.insert(step.id().to_string(), output);
7004            }
7005            control.checkpoint()?;
7006            Ok(ProcedureCallResult {
7007                epoch: None,
7008                output: eval_return_output(&procedure.body.return_value, &args, &outputs)?,
7009            })
7010        }
7011    }
7012
7013    fn execute_procedure_step(
7014        &self,
7015        step: &ProcedureStep,
7016        args: &HashMap<String, crate::Value>,
7017        outputs: &HashMap<String, ProcedureCallOutput>,
7018        tx: Option<&mut crate::txn::Transaction<'_>>,
7019        principal: Option<&crate::auth::Principal>,
7020        control: Option<&crate::ExecutionControl>,
7021    ) -> Result<ProcedureCallOutput> {
7022        if let Some(control) = control {
7023            control.checkpoint()?;
7024        }
7025        match step {
7026            ProcedureStep::NativeQuery {
7027                table,
7028                conditions,
7029                projection,
7030                limit,
7031                ..
7032            } => {
7033                let mut q = crate::Query::new();
7034                for condition in conditions {
7035                    q = q.and(eval_condition(condition, args, outputs)?);
7036                }
7037                let handle = self.table(table)?;
7038                let rows = handle.lock().query(&q)?;
7039                let mut rows = self.secure_rows_for(table, rows, principal)?;
7040                if let Some(limit) = limit {
7041                    rows.truncate(*limit);
7042                }
7043                let projection = projection.as_ref();
7044                let mut output = Vec::with_capacity(rows.len());
7045                for (row_index, row) in rows.into_iter().enumerate() {
7046                    if row_index % 256 == 0 {
7047                        if let Some(control) = control {
7048                            control.checkpoint()?;
7049                        }
7050                    }
7051                    output.push(ProcedureCallRow {
7052                        row_id: Some(row.row_id),
7053                        columns: match projection {
7054                            Some(ids) => row
7055                                .columns
7056                                .into_iter()
7057                                .filter(|(id, _)| ids.contains(id))
7058                                .collect(),
7059                            None => row.columns,
7060                        },
7061                    });
7062                }
7063                Ok(ProcedureCallOutput::Rows(output))
7064            }
7065            ProcedureStep::Put {
7066                table,
7067                cells,
7068                returning,
7069                ..
7070            } => {
7071                let tx = tx.ok_or_else(|| {
7072                    MongrelError::InvalidArgument(
7073                        "write procedure step requires a transaction".into(),
7074                    )
7075                })?;
7076                let cells = eval_cells(cells, args, outputs)?;
7077                if *returning {
7078                    let out = tx.put_returning(table, cells)?;
7079                    Ok(ProcedureCallOutput::Row(ProcedureCallRow {
7080                        row_id: None,
7081                        columns: out.row.columns.into_iter().collect(),
7082                    }))
7083                } else {
7084                    tx.put(table, cells)?;
7085                    Ok(ProcedureCallOutput::Null)
7086                }
7087            }
7088            ProcedureStep::Upsert {
7089                table,
7090                cells,
7091                update_cells,
7092                returning,
7093                ..
7094            } => {
7095                let tx = tx.ok_or_else(|| {
7096                    MongrelError::InvalidArgument(
7097                        "write procedure step requires a transaction".into(),
7098                    )
7099                })?;
7100                let cells = eval_cells(cells, args, outputs)?;
7101                let action = match update_cells {
7102                    Some(update_cells) => {
7103                        crate::UpsertAction::DoUpdate(eval_cells(update_cells, args, outputs)?)
7104                    }
7105                    None => crate::UpsertAction::DoNothing,
7106                };
7107                let out = tx.upsert(table, cells, action)?;
7108                if *returning {
7109                    Ok(ProcedureCallOutput::Row(ProcedureCallRow {
7110                        row_id: None,
7111                        columns: out.row.columns.into_iter().collect(),
7112                    }))
7113                } else {
7114                    Ok(ProcedureCallOutput::Null)
7115                }
7116            }
7117            ProcedureStep::DeleteByPk { table, pk, .. } => {
7118                let tx = tx.ok_or_else(|| {
7119                    MongrelError::InvalidArgument(
7120                        "write procedure step requires a transaction".into(),
7121                    )
7122                })?;
7123                let pk = eval_value(pk, args, outputs)?;
7124                let handle = self.table(table)?;
7125                let row_id = handle.lock().lookup_pk(&pk.encode_key()).ok_or_else(|| {
7126                    MongrelError::NotFound("procedure delete_by_pk target not found".into())
7127                })?;
7128                tx.delete(table, row_id)?;
7129                Ok(ProcedureCallOutput::Scalar(crate::Value::Bool(true)))
7130            }
7131            ProcedureStep::DeleteRows { .. } => Err(MongrelError::InvalidArgument(
7132                "DeleteRows procedure step is not supported by the core executor yet".into(),
7133            )),
7134            ProcedureStep::SqlQuery { .. } => Err(MongrelError::InvalidArgument(
7135                "SqlQuery procedure step must be executed by mongreldb-query".into(),
7136            )),
7137        }
7138    }
7139
7140    fn validate_procedure_references(&self, procedure: &StoredProcedure) -> Result<()> {
7141        let cat = self.catalog.read();
7142        for step in &procedure.body.steps {
7143            let Some(table_name) = step.table() else {
7144                continue;
7145            };
7146            let schema = &cat
7147                .live(table_name)
7148                .ok_or_else(|| {
7149                    MongrelError::InvalidArgument(format!(
7150                        "procedure {:?} references unknown table {table_name:?}",
7151                        procedure.name
7152                    ))
7153                })?
7154                .schema;
7155            match step {
7156                ProcedureStep::NativeQuery {
7157                    conditions,
7158                    projection,
7159                    ..
7160                } => {
7161                    for condition in conditions {
7162                        validate_condition_columns(condition, schema)?;
7163                    }
7164                    if let Some(projection) = projection {
7165                        for id in projection {
7166                            validate_column_id(*id, schema)?;
7167                        }
7168                    }
7169                }
7170                ProcedureStep::Put { cells, .. } => {
7171                    for cell in cells {
7172                        validate_column_id(cell.column_id, schema)?;
7173                    }
7174                }
7175                ProcedureStep::Upsert {
7176                    cells,
7177                    update_cells,
7178                    ..
7179                } => {
7180                    for cell in cells {
7181                        validate_column_id(cell.column_id, schema)?;
7182                    }
7183                    if let Some(update_cells) = update_cells {
7184                        for cell in update_cells {
7185                            validate_column_id(cell.column_id, schema)?;
7186                        }
7187                    }
7188                }
7189                ProcedureStep::DeleteByPk { .. } => {
7190                    if schema.primary_key().is_none() {
7191                        return Err(MongrelError::InvalidArgument(format!(
7192                            "procedure {:?} references DeleteByPk on table {table_name:?} without a primary key",
7193                            procedure.name
7194                        )));
7195                    }
7196                }
7197                ProcedureStep::DeleteRows { .. } | ProcedureStep::SqlQuery { .. } => {}
7198            }
7199        }
7200        Ok(())
7201    }
7202
7203    fn validate_trigger_references(&self, trigger: &StoredTrigger) -> Result<()> {
7204        let cat = self.catalog.read();
7205        let target_schema = match &trigger.target {
7206            TriggerTarget::Table(target_name) => cat
7207                .live(target_name)
7208                .ok_or_else(|| {
7209                    MongrelError::InvalidArgument(format!(
7210                        "trigger {:?} references unknown target table {target_name:?}",
7211                        trigger.name
7212                    ))
7213                })?
7214                .schema
7215                .clone(),
7216            TriggerTarget::View(_) => Schema {
7217                columns: trigger.target_columns.clone(),
7218                ..Schema::default()
7219            },
7220        };
7221        for col in &trigger.update_of {
7222            if target_schema.column(col).is_none() {
7223                return Err(MongrelError::InvalidArgument(format!(
7224                    "trigger {:?} UPDATE OF references unknown column {col:?}",
7225                    trigger.name
7226                )));
7227            }
7228        }
7229        if let Some(expr) = &trigger.when {
7230            validate_trigger_expr(expr, &target_schema, trigger.event)?;
7231        }
7232        let mut select_schemas: HashMap<String, &Schema> = HashMap::new();
7233        for step in &trigger.program.steps {
7234            if matches!(step, TriggerStep::SetNew { .. }) && trigger.timing != TriggerTiming::Before
7235            {
7236                return Err(MongrelError::InvalidArgument(
7237                    "SetNew trigger steps are only valid in BEFORE triggers".into(),
7238                ));
7239            }
7240            validate_trigger_step(
7241                step,
7242                &cat,
7243                &target_schema,
7244                trigger.event,
7245                &mut select_schemas,
7246            )?;
7247        }
7248        Ok(())
7249    }
7250
7251    /// Begin a new transaction reading at the current visible epoch.
7252    pub fn begin(&self) -> crate::txn::Transaction<'_> {
7253        self.begin_with_isolation(crate::txn::IsolationLevel::default())
7254    }
7255
7256    fn transaction_principal_snapshot(&self) -> (Option<crate::auth::Principal>, bool) {
7257        let principal = self.principal.read().clone();
7258        let catalog_bound = principal.as_ref().is_some_and(|principal| {
7259            let catalog = self.catalog.read();
7260            catalog.require_auth || principal.user_id != 0
7261        });
7262        (principal, catalog_bound)
7263    }
7264
7265    pub fn begin_as(
7266        &self,
7267        principal: Option<crate::auth::Principal>,
7268    ) -> crate::txn::Transaction<'_> {
7269        let catalog_bound = principal.as_ref().is_some_and(|principal| {
7270            let catalog = self.catalog.read();
7271            catalog.require_auth || principal.user_id != 0
7272        });
7273        let txn_id = self.alloc_txn_id();
7274        let read = Snapshot::at(self.epoch.visible());
7275        crate::txn::Transaction::new(self, txn_id, read).with_principal(principal, catalog_bound)
7276    }
7277
7278    /// Begin a transaction with a specific isolation level.
7279    pub fn begin_with_isolation(
7280        &self,
7281        level: crate::txn::IsolationLevel,
7282    ) -> crate::txn::Transaction<'_> {
7283        let txn_id = self.alloc_txn_id();
7284        let epoch = match level {
7285            crate::txn::IsolationLevel::ReadCommitted => self.epoch.visible(),
7286            _ => self.epoch.visible(),
7287        };
7288        let read = Snapshot::at(epoch);
7289        let (principal, catalog_bound) = self.transaction_principal_snapshot();
7290        crate::txn::Transaction::new(self, txn_id, read).with_principal(principal, catalog_bound)
7291    }
7292
7293    /// Begin a transaction whose trigger programs may route external-table DML
7294    /// through an application/query-layer module bridge.
7295    pub fn begin_with_external_trigger_bridge<'a>(
7296        &'a self,
7297        bridge: &'a dyn ExternalTriggerBridge,
7298    ) -> crate::txn::Transaction<'a> {
7299        let txn_id = self.alloc_txn_id();
7300        let read = Snapshot::at(self.epoch.visible());
7301        let (principal, catalog_bound) = self.transaction_principal_snapshot();
7302        crate::txn::Transaction::new(self, txn_id, read)
7303            .with_external_trigger_bridge(bridge)
7304            .with_principal(principal, catalog_bound)
7305    }
7306
7307    pub fn begin_with_external_trigger_bridge_as<'a>(
7308        &'a self,
7309        bridge: &'a dyn ExternalTriggerBridge,
7310        principal: Option<crate::auth::Principal>,
7311    ) -> crate::txn::Transaction<'a> {
7312        let catalog_bound = principal.as_ref().is_some_and(|principal| {
7313            let catalog = self.catalog.read();
7314            catalog.require_auth || principal.user_id != 0
7315        });
7316        let txn_id = self.alloc_txn_id();
7317        let read = Snapshot::at(self.epoch.visible());
7318        crate::txn::Transaction::new(self, txn_id, read)
7319            .with_external_trigger_bridge(bridge)
7320            .with_principal(principal, catalog_bound)
7321    }
7322
7323    /// Run `f` in a transaction; commit on `Ok`, rollback on `Err`.
7324    pub fn transaction<T>(
7325        &self,
7326        f: impl FnOnce(&mut crate::txn::Transaction) -> Result<T>,
7327    ) -> Result<T> {
7328        let mut tx = self.begin();
7329        match f(&mut tx) {
7330            Ok(out) => {
7331                tx.commit()?;
7332                Ok(out)
7333            }
7334            Err(e) => {
7335                tx.rollback();
7336                Err(e)
7337            }
7338        }
7339    }
7340
7341    pub fn transaction_with_row_ids<T>(
7342        &self,
7343        f: impl FnOnce(&mut crate::txn::Transaction) -> Result<T>,
7344    ) -> Result<(T, Vec<RowId>)> {
7345        let mut tx = self.begin();
7346        match f(&mut tx) {
7347            Ok(output) => {
7348                let (_, row_ids) = tx.commit_with_row_ids()?;
7349                Ok((output, row_ids))
7350            }
7351            Err(error) => {
7352                tx.rollback();
7353                Err(error)
7354            }
7355        }
7356    }
7357
7358    pub fn transaction_for_current_principal<T>(
7359        &self,
7360        f: impl FnOnce(&mut crate::txn::Transaction) -> Result<T>,
7361    ) -> Result<T> {
7362        if self.principal.read().is_some() {
7363            self.refresh_principal()?;
7364        }
7365        let mut transaction = self.begin_as(self.principal.read().clone());
7366        match f(&mut transaction) {
7367            Ok(output) => {
7368                transaction.commit()?;
7369                Ok(output)
7370            }
7371            Err(error) => {
7372                transaction.rollback();
7373                Err(error)
7374            }
7375        }
7376    }
7377
7378    pub fn transaction_for_current_principal_with_epoch<T>(
7379        &self,
7380        f: impl FnOnce(&mut crate::txn::Transaction) -> Result<T>,
7381    ) -> Result<(Epoch, T)> {
7382        if self.principal.read().is_some() {
7383            self.refresh_principal()?;
7384        }
7385        let mut transaction = self.begin_as(self.principal.read().clone());
7386        match f(&mut transaction) {
7387            Ok(output) => {
7388                let epoch = transaction.commit()?;
7389                Ok((epoch, output))
7390            }
7391            Err(error) => {
7392                transaction.rollback();
7393                Err(error)
7394            }
7395        }
7396    }
7397
7398    pub fn transaction_with_row_ids_for_current_principal<T>(
7399        &self,
7400        f: impl FnOnce(&mut crate::txn::Transaction) -> Result<T>,
7401    ) -> Result<(T, Vec<RowId>)> {
7402        if self.principal.read().is_some() {
7403            self.refresh_principal()?;
7404        }
7405        let mut transaction = self.begin_as(self.principal.read().clone());
7406        match f(&mut transaction) {
7407            Ok(output) => {
7408                let (_, row_ids) = transaction.commit_with_row_ids()?;
7409                Ok((output, row_ids))
7410            }
7411            Err(error) => {
7412                transaction.rollback();
7413                Err(error)
7414            }
7415        }
7416    }
7417
7418    /// Run `f` in a transaction with an external-trigger bridge; commit on
7419    /// `Ok`, rollback on `Err`.
7420    pub fn transaction_with_external_trigger_bridge<'a, T>(
7421        &'a self,
7422        bridge: &'a dyn ExternalTriggerBridge,
7423        f: impl FnOnce(&mut crate::txn::Transaction) -> Result<T>,
7424    ) -> Result<T> {
7425        let mut tx = self.begin_with_external_trigger_bridge(bridge);
7426        match f(&mut tx) {
7427            Ok(out) => {
7428                tx.commit()?;
7429                Ok(out)
7430            }
7431            Err(e) => {
7432                tx.rollback();
7433                Err(e)
7434            }
7435        }
7436    }
7437
7438    pub fn transaction_with_external_trigger_bridge_as<'a, T>(
7439        &'a self,
7440        bridge: &'a dyn ExternalTriggerBridge,
7441        principal: Option<crate::auth::Principal>,
7442        f: impl FnOnce(&mut crate::txn::Transaction) -> Result<T>,
7443    ) -> Result<T> {
7444        let mut tx = self.begin_with_external_trigger_bridge_as(bridge, principal);
7445        match f(&mut tx) {
7446            Ok(output) => {
7447                tx.commit()?;
7448                Ok(output)
7449            }
7450            Err(error) => {
7451                tx.rollback();
7452                Err(error)
7453            }
7454        }
7455    }
7456
7457    /// Register a txn in `ActiveTxns` (spec §9.2, review fix #12). Called from
7458    /// `Transaction::new` so registration happens **before** any read.
7459    pub(crate) fn register_active(&self, epoch: Epoch) -> crate::txn::ActiveTxnGuard<'_> {
7460        self.active_txns.register(epoch)
7461    }
7462
7463    fn fill_auto_increment_for_staging(
7464        &self,
7465        staging: &mut [(u64, crate::txn::Staged)],
7466        control: Option<&crate::ExecutionControl>,
7467    ) -> Result<()> {
7468        let mut puts_by_table: HashMap<u64, Vec<usize>> = HashMap::new();
7469        for (index, (table_id, staged)) in staging.iter().enumerate() {
7470            commit_prepare_checkpoint(control, index)?;
7471            if matches!(staged, crate::txn::Staged::Put(_)) {
7472                puts_by_table.entry(*table_id).or_default().push(index);
7473            }
7474        }
7475
7476        let tables = self.tables.read();
7477        for (table_index, (table_id, indexes)) in puts_by_table.into_iter().enumerate() {
7478            commit_prepare_checkpoint(control, table_index)?;
7479            if let Some(handle) = tables.get(&table_id) {
7480                #[cfg(test)]
7481                AUTO_INCREMENT_TABLE_LOCKS.with(|count| count.set(count.get() + 1));
7482                let mut t = handle.lock();
7483                for (fill_index, index) in indexes.into_iter().enumerate() {
7484                    commit_prepare_checkpoint(control, fill_index)?;
7485                    if let crate::txn::Staged::Put(cells) = &mut staging[index].1 {
7486                        t.fill_auto_inc(cells)?;
7487                    }
7488                }
7489            }
7490        }
7491        Ok(())
7492    }
7493
7494    fn expand_table_triggers(
7495        &self,
7496        staging: &mut Vec<(u64, crate::txn::Staged)>,
7497        read_epoch: Epoch,
7498        external_trigger_bridge: Option<&dyn ExternalTriggerBridge>,
7499        external_states: &mut Vec<(String, Vec<u8>)>,
7500        control: Option<&crate::ExecutionControl>,
7501    ) -> Result<()> {
7502        commit_prepare_checkpoint(control, 0)?;
7503        let mut external_writes = Vec::new();
7504        let config = self.trigger_config();
7505        if config.recursive_triggers {
7506            let chunk = std::mem::take(staging);
7507            let stacks = vec![Vec::new(); chunk.len()];
7508            *staging = self.expand_trigger_chunk(
7509                chunk,
7510                stacks,
7511                read_epoch,
7512                0,
7513                config.max_depth,
7514                &mut external_writes,
7515                &config,
7516                control,
7517            )?;
7518            self.apply_external_trigger_writes(
7519                external_writes,
7520                external_trigger_bridge,
7521                external_states,
7522                staging,
7523                control,
7524            )?;
7525            return Ok(());
7526        }
7527
7528        let mut expansion =
7529            self.expand_table_triggers_once(staging, read_epoch, None, &config, control)?;
7530        if !expansion.before.is_empty() {
7531            let mut final_staging = expansion.before;
7532            final_staging.extend(filter_ignored_staging(
7533                std::mem::take(staging),
7534                &expansion.ignored_indices,
7535            ));
7536            *staging = final_staging;
7537        } else if !expansion.ignored_indices.is_empty() {
7538            *staging = filter_ignored_staging(std::mem::take(staging), &expansion.ignored_indices);
7539        }
7540        staging.append(&mut expansion.after);
7541        external_writes.append(&mut expansion.before_external);
7542        external_writes.append(&mut expansion.after_external);
7543        self.apply_external_trigger_writes(
7544            external_writes,
7545            external_trigger_bridge,
7546            external_states,
7547            staging,
7548            control,
7549        )?;
7550        Ok(())
7551    }
7552
7553    #[allow(clippy::too_many_arguments)]
7554    fn expand_trigger_chunk(
7555        &self,
7556        mut chunk: Vec<(u64, crate::txn::Staged)>,
7557        stacks: Vec<Vec<String>>,
7558        read_epoch: Epoch,
7559        depth: u32,
7560        max_depth: u32,
7561        external_writes: &mut Vec<ExternalTriggerWrite>,
7562        config: &TriggerConfig,
7563        control: Option<&crate::ExecutionControl>,
7564    ) -> Result<Vec<(u64, crate::txn::Staged)>> {
7565        if chunk.is_empty() {
7566            return Ok(Vec::new());
7567        }
7568        commit_prepare_checkpoint(control, 0)?;
7569        self.fill_auto_increment_for_staging(&mut chunk, control)?;
7570        let expansion = self.expand_table_triggers_once(
7571            &mut chunk,
7572            read_epoch,
7573            Some(&stacks),
7574            config,
7575            control,
7576        )?;
7577        if depth >= max_depth && (!expansion.before.is_empty() || !expansion.after.is_empty()) {
7578            let stack = expansion
7579                .before_stacks
7580                .first()
7581                .or_else(|| expansion.after_stacks.first())
7582                .cloned()
7583                .unwrap_or_default();
7584            return Err(MongrelError::TriggerValidation(format!(
7585                "trigger recursion exceeded max depth {max_depth}; trigger stack: {}",
7586                Self::format_trigger_stack(&stack)
7587            )));
7588        }
7589
7590        let mut out = Vec::new();
7591        external_writes.extend(expansion.before_external);
7592        out.extend(self.expand_trigger_chunk(
7593            expansion.before,
7594            expansion.before_stacks,
7595            read_epoch,
7596            depth + 1,
7597            max_depth,
7598            external_writes,
7599            config,
7600            control,
7601        )?);
7602        out.extend(filter_ignored_staging(chunk, &expansion.ignored_indices));
7603        external_writes.extend(expansion.after_external);
7604        out.extend(self.expand_trigger_chunk(
7605            expansion.after,
7606            expansion.after_stacks,
7607            read_epoch,
7608            depth + 1,
7609            max_depth,
7610            external_writes,
7611            config,
7612            control,
7613        )?);
7614        Ok(out)
7615    }
7616
7617    fn apply_external_trigger_writes(
7618        &self,
7619        writes: Vec<ExternalTriggerWrite>,
7620        bridge: Option<&dyn ExternalTriggerBridge>,
7621        external_states: &mut Vec<(String, Vec<u8>)>,
7622        staging: &mut Vec<(u64, crate::txn::Staged)>,
7623        control: Option<&crate::ExecutionControl>,
7624    ) -> Result<()> {
7625        if writes.is_empty() {
7626            return Ok(());
7627        }
7628        let bridge = bridge.ok_or_else(|| {
7629            MongrelError::TriggerValidation(
7630                "trigger program wrote an external table, but this transaction has no external trigger bridge".into(),
7631            )
7632        })?;
7633        for (write_index, write) in writes.into_iter().enumerate() {
7634            commit_prepare_checkpoint(control, write_index)?;
7635            let table = write.table().to_string();
7636            let entry = self.external_table(&table).ok_or_else(|| {
7637                MongrelError::NotFound(format!("external table {table:?} not found"))
7638            })?;
7639            let base_state = current_external_state_bytes(&self.root, external_states, &table)?;
7640            let result = bridge.apply_trigger_external_write(&entry, base_state, write)?;
7641            external_states.push((table, result.state));
7642            for (base_index, base_write) in result.base_writes.into_iter().enumerate() {
7643                commit_prepare_checkpoint(control, base_index)?;
7644                match base_write {
7645                    ExternalTriggerBaseWrite::Put { table, cells } => {
7646                        let table_id = self.table_id(&table)?;
7647                        staging.push((table_id, crate::txn::Staged::Put(cells)));
7648                    }
7649                    ExternalTriggerBaseWrite::Delete { table, row_id } => {
7650                        let table_id = self.table_id(&table)?;
7651                        staging.push((table_id, crate::txn::Staged::Delete(row_id)));
7652                    }
7653                }
7654            }
7655        }
7656        dedup_external_states_in_place(external_states);
7657        Ok(())
7658    }
7659
7660    fn expand_table_triggers_once(
7661        &self,
7662        staging: &mut Vec<(u64, crate::txn::Staged)>,
7663        read_epoch: Epoch,
7664        trigger_stacks: Option<&[Vec<String>]>,
7665        config: &TriggerConfig,
7666        control: Option<&crate::ExecutionControl>,
7667    ) -> Result<TriggerExpansion> {
7668        commit_prepare_checkpoint(control, 0)?;
7669        let triggers: Vec<StoredTrigger> = self
7670            .catalog
7671            .read()
7672            .triggers
7673            .iter()
7674            .filter(|entry| {
7675                entry.trigger.enabled
7676                    && matches!(
7677                        entry.trigger.timing,
7678                        TriggerTiming::Before | TriggerTiming::After
7679                    )
7680                    && matches!(entry.trigger.target, TriggerTarget::Table(_))
7681            })
7682            .map(|entry| entry.trigger.clone())
7683            .collect();
7684        if triggers.is_empty() || staging.is_empty() {
7685            return Ok(TriggerExpansion::default());
7686        }
7687
7688        let before_triggers = triggers
7689            .iter()
7690            .filter(|trigger| trigger.timing == TriggerTiming::Before)
7691            .cloned()
7692            .collect::<Vec<_>>();
7693        let after_triggers = triggers
7694            .iter()
7695            .filter(|trigger| trigger.timing == TriggerTiming::After)
7696            .cloned()
7697            .collect::<Vec<_>>();
7698
7699        let mut before_added = Vec::new();
7700        let mut before_stacks = Vec::new();
7701        let mut before_external = Vec::new();
7702        let mut ignored_indices = std::collections::BTreeSet::new();
7703        if !before_triggers.is_empty() {
7704            let before_events =
7705                self.trigger_events_for_staging(staging, read_epoch, trigger_stacks, control)?;
7706            let mut out = TriggerProgramOutput {
7707                added: &mut before_added,
7708                added_stacks: &mut before_stacks,
7709                added_external: &mut before_external,
7710                ignored_indices: &mut ignored_indices,
7711            };
7712            self.execute_triggers_for_events(
7713                &before_triggers,
7714                &before_events,
7715                Some(staging),
7716                &mut out,
7717                config,
7718                read_epoch,
7719                control,
7720            )?;
7721        }
7722
7723        let after_events = if after_triggers.is_empty() {
7724            Vec::new()
7725        } else {
7726            self.trigger_events_for_staging(staging, read_epoch, trigger_stacks, control)?
7727                .into_iter()
7728                .filter(|event| {
7729                    !event
7730                        .op_indices
7731                        .iter()
7732                        .any(|idx| ignored_indices.contains(idx))
7733                })
7734                .collect()
7735        };
7736
7737        let mut after_added = Vec::new();
7738        let mut after_stacks = Vec::new();
7739        let mut after_external = Vec::new();
7740        let mut out = TriggerProgramOutput {
7741            added: &mut after_added,
7742            added_stacks: &mut after_stacks,
7743            added_external: &mut after_external,
7744            ignored_indices: &mut ignored_indices,
7745        };
7746        self.execute_triggers_for_events(
7747            &after_triggers,
7748            &after_events,
7749            None,
7750            &mut out,
7751            config,
7752            read_epoch,
7753            control,
7754        )?;
7755        Ok(TriggerExpansion {
7756            before: before_added,
7757            before_stacks,
7758            before_external,
7759            after: after_added,
7760            after_stacks,
7761            after_external,
7762            ignored_indices,
7763        })
7764    }
7765
7766    #[allow(clippy::too_many_arguments)]
7767    fn execute_triggers_for_events(
7768        &self,
7769        triggers: &[StoredTrigger],
7770        events: &[WriteEvent],
7771        mut staging: Option<&mut Vec<(u64, crate::txn::Staged)>>,
7772        out: &mut TriggerProgramOutput<'_>,
7773        config: &TriggerConfig,
7774        read_epoch: Epoch,
7775        control: Option<&crate::ExecutionControl>,
7776    ) -> Result<()> {
7777        let mut checkpoint_index = 0_usize;
7778        for event in events {
7779            for trigger in triggers {
7780                commit_prepare_checkpoint(control, checkpoint_index)?;
7781                checkpoint_index += 1;
7782                if event
7783                    .op_indices
7784                    .iter()
7785                    .any(|idx| out.ignored_indices.contains(idx))
7786                {
7787                    break;
7788                }
7789                let matches = {
7790                    let cat = self.catalog.read();
7791                    trigger_matches_event(trigger, event, &cat)?
7792                };
7793                if !matches {
7794                    continue;
7795                }
7796                if let Some(when) = &trigger.when {
7797                    if !eval_trigger_expr(when, event)? {
7798                        continue;
7799                    }
7800                }
7801                let trigger_stack = Self::trigger_stack_with(&event.trigger_stack, &trigger.name);
7802                if event.trigger_stack.iter().any(|name| name == &trigger.name) {
7803                    return Err(MongrelError::TriggerValidation(format!(
7804                        "trigger recursion cycle detected; trigger stack: {}",
7805                        Self::format_trigger_stack(&trigger_stack)
7806                    )));
7807                }
7808                let outcome = match staging.as_mut() {
7809                    Some(staging) => self.execute_trigger_program(
7810                        trigger,
7811                        event,
7812                        Some(&mut **staging),
7813                        out,
7814                        &trigger_stack,
7815                        config,
7816                        read_epoch,
7817                        control,
7818                    )?,
7819                    None => self.execute_trigger_program(
7820                        trigger,
7821                        event,
7822                        None,
7823                        out,
7824                        &trigger_stack,
7825                        config,
7826                        read_epoch,
7827                        control,
7828                    )?,
7829                };
7830                if outcome == TriggerProgramOutcome::Ignore {
7831                    out.ignored_indices.extend(event.op_indices.iter().copied());
7832                    break;
7833                }
7834            }
7835        }
7836        Ok(())
7837    }
7838
7839    fn trigger_events_for_staging(
7840        &self,
7841        staging: &[(u64, crate::txn::Staged)],
7842        read_epoch: Epoch,
7843        trigger_stacks: Option<&[Vec<String>]>,
7844        control: Option<&crate::ExecutionControl>,
7845    ) -> Result<Vec<WriteEvent>> {
7846        use crate::txn::Staged;
7847        use std::collections::{HashMap, VecDeque};
7848
7849        let snapshot = Snapshot::at(read_epoch);
7850        let cat = self.catalog.read();
7851        let mut table_names = HashMap::new();
7852        let mut table_schemas = HashMap::new();
7853        for entry in cat
7854            .tables
7855            .iter()
7856            .filter(|entry| matches!(entry.state, TableState::Live))
7857        {
7858            table_names.insert(entry.table_id, entry.name.clone());
7859            table_schemas.insert(entry.table_id, entry.schema.clone());
7860        }
7861        drop(cat);
7862
7863        let mut old_rows: HashMap<usize, TriggerRowImage> = HashMap::new();
7864        let mut delete_by_key: HashMap<(u64, Vec<u8>), VecDeque<usize>> = HashMap::new();
7865        let mut put_by_key: HashMap<(u64, Vec<u8>), VecDeque<usize>> = HashMap::new();
7866
7867        for (idx, (table_id, staged)) in staging.iter().enumerate() {
7868            commit_prepare_checkpoint(control, idx)?;
7869            let Some(schema) = table_schemas.get(table_id) else {
7870                continue;
7871            };
7872            let Some(pk) = schema.primary_key() else {
7873                continue;
7874            };
7875            match staged {
7876                Staged::Delete(row_id) => {
7877                    let handle = self.table_by_id(*table_id)?;
7878                    let Some(row) = handle.lock().get(*row_id, snapshot) else {
7879                        continue;
7880                    };
7881                    let Some(pk_value) = row.columns.get(&pk.id) else {
7882                        continue;
7883                    };
7884                    old_rows.insert(idx, TriggerRowImage::from_row(row.clone()));
7885                    delete_by_key
7886                        .entry((*table_id, pk_value.encode_key()))
7887                        .or_default()
7888                        .push_back(idx);
7889                }
7890                Staged::Put(cells) => {
7891                    if let Some((_, value)) = cells.iter().find(|(id, _)| *id == pk.id) {
7892                        put_by_key
7893                            .entry((*table_id, value.encode_key()))
7894                            .or_default()
7895                            .push_back(idx);
7896                    }
7897                }
7898                Staged::Update { row_id, .. } => {
7899                    let handle = self.table_by_id(*table_id)?;
7900                    let row = handle.lock().get(*row_id, snapshot);
7901                    if let Some(row) = row {
7902                        old_rows.insert(idx, TriggerRowImage::from_row(row));
7903                    }
7904                }
7905                Staged::Truncate => {}
7906            }
7907        }
7908
7909        let mut paired_delete = std::collections::HashSet::new();
7910        let mut paired_put = std::collections::HashSet::new();
7911        let mut events = Vec::new();
7912
7913        for (pair_index, (key, deletes)) in delete_by_key.iter_mut().enumerate() {
7914            commit_prepare_checkpoint(control, pair_index)?;
7915            let Some(puts) = put_by_key.get_mut(key) else {
7916                continue;
7917            };
7918            while let (Some(delete_idx), Some(put_idx)) = (deletes.pop_front(), puts.pop_front()) {
7919                paired_delete.insert(delete_idx);
7920                paired_put.insert(put_idx);
7921                let (table_id, _) = &staging[put_idx];
7922                let Some(table_name) = table_names.get(table_id).cloned() else {
7923                    continue;
7924                };
7925                let old = old_rows.get(&delete_idx).cloned();
7926                let new = match &staging[put_idx].1 {
7927                    Staged::Put(cells) => Some(TriggerRowImage::from_cells(cells)),
7928                    _ => None,
7929                };
7930                let changed_columns = changed_columns(old.as_ref(), new.as_ref());
7931                events.push(WriteEvent {
7932                    table: table_name,
7933                    kind: TriggerEvent::Update,
7934                    old,
7935                    new,
7936                    changed_columns,
7937                    op_indices: vec![delete_idx, put_idx],
7938                    put_idx: Some(put_idx),
7939                    trigger_stack: Self::trigger_stack_for_indices(
7940                        trigger_stacks,
7941                        &[delete_idx, put_idx],
7942                    ),
7943                });
7944            }
7945        }
7946
7947        for (idx, (table_id, staged)) in staging.iter().enumerate() {
7948            commit_prepare_checkpoint(control, idx)?;
7949            let Some(table_name) = table_names.get(table_id).cloned() else {
7950                continue;
7951            };
7952            match staged {
7953                Staged::Put(cells) if !paired_put.contains(&idx) => {
7954                    let new = Some(TriggerRowImage::from_cells(cells));
7955                    let changed_columns = cells.iter().map(|(id, _)| *id).collect();
7956                    events.push(WriteEvent {
7957                        table: table_name,
7958                        kind: TriggerEvent::Insert,
7959                        old: None,
7960                        new,
7961                        changed_columns,
7962                        op_indices: vec![idx],
7963                        put_idx: Some(idx),
7964                        trigger_stack: Self::trigger_stack_for_indices(trigger_stacks, &[idx]),
7965                    });
7966                }
7967                Staged::Delete(row_id) if !paired_delete.contains(&idx) => {
7968                    let old = match old_rows.get(&idx).cloned() {
7969                        Some(old) => Some(old),
7970                        None => {
7971                            let handle = self.table_by_id(*table_id)?;
7972                            let row = handle.lock().get(*row_id, snapshot);
7973                            row.map(TriggerRowImage::from_row)
7974                        }
7975                    };
7976                    let Some(old) = old else {
7977                        continue;
7978                    };
7979                    let changed_columns = old.columns.keys().copied().collect();
7980                    events.push(WriteEvent {
7981                        table: table_name,
7982                        kind: TriggerEvent::Delete,
7983                        old: Some(old),
7984                        new: None,
7985                        changed_columns,
7986                        op_indices: vec![idx],
7987                        put_idx: None,
7988                        trigger_stack: Self::trigger_stack_for_indices(trigger_stacks, &[idx]),
7989                    });
7990                }
7991                Staged::Update { new_row: cells, .. } => {
7992                    let old = old_rows.get(&idx).cloned();
7993                    let new = Some(TriggerRowImage::from_cells(cells));
7994                    let changed_columns = changed_columns(old.as_ref(), new.as_ref());
7995                    events.push(WriteEvent {
7996                        table: table_name,
7997                        kind: TriggerEvent::Update,
7998                        old,
7999                        new,
8000                        changed_columns,
8001                        op_indices: vec![idx],
8002                        put_idx: Some(idx),
8003                        trigger_stack: Self::trigger_stack_for_indices(trigger_stacks, &[idx]),
8004                    });
8005                }
8006                Staged::Truncate => {}
8007                _ => {}
8008            }
8009        }
8010
8011        Ok(events)
8012    }
8013
8014    #[allow(clippy::too_many_arguments)]
8015    fn execute_trigger_program(
8016        &self,
8017        trigger: &StoredTrigger,
8018        event: &WriteEvent,
8019        staging: Option<&mut Vec<(u64, crate::txn::Staged)>>,
8020        out: &mut TriggerProgramOutput<'_>,
8021        trigger_stack: &[String],
8022        config: &TriggerConfig,
8023        read_epoch: Epoch,
8024        control: Option<&crate::ExecutionControl>,
8025    ) -> Result<TriggerProgramOutcome> {
8026        let mut event = event.clone();
8027        let mut select_results: HashMap<String, Vec<TriggerRowImage>> = HashMap::new();
8028        self.execute_trigger_steps(
8029            trigger,
8030            &trigger.program.steps,
8031            &mut event,
8032            staging,
8033            out,
8034            trigger_stack,
8035            config,
8036            &mut select_results,
8037            0,
8038            None,
8039            read_epoch,
8040            control,
8041        )
8042    }
8043
8044    #[allow(clippy::too_many_arguments)]
8045    fn execute_trigger_steps(
8046        &self,
8047        trigger: &StoredTrigger,
8048        steps: &[TriggerStep],
8049        event: &mut WriteEvent,
8050        mut staging: Option<&mut Vec<(u64, crate::txn::Staged)>>,
8051        out: &mut TriggerProgramOutput<'_>,
8052        trigger_stack: &[String],
8053        config: &TriggerConfig,
8054        select_results: &mut HashMap<String, Vec<TriggerRowImage>>,
8055        depth: u32,
8056        selected: Option<&TriggerRowImage>,
8057        read_epoch: Epoch,
8058        control: Option<&crate::ExecutionControl>,
8059    ) -> Result<TriggerProgramOutcome> {
8060        let _ = depth;
8061        for (step_index, step) in steps.iter().enumerate() {
8062            commit_prepare_checkpoint(control, step_index)?;
8063            match step {
8064                TriggerStep::SetNew { cells } => {
8065                    if trigger.timing != TriggerTiming::Before {
8066                        return Err(MongrelError::InvalidArgument(
8067                            "SetNew trigger step is only valid in BEFORE triggers".into(),
8068                        ));
8069                    }
8070                    let put_idx = event.put_idx.ok_or_else(|| {
8071                        MongrelError::InvalidArgument(
8072                            "SetNew trigger step requires INSERT or UPDATE NEW row".into(),
8073                        )
8074                    })?;
8075                    let staging = staging.as_deref_mut().ok_or_else(|| {
8076                        MongrelError::InvalidArgument(
8077                            "SetNew trigger step requires mutable trigger staging".into(),
8078                        )
8079                    })?;
8080                    let mut update_changed_columns = None;
8081                    let row_cells = match staging.get_mut(put_idx).map(|(_, op)| op) {
8082                        Some(crate::txn::Staged::Put(cells)) => cells,
8083                        Some(crate::txn::Staged::Update {
8084                            new_row,
8085                            changed_columns,
8086                            ..
8087                        }) => {
8088                            update_changed_columns = Some(changed_columns);
8089                            new_row
8090                        }
8091                        _ => {
8092                            return Err(MongrelError::InvalidArgument(
8093                                "SetNew trigger step target row is not mutable".into(),
8094                            ))
8095                        }
8096                    };
8097                    for (column_id, value) in eval_trigger_cells(cells, event, selected)? {
8098                        row_cells.retain(|(id, _)| *id != column_id);
8099                        row_cells.push((column_id, value.clone()));
8100                        if let Some(changed_columns) = &mut update_changed_columns {
8101                            changed_columns.push(column_id);
8102                        }
8103                        if let Some(new) = &mut event.new {
8104                            new.columns.insert(column_id, value);
8105                        }
8106                    }
8107                    row_cells.sort_by_key(|(id, _)| *id);
8108                    if let Some(changed_columns) = update_changed_columns {
8109                        changed_columns.sort_unstable();
8110                        changed_columns.dedup();
8111                    }
8112                }
8113                TriggerStep::Insert { table, cells } => {
8114                    let cells = eval_trigger_cells(cells, event, selected)?;
8115                    if let Ok(table_id) = self.table_id(table) {
8116                        out.added.push((table_id, crate::txn::Staged::Put(cells)));
8117                        out.added_stacks.push(trigger_stack.to_vec());
8118                    } else if self.external_table(table).is_some() {
8119                        out.added_external.push(ExternalTriggerWrite::Insert {
8120                            table: table.clone(),
8121                            cells,
8122                        });
8123                    } else {
8124                        return Err(MongrelError::NotFound(format!(
8125                            "trigger {:?} insert target {table:?} not found",
8126                            trigger.name
8127                        )));
8128                    }
8129                }
8130                TriggerStep::UpdateByPk { table, pk, cells } => {
8131                    let pk = eval_trigger_value(pk, event, selected)?;
8132                    let cells = eval_trigger_cells(cells, event, selected)?;
8133                    if self.external_table(table).is_some() {
8134                        out.added_external.push(ExternalTriggerWrite::UpdateByPk {
8135                            table: table.clone(),
8136                            pk,
8137                            cells,
8138                        });
8139                    } else {
8140                        let row_id = self
8141                            .table(table)?
8142                            .lock()
8143                            .lookup_pk(&pk.encode_key())
8144                            .ok_or_else(|| {
8145                                MongrelError::NotFound(format!(
8146                                    "trigger {:?} update target not found",
8147                                    trigger.name
8148                                ))
8149                            })?;
8150                        let handle = self.table(table)?;
8151                        let snapshot = Snapshot::at(self.epoch.visible());
8152                        let old = handle.lock().get(row_id, snapshot).ok_or_else(|| {
8153                            MongrelError::NotFound(format!(
8154                                "trigger {:?} update target not visible",
8155                                trigger.name
8156                            ))
8157                        })?;
8158                        let mut changed_columns = cells
8159                            .iter()
8160                            .map(|(column_id, _)| *column_id)
8161                            .collect::<Vec<_>>();
8162                        changed_columns.sort_unstable();
8163                        changed_columns.dedup();
8164                        let mut merged = old.columns;
8165                        for (column_id, value) in cells {
8166                            merged.insert(column_id, value);
8167                        }
8168                        out.added.push((
8169                            self.table_id(table)?,
8170                            crate::txn::Staged::Update {
8171                                row_id,
8172                                new_row: merged.into_iter().collect(),
8173                                changed_columns,
8174                            },
8175                        ));
8176                        out.added_stacks.push(trigger_stack.to_vec());
8177                    }
8178                }
8179                TriggerStep::DeleteByPk { table, pk } => {
8180                    let pk = eval_trigger_value(pk, event, selected)?;
8181                    if self.external_table(table).is_some() {
8182                        out.added_external.push(ExternalTriggerWrite::DeleteByPk {
8183                            table: table.clone(),
8184                            pk,
8185                        });
8186                    } else {
8187                        let row_id = self
8188                            .table(table)?
8189                            .lock()
8190                            .lookup_pk(&pk.encode_key())
8191                            .ok_or_else(|| {
8192                                MongrelError::NotFound(format!(
8193                                    "trigger {:?} delete target not found",
8194                                    trigger.name
8195                                ))
8196                            })?;
8197                        out.added
8198                            .push((self.table_id(table)?, crate::txn::Staged::Delete(row_id)));
8199                        out.added_stacks.push(trigger_stack.to_vec());
8200                    }
8201                }
8202                TriggerStep::Select {
8203                    id,
8204                    table,
8205                    conditions,
8206                } => {
8207                    let schema = self.table(table)?.lock().schema().clone();
8208                    let snapshot = Snapshot::at(read_epoch);
8209                    let handle = self.table(table)?;
8210                    let rows = match control {
8211                        Some(control) => {
8212                            handle.lock().visible_rows_controlled(snapshot, control)?
8213                        }
8214                        None => handle.lock().visible_rows(snapshot)?,
8215                    };
8216                    let mut matched = Vec::new();
8217                    for (row_index, row) in rows.into_iter().enumerate() {
8218                        commit_prepare_checkpoint(control, row_index)?;
8219                        let image = TriggerRowImage::from_row(row);
8220                        let passes = conditions
8221                            .iter()
8222                            .map(|cond| eval_trigger_condition(cond, event, &image, &schema))
8223                            .collect::<Result<Vec<_>>>()?
8224                            .into_iter()
8225                            .all(|b| b);
8226                        if passes {
8227                            matched.push(image);
8228                        }
8229                    }
8230                    if let Some(pk) = schema.primary_key() {
8231                        matched.sort_by(|a, b| {
8232                            let av = a.columns.get(&pk.id).unwrap_or(&Value::Null);
8233                            let bv = b.columns.get(&pk.id).unwrap_or(&Value::Null);
8234                            value_order(av, bv).unwrap_or(std::cmp::Ordering::Equal)
8235                        });
8236                    }
8237                    select_results.insert(id.clone(), matched);
8238                }
8239                TriggerStep::Foreach { id, steps } => {
8240                    let rows = select_results.get(id).ok_or_else(|| {
8241                        MongrelError::InvalidArgument(format!(
8242                            "trigger {:?} foreach references unknown select id {id:?}",
8243                            trigger.name
8244                        ))
8245                    })?;
8246                    if rows.len() > config.max_loop_iterations as usize {
8247                        return Err(MongrelError::InvalidArgument(format!(
8248                            "trigger {:?} foreach exceeded max_loop_iterations ({})",
8249                            trigger.name, config.max_loop_iterations
8250                        )));
8251                    }
8252                    for (row_index, row) in rows.clone().into_iter().enumerate() {
8253                        commit_prepare_checkpoint(control, row_index)?;
8254                        let result = self.execute_trigger_steps(
8255                            trigger,
8256                            steps,
8257                            event,
8258                            staging.as_deref_mut(),
8259                            out,
8260                            trigger_stack,
8261                            config,
8262                            select_results,
8263                            depth + 1,
8264                            Some(&row),
8265                            read_epoch,
8266                            control,
8267                        )?;
8268                        if result == TriggerProgramOutcome::Ignore {
8269                            return Ok(TriggerProgramOutcome::Ignore);
8270                        }
8271                    }
8272                }
8273                TriggerStep::DeleteWhere { table, conditions } => {
8274                    let schema = self.table(table)?.lock().schema().clone();
8275                    let snapshot = Snapshot::at(read_epoch);
8276                    let handle = self.table(table)?;
8277                    let rows = match control {
8278                        Some(control) => {
8279                            handle.lock().visible_rows_controlled(snapshot, control)?
8280                        }
8281                        None => handle.lock().visible_rows(snapshot)?,
8282                    };
8283                    let table_id = self.table_id(table)?;
8284                    let mut to_delete = Vec::new();
8285                    for (row_index, row) in rows.into_iter().enumerate() {
8286                        commit_prepare_checkpoint(control, row_index)?;
8287                        let image = TriggerRowImage::from_row(row.clone());
8288                        let passes = conditions
8289                            .iter()
8290                            .map(|cond| eval_trigger_condition(cond, event, &image, &schema))
8291                            .collect::<Result<Vec<_>>>()?
8292                            .into_iter()
8293                            .all(|b| b);
8294                        if passes {
8295                            to_delete.push((table_id, row.row_id));
8296                        }
8297                    }
8298                    for (row_index, (table_id, row_id)) in to_delete.into_iter().enumerate() {
8299                        commit_prepare_checkpoint(control, row_index)?;
8300                        out.added
8301                            .push((table_id, crate::txn::Staged::Delete(row_id)));
8302                        out.added_stacks.push(trigger_stack.to_vec());
8303                    }
8304                }
8305                TriggerStep::UpdateWhere {
8306                    table,
8307                    conditions,
8308                    cells,
8309                } => {
8310                    let schema = self.table(table)?.lock().schema().clone();
8311                    let snapshot = Snapshot::at(read_epoch);
8312                    let handle = self.table(table)?;
8313                    let rows = match control {
8314                        Some(control) => {
8315                            handle.lock().visible_rows_controlled(snapshot, control)?
8316                        }
8317                        None => handle.lock().visible_rows(snapshot)?,
8318                    };
8319                    let table_id = self.table_id(table)?;
8320                    let mut changed_columns =
8321                        cells.iter().map(|cell| cell.column_id).collect::<Vec<_>>();
8322                    changed_columns.sort_unstable();
8323                    changed_columns.dedup();
8324                    let mut to_update = Vec::new();
8325                    for (row_index, row) in rows.into_iter().enumerate() {
8326                        commit_prepare_checkpoint(control, row_index)?;
8327                        let image = TriggerRowImage::from_row(row.clone());
8328                        let passes = conditions
8329                            .iter()
8330                            .map(|cond| eval_trigger_condition(cond, event, &image, &schema))
8331                            .collect::<Result<Vec<_>>>()?
8332                            .into_iter()
8333                            .all(|b| b);
8334                        if passes {
8335                            let new_cells = cells
8336                                .iter()
8337                                .map(|cell| {
8338                                    Ok((
8339                                        cell.column_id,
8340                                        eval_trigger_value(&cell.value, event, Some(&image))?,
8341                                    ))
8342                                })
8343                                .collect::<Result<Vec<_>>>()?;
8344                            let mut merged = row.columns.clone();
8345                            for (column_id, value) in new_cells {
8346                                merged.insert(column_id, value);
8347                            }
8348                            to_update.push((table_id, row.row_id, merged));
8349                        }
8350                    }
8351                    for (row_index, (table_id, row_id, merged)) in to_update.into_iter().enumerate()
8352                    {
8353                        commit_prepare_checkpoint(control, row_index)?;
8354                        out.added.push((
8355                            table_id,
8356                            crate::txn::Staged::Update {
8357                                row_id,
8358                                new_row: merged.into_iter().collect(),
8359                                changed_columns: changed_columns.clone(),
8360                            },
8361                        ));
8362                        out.added_stacks.push(trigger_stack.to_vec());
8363                    }
8364                }
8365                TriggerStep::Raise { action, message } => match action {
8366                    TriggerRaiseAction::Ignore => return Ok(TriggerProgramOutcome::Ignore),
8367                    TriggerRaiseAction::Abort
8368                    | TriggerRaiseAction::Fail
8369                    | TriggerRaiseAction::Rollback => {
8370                        let message = eval_trigger_value(message, event, selected)?;
8371                        return Err(MongrelError::TriggerValidation(format!(
8372                            "trigger {:?} raised: {}; trigger stack: {}",
8373                            trigger.name,
8374                            trigger_message(message),
8375                            Self::format_trigger_stack(trigger_stack)
8376                        )));
8377                    }
8378                },
8379            }
8380        }
8381        Ok(TriggerProgramOutcome::Continue)
8382    }
8383
8384    fn trigger_stack_for_indices(stacks: Option<&[Vec<String>]>, indices: &[usize]) -> Vec<String> {
8385        let Some(stacks) = stacks else {
8386            return Vec::new();
8387        };
8388        let mut out = Vec::new();
8389        for idx in indices {
8390            let Some(stack) = stacks.get(*idx) else {
8391                continue;
8392            };
8393            for name in stack {
8394                if !out.iter().any(|existing| existing == name) {
8395                    out.push(name.clone());
8396                }
8397            }
8398        }
8399        out
8400    }
8401
8402    fn trigger_stack_with(stack: &[String], trigger_name: &str) -> Vec<String> {
8403        let mut out = stack.to_vec();
8404        out.push(trigger_name.to_string());
8405        out
8406    }
8407
8408    fn format_trigger_stack(stack: &[String]) -> String {
8409        if stack.is_empty() {
8410            "<root>".into()
8411        } else {
8412            stack.join(" -> ")
8413        }
8414    }
8415
8416    /// Authoritatively validate every declared constraint on the staged write
8417    /// set under the transaction's read snapshot, AND expand ON DELETE CASCADE /
8418    /// SET NULL actions into explicit child ops. Called from
8419    /// [`Self::commit_transaction`] outside the WAL mutex. Returns the first
8420    /// violation as an `Err`, aborting the commit atomically. This is the
8421    /// server-side authority point: concurrent remote writers that each pass
8422    /// their own client-side checks still cannot both commit a violating batch.
8423    ///
8424    /// Scope: CHECK (full, three-valued), UNIQUE beyond the PK (existence scan +
8425    /// intra-transaction dedup; concurrent-txn races are additionally caught by
8426    /// `WriteKey::Unique`), and FK insert-side parent existence + ON DELETE
8427    /// {RESTRICT, CASCADE, SET NULL}. CASCADE appends child deletes (transitive
8428    /// fixpoint); SET NULL appends child updates (FK columns nulled). Truncate is
8429    /// RESTRICT-only (cascade-truncate is unsupported).
8430    fn validate_constraints(
8431        &self,
8432        staging: &mut Vec<(u64, crate::txn::Staged)>,
8433        read_epoch: Epoch,
8434        control: Option<&crate::ExecutionControl>,
8435    ) -> Result<()> {
8436        use crate::constraint::{encode_composite_key, validate_checks, FkAction};
8437        use crate::memtable::Row;
8438        use crate::txn::Staged;
8439        use std::collections::HashSet;
8440
8441        commit_prepare_checkpoint(control, 0)?;
8442        let snapshot = Snapshot::at(read_epoch);
8443        let cat = self.catalog.read();
8444
8445        // Collect live (id, name, constraints-bearing?) for staged tables.
8446        let live: Vec<(u64, &str, &crate::schema::Schema)> = cat
8447            .tables
8448            .iter()
8449            .filter(|entry| matches!(entry.state, TableState::Live | TableState::Building { .. }))
8450            .map(|e| (e.table_id, e.name.as_str(), &e.schema))
8451            .collect();
8452
8453        // Fast path: bail if no live table declares any constraints at all.
8454        let any_constraints = live.iter().any(|(_, _, s)| !s.constraints.is_empty());
8455        if !any_constraints {
8456            return Ok(());
8457        }
8458
8459        // Lazily-loaded visible rows per table, shared across checks.
8460        let mut rows_cache: HashMap<u64, Vec<Row>> = HashMap::new();
8461        let mut load_rows = |table_id: u64| -> Result<Vec<Row>> {
8462            if let Some(r) = rows_cache.get(&table_id) {
8463                return Ok(r.clone());
8464            }
8465            let handle = self.table_by_id(table_id)?;
8466            let rows = match control {
8467                Some(control) => handle.lock().visible_rows_controlled(snapshot, control)?,
8468                None => handle.lock().visible_rows(snapshot)?,
8469            };
8470            rows_cache.insert(table_id, rows.clone());
8471            Ok(rows)
8472        };
8473
8474        // ── Phase A1: expand ON UPDATE CASCADE / SET NULL while updates still
8475        // carry an explicit old RowId + full new image. This makes action choice
8476        // reliable even when the referenced key itself changes; a delete+put
8477        // heuristic cannot distinguish that from unrelated operations.
8478        let mut processed_updates = HashSet::new();
8479        type PendingUpdate = (usize, u64, crate::rowid::RowId, Vec<(u16, Value)>);
8480        let mut update_pass = 0_usize;
8481        loop {
8482            commit_prepare_checkpoint(control, update_pass)?;
8483            update_pass += 1;
8484            let updates: Vec<PendingUpdate> = staging
8485                .iter()
8486                .enumerate()
8487                .filter_map(|(index, (table_id, op))| match op {
8488                    Staged::Update {
8489                        row_id,
8490                        new_row: cells,
8491                        ..
8492                    } if !processed_updates.contains(&index) => {
8493                        Some((index, *table_id, *row_id, cells.clone()))
8494                    }
8495                    _ => None,
8496                })
8497                .collect();
8498            if updates.is_empty() {
8499                break;
8500            }
8501            let mut new_ops = Vec::new();
8502            for (update_index, (index, table_id, row_id, new_cells)) in
8503                updates.into_iter().enumerate()
8504            {
8505                commit_prepare_checkpoint(control, update_index)?;
8506                processed_updates.insert(index);
8507                let Some(tname) = live
8508                    .iter()
8509                    .find(|(id, _, _)| *id == table_id)
8510                    .map(|(_, name, _)| *name)
8511                else {
8512                    continue;
8513                };
8514                let Some(old_row) = self.table_by_id(table_id)?.lock().get(row_id, snapshot) else {
8515                    continue;
8516                };
8517                let new_map: HashMap<u16, Value> = new_cells.iter().cloned().collect();
8518                for (child_id, _child_name, child_schema) in &live {
8519                    for fk in &child_schema.constraints.foreign_keys {
8520                        if fk.ref_table != tname {
8521                            continue;
8522                        }
8523                        let Some(old_key) = encode_composite_key(&fk.ref_columns, &old_row.columns)
8524                        else {
8525                            continue;
8526                        };
8527                        if encode_composite_key(&fk.ref_columns, &new_map).as_deref()
8528                            == Some(old_key.as_slice())
8529                        {
8530                            continue;
8531                        }
8532                        if fk.on_update == FkAction::Restrict {
8533                            continue;
8534                        }
8535                        let child_rows = load_rows(*child_id)?;
8536                        for (child_index, child) in child_rows.into_iter().enumerate() {
8537                            commit_prepare_checkpoint(control, child_index)?;
8538                            if encode_composite_key(&fk.columns, &child.columns).as_deref()
8539                                != Some(old_key.as_slice())
8540                            {
8541                                continue;
8542                            }
8543                            if staging.iter().any(|(id, op)| {
8544                                *id == *child_id
8545                                    && matches!(op, Staged::Delete(id) if *id == child.row_id)
8546                            }) {
8547                                continue;
8548                            }
8549                            let mut cells: Vec<(u16, Value)> = child
8550                                .columns
8551                                .iter()
8552                                .map(|(column_id, value)| (*column_id, value.clone()))
8553                                .collect();
8554                            for (child_column, parent_column) in
8555                                fk.columns.iter().zip(&fk.ref_columns)
8556                            {
8557                                cells.retain(|(column_id, _)| column_id != child_column);
8558                                let value = match fk.on_update {
8559                                    FkAction::Cascade => {
8560                                        new_map.get(parent_column).cloned().unwrap_or(Value::Null)
8561                                    }
8562                                    FkAction::SetNull => Value::Null,
8563                                    FkAction::Restrict => {
8564                                        return Err(MongrelError::Other(
8565                                            "restricted foreign-key update reached cascade preparation"
8566                                                .into(),
8567                                        ));
8568                                    }
8569                                };
8570                                cells.push((*child_column, value));
8571                            }
8572                            cells.sort_by_key(|(column_id, _)| *column_id);
8573                            if let Some(existing_index) = staging.iter().position(|(id, op)| {
8574                                *id == *child_id
8575                                    && matches!(op, Staged::Update { row_id, .. } if *row_id == child.row_id)
8576                            }) {
8577                                if let Staged::Update {
8578                                    new_row: existing,
8579                                    changed_columns,
8580                                    ..
8581                                } = &mut staging[existing_index].1 {
8582                                    changed_columns.extend(fk.columns.iter().copied());
8583                                    changed_columns.sort_unstable();
8584                                    changed_columns.dedup();
8585                                    if *existing != cells {
8586                                        *existing = cells;
8587                                        processed_updates.remove(&existing_index);
8588                                    }
8589                                }
8590                            } else {
8591                                new_ops.push((
8592                                    *child_id,
8593                                    Staged::Update {
8594                                        row_id: child.row_id,
8595                                        new_row: cells,
8596                                        changed_columns: fk.columns.clone(),
8597                                    },
8598                                ));
8599                            }
8600                        }
8601                    }
8602                }
8603            }
8604            staging.extend(new_ops);
8605        }
8606
8607        // ── Phase A2: expand ON DELETE CASCADE / SET NULL into explicit child
8608        // ops (transitive fixpoint). RESTRICT is not expanded here — it is
8609        // enforced as a violation in Phase B. `cascaded` records every delete
8610        // we have already expanded so a self-referential CASCADE FK cannot loop.
8611        let mut cascaded: HashSet<(u64, u64)> = HashSet::new();
8612        let mut cascade_pass = 0_usize;
8613        loop {
8614            commit_prepare_checkpoint(control, cascade_pass)?;
8615            cascade_pass += 1;
8616            let mut new_ops: Vec<(u64, Staged)> = Vec::new();
8617            let deletes: Vec<(u64, crate::rowid::RowId)> = staging
8618                .iter()
8619                .filter_map(|(t, op)| match op {
8620                    Staged::Delete(rid) => Some((*t, *rid)),
8621                    _ => None,
8622                })
8623                .collect();
8624            for (delete_index, (table_id, rid)) in deletes.into_iter().enumerate() {
8625                commit_prepare_checkpoint(control, delete_index)?;
8626                if !cascaded.insert((table_id, rid.0)) {
8627                    continue;
8628                }
8629                let Some(tname) = live
8630                    .iter()
8631                    .find(|(t, _, _)| *t == table_id)
8632                    .map(|(_, n, _)| *n)
8633                else {
8634                    continue;
8635                };
8636                let parent_handle = self.table_by_id(table_id)?;
8637                let Some(parent_row) = parent_handle.lock().get(rid, snapshot) else {
8638                    continue;
8639                };
8640                for (child_id, _child_name, child_schema) in &live {
8641                    for fk in &child_schema.constraints.foreign_keys {
8642                        if fk.ref_table != tname {
8643                            continue;
8644                        }
8645                        let Some(parent_key) =
8646                            encode_composite_key(&fk.ref_columns, &parent_row.columns)
8647                        else {
8648                            continue;
8649                        };
8650                        // Suppress ON DELETE cascade/set-null when this "delete"
8651                        // is actually half of an UPDATE encoded as Delete(old)+
8652                        // Put(new): if a staged Put in the SAME table still
8653                        // provides the referenced parent key, the parent still
8654                        // exists (its non-key columns changed) and the children
8655                        // must be left alone. A genuine delete, or an update
8656                        // that CHANGES the referenced key, has no preserving Put
8657                        // → cascade fires as before.
8658                        let key_preserved = staging.iter().any(|(t, op)| {
8659                            if *t != table_id {
8660                                return false;
8661                            }
8662                            let Staged::Put(cells) = op else {
8663                                return false;
8664                            };
8665                            let map: HashMap<u16, crate::memtable::Value> =
8666                                cells.iter().cloned().collect();
8667                            encode_composite_key(&fk.ref_columns, &map).as_deref()
8668                                == Some(parent_key.as_slice())
8669                        });
8670                        if key_preserved {
8671                            continue;
8672                        }
8673                        match fk.on_delete {
8674                            FkAction::Restrict => continue,
8675                            FkAction::Cascade => {
8676                                let child_rows = load_rows(*child_id)?;
8677                                for (child_index, cr) in child_rows.iter().enumerate() {
8678                                    commit_prepare_checkpoint(control, child_index)?;
8679                                    if !cascaded.contains(&(*child_id, cr.row_id.0))
8680                                        && encode_composite_key(&fk.columns, &cr.columns).as_deref()
8681                                            == Some(parent_key.as_slice())
8682                                    {
8683                                        new_ops.push((*child_id, Staged::Delete(cr.row_id)));
8684                                    }
8685                                }
8686                            }
8687                            FkAction::SetNull => {
8688                                let child_rows = load_rows(*child_id)?;
8689                                for (child_index, cr) in child_rows.iter().enumerate() {
8690                                    commit_prepare_checkpoint(control, child_index)?;
8691                                    if !cascaded.contains(&(*child_id, cr.row_id.0))
8692                                        && encode_composite_key(&fk.columns, &cr.columns).as_deref()
8693                                            == Some(parent_key.as_slice())
8694                                    {
8695                                        // Re-emit the child row with the FK
8696                                        // columns set to NULL (delete + put).
8697                                        let mut cells: Vec<(u16, crate::memtable::Value)> = cr
8698                                            .columns
8699                                            .iter()
8700                                            .map(|(k, v)| (*k, v.clone()))
8701                                            .collect();
8702                                        for cid in &fk.columns {
8703                                            cells.retain(|(k, _)| k != cid);
8704                                            cells.push((*cid, crate::memtable::Value::Null));
8705                                        }
8706                                        new_ops.push((
8707                                            *child_id,
8708                                            Staged::Update {
8709                                                row_id: cr.row_id,
8710                                                new_row: cells,
8711                                                changed_columns: fk.columns.clone(),
8712                                            },
8713                                        ));
8714                                    }
8715                                }
8716                            }
8717                        }
8718                    }
8719                }
8720            }
8721            if new_ops.is_empty() {
8722                break;
8723            }
8724            staging.extend(new_ops);
8725        }
8726
8727        // Rows staged for deletion in THIS transaction (now including cascaded
8728        // deletes). Used to exclude the old version of an updated row from
8729        // unique-existence scans.
8730        let staged_deletes: HashSet<(u64, u64)> = staging
8731            .iter()
8732            .filter_map(|(t, op)| match op {
8733                Staged::Delete(rid) | Staged::Update { row_id: rid, .. } => Some((*t, rid.0)),
8734                _ => None,
8735            })
8736            .collect();
8737
8738        // Intra-transaction unique-key dedup: (table_id, uc_id, key).
8739        let mut seen_unique: HashSet<(u64, u16, Vec<u8>)> = HashSet::new();
8740
8741        // ── Phase B: validate the fully-expanded staging set.
8742        for (operation_index, (table_id, op)) in staging.iter().enumerate() {
8743            commit_prepare_checkpoint(control, operation_index)?;
8744            let Some((_, tname, schema)) = live.iter().find(|(t, _, _)| t == table_id).copied()
8745            else {
8746                continue;
8747            };
8748            let cells_map: HashMap<u16, crate::memtable::Value>;
8749            match op {
8750                Staged::Put(cells) | Staged::Update { new_row: cells, .. } => {
8751                    cells_map = cells.iter().cloned().collect();
8752
8753                    // CHECK constraints.
8754                    if !schema.constraints.checks.is_empty() {
8755                        validate_checks(&schema.constraints.checks, &cells_map)?;
8756                    }
8757
8758                    // UNIQUE (non-PK) constraints.
8759                    for uc in &schema.constraints.uniques {
8760                        let Some(key) = encode_composite_key(&uc.columns, &cells_map) else {
8761                            continue; // NULL in a constrained column → skip (SQL).
8762                        };
8763                        let marker = (*table_id, uc.id, key.clone());
8764                        if !seen_unique.insert(marker) {
8765                            return Err(MongrelError::Conflict(format!(
8766                                "UNIQUE constraint '{}' on table '{tname}' violated within batch",
8767                                uc.name
8768                            )));
8769                        }
8770                        let rows = load_rows(*table_id)?;
8771                        for (row_index, r) in rows.iter().enumerate() {
8772                            commit_prepare_checkpoint(control, row_index)?;
8773                            // Skip rows this same transaction is deleting (the
8774                            // old version of an updated/cascade-deleted row).
8775                            if staged_deletes.contains(&(*table_id, r.row_id.0)) {
8776                                continue;
8777                            }
8778                            if let Some(theirs) = encode_composite_key(&uc.columns, &r.columns) {
8779                                if theirs == key {
8780                                    return Err(MongrelError::Conflict(format!(
8781                                        "UNIQUE constraint '{}' on table '{tname}' violated",
8782                                        uc.name
8783                                    )));
8784                                }
8785                            }
8786                        }
8787                    }
8788
8789                    // FK insert-side: parent must exist.
8790                    for fk in &schema.constraints.foreign_keys {
8791                        let Some(child_key) = encode_composite_key(&fk.columns, &cells_map) else {
8792                            continue; // NULL FK component → not checked (SQL).
8793                        };
8794                        let Some(parent_id) = cat
8795                            .tables
8796                            .iter()
8797                            .find(|t| t.name == fk.ref_table)
8798                            .map(|t| t.table_id)
8799                        else {
8800                            return Err(MongrelError::InvalidArgument(format!(
8801                                "FOREIGN KEY '{}' references unknown table '{}'",
8802                                fk.name, fk.ref_table
8803                            )));
8804                        };
8805                        let parent_rows = load_rows(parent_id)?;
8806                        let mut found = false;
8807                        for (row_index, r) in parent_rows.iter().enumerate() {
8808                            commit_prepare_checkpoint(control, row_index)?;
8809                            if staged_deletes.contains(&(parent_id, r.row_id.0)) {
8810                                continue;
8811                            }
8812                            if let Some(pkey) = encode_composite_key(&fk.ref_columns, &r.columns) {
8813                                if pkey == child_key {
8814                                    found = true;
8815                                    break;
8816                                }
8817                            }
8818                        }
8819                        // Final-write-set FK validation: a parent inserted in
8820                        // THIS transaction also satisfies the FK. This enables
8821                        // atomic parent+child batches and cyclical/mutual FK
8822                        // inserts within a single transaction — the child sees
8823                        // the staged parent put even though it is not committed
8824                        // yet.
8825                        if !found {
8826                            for (staged_index, (st_table, st_op)) in staging.iter().enumerate() {
8827                                commit_prepare_checkpoint(control, staged_index)?;
8828                                if *st_table != parent_id {
8829                                    continue;
8830                                }
8831                                if let Staged::Put(pcells)
8832                                | Staged::Update {
8833                                    new_row: pcells, ..
8834                                } = st_op
8835                                {
8836                                    let pmap: HashMap<u16, crate::memtable::Value> =
8837                                        pcells.iter().cloned().collect();
8838                                    if let Some(pkey) = encode_composite_key(&fk.ref_columns, &pmap)
8839                                    {
8840                                        if pkey == child_key {
8841                                            found = true;
8842                                            break;
8843                                        }
8844                                    }
8845                                }
8846                            }
8847                        }
8848                        if !found {
8849                            return Err(MongrelError::Conflict(format!(
8850                                "FOREIGN KEY '{}' on table '{tname}' has no matching parent in '{}'",
8851                                fk.name, fk.ref_table
8852                            )));
8853                        }
8854                    }
8855
8856                    // Parent-side ON UPDATE RESTRICT. CASCADE/SET NULL were
8857                    // expanded in Phase A; here the final child write set is
8858                    // known, so a child explicitly moved/deleted by this same
8859                    // transaction does not cause a false violation.
8860                    if let Staged::Update { row_id, .. } = op {
8861                        let parent_handle = self.table_by_id(*table_id)?;
8862                        let Some(old_parent) = parent_handle.lock().get(*row_id, snapshot) else {
8863                            continue;
8864                        };
8865                        for (child_id, child_name, child_schema) in &live {
8866                            for fk in &child_schema.constraints.foreign_keys {
8867                                if fk.ref_table != tname || fk.on_update != FkAction::Restrict {
8868                                    continue;
8869                                }
8870                                let Some(old_key) =
8871                                    encode_composite_key(&fk.ref_columns, &old_parent.columns)
8872                                else {
8873                                    continue;
8874                                };
8875                                if encode_composite_key(&fk.ref_columns, &cells_map).as_deref()
8876                                    == Some(old_key.as_slice())
8877                                {
8878                                    continue;
8879                                }
8880                                for (child_index, child) in
8881                                    load_rows(*child_id)?.into_iter().enumerate()
8882                                {
8883                                    commit_prepare_checkpoint(control, child_index)?;
8884                                    if encode_composite_key(&fk.columns, &child.columns).as_deref()
8885                                        != Some(old_key.as_slice())
8886                                    {
8887                                        continue;
8888                                    }
8889                                    let replacement = staging.iter().find_map(|(id, op)| {
8890                                        if *id != *child_id {
8891                                            return None;
8892                                        }
8893                                        match op {
8894                                            Staged::Delete(id) if *id == child.row_id => Some(None),
8895                                            Staged::Update {
8896                                                row_id,
8897                                                new_row: cells,
8898                                                ..
8899                                            } if *row_id == child.row_id => {
8900                                                let map: HashMap<u16, Value> =
8901                                                    cells.iter().cloned().collect();
8902                                                Some(encode_composite_key(&fk.columns, &map))
8903                                            }
8904                                            _ => None,
8905                                        }
8906                                    });
8907                                    if replacement.is_some_and(|key| {
8908                                        key.as_deref() != Some(old_key.as_slice())
8909                                    }) {
8910                                        continue;
8911                                    }
8912                                    return Err(MongrelError::Conflict(format!(
8913                                        "FOREIGN KEY '{}' on table '{child_name}' restricts update (parent key referenced)",
8914                                        fk.name
8915                                    )));
8916                                }
8917                            }
8918                        }
8919                    }
8920                }
8921                Staged::Delete(rid) => {
8922                    // FK ON DELETE RESTRICT: a child row (whose FK action is
8923                    // RESTRICT) referencing this parent blocks the delete.
8924                    // CASCADE/SET NULL children were expanded in Phase A.
8925                    let parent_handle = self.table_by_id(*table_id)?;
8926                    let Some(parent_row) = parent_handle.lock().get(*rid, snapshot) else {
8927                        continue;
8928                    };
8929                    for (child_id, child_name, child_schema) in &live {
8930                        for fk in &child_schema.constraints.foreign_keys {
8931                            if fk.ref_table != tname || fk.on_delete != FkAction::Restrict {
8932                                continue;
8933                            }
8934                            let Some(parent_key) =
8935                                encode_composite_key(&fk.ref_columns, &parent_row.columns)
8936                            else {
8937                                continue;
8938                            };
8939                            let child_rows = load_rows(*child_id)?;
8940                            for (row_index, r) in child_rows.iter().enumerate() {
8941                                commit_prepare_checkpoint(control, row_index)?;
8942                                // A child already being deleted by this txn
8943                                // (cascade/inline) is not a restrict violation.
8944                                if staged_deletes.contains(&(*child_id, r.row_id.0)) {
8945                                    continue;
8946                                }
8947                                if let Some(ck) = encode_composite_key(&fk.columns, &r.columns) {
8948                                    if ck == parent_key {
8949                                        return Err(MongrelError::Conflict(format!(
8950                                            "FOREIGN KEY '{}' on table '{child_name}' restricts delete (parent referenced)",
8951                                            fk.name
8952                                        )));
8953                                    }
8954                                }
8955                            }
8956                        }
8957                    }
8958                }
8959                Staged::Truncate => {
8960                    // Truncate is RESTRICT-only: reject if any child references
8961                    // this table (any FK action), since cascade-truncate is
8962                    // unsupported.
8963                    for (child_id, child_name, child_schema) in &live {
8964                        for fk in &child_schema.constraints.foreign_keys {
8965                            if fk.ref_table != tname {
8966                                continue;
8967                            }
8968                            let child_rows = load_rows(*child_id)?;
8969                            if child_rows
8970                                .iter()
8971                                .any(|r| encode_composite_key(&fk.columns, &r.columns).is_some())
8972                            {
8973                                return Err(MongrelError::Conflict(format!(
8974                                    "FOREIGN KEY '{}' on table '{child_name}' restricts truncate of '{tname}'",
8975                                    fk.name
8976                                )));
8977                            }
8978                        }
8979                    }
8980                }
8981            }
8982        }
8983        Ok(())
8984    }
8985
8986    fn validate_write_permissions(
8987        &self,
8988        staging: &[(u64, crate::txn::Staged)],
8989        principal: Option<&crate::auth::Principal>,
8990        control: Option<&crate::ExecutionControl>,
8991    ) -> Result<()> {
8992        commit_prepare_checkpoint(control, 0)?;
8993        if principal.is_none() && !self.auth_state.require_auth() {
8994            return Ok(());
8995        }
8996        let principal = principal.ok_or(MongrelError::AuthRequired)?;
8997        let needs = summarize_write_permissions(staging);
8998        let catalog = self.catalog.read();
8999
9000        if needs.values().any(|need| need.truncate) {
9001            self.require_for(Some(principal), &crate::auth::Permission::Admin)?;
9002        }
9003        for (need_index, (table_id, need)) in needs.into_iter().enumerate() {
9004            commit_prepare_checkpoint(control, need_index)?;
9005            let entry = catalog
9006                .tables
9007                .iter()
9008                .find(|entry| {
9009                    entry.table_id == table_id
9010                        && matches!(entry.state, TableState::Live | TableState::Building { .. })
9011                })
9012                .ok_or_else(|| {
9013                    MongrelError::NotFound(format!(
9014                        "live table {table_id} not found during write validation"
9015                    ))
9016                })?;
9017            if matches!(entry.state, TableState::Building { .. }) {
9018                self.require_for(Some(principal), &crate::auth::Permission::Ddl)?;
9019                continue;
9020            }
9021            if need.insert {
9022                Self::require_columns_for_principal(
9023                    &entry.name,
9024                    &entry.schema,
9025                    crate::auth::ColumnOperation::Insert,
9026                    &need.insert_columns,
9027                    principal,
9028                )?;
9029            }
9030            if need.update {
9031                Self::require_columns_for_principal(
9032                    &entry.name,
9033                    &entry.schema,
9034                    crate::auth::ColumnOperation::Update,
9035                    &need.update_columns,
9036                    principal,
9037                )?;
9038            }
9039            if need.delete {
9040                self.require_for(
9041                    Some(principal),
9042                    &crate::auth::Permission::Delete {
9043                        table: entry.name.clone(),
9044                    },
9045                )?;
9046            }
9047        }
9048        Ok(())
9049    }
9050
9051    fn validate_security_writes(
9052        &self,
9053        staging: &[(u64, crate::txn::Staged)],
9054        read_epoch: Epoch,
9055        explicit_principal: Option<&crate::auth::Principal>,
9056        control: Option<&crate::ExecutionControl>,
9057    ) -> Result<()> {
9058        commit_prepare_checkpoint(control, 0)?;
9059        use crate::security::PolicyCommand;
9060        use crate::txn::Staged;
9061
9062        let catalog = self.catalog.read();
9063        if catalog.security.rls_tables.is_empty() {
9064            return Ok(());
9065        }
9066        let security = catalog.security.clone();
9067        let table_names = catalog
9068            .tables
9069            .iter()
9070            .filter(|entry| matches!(entry.state, TableState::Live))
9071            .map(|entry| (entry.table_id, entry.name.clone()))
9072            .collect::<HashMap<_, _>>();
9073        drop(catalog);
9074        if !staging.iter().any(|(table_id, _)| {
9075            table_names
9076                .get(table_id)
9077                .is_some_and(|table| security.rls_enabled(table))
9078        }) {
9079            return Ok(());
9080        }
9081        let principal = explicit_principal.ok_or(MongrelError::AuthRequired)?;
9082
9083        for (operation_index, (table_id, operation)) in staging.iter().enumerate() {
9084            commit_prepare_checkpoint(control, operation_index)?;
9085            let Some(table) = table_names.get(table_id) else {
9086                continue;
9087            };
9088            if !security.rls_enabled(table) || principal.is_admin {
9089                continue;
9090            }
9091            let denied = |command| MongrelError::PermissionDenied {
9092                required: match command {
9093                    PolicyCommand::Insert => crate::auth::Permission::Insert {
9094                        table: table.clone(),
9095                    },
9096                    PolicyCommand::Update => crate::auth::Permission::Update {
9097                        table: table.clone(),
9098                    },
9099                    PolicyCommand::Delete | PolicyCommand::All | PolicyCommand::Select => {
9100                        crate::auth::Permission::Delete {
9101                            table: table.clone(),
9102                        }
9103                    }
9104                },
9105                principal: principal.username.clone(),
9106            };
9107            match operation {
9108                Staged::Put(cells) => {
9109                    let mut row = crate::memtable::Row::new(RowId(0), Epoch(read_epoch.0));
9110                    row.columns.extend(cells.iter().cloned());
9111                    if !security.row_allowed(table, PolicyCommand::Insert, &row, principal, true) {
9112                        return Err(denied(PolicyCommand::Insert));
9113                    }
9114                }
9115                Staged::Update {
9116                    row_id,
9117                    new_row: cells,
9118                    ..
9119                } => {
9120                    let old = self
9121                        .table_by_id(*table_id)?
9122                        .lock()
9123                        .get(*row_id, Snapshot::at(read_epoch))
9124                        .ok_or_else(|| {
9125                            MongrelError::NotFound(format!("row {} not found", row_id.0))
9126                        })?;
9127                    if !security.row_allowed(table, PolicyCommand::Update, &old, principal, false) {
9128                        return Err(denied(PolicyCommand::Update));
9129                    }
9130                    let mut new = crate::memtable::Row::new(*row_id, Epoch(read_epoch.0));
9131                    new.columns.extend(cells.iter().cloned());
9132                    if !security.row_allowed(table, PolicyCommand::Update, &new, principal, true) {
9133                        return Err(denied(PolicyCommand::Update));
9134                    }
9135                }
9136                Staged::Delete(row_id) => {
9137                    let old = self
9138                        .table_by_id(*table_id)?
9139                        .lock()
9140                        .get(*row_id, Snapshot::at(read_epoch))
9141                        .ok_or_else(|| {
9142                            MongrelError::NotFound(format!("row {} not found", row_id.0))
9143                        })?;
9144                    if !security.row_allowed(table, PolicyCommand::Delete, &old, principal, false) {
9145                        return Err(denied(PolicyCommand::Delete));
9146                    }
9147                }
9148                Staged::Truncate => return Err(denied(PolicyCommand::Delete)),
9149            }
9150        }
9151        Ok(())
9152    }
9153
9154    /// Seal a transaction (spec §9.3):
9155    /// 1. Prepare — derive write keys, allocate row ids (brief table locks).
9156    /// 2. Sequencer — validate-first under the WAL mutex; abort on conflict
9157    ///    with no epoch consumed; assign epoch, append data records + TxnCommit,
9158    ///    group-sync, record conflict keys.
9159    /// 3. Publish — apply to tables, advance visible in-order.
9160    #[allow(clippy::too_many_arguments)]
9161    pub(crate) fn commit_transaction_with_external_states(
9162        &self,
9163        txn_id: u64,
9164        read_epoch: Epoch,
9165        staging: Vec<(u64, crate::txn::Staged)>,
9166        external_states: Vec<(String, Vec<u8>)>,
9167        materialized_view_updates: Vec<crate::catalog::MaterializedViewEntry>,
9168        security_principal: Option<crate::auth::Principal>,
9169        principal_catalog_bound: bool,
9170        external_trigger_bridge: Option<&dyn ExternalTriggerBridge>,
9171    ) -> Result<(Epoch, Vec<RowId>)> {
9172        self.commit_transaction_with_external_states_inner(
9173            txn_id,
9174            read_epoch,
9175            staging,
9176            external_states,
9177            materialized_view_updates,
9178            security_principal,
9179            principal_catalog_bound,
9180            external_trigger_bridge,
9181            None,
9182            None,
9183        )
9184    }
9185
9186    #[allow(clippy::too_many_arguments)]
9187    pub(crate) fn commit_transaction_with_external_states_controlled(
9188        &self,
9189        txn_id: u64,
9190        read_epoch: Epoch,
9191        staging: Vec<(u64, crate::txn::Staged)>,
9192        external_states: Vec<(String, Vec<u8>)>,
9193        materialized_view_updates: Vec<crate::catalog::MaterializedViewEntry>,
9194        security_principal: Option<crate::auth::Principal>,
9195        principal_catalog_bound: bool,
9196        external_trigger_bridge: Option<&dyn ExternalTriggerBridge>,
9197        control: &crate::ExecutionControl,
9198        before_commit: &mut dyn FnMut() -> Result<()>,
9199    ) -> Result<(Epoch, Vec<RowId>)> {
9200        self.commit_transaction_with_external_states_inner(
9201            txn_id,
9202            read_epoch,
9203            staging,
9204            external_states,
9205            materialized_view_updates,
9206            security_principal,
9207            principal_catalog_bound,
9208            external_trigger_bridge,
9209            Some(control),
9210            Some(before_commit),
9211        )
9212    }
9213
9214    #[allow(clippy::too_many_arguments)]
9215    fn commit_transaction_with_external_states_inner(
9216        &self,
9217        txn_id: u64,
9218        read_epoch: Epoch,
9219        mut staging: Vec<(u64, crate::txn::Staged)>,
9220        external_states: Vec<(String, Vec<u8>)>,
9221        materialized_view_updates: Vec<crate::catalog::MaterializedViewEntry>,
9222        mut security_principal: Option<crate::auth::Principal>,
9223        principal_catalog_bound: bool,
9224        external_trigger_bridge: Option<&dyn ExternalTriggerBridge>,
9225        control: Option<&crate::ExecutionControl>,
9226        mut before_commit: Option<&mut dyn FnMut() -> Result<()>>,
9227    ) -> Result<(Epoch, Vec<RowId>)> {
9228        use crate::memtable::Row;
9229        use crate::txn::{Staged, StagedOp, WriteKey};
9230        use crate::wal::Op;
9231        use std::collections::hash_map::DefaultHasher;
9232        use std::hash::{Hash, Hasher};
9233        use std::sync::atomic::Ordering;
9234
9235        if txn_id == crate::wal::SYSTEM_TXN_ID {
9236            return Err(MongrelError::Full(
9237                "per-open transaction id namespace exhausted; reopen the database".into(),
9238            ));
9239        }
9240        if self.read_only {
9241            return Err(MongrelError::ReadOnlyReplica);
9242        }
9243        commit_prepare_checkpoint(control, 0)?;
9244        let observed_security_version = self.security_coordinator.version.load(Ordering::Acquire);
9245        self.refresh_security_catalog_if_stale(observed_security_version)?;
9246        let trigger_binding = trigger_catalog_binding(&self.catalog.read());
9247        if self.auth_state.require_auth() && security_principal.is_none() {
9248            return Err(MongrelError::AuthRequired);
9249        }
9250        {
9251            let catalog = self.catalog.read();
9252            if catalog.require_auth
9253                || principal_catalog_bound
9254                || security_principal
9255                    .as_ref()
9256                    .is_some_and(|principal| principal.user_id != 0)
9257            {
9258                let principal = security_principal
9259                    .as_ref()
9260                    .ok_or(MongrelError::AuthRequired)?;
9261                security_principal =
9262                    Self::resolve_bound_principal_from_catalog(&catalog, principal);
9263                if security_principal.is_none() {
9264                    return Err(MongrelError::AuthRequired);
9265                }
9266            }
9267        }
9268        let _replication_guard = self.replication_barrier.read();
9269        if self.poisoned.load(Ordering::Relaxed) {
9270            return Err(MongrelError::Other(
9271                "database poisoned by fsync error".into(),
9272            ));
9273        }
9274        let mut external_states = dedup_external_states(external_states);
9275        if !external_states.is_empty() {
9276            let cat = self.catalog.read();
9277            for (name, _) in &external_states {
9278                if !cat.external_tables.iter().any(|entry| entry.name == *name) {
9279                    return Err(MongrelError::NotFound(format!(
9280                        "external table {name:?} not found"
9281                    )));
9282                }
9283            }
9284        }
9285        let prepared_materialized_views = {
9286            let mut deduplicated = HashMap::new();
9287            for (definition_index, definition) in materialized_view_updates.into_iter().enumerate()
9288            {
9289                commit_prepare_checkpoint(control, definition_index)?;
9290                if definition.name.is_empty() || definition.query.trim().is_empty() {
9291                    return Err(MongrelError::InvalidArgument(
9292                        "materialized view name and query must not be empty".into(),
9293                    ));
9294                }
9295                deduplicated.insert(definition.name.clone(), definition);
9296            }
9297            let catalog = self.catalog.read();
9298            let mut prepared = Vec::with_capacity(deduplicated.len());
9299            for (definition_index, definition) in deduplicated.into_values().enumerate() {
9300                commit_prepare_checkpoint(control, definition_index)?;
9301                let table_id = catalog
9302                    .live(&definition.name)
9303                    .ok_or_else(|| {
9304                        MongrelError::NotFound(format!(
9305                            "materialized view table {:?} not found",
9306                            definition.name
9307                        ))
9308                    })?
9309                    .table_id;
9310                prepared.push((table_id, definition));
9311            }
9312            prepared.sort_by(|left, right| left.1.name.cmp(&right.1.name));
9313            prepared
9314        };
9315
9316        // ── 1. Prepare: fill generated values, expand triggers, validate, then
9317        // derive write keys from the final atomic write set.
9318        self.fill_auto_increment_for_staging(&mut staging, control)?;
9319        self.expand_table_triggers(
9320            &mut staging,
9321            read_epoch,
9322            external_trigger_bridge,
9323            &mut external_states,
9324            control,
9325        )?;
9326        self.fill_auto_increment_for_staging(&mut staging, control)?;
9327        external_states = dedup_external_states(external_states);
9328        let expected_external_generations = {
9329            let catalog = self.catalog.read();
9330            let mut generations = HashMap::with_capacity(external_states.len());
9331            for (name, _) in &external_states {
9332                let entry = catalog
9333                    .external_tables
9334                    .iter()
9335                    .find(|entry| entry.name == *name)
9336                    .ok_or_else(|| {
9337                        MongrelError::NotFound(format!("external table {name:?} not found"))
9338                    })?;
9339                generations.insert(name.clone(), entry.created_epoch);
9340            }
9341            generations
9342        };
9343
9344        // Validate declarative constraints (unique / FK / check) under the read
9345        // snapshot, outside the WAL mutex. Trigger-produced writes are included
9346        // here, so the batch either satisfies every declared constraint or is
9347        // rejected atomically.
9348        self.validate_constraints(&mut staging, read_epoch, control)?;
9349        self.validate_write_permissions(&staging, security_principal.as_ref(), control)?;
9350        self.validate_security_writes(&staging, read_epoch, security_principal.as_ref(), control)?;
9351        let mut normalized = Vec::with_capacity(staging.len() * 2);
9352        for (staged_index, (table_id, op)) in staging.into_iter().enumerate() {
9353            commit_prepare_checkpoint(control, staged_index)?;
9354            match op {
9355                crate::txn::Staged::Update {
9356                    row_id,
9357                    new_row: cells,
9358                    ..
9359                } => {
9360                    normalized.push((table_id, crate::txn::Staged::Delete(row_id)));
9361                    normalized.push((table_id, crate::txn::Staged::Put(cells)));
9362                }
9363                op => normalized.push((table_id, op)),
9364            }
9365        }
9366        staging = normalized;
9367        let has_changes = !staging.is_empty()
9368            || !external_states.is_empty()
9369            || !prepared_materialized_views.is_empty();
9370        let truncated_tables: HashSet<u64> = staging
9371            .iter()
9372            .filter_map(|(table_id, op)| matches!(op, Staged::Truncate).then_some(*table_id))
9373            .collect();
9374
9375        let write_keys = {
9376            let cat = self.catalog.read();
9377            let mut keys: Vec<WriteKey> = Vec::new();
9378            for (staged_index, (table_id, staged)) in staging.iter().enumerate() {
9379                commit_prepare_checkpoint(control, staged_index)?;
9380                match staged {
9381                    Staged::Put(cells) => {
9382                        if let Some(entry) = cat.tables.iter().find(|t| t.table_id == *table_id) {
9383                            for col in &entry.schema.columns {
9384                                if col.flags.contains(crate::schema::ColumnFlags::PRIMARY_KEY) {
9385                                    if let Some((_, val)) =
9386                                        cells.iter().find(|(id, _)| *id == col.id)
9387                                    {
9388                                        let mut h = DefaultHasher::new();
9389                                        val.encode_key().hash(&mut h);
9390                                        keys.push(WriteKey::Unique {
9391                                            table_id: *table_id,
9392                                            index_id: 0,
9393                                            key_hash: h.finish(),
9394                                        });
9395                                    }
9396                                }
9397                            }
9398                            // Declared non-PK unique constraints register a
9399                            // `WriteKey::Unique` (namespace-separated from the
9400                            // PK's index_id==0 by setting the high bit) so two
9401                            // concurrent transactions inserting the same key
9402                            // cannot both commit. Rows with any NULL constrained
9403                            // column are skipped (SQL semantics).
9404                            for uc in &entry.schema.constraints.uniques {
9405                                if let Some(key_bytes) = crate::constraint::encode_composite_key(
9406                                    &uc.columns,
9407                                    &cells.iter().cloned().collect(),
9408                                ) {
9409                                    let mut h = DefaultHasher::new();
9410                                    key_bytes.hash(&mut h);
9411                                    keys.push(WriteKey::Unique {
9412                                        table_id: *table_id,
9413                                        index_id: uc.id | 0x8000,
9414                                        key_hash: h.finish(),
9415                                    });
9416                                }
9417                            }
9418                        }
9419                    }
9420                    Staged::Delete(rid) => keys.push(WriteKey::Row {
9421                        table_id: *table_id,
9422                        row_id: rid.0,
9423                    }),
9424                    Staged::Truncate => keys.push(WriteKey::Table {
9425                        table_id: *table_id,
9426                    }),
9427                    Staged::Update { .. } => {
9428                        return Err(MongrelError::Other(
9429                            "transaction contains an unnormalized update during preparation".into(),
9430                        ));
9431                    }
9432                }
9433            }
9434            for (external_index, (name, _)) in external_states.iter().enumerate() {
9435                commit_prepare_checkpoint(control, external_index)?;
9436                let mut h = DefaultHasher::new();
9437                name.hash(&mut h);
9438                keys.push(WriteKey::Unique {
9439                    table_id: EXTERNAL_TABLE_ID,
9440                    index_id: 0,
9441                    key_hash: h.finish(),
9442                });
9443            }
9444            keys
9445        };
9446
9447        // Opportunistic pruning.
9448        let min_active = self.active_txns.min_read_epoch();
9449        if min_active < u64::MAX {
9450            self.conflicts.prune_below(Epoch(min_active));
9451        }
9452
9453        // ── 1a. Pre-validate the full write-set OUTSIDE the sequencer (spec
9454        // §8.5, review fix #17). Snapshot the conflict-index version so the
9455        // sequencer only re-checks if new commits arrived in the interim.
9456        if self.conflicts.conflicts(&write_keys, read_epoch) {
9457            return Err(MongrelError::Conflict(
9458                "write-write conflict (pre-validate, first-committer-wins)".into(),
9459            ));
9460        }
9461        let pre_validate_version = self.conflicts.version();
9462
9463        // ── 1b. Spill: if a table's staged puts exceed the threshold, write a
9464        // uniform-epoch pending run (spec §8.5). Rows in the run are NOT
9465        // streamed as Put records; they are linked at publish time.
9466        let mut spilled: Vec<SpilledRun> = Vec::new();
9467        let mut spilled_tables: std::collections::HashSet<u64> = std::collections::HashSet::new();
9468        // Protect this txn's `_txn/<id>/` dir from a concurrent `gc()` for as long
9469        // as the spill runs are live (registered on first spill, dropped at the
9470        // end of this function on commit/abort/error).
9471        let mut spill_guard: Option<crate::retention::SpillGuard> = None;
9472        {
9473            let mut table_bytes: HashMap<u64, u64> = HashMap::new();
9474            let mut put_indexes: HashMap<u64, Vec<usize>> = HashMap::new();
9475            for (staged_index, (table_id, staged)) in staging.iter().enumerate() {
9476                commit_prepare_checkpoint(control, staged_index)?;
9477                if let Staged::Put(cells) = staged {
9478                    let bytes = cells.iter().fold(32_u64, |bytes, (_, value)| {
9479                        bytes.saturating_add(value.estimated_bytes())
9480                    });
9481                    let table_bytes = table_bytes.entry(*table_id).or_default();
9482                    *table_bytes = table_bytes.saturating_add(bytes);
9483                    put_indexes.entry(*table_id).or_default().push(staged_index);
9484                }
9485            }
9486            let tables = self.tables.read();
9487            for (table_index, (&table_id, &bytes)) in table_bytes.iter().enumerate() {
9488                commit_prepare_checkpoint(control, table_index)?;
9489                if bytes
9490                    <= self
9491                        .spill_threshold
9492                        .load(std::sync::atomic::Ordering::Relaxed)
9493                {
9494                    continue;
9495                }
9496                let Some(handle) = tables.get(&table_id) else {
9497                    continue;
9498                };
9499                spill_guard.get_or_insert_with(|| self.active_spills.register(txn_id));
9500                let mut t = handle.lock();
9501                let tdir = t.table_dir().to_path_buf();
9502                let txn_dir = tdir.join("_txn").join(txn_id.to_string());
9503                std::fs::create_dir_all(&txn_dir)?;
9504                let run_id = t.alloc_run_id()? as u128;
9505                let pending_path = txn_dir.join(format!("r-{run_id}.sr"));
9506                let final_path = t.run_path(run_id as u64);
9507
9508                let mut rows: Vec<Row> = Vec::new();
9509                for (put_index, staged_index) in put_indexes[&table_id].iter().enumerate() {
9510                    commit_prepare_checkpoint(control, put_index)?;
9511                    let Staged::Put(cells) = &mut staging[*staged_index].1 else {
9512                        return Err(MongrelError::Other(
9513                            "transaction put index no longer references a put".into(),
9514                        ));
9515                    };
9516                    t.validate_cells_not_null(cells)?;
9517                    let row_id = t.alloc_row_id()?;
9518                    let mut row = Row::new(row_id, Epoch(0));
9519                    row.columns.extend(std::mem::take(cells));
9520                    rows.push(row);
9521                }
9522                let schema = t.schema_ref().clone();
9523                let kek = t.kek_ref().cloned();
9524                let specs = t.indexable_column_specs();
9525                drop(t);
9526
9527                let mut writer = crate::sorted_run::RunWriter::new(&schema, run_id, Epoch(0), 0)
9528                    .uniform_epoch(true);
9529                if let Some(ref kek) = kek {
9530                    writer = writer.with_encryption(kek.as_ref(), specs);
9531                }
9532                commit_prepare_checkpoint(control, 0)?;
9533                let header = writer.write(&pending_path, &rows)?;
9534                commit_prepare_checkpoint(control, 0)?;
9535                let row_count = header.row_count;
9536                let min_rid = rows.first().map(|r| r.row_id.0).unwrap_or(0);
9537                let max_rid = rows.last().map(|r| r.row_id.0).unwrap_or(0);
9538
9539                spilled.push(SpilledRun {
9540                    table_id,
9541                    run_id,
9542                    pending_path,
9543                    final_path,
9544                    rows,
9545                    row_count,
9546                    min_rid,
9547                    max_rid,
9548                    content_hash: header.content_hash,
9549                });
9550                spilled_tables.insert(table_id);
9551            }
9552        }
9553
9554        // Test seam: let a test race `gc()` against this in-flight spill.
9555        if spill_guard.is_some() {
9556            if let Some(hook) = self.spill_hook.lock().as_ref() {
9557                hook();
9558            }
9559        }
9560
9561        // ── 1c. Pre-build non-spilled put rows OUTSIDE the WAL critical section.
9562        // Allocating row ids + building the rows here (lock order: table handle →
9563        // nothing) means the sequencer never locks a table handle while holding
9564        // the shared-WAL mutex. That matters because `Table::commit`/`flush` lock
9565        // the table handle THEN the shared WAL; if the sequencer did the reverse
9566        // (WAL then handle) the two paths would deadlock (review fix: B1).
9567        // Aligned 1:1 with `staging`; `None` for deletes and spilled puts.
9568        // Row ids are allocated here, before the sequencer's delta conflict
9569        // re-check, so a losing txn leaks the ids it reserved — harmless, the
9570        // u64 row-id space is monotonic and gaps are expected (spills do the same).
9571        let mut prebuilt: Vec<Option<Row>> = std::iter::repeat_with(|| None)
9572            .take(staging.len())
9573            .collect();
9574        let mut delete_images: Vec<Option<Row>> = std::iter::repeat_with(|| None)
9575            .take(staging.len())
9576            .collect();
9577        {
9578            let mut indexes_by_table: HashMap<u64, Vec<usize>> = HashMap::new();
9579            for (index, (table_id, staged)) in staging.iter().enumerate() {
9580                commit_prepare_checkpoint(control, index)?;
9581                if matches!(staged, Staged::Delete(_))
9582                    || matches!(staged, Staged::Put(_) if !spilled_tables.contains(table_id))
9583                {
9584                    indexes_by_table.entry(*table_id).or_default().push(index);
9585                }
9586            }
9587            let tables = self.tables.read();
9588            for (table_index, (table_id, indexes)) in indexes_by_table.into_iter().enumerate() {
9589                commit_prepare_checkpoint(control, table_index)?;
9590                let handle = tables.get(&table_id).ok_or_else(|| {
9591                    MongrelError::NotFound(format!("table {table_id} not mounted"))
9592                })?;
9593                #[cfg(test)]
9594                PREBUILD_TABLE_LOCKS.with(|count| count.set(count.get() + 1));
9595                let mut t = handle.lock();
9596                for (prepare_index, index) in indexes.into_iter().enumerate() {
9597                    commit_prepare_checkpoint(control, prepare_index)?;
9598                    match &staging[index].1 {
9599                        Staged::Put(cells) if !spilled_tables.contains(&table_id) => {
9600                            t.validate_cells_not_null(cells)?;
9601                            let mut row = Row::new(t.alloc_row_id()?, Epoch(0));
9602                            for (column, value) in cells {
9603                                row.columns.insert(*column, value.clone());
9604                            }
9605                            prebuilt[index] = Some(row);
9606                        }
9607                        Staged::Delete(row_id) => {
9608                            delete_images[index] = t.get(*row_id, Snapshot::at(read_epoch));
9609                        }
9610                        Staged::Put(_) | Staged::Truncate => {}
9611                        Staged::Update { .. } => {
9612                            return Err(MongrelError::Other(
9613                                "transaction contains an unnormalized update during row preparation"
9614                                    .into(),
9615                            ));
9616                        }
9617                    }
9618                }
9619            }
9620        }
9621
9622        // Finish every fallible index read before the commit marker can become
9623        // durable. Post-durable row/run metadata application is then entirely
9624        // in-memory and cannot stop halfway through a multi-table publish.
9625        let prepared_table_handles = {
9626            let table_ids: HashSet<u64> = staging.iter().map(|(table_id, _)| *table_id).collect();
9627            let put_table_ids: HashSet<u64> = staging
9628                .iter()
9629                .filter_map(|(table_id, staged)| {
9630                    matches!(staged, Staged::Put(_)).then_some(*table_id)
9631                })
9632                .collect();
9633            let tables = self.tables.read();
9634            let mut handles = HashMap::with_capacity(table_ids.len());
9635            for (table_index, table_id) in table_ids.into_iter().enumerate() {
9636                commit_prepare_checkpoint(control, table_index)?;
9637                let handle = tables.get(&table_id).ok_or_else(|| {
9638                    MongrelError::NotFound(format!("table {table_id} not mounted"))
9639                })?;
9640                if put_table_ids.contains(&table_id) {
9641                    match control {
9642                        Some(control) => {
9643                            handle.lock().prepare_durable_publish_controlled(control)?
9644                        }
9645                        None => handle.lock().prepare_durable_publish()?,
9646                    }
9647                }
9648                handles.insert(table_id, handle.clone());
9649            }
9650            handles
9651        };
9652
9653        // Link large-transaction spill files before WAL durability. The guard
9654        // restores their pending names on every error before WAL append begins;
9655        // publication only attaches already-present files in memory.
9656        let mut prepared_run_links = PreparedRunLinks::prepare(&spilled)?;
9657
9658        let mut spilled_row_ids: HashMap<u64, VecDeque<RowId>> = spilled
9659            .iter()
9660            .map(|run| {
9661                (
9662                    run.table_id,
9663                    run.rows.iter().map(|row| row.row_id).collect(),
9664                )
9665            })
9666            .collect();
9667        let committed_row_ids = staging
9668            .iter()
9669            .enumerate()
9670            .filter_map(|(index, (table_id, staged))| {
9671                if !matches!(staged, Staged::Put(_)) {
9672                    return None;
9673                }
9674                prebuilt[index].as_ref().map(|row| row.row_id).or_else(|| {
9675                    spilled_row_ids
9676                        .get_mut(table_id)
9677                        .and_then(VecDeque::pop_front)
9678                })
9679            })
9680            .collect();
9681
9682        let mut prepared_external = Vec::with_capacity(external_states.len());
9683        for (external_index, (name, state)) in external_states.iter().enumerate() {
9684            commit_prepare_checkpoint(control, external_index)?;
9685            let pending = prepare_external_state_file(&self.root, name, state, txn_id)?;
9686            prepared_external.push((name.clone(), state.clone(), pending));
9687        }
9688
9689        // ── 2. Sequencer: validate-first → assign → append → sync → record ──
9690        let added_runs: Vec<crate::wal::AddedRun> = spilled
9691            .iter()
9692            .map(|s| crate::wal::AddedRun {
9693                table_id: s.table_id,
9694                run_id: s.run_id,
9695                row_count: s.row_count,
9696                level: 0,
9697                min_row_id: s.min_rid,
9698                max_row_id: s.max_rid,
9699                content_hash: s.content_hash,
9700            })
9701            .collect();
9702        if let Some(hook) = self.catalog_commit_hook.lock().as_ref() {
9703            hook();
9704        }
9705        // Lock order: security gate -> commit lock -> shared WAL -> table locks.
9706        // Security mutations cannot overtake an authorized commit before its
9707        // commit marker is durable.
9708        let security_guard = self.security_coordinator.gate.read();
9709        if self.security_coordinator.version.load(Ordering::Acquire) != observed_security_version {
9710            return Err(MongrelError::Conflict(
9711                "security policy changed during write".into(),
9712            ));
9713        }
9714        if spill_guard.is_some() {
9715            if let Some(hook) = self.security_commit_hook.lock().as_ref() {
9716                hook();
9717            }
9718        }
9719        let commit_guard = self.commit_lock.lock();
9720        let catalog_generation_result = (|| {
9721            {
9722                let catalog = self.catalog.read();
9723                for table_id in prepared_table_handles.keys() {
9724                    let is_current = catalog.tables.iter().any(|entry| {
9725                        entry.table_id == *table_id
9726                            && matches!(entry.state, TableState::Live | TableState::Building { .. })
9727                    });
9728                    if !is_current {
9729                        return Err(MongrelError::Conflict(format!(
9730                            "table {table_id} changed during transaction preparation"
9731                        )));
9732                    }
9733                }
9734                for (name, created_epoch) in &expected_external_generations {
9735                    let current = catalog
9736                        .external_tables
9737                        .iter()
9738                        .find(|entry| entry.name == *name)
9739                        .map(|entry| entry.created_epoch);
9740                    if current != Some(*created_epoch) {
9741                        return Err(MongrelError::Conflict(format!(
9742                            "external table {name:?} changed during transaction preparation"
9743                        )));
9744                    }
9745                }
9746                for (table_id, definition) in &prepared_materialized_views {
9747                    let current = catalog.live(&definition.name).map(|entry| entry.table_id);
9748                    if current != Some(*table_id) {
9749                        return Err(MongrelError::Conflict(format!(
9750                            "materialized view {:?} changed during transaction preparation",
9751                            definition.name
9752                        )));
9753                    }
9754                }
9755                if trigger_catalog_binding(&catalog) != trigger_binding {
9756                    return Err(MongrelError::Conflict(
9757                        "trigger or referenced table generation changed during transaction preparation"
9758                            .into(),
9759                    ));
9760                }
9761            }
9762            let tables = self.tables.read();
9763            for (table_id, prepared) in &prepared_table_handles {
9764                if !tables
9765                    .get(table_id)
9766                    .is_some_and(|current| current.ptr_eq(prepared))
9767                {
9768                    return Err(MongrelError::Conflict(format!(
9769                        "table {table_id} mount changed during transaction preparation"
9770                    )));
9771                }
9772            }
9773            Ok(())
9774        })();
9775        if let Err(error) = catalog_generation_result {
9776            drop(commit_guard);
9777            for (_, _, pending) in &prepared_external {
9778                let _ = std::fs::remove_file(pending);
9779            }
9780            return Err(error);
9781        }
9782        // The commit lock keeps the next epoch stable while logical spill
9783        // records are serialized. Build them before taking the shared WAL
9784        // lock, and cap their aggregate memory/WAL footprint.
9785        let new_epoch = self.epoch.assigned().next();
9786        let mut spilled_wal_bytes = 0;
9787        let mut spilled_wal_records = Vec::<(u64, Op)>::new();
9788        let spill_prepare = (|| {
9789            for run in &mut spilled {
9790                for row in &mut run.rows {
9791                    row.committed_epoch = new_epoch;
9792                }
9793                for rows in encode_spilled_row_chunks(
9794                    &run.rows,
9795                    &mut spilled_wal_bytes,
9796                    SPILLED_WAL_TOTAL_MAX_BYTES,
9797                    control,
9798                )? {
9799                    spilled_wal_records.push((
9800                        run.table_id,
9801                        Op::SpilledRows {
9802                            table_id: run.table_id,
9803                            rows,
9804                        },
9805                    ));
9806                }
9807            }
9808            Result::<()>::Ok(())
9809        })();
9810        if let Err(error) = spill_prepare {
9811            for (_, _, pending) in &prepared_external {
9812                let _ = std::fs::remove_file(pending);
9813            }
9814            return Err(error);
9815        }
9816        let (new_epoch, mut _epoch_guard, applies, committed_materialized_views, commit_seq) = {
9817            let mut wal = self.shared_wal.lock();
9818
9819            // Re-check only if the conflict index advanced since pre-validation
9820            // (bounded delta — spec §8.5, review fix #17). If the version is
9821            // unchanged, the pre-check result is still valid and the sequencer
9822            // does O(1) work regardless of write-set size.
9823            if self.conflicts.version() != pre_validate_version
9824                && self.conflicts.conflicts(&write_keys, read_epoch)
9825            {
9826                // Abort: this txn assigned no epoch yet. The prepared-run guard
9827                // restores final run names to their pending paths on return.
9828                drop(wal);
9829                for (_, _, pending) in &prepared_external {
9830                    let _ = std::fs::remove_file(pending);
9831                }
9832                return Err(MongrelError::Conflict(
9833                    "write-write conflict (sequencer delta re-check)".into(),
9834                ));
9835            }
9836
9837            if let Some(control) = control {
9838                if let Err(error) = control.checkpoint() {
9839                    drop(wal);
9840                    for (_, _, pending) in &prepared_external {
9841                        let _ = std::fs::remove_file(pending);
9842                    }
9843                    return Err(error);
9844                }
9845            }
9846            let mut applies = Vec::<TableApplyBatch>::new();
9847            let mut apply_indexes = HashMap::<u64, usize>::new();
9848            let mut committed_materialized_views = Vec::new();
9849            let mut wal_records = spilled_wal_records;
9850
9851            let mut index = 0;
9852            while index < staging.len() {
9853                let table_id = staging[index].0;
9854                let handle = prepared_table_handles
9855                    .get(&table_id)
9856                    .cloned()
9857                    .ok_or_else(|| {
9858                        MongrelError::NotFound(format!("table {table_id} not prepared"))
9859                    })?;
9860                let batch_index = *apply_indexes.entry(table_id).or_insert_with(|| {
9861                    let index = applies.len();
9862                    applies.push(TableApplyBatch {
9863                        table_id,
9864                        handle,
9865                        ops: Vec::new(),
9866                    });
9867                    index
9868                });
9869
9870                // Skip puts for tables that were spilled — their data is in a
9871                // pending run, not in streamed Put records.
9872                if spilled_tables.contains(&table_id) && matches!(&staging[index].1, Staged::Put(_))
9873                {
9874                    index += 1;
9875                    continue;
9876                }
9877
9878                match &staging[index].1 {
9879                    Staged::Put(_) => {
9880                        let mut rows = Vec::new();
9881                        while index < staging.len()
9882                            && staging[index].0 == table_id
9883                            && matches!(&staging[index].1, Staged::Put(_))
9884                        {
9885                            let mut row = prebuilt[index].take().ok_or_else(|| {
9886                                MongrelError::Other(
9887                                    "transaction prepare lost a prebuilt put row".into(),
9888                                )
9889                            })?;
9890                            row.committed_epoch = new_epoch;
9891                            rows.push(row);
9892                            index += 1;
9893                        }
9894                        let payload = bincode::serialize(&rows)
9895                            .map_err(|e| MongrelError::Other(format!("row serialize: {e}")))?;
9896                        wal_records.push((
9897                            table_id,
9898                            Op::Put {
9899                                table_id,
9900                                rows: payload,
9901                            },
9902                        ));
9903                        applies[batch_index].ops.push(StagedOp::Put(rows));
9904                    }
9905                    Staged::Delete(_) => {
9906                        let mut row_ids = Vec::new();
9907                        while index < staging.len()
9908                            && staging[index].0 == table_id
9909                            && matches!(&staging[index].1, Staged::Delete(_))
9910                        {
9911                            let Staged::Delete(row_id) = &staging[index].1 else {
9912                                return Err(MongrelError::Other(
9913                                    "transaction delete batch changed during WAL preparation"
9914                                        .into(),
9915                                ));
9916                            };
9917                            if let Some(before) = &delete_images[index] {
9918                                wal_records.push((
9919                                    table_id,
9920                                    Op::BeforeImage {
9921                                        table_id,
9922                                        row_id: *row_id,
9923                                        row: bincode::serialize(before).map_err(|error| {
9924                                            MongrelError::Other(format!(
9925                                                "before-image serialize: {error}"
9926                                            ))
9927                                        })?,
9928                                    },
9929                                ));
9930                            }
9931                            row_ids.push(*row_id);
9932                            index += 1;
9933                        }
9934                        wal_records.push((
9935                            table_id,
9936                            Op::Delete {
9937                                table_id,
9938                                row_ids: row_ids.clone(),
9939                            },
9940                        ));
9941                        applies[batch_index].ops.push(StagedOp::Delete(row_ids));
9942                    }
9943                    Staged::Truncate => {
9944                        wal_records.push((table_id, Op::TruncateTable { table_id }));
9945                        applies[batch_index].ops.push(StagedOp::Truncate);
9946                        index += 1;
9947                    }
9948                    Staged::Update { .. } => {
9949                        return Err(MongrelError::Other(
9950                            "transaction contains an unnormalized update at the sequencer".into(),
9951                        ));
9952                    }
9953                }
9954            }
9955
9956            for (name, state, _) in &prepared_external {
9957                wal_records.push((
9958                    EXTERNAL_TABLE_ID,
9959                    Op::ExternalTableState {
9960                        name: name.clone(),
9961                        state: state.clone(),
9962                    },
9963                ));
9964            }
9965
9966            for (table_id, definition) in &prepared_materialized_views {
9967                let mut definition = definition.clone();
9968                definition.last_refresh_epoch = new_epoch.0;
9969                wal_records.push((
9970                    *table_id,
9971                    Op::Ddl(crate::wal::DdlOp::SetMaterializedView {
9972                        name: definition.name.clone(),
9973                        definition_json: crate::wal::DdlOp::encode_materialized_view(&definition)?,
9974                    }),
9975                ));
9976                committed_materialized_views.push(definition);
9977            }
9978            if !committed_materialized_views.is_empty() {
9979                let mut next_catalog = self.catalog.read().clone();
9980                for definition in &committed_materialized_views {
9981                    if let Some(existing) = next_catalog
9982                        .materialized_views
9983                        .iter_mut()
9984                        .find(|existing| existing.name == definition.name)
9985                    {
9986                        *existing = definition.clone();
9987                    } else {
9988                        next_catalog.materialized_views.push(definition.clone());
9989                    }
9990                }
9991                next_catalog.db_epoch = next_catalog.db_epoch.max(new_epoch.0);
9992                wal_records.push((
9993                    WAL_TABLE_ID,
9994                    Op::Ddl(crate::wal::DdlOp::CatalogSnapshot {
9995                        catalog_json: crate::wal::DdlOp::encode_catalog(&next_catalog)?,
9996                    }),
9997                ));
9998            }
9999
10000            if let Some(control) = control {
10001                if let Err(error) = control.checkpoint() {
10002                    drop(wal);
10003                    for (_, _, pending) in &prepared_external {
10004                        let _ = std::fs::remove_file(pending);
10005                    }
10006                    return Err(error);
10007                }
10008            }
10009            if let Some(before_commit) = before_commit.as_mut() {
10010                if let Err(error) = before_commit() {
10011                    drop(wal);
10012                    for (_, _, pending) in &prepared_external {
10013                        let _ = std::fs::remove_file(pending);
10014                    }
10015                    return Err(error);
10016                }
10017            }
10018
10019            let assigned_epoch = self.epoch.bump_assigned();
10020            let _epoch_guard = EpochGuard::new(self.epoch.as_ref(), assigned_epoch);
10021            if assigned_epoch != new_epoch {
10022                for (_, _, pending) in &prepared_external {
10023                    let _ = std::fs::remove_file(pending);
10024                }
10025                return Err(MongrelError::Conflict(
10026                    "commit epoch changed while sequencer lock was held".into(),
10027                ));
10028            }
10029
10030            // From this point the outcome can become ambiguous. Keep prepared
10031            // spill files at the final names referenced by a possibly durable
10032            // commit marker; orphan cleanup is safe when the append did fail.
10033            prepared_run_links.disarm();
10034
10035            let append: Result<u64> = (|| {
10036                for (table_id, op) in wal_records {
10037                    wal.append(txn_id, table_id, op)?;
10038                }
10039                wal.append_commit(txn_id, new_epoch, &added_runs)
10040            })();
10041            let commit_seq =
10042                append.map_err(|error| self.commit_outcome_unknown(new_epoch, error))?;
10043
10044            // Record the conflict + assign the epoch under the WAL lock so commit
10045            // order == WAL append order, but DO NOT fsync here (P3.2): the fsync
10046            // moves out of this critical section to the group-commit coordinator
10047            // so concurrent committers share a single leader fsync.
10048            self.conflicts.record(&write_keys, new_epoch);
10049            (
10050                new_epoch,
10051                _epoch_guard,
10052                applies,
10053                committed_materialized_views,
10054                commit_seq,
10055            )
10056        };
10057        drop(commit_guard);
10058
10059        // ── 2b. Durability: one leader fsync serves this whole batch (P3.2). ──
10060        self.await_durable_commit(commit_seq, new_epoch)?;
10061        drop(security_guard);
10062
10063        // ── 3. Publish: apply non-spilled ops + link spilled runs ──
10064        let publish_result: Result<()> = {
10065            let mut first_error = None;
10066            let mut spilled_by_table: HashMap<u64, Vec<&SpilledRun>> = HashMap::new();
10067            for run in &spilled {
10068                spilled_by_table.entry(run.table_id).or_default().push(run);
10069            }
10070            let mut modified_tables = Vec::with_capacity(applies.len());
10071            // Apply every table completely before any fallible manifest write.
10072            // The visible epoch remains unchanged until all tables are coherent.
10073            for batch in applies {
10074                #[cfg(test)]
10075                PUBLISH_TABLE_LOCKS.with(|count| count.set(count.get() + 1));
10076                let mut t = batch.handle.lock();
10077                for op in batch.ops {
10078                    match op {
10079                        StagedOp::Put(rows) => t.apply_put_rows_prepared(rows),
10080                        StagedOp::Delete(row_ids) => {
10081                            for row_id in row_ids {
10082                                t.apply_delete(row_id, new_epoch);
10083                            }
10084                        }
10085                        StagedOp::Truncate => t.apply_truncate(new_epoch),
10086                    }
10087                }
10088                if let Some(runs) = spilled_by_table.remove(&batch.table_id) {
10089                    for run in runs {
10090                        t.link_run(crate::manifest::RunRef {
10091                            run_id: run.run_id,
10092                            level: 0,
10093                            epoch_created: new_epoch.0,
10094                            row_count: run.row_count,
10095                        });
10096                        t.apply_run_metadata_prepared(&run.rows)?;
10097                        if truncated_tables.contains(&batch.table_id) {
10098                            // TRUNCATE + spilled puts fully describe this table at
10099                            // the commit epoch. Endorse the epoch so clean-reopen
10100                            // recovery does not replay the truncate over the
10101                            // already-linked replacement run.
10102                            t.set_flushed_epoch(new_epoch);
10103                        }
10104                    }
10105                }
10106                t.invalidate_pending_cache();
10107                drop(t);
10108                modified_tables.push(batch.handle);
10109            }
10110
10111            // Checkpoint only after every live table carries the durable state.
10112            // Continue after one checkpoint failure so runtime publication stays
10113            // all-or-nothing; WAL recovery repairs failed files on reopen.
10114            for handle in modified_tables {
10115                #[cfg(test)]
10116                COMMIT_MANIFEST_WRITES.with(|count| count.set(count.get() + 1));
10117                if let Err(error) = handle.lock().persist_manifest(new_epoch) {
10118                    first_error.get_or_insert(error);
10119                }
10120            }
10121            for (name, _, pending) in &prepared_external {
10122                if let Err(error) = publish_external_state_file(&self.root, name, pending) {
10123                    first_error.get_or_insert(error);
10124                }
10125            }
10126            if !committed_materialized_views.is_empty() {
10127                let mut next_catalog = self.catalog.read().clone();
10128                for definition in committed_materialized_views {
10129                    if let Some(existing) = next_catalog
10130                        .materialized_views
10131                        .iter_mut()
10132                        .find(|existing| existing.name == definition.name)
10133                    {
10134                        *existing = definition;
10135                    } else {
10136                        next_catalog.materialized_views.push(definition);
10137                    }
10138                }
10139                next_catalog.db_epoch = next_catalog.db_epoch.max(new_epoch.0);
10140                if let Err(error) = self.checkpoint_catalog_after_durable(next_catalog) {
10141                    first_error.get_or_insert(error);
10142                }
10143            }
10144            match first_error {
10145                Some(error) => Err(error),
10146                None => Ok(()),
10147            }
10148        };
10149
10150        if has_changes {
10151            let _ = self.change_wake.send(());
10152        }
10153        self.finish_durable_publish(new_epoch, &mut _epoch_guard, publish_result)?;
10154        Ok((new_epoch, committed_row_ids))
10155    }
10156
10157    /// Register a read snapshot at the current visible epoch and return it with
10158    /// a guard that retains it for GC until dropped.
10159    pub fn snapshot(&self) -> (Snapshot, SnapshotGuard<'_>) {
10160        let e = self.epoch.visible();
10161        let g = self.snapshots.register(e);
10162        (Snapshot::at(e), g)
10163    }
10164
10165    /// Owned (clonable-handle) variant of [`Self::snapshot`] for cross-thread
10166    /// retention.
10167    pub fn snapshot_owned(&self) -> (Snapshot, OwnedSnapshotGuard) {
10168        let e = self.epoch.visible();
10169        let g = self.snapshots.register_owned(e);
10170        (Snapshot::at(e), g)
10171    }
10172
10173    /// Configure a rolling history window measured in prior commit epochs.
10174    /// The first enable starts at the current epoch because earlier versions
10175    /// may already have been compacted. Increasing the window likewise cannot
10176    /// recreate history that fell outside the previous guarantee.
10177    pub fn set_history_retention_epochs(&self, epochs: u64) -> Result<()> {
10178        let _guard = self.ddl_lock.lock();
10179        let current = self.epoch.visible();
10180        let (old_epochs, old_start) = self.snapshots.history_config();
10181        let earliest_already_guaranteed = if old_epochs == 0 {
10182            current
10183        } else {
10184            Epoch(old_start.0.max(current.0.saturating_sub(old_epochs)))
10185        };
10186        let start = if epochs == 0 {
10187            current
10188        } else {
10189            earliest_already_guaranteed
10190        };
10191        let published = std::cell::Cell::new(false);
10192        let result = write_history_retention(&self.root, epochs, start, || {
10193            self.snapshots.configure_history(epochs, start);
10194            published.set(true);
10195        });
10196        match result {
10197            Err(error) if published.get() => Err(MongrelError::CommitOutcomeUnknown {
10198                epoch: current.0,
10199                message: format!("history-retention publication was not durable: {error}"),
10200            }),
10201            result => result,
10202        }
10203    }
10204
10205    pub fn history_retention_epochs(&self) -> u64 {
10206        self.snapshots.history_config().0
10207    }
10208
10209    pub fn earliest_retained_epoch(&self) -> Epoch {
10210        let current = self.epoch.visible();
10211        self.snapshots.history_floor(current).unwrap_or(current)
10212    }
10213
10214    /// Pin a guaranteed historical epoch for the lifetime of the returned
10215    /// guard. Rejects future epochs and epochs outside the configured window.
10216    pub fn snapshot_at_owned(&self, epoch: Epoch) -> Result<(Snapshot, OwnedSnapshotGuard)> {
10217        let current = self.epoch.visible();
10218        if epoch > current {
10219            return Err(MongrelError::InvalidArgument(format!(
10220                "epoch {} is in the future; current epoch is {}",
10221                epoch.0, current.0
10222            )));
10223        }
10224        let earliest = self.earliest_retained_epoch();
10225        if epoch < earliest {
10226            return Err(MongrelError::InvalidArgument(format!(
10227                "epoch {} is no longer retained; earliest available epoch is {}",
10228                epoch.0, earliest.0
10229            )));
10230        }
10231        let guard = self.snapshots.register_owned(epoch);
10232        Ok((Snapshot::at(epoch), guard))
10233    }
10234
10235    /// Names of all live tables.
10236    pub fn table_names(&self) -> Vec<String> {
10237        self.catalog
10238            .read()
10239            .tables
10240            .iter()
10241            .filter(|t| matches!(t.state, TableState::Live))
10242            .map(|t| t.name.clone())
10243            .collect()
10244    }
10245
10246    /// Best-effort flush-on-close (§4.4): force-flush every mounted table
10247    /// that has pending writes to a `.sr` sorted run, so WAL segments can be
10248    /// reaped on the next open. Call this as the last action before a
10249    /// short-lived process (CLI, one-shot script) exits. The daemon does not
10250    /// need this — its background auto-compactor handles run management.
10251    pub fn close(&self) -> Result<()> {
10252        for name in self.table_names() {
10253            if let Ok(handle) = self.table(&name) {
10254                if let Err(e) = handle.lock().close() {
10255                    eprintln!("[close] flush failed for {name}: {e}");
10256                }
10257            }
10258        }
10259        Ok(())
10260    }
10261
10262    /// Compact every mounted table: merge all sorted runs into one clean run
10263    /// so query cost stays flat (single-run fast path) instead of growing
10264    /// with run count. Tables with < 2 runs are skipped unless TTL has expired
10265    /// rows to reclaim. Each table
10266    /// is locked individually for its own compaction; snapshot retention is
10267    /// honored by `Table::compact`. Returns `(tables_compacted, tables_skipped)`.
10268    pub fn compact(&self) -> Result<(usize, usize)> {
10269        self.require(&crate::auth::Permission::Ddl)?;
10270        let mut compacted = 0;
10271        let mut skipped = 0;
10272        for name in self.table_names() {
10273            let Ok(handle) = self.table(&name) else {
10274                continue;
10275            };
10276            {
10277                let mut t = handle.lock();
10278                let before = t.run_count();
10279                if before < 2 && !t.should_compact() {
10280                    skipped += 1;
10281                    continue;
10282                }
10283                match t.compact() {
10284                    Ok(()) => {
10285                        let after = t.run_count();
10286                        compacted += 1;
10287                        eprintln!("[compact] {name}: {before} -> {after} runs");
10288                    }
10289                    Err(e) => {
10290                        eprintln!("[compact] {name}: compaction failed: {e}");
10291                        skipped += 1;
10292                    }
10293                }
10294            }
10295        }
10296        Ok((compacted, skipped))
10297    }
10298
10299    /// Compact a single table by name. Returns `Ok(true)` if it was
10300    /// compacted, `Ok(false)` if skipped (< 2 runs).
10301    pub fn compact_table(&self, name: &str) -> Result<bool> {
10302        self.require(&crate::auth::Permission::Ddl)?;
10303        let handle = self.table(name)?;
10304        let mut t = handle.lock();
10305        let before = t.run_count();
10306        if before < 2 {
10307            return Ok(false);
10308        }
10309        t.compact()?;
10310        Ok(t.run_count() < before)
10311    }
10312
10313    /// Look up a live table by name.
10314    pub fn table(&self, name: &str) -> Result<TableHandle> {
10315        let cat = self.catalog.read();
10316        let entry = cat
10317            .live(name)
10318            .ok_or_else(|| MongrelError::NotFound(format!("table {name:?} not found")))?;
10319        let id = entry.table_id;
10320        drop(cat);
10321        self.tables
10322            .read()
10323            .get(&id)
10324            .cloned()
10325            .ok_or_else(|| MongrelError::NotFound(format!("table {name:?} not mounted")))
10326    }
10327
10328    /// Whether any mounted table has wall-clock TTL retention. SQL sessions
10329    /// use this to avoid epoch-keyed result caches that can outlive a cutoff.
10330    pub fn has_ttl_tables(&self) -> bool {
10331        self.tables
10332            .read()
10333            .values()
10334            .any(|table| table.lock().ttl().is_some())
10335    }
10336
10337    /// Resolve a live table id → mounted handle (used by the constraint
10338    /// validation pass and other id-qualified internal paths).
10339    pub(crate) fn table_by_id(&self, id: u64) -> Result<TableHandle> {
10340        self.tables
10341            .read()
10342            .get(&id)
10343            .cloned()
10344            .ok_or_else(|| MongrelError::NotFound(format!("table id {id} not mounted")))
10345    }
10346
10347    /// Create a new table. The DDL is first logged to the shared WAL
10348    /// (`Op::Ddl(CreateTable)` + `TxnCommit`) and group-synced so it is durable
10349    /// BEFORE the in-memory catalog and table map are mutated; the catalog
10350    /// checkpoint is rewritten afterwards (spec §15, review fix #16). A reopen
10351    /// that sees a stale catalog still recovers the table by replaying the Ddl.
10352    pub fn create_table(&self, name: &str, schema: Schema) -> Result<u64> {
10353        if name.starts_with(CTAS_BUILD_TABLE_PREFIX) {
10354            return Err(MongrelError::InvalidArgument(format!(
10355                "table names beginning with {CTAS_BUILD_TABLE_PREFIX:?} are reserved"
10356            )));
10357        }
10358        self.create_table_with_state(name, schema, TableState::Live)
10359    }
10360
10361    /// Create a durable but non-queryable CTAS build table.
10362    #[doc(hidden)]
10363    pub fn create_building_table(
10364        &self,
10365        build_name: &str,
10366        intended_name: &str,
10367        query_id: &str,
10368        schema: Schema,
10369    ) -> Result<u64> {
10370        if !build_name.starts_with(CTAS_BUILD_TABLE_PREFIX)
10371            || intended_name.is_empty()
10372            || intended_name.starts_with(CTAS_BUILD_TABLE_PREFIX)
10373            || query_id.is_empty()
10374        {
10375            return Err(MongrelError::InvalidArgument(
10376                "invalid CTAS building-table identity".into(),
10377            ));
10378        }
10379        self.create_table_with_state(
10380            build_name,
10381            schema,
10382            TableState::Building {
10383                intended_name: intended_name.to_string(),
10384                query_id: query_id.to_string(),
10385                created_at_unix_nanos: current_unix_nanos(),
10386                replaces_table_id: None,
10387            },
10388        )
10389    }
10390
10391    /// Create a hidden schema-rebuild table while the intended target remains
10392    /// live. Publication later validates that the same target is still live.
10393    #[doc(hidden)]
10394    pub fn create_rebuilding_table(
10395        &self,
10396        build_name: &str,
10397        intended_name: &str,
10398        query_id: &str,
10399        schema: Schema,
10400    ) -> Result<u64> {
10401        if !build_name.starts_with(CTAS_BUILD_TABLE_PREFIX)
10402            || intended_name.is_empty()
10403            || intended_name.starts_with(CTAS_BUILD_TABLE_PREFIX)
10404            || query_id.is_empty()
10405        {
10406            return Err(MongrelError::InvalidArgument(
10407                "invalid rebuilding-table identity".into(),
10408            ));
10409        }
10410        let replaces_table_id = self
10411            .catalog
10412            .read()
10413            .live(intended_name)
10414            .ok_or_else(|| MongrelError::NotFound(format!("table {intended_name:?} not found")))?
10415            .table_id;
10416        self.create_table_with_state(
10417            build_name,
10418            schema,
10419            TableState::Building {
10420                intended_name: intended_name.to_string(),
10421                query_id: query_id.to_string(),
10422                created_at_unix_nanos: current_unix_nanos(),
10423                replaces_table_id: Some(replaces_table_id),
10424            },
10425        )
10426    }
10427
10428    fn create_table_with_state(
10429        &self,
10430        name: &str,
10431        schema: Schema,
10432        state: TableState,
10433    ) -> Result<u64> {
10434        use crate::wal::DdlOp;
10435        use std::sync::atomic::Ordering;
10436
10437        self.require(&crate::auth::Permission::Ddl)?;
10438        if self.poisoned.load(Ordering::Relaxed) {
10439            return Err(MongrelError::Other(
10440                "database poisoned by fsync error".into(),
10441            ));
10442        }
10443
10444        let _g = self.ddl_lock.lock();
10445        let _security_write = self.security_write()?;
10446        self.require(&crate::auth::Permission::Ddl)?;
10447        {
10448            let cat = self.catalog.read();
10449            match &state {
10450                TableState::Live => {
10451                    if cat.live(name).is_some() || cat.building_for(name).is_some() {
10452                        return Err(MongrelError::InvalidArgument(format!(
10453                            "table {name:?} already exists or is being built"
10454                        )));
10455                    }
10456                }
10457                TableState::Building {
10458                    intended_name,
10459                    replaces_table_id,
10460                    ..
10461                } => {
10462                    let target_matches = match replaces_table_id {
10463                        Some(table_id) => cat
10464                            .live(intended_name)
10465                            .is_some_and(|entry| entry.table_id == *table_id),
10466                        None => cat.live(intended_name).is_none(),
10467                    };
10468                    if !target_matches || cat.building_for(intended_name).is_some() {
10469                        return Err(MongrelError::InvalidArgument(format!(
10470                            "table {intended_name:?} changed or is already being built"
10471                        )));
10472                    }
10473                    if cat.building(name).is_some() {
10474                        return Err(MongrelError::InvalidArgument(format!(
10475                            "building table {name:?} already exists"
10476                        )));
10477                    }
10478                }
10479                TableState::Dropped { .. } => {
10480                    return Err(MongrelError::InvalidArgument(
10481                        "cannot create a dropped table".into(),
10482                    ));
10483                }
10484            }
10485        }
10486
10487        // Allocate id + epoch + txn id under the commit lock so the DDL commit
10488        // is serialized with data commits (in-order publish).
10489        let commit_lock = Arc::clone(&self.commit_lock);
10490        let _c = commit_lock.lock();
10491        let table_id = {
10492            let mut cat = self.catalog.write();
10493            let id = cat.next_table_id;
10494            cat.next_table_id = id
10495                .checked_add(1)
10496                .ok_or_else(|| MongrelError::InvalidArgument("table id space exhausted".into()))?;
10497            Result::<u64>::Ok(id)
10498        }?;
10499        let epoch = self.epoch.bump_assigned();
10500        let mut _epoch_guard = EpochGuard::new(self.epoch.as_ref(), epoch);
10501        let txn_id = self.alloc_txn_id()?;
10502
10503        // Stamp the schema_id with the unique table_id so every table in the
10504        // database has a distinct schema_id (caller-provided values are
10505        // ignored to prevent collisions).
10506        let mut schema = schema;
10507        schema.schema_id = table_id;
10508        // Defense in depth: reject an invalid schema BEFORE any durable
10509        // side-effect. `Table::create_in` re-validates, but by then the DDL has
10510        // already been appended to the shared WAL; a failing create_in would
10511        // leave a dangling entry that `recover_ddl_from_wal` replays without
10512        // re-validating, corrupting the catalog on reopen. Validating here
10513        // keeps the WAL free of schemas that can never be opened.
10514        schema.validate_auto_increment()?;
10515        schema.validate_defaults()?;
10516        schema.validate_ai()?;
10517        for index in &schema.indexes {
10518            index.validate_options()?;
10519        }
10520        for constraint in &schema.constraints.checks {
10521            constraint.expr.validate()?;
10522        }
10523
10524        // Build the complete mounted table before its DDL can become durable.
10525        // Any failure removes the unpublished directory and abandons the epoch.
10526        let table_relative = Path::new(TABLES_DIR).join(table_id.to_string());
10527        let canonical_tdir = self.root.join(&table_relative);
10528        let table_root = Arc::new(
10529            self.durable_root
10530                .create_directory_all_pinned(&table_relative)?,
10531        );
10532        let tdir = table_root.io_path()?;
10533        let mut pending_table_dir = PendingTableDir::new(canonical_tdir);
10534        let ctx = SharedCtx {
10535            root_guard: Some(table_root),
10536            epoch: Arc::clone(&self.epoch),
10537            page_cache: Arc::clone(&self.page_cache),
10538            decoded_cache: Arc::clone(&self.decoded_cache),
10539            snapshots: Arc::clone(&self.snapshots),
10540            kek: self.kek.clone(),
10541            commit_lock: Arc::clone(&self.commit_lock),
10542            shared: Some(crate::engine::SharedWalCtx {
10543                wal: Arc::clone(&self.shared_wal),
10544                group: Arc::clone(&self.group),
10545                poisoned: Arc::clone(&self.poisoned),
10546                txn_ids: Arc::clone(&self.next_txn_id),
10547                change_wake: self.change_wake.clone(),
10548            }),
10549            table_name: Some(name.to_string()),
10550            auth: self.table_auth_checker(),
10551            read_only: self.read_only,
10552        };
10553        let table = Table::create_in(&tdir, schema.clone(), table_id, ctx)?;
10554
10555        // 1. Log the DDL + commit marker to the shared WAL, then make it durable
10556        //    via the group-commit coordinator (no fsync under the WAL lock — P3.2).
10557        let schema_json = DdlOp::encode_schema(&schema)?;
10558        let ddl = match &state {
10559            TableState::Live => DdlOp::CreateTable {
10560                table_id,
10561                name: name.to_string(),
10562                schema_json,
10563            },
10564            TableState::Building {
10565                intended_name,
10566                query_id,
10567                created_at_unix_nanos,
10568                replaces_table_id,
10569            } => match replaces_table_id {
10570                Some(replaces_table_id) => DdlOp::CreateRebuildingTable {
10571                    table_id,
10572                    build_name: name.to_string(),
10573                    intended_name: intended_name.clone(),
10574                    query_id: query_id.clone(),
10575                    created_at_unix_nanos: *created_at_unix_nanos,
10576                    replaces_table_id: *replaces_table_id,
10577                    schema_json,
10578                },
10579                None => DdlOp::CreateBuildingTable {
10580                    table_id,
10581                    build_name: name.to_string(),
10582                    intended_name: intended_name.clone(),
10583                    query_id: query_id.clone(),
10584                    created_at_unix_nanos: *created_at_unix_nanos,
10585                    schema_json,
10586                },
10587            },
10588            TableState::Dropped { .. } => {
10589                return Err(MongrelError::InvalidArgument(
10590                    "cannot create a table in dropped state".into(),
10591                ));
10592            }
10593        };
10594        let mut next_catalog = self.catalog.read().clone();
10595        next_catalog.tables.push(CatalogEntry {
10596            table_id,
10597            name: name.to_string(),
10598            schema: schema.clone(),
10599            state: state.clone(),
10600            created_epoch: epoch.0,
10601        });
10602        next_catalog.db_epoch = next_catalog.db_epoch.max(epoch.0);
10603        let commit_seq = {
10604            let mut wal = self.shared_wal.lock();
10605            let append: Result<u64> = (|| {
10606                wal.append(txn_id, table_id, crate::wal::Op::Ddl(ddl))?;
10607                append_catalog_snapshot(&mut wal, txn_id, &next_catalog)?;
10608                wal.append_commit(txn_id, epoch, &[])
10609            })();
10610            append.map_err(|error| self.commit_outcome_unknown(epoch, error))?
10611        };
10612        self.await_durable_commit(commit_seq, epoch)?;
10613        pending_table_dir.disarm();
10614
10615        // Publish the mounted table and catalog in memory even if the catalog
10616        // checkpoint fails after the WAL commit.
10617        self.tables
10618            .write()
10619            .insert(table_id, TableHandle::new(table));
10620        let checkpoint = self.checkpoint_catalog_after_durable(next_catalog);
10621        self.finish_durable_publish(epoch, &mut _epoch_guard, checkpoint)?;
10622        Ok(table_id)
10623    }
10624
10625    /// Logically drop a table, logging the DDL through the shared WAL first.
10626    pub fn drop_table(&self, name: &str) -> Result<()> {
10627        self.drop_table_with_epoch(name).map(|_| ())
10628    }
10629
10630    /// Logically drop a table and return the exact publication epoch.
10631    pub fn drop_table_with_epoch(&self, name: &str) -> Result<Epoch> {
10632        self.drop_table_with_state(name, false, None)
10633    }
10634
10635    pub fn drop_table_with_epoch_controlled<F>(
10636        &self,
10637        name: &str,
10638        mut before_commit: F,
10639    ) -> Result<Epoch>
10640    where
10641        F: FnMut() -> Result<()>,
10642    {
10643        self.drop_table_with_state(name, false, Some(&mut before_commit))
10644    }
10645
10646    /// Discard an unpublished CTAS build.
10647    #[doc(hidden)]
10648    pub fn discard_building_table(&self, name: &str) -> Result<()> {
10649        if !name.starts_with(CTAS_BUILD_TABLE_PREFIX) {
10650            return Err(MongrelError::InvalidArgument(
10651                "not a CTAS building table".into(),
10652            ));
10653        }
10654        self.drop_table_with_state(name, true, None).map(|_| ())
10655    }
10656
10657    fn drop_table_with_state(
10658        &self,
10659        name: &str,
10660        building: bool,
10661        before_commit: Option<&mut dyn FnMut() -> Result<()>>,
10662    ) -> Result<Epoch> {
10663        use crate::wal::DdlOp;
10664        use std::sync::atomic::Ordering;
10665
10666        self.require(&crate::auth::Permission::Ddl)?;
10667        if self.poisoned.load(Ordering::Relaxed) {
10668            return Err(MongrelError::Other(
10669                "database poisoned by fsync error".into(),
10670            ));
10671        }
10672
10673        let _g = self.ddl_lock.lock();
10674        let _security_write = self.security_write()?;
10675        self.require(&crate::auth::Permission::Ddl)?;
10676        let table_id = {
10677            let cat = self.catalog.read();
10678            if building {
10679                cat.building(name)
10680            } else {
10681                cat.live(name)
10682            }
10683            .ok_or_else(|| MongrelError::NotFound(format!("table {name:?} not found")))?
10684            .table_id
10685        };
10686
10687        let commit_lock = Arc::clone(&self.commit_lock);
10688        let _c = commit_lock.lock();
10689        let epoch = self.epoch.bump_assigned();
10690        let mut _epoch_guard = EpochGuard::new(self.epoch.as_ref(), epoch);
10691        let txn_id = self.alloc_txn_id()?;
10692        let mut next_catalog = self.catalog.read().clone();
10693        let entry = next_catalog
10694            .tables
10695            .iter_mut()
10696            .find(|t| t.table_id == table_id)
10697            .ok_or_else(|| MongrelError::NotFound(format!("table {name:?} not found")))?;
10698        entry.state = TableState::Dropped { at_epoch: epoch.0 };
10699        next_catalog.triggers.retain(|trigger| {
10700            !matches!(
10701                &trigger.trigger.target,
10702                TriggerTarget::Table(target) if target == name
10703            )
10704        });
10705        next_catalog
10706            .materialized_views
10707            .retain(|definition| definition.name != name);
10708        next_catalog
10709            .security
10710            .rls_tables
10711            .retain(|table| table != name);
10712        next_catalog
10713            .security
10714            .policies
10715            .retain(|policy| policy.table != name);
10716        next_catalog
10717            .security
10718            .masks
10719            .retain(|mask| mask.table != name);
10720        for role in &mut next_catalog.roles {
10721            role.permissions
10722                .retain(|permission| permission_table(permission) != Some(name));
10723        }
10724        advance_security_version(&mut next_catalog)?;
10725        next_catalog.db_epoch = next_catalog.db_epoch.max(epoch.0);
10726        let commit_seq = {
10727            let mut wal = self.shared_wal.lock();
10728            if let Some(before_commit) = before_commit {
10729                before_commit()?;
10730            }
10731            let append: Result<u64> = (|| {
10732                wal.append(
10733                    txn_id,
10734                    table_id,
10735                    crate::wal::Op::Ddl(DdlOp::DropTable { table_id }),
10736                )?;
10737                append_catalog_snapshot(&mut wal, txn_id, &next_catalog)?;
10738                wal.append_commit(txn_id, epoch, &[])
10739            })();
10740            append.map_err(|error| self.commit_outcome_unknown(epoch, error))?
10741        };
10742        self.await_durable_commit(commit_seq, epoch)?;
10743
10744        let checkpoint = self.checkpoint_catalog_after_durable(next_catalog);
10745        self.tables.write().remove(&table_id);
10746        self.finish_durable_publish(epoch, &mut _epoch_guard, checkpoint)?;
10747        Ok(epoch)
10748    }
10749
10750    /// Rename a live table. `name` must exist and `new_name` must not collide
10751    /// with any live table; both checks run under `ddl_lock` so they are atomic
10752    /// with the rename and with concurrent `create_table` existence checks (no
10753    /// TOCTOU window). A no-op rename (`name == new_name`) succeeds without
10754    /// side-effects. The rename is logged to the shared WAL as
10755    /// `DdlOp::RenameTable` and recovered on reopen; the `table_id`, schema,
10756    /// and on-disk layout are unchanged (the table is keyed by `table_id`, so
10757    /// the in-memory object does not move — only the catalog name changes).
10758    pub fn rename_table(&self, name: &str, new_name: &str) -> Result<()> {
10759        self.rename_table_with_epoch(name, new_name).map(|_| ())
10760    }
10761
10762    /// Rename a table and return its exact publication epoch.
10763    pub fn rename_table_with_epoch(&self, name: &str, new_name: &str) -> Result<Epoch> {
10764        self.rename_table_with_epoch_inner(name, new_name, None)
10765    }
10766
10767    pub fn rename_table_with_epoch_controlled<F>(
10768        &self,
10769        name: &str,
10770        new_name: &str,
10771        mut before_commit: F,
10772    ) -> Result<Epoch>
10773    where
10774        F: FnMut() -> Result<()>,
10775    {
10776        self.rename_table_with_epoch_inner(name, new_name, Some(&mut before_commit))
10777    }
10778
10779    fn rename_table_with_epoch_inner(
10780        &self,
10781        name: &str,
10782        new_name: &str,
10783        before_commit: Option<&mut dyn FnMut() -> Result<()>>,
10784    ) -> Result<Epoch> {
10785        if name.starts_with(CTAS_BUILD_TABLE_PREFIX)
10786            || new_name.starts_with(CTAS_BUILD_TABLE_PREFIX)
10787        {
10788            return Err(MongrelError::InvalidArgument(
10789                "the CTAS building-table namespace is reserved".into(),
10790            ));
10791        }
10792        self.rename_table_with_state(name, new_name, false, None, before_commit)
10793    }
10794
10795    /// Atomically publish a hidden CTAS build under its intended live name.
10796    #[doc(hidden)]
10797    pub fn publish_building_table(&self, build_name: &str, new_name: &str) -> Result<Epoch> {
10798        self.publish_building_table_inner(build_name, new_name, None)
10799    }
10800
10801    #[doc(hidden)]
10802    pub fn publish_building_table_controlled<F>(
10803        &self,
10804        build_name: &str,
10805        new_name: &str,
10806        mut before_commit: F,
10807    ) -> Result<Epoch>
10808    where
10809        F: FnMut() -> Result<()>,
10810    {
10811        self.publish_building_table_inner(build_name, new_name, Some(&mut before_commit))
10812    }
10813
10814    fn publish_building_table_inner(
10815        &self,
10816        build_name: &str,
10817        new_name: &str,
10818        before_commit: Option<&mut dyn FnMut() -> Result<()>>,
10819    ) -> Result<Epoch> {
10820        if !build_name.starts_with(CTAS_BUILD_TABLE_PREFIX)
10821            || new_name.starts_with(CTAS_BUILD_TABLE_PREFIX)
10822        {
10823            return Err(MongrelError::InvalidArgument(
10824                "invalid CTAS publish identity".into(),
10825            ));
10826        }
10827        self.rename_table_with_state(build_name, new_name, true, None, before_commit)
10828    }
10829
10830    /// Atomically publish a hidden build and its materialized-view definition.
10831    #[doc(hidden)]
10832    pub fn publish_materialized_building_table(
10833        &self,
10834        build_name: &str,
10835        new_name: &str,
10836        definition: crate::catalog::MaterializedViewEntry,
10837    ) -> Result<Epoch> {
10838        self.publish_materialized_building_table_inner(build_name, new_name, definition, None)
10839    }
10840
10841    #[doc(hidden)]
10842    pub fn publish_materialized_building_table_controlled<F>(
10843        &self,
10844        build_name: &str,
10845        new_name: &str,
10846        definition: crate::catalog::MaterializedViewEntry,
10847        mut before_commit: F,
10848    ) -> Result<Epoch>
10849    where
10850        F: FnMut() -> Result<()>,
10851    {
10852        self.publish_materialized_building_table_inner(
10853            build_name,
10854            new_name,
10855            definition,
10856            Some(&mut before_commit),
10857        )
10858    }
10859
10860    fn publish_materialized_building_table_inner(
10861        &self,
10862        build_name: &str,
10863        new_name: &str,
10864        definition: crate::catalog::MaterializedViewEntry,
10865        before_commit: Option<&mut dyn FnMut() -> Result<()>>,
10866    ) -> Result<Epoch> {
10867        if definition.name != new_name || definition.query.trim().is_empty() {
10868            return Err(MongrelError::InvalidArgument(
10869                "invalid materialized-view publication".into(),
10870            ));
10871        }
10872        self.rename_table_with_state(build_name, new_name, true, Some(definition), before_commit)
10873    }
10874
10875    /// Atomically replace a still-live table with its completed hidden rebuild.
10876    #[doc(hidden)]
10877    pub fn publish_rebuilding_table(&self, build_name: &str, new_name: &str) -> Result<Epoch> {
10878        self.publish_rebuilding_table_inner(build_name, new_name, None, None)
10879    }
10880
10881    #[doc(hidden)]
10882    pub fn publish_rebuilding_table_controlled<F>(
10883        &self,
10884        build_name: &str,
10885        new_name: &str,
10886        mut before_commit: F,
10887    ) -> Result<Epoch>
10888    where
10889        F: FnMut() -> Result<()>,
10890    {
10891        self.publish_rebuilding_table_inner(build_name, new_name, None, Some(&mut before_commit))
10892    }
10893
10894    /// Atomically replace a live materialized-view table and its definition.
10895    #[doc(hidden)]
10896    pub fn publish_materialized_rebuilding_table_controlled<F>(
10897        &self,
10898        build_name: &str,
10899        new_name: &str,
10900        definition: crate::catalog::MaterializedViewEntry,
10901        mut before_commit: F,
10902    ) -> Result<Epoch>
10903    where
10904        F: FnMut() -> Result<()>,
10905    {
10906        self.publish_rebuilding_table_inner(
10907            build_name,
10908            new_name,
10909            Some(definition),
10910            Some(&mut before_commit),
10911        )
10912    }
10913
10914    fn publish_rebuilding_table_inner(
10915        &self,
10916        build_name: &str,
10917        new_name: &str,
10918        mut materialized_view: Option<crate::catalog::MaterializedViewEntry>,
10919        before_commit: Option<&mut dyn FnMut() -> Result<()>>,
10920    ) -> Result<Epoch> {
10921        use crate::wal::DdlOp;
10922
10923        if !build_name.starts_with(CTAS_BUILD_TABLE_PREFIX)
10924            || new_name.is_empty()
10925            || new_name.starts_with(CTAS_BUILD_TABLE_PREFIX)
10926        {
10927            return Err(MongrelError::InvalidArgument(
10928                "invalid rebuilding-table publish identity".into(),
10929            ));
10930        }
10931        if materialized_view.as_ref().is_some_and(|definition| {
10932            definition.name != new_name || definition.query.trim().is_empty()
10933        }) {
10934            return Err(MongrelError::InvalidArgument(
10935                "invalid materialized-view replacement".into(),
10936            ));
10937        }
10938        self.require(&crate::auth::Permission::Ddl)?;
10939        if self.poisoned.load(Ordering::Relaxed) {
10940            return Err(MongrelError::Other(
10941                "database poisoned by fsync error".into(),
10942            ));
10943        }
10944
10945        let _ddl = self.ddl_lock.lock();
10946        let _security_write = self.security_write()?;
10947        let (table_id, replaced_table_id) = {
10948            let catalog = self.catalog.read();
10949            let build = catalog.building(build_name).ok_or_else(|| {
10950                MongrelError::NotFound(format!("building table {build_name:?} not found"))
10951            })?;
10952            let replaced_table_id = match &build.state {
10953                TableState::Building {
10954                    intended_name,
10955                    replaces_table_id: Some(replaced_table_id),
10956                    ..
10957                } if intended_name == new_name => *replaced_table_id,
10958                _ => {
10959                    return Err(MongrelError::InvalidArgument(format!(
10960                        "building table {build_name:?} is not a replacement for {new_name:?}"
10961                    )))
10962                }
10963            };
10964            if catalog
10965                .live(new_name)
10966                .is_none_or(|entry| entry.table_id != replaced_table_id)
10967            {
10968                return Err(MongrelError::Conflict(format!(
10969                    "table {new_name:?} changed while its replacement was built"
10970                )));
10971            }
10972            (build.table_id, replaced_table_id)
10973        };
10974
10975        let _commit = self.commit_lock.lock();
10976        let epoch = self.epoch.assigned().next();
10977        let txn_id = self.alloc_txn_id()?;
10978        let mut next_catalog = self.catalog.read().clone();
10979        apply_rebuilding_publish(
10980            &mut next_catalog,
10981            table_id,
10982            replaced_table_id,
10983            new_name,
10984            epoch.0,
10985        )?;
10986        if let Some(definition) = materialized_view.as_mut() {
10987            definition.last_refresh_epoch = epoch.0;
10988        }
10989        let materialized_view_json = materialized_view
10990            .as_ref()
10991            .map(DdlOp::encode_materialized_view)
10992            .transpose()?;
10993        if let Some(definition) = materialized_view {
10994            if let Some(existing) = next_catalog
10995                .materialized_views
10996                .iter_mut()
10997                .find(|existing| existing.name == definition.name)
10998            {
10999                *existing = definition;
11000            } else {
11001                next_catalog.materialized_views.push(definition);
11002            }
11003        }
11004        next_catalog.db_epoch = next_catalog.db_epoch.max(epoch.0);
11005        if let Some(before_commit) = before_commit {
11006            before_commit()?;
11007        }
11008        let assigned_epoch = self.epoch.bump_assigned();
11009        let mut epoch_guard = EpochGuard::new(self.epoch.as_ref(), assigned_epoch);
11010        if assigned_epoch != epoch {
11011            return Err(MongrelError::Conflict(
11012                "commit epoch changed while sequencer lock was held".into(),
11013            ));
11014        }
11015        let commit_seq = {
11016            let mut wal = self.shared_wal.lock();
11017            let append: Result<u64> = (|| {
11018                wal.append(
11019                    txn_id,
11020                    table_id,
11021                    crate::wal::Op::Ddl(DdlOp::ReplaceBuildingTable {
11022                        table_id,
11023                        replaced_table_id,
11024                        new_name: new_name.to_string(),
11025                    }),
11026                )?;
11027                if let Some(definition_json) = materialized_view_json {
11028                    wal.append(
11029                        txn_id,
11030                        table_id,
11031                        crate::wal::Op::Ddl(DdlOp::SetMaterializedView {
11032                            name: new_name.to_string(),
11033                            definition_json,
11034                        }),
11035                    )?;
11036                }
11037                append_catalog_snapshot(&mut wal, txn_id, &next_catalog)?;
11038                wal.append_commit(txn_id, epoch, &[])
11039            })();
11040            append.map_err(|error| self.commit_outcome_unknown(epoch, error))?
11041        };
11042        self.await_durable_commit(commit_seq, epoch)?;
11043
11044        let checkpoint = self.checkpoint_catalog_after_durable(next_catalog);
11045        self.tables.write().remove(&replaced_table_id);
11046        if let Some(table) = self.tables.read().get(&table_id) {
11047            table.lock().set_catalog_name(new_name.to_string());
11048        }
11049        self.finish_durable_publish(epoch, &mut epoch_guard, checkpoint)?;
11050        Ok(epoch)
11051    }
11052
11053    fn rename_table_with_state(
11054        &self,
11055        name: &str,
11056        new_name: &str,
11057        building: bool,
11058        mut materialized_view: Option<crate::catalog::MaterializedViewEntry>,
11059        before_commit: Option<&mut dyn FnMut() -> Result<()>>,
11060    ) -> Result<Epoch> {
11061        use crate::wal::DdlOp;
11062        use std::sync::atomic::Ordering;
11063
11064        self.require(&crate::auth::Permission::Ddl)?;
11065        if self.poisoned.load(Ordering::Relaxed) {
11066            return Err(MongrelError::Other(
11067                "database poisoned by fsync error".into(),
11068            ));
11069        }
11070
11071        // A no-op rename short-circuits before any locking, so it can never
11072        // trip the "target already exists" check (the source *is* that name).
11073        if name == new_name {
11074            return Ok(self.visible_epoch());
11075        }
11076        if new_name.is_empty() {
11077            return Err(MongrelError::InvalidArgument(
11078                "rename_table: new name must not be empty".into(),
11079            ));
11080        }
11081
11082        let _g = self.ddl_lock.lock();
11083        let _security_write = self.security_write()?;
11084        self.require(&crate::auth::Permission::Ddl)?;
11085        let table_id = {
11086            let cat = self.catalog.read();
11087            let src = if building {
11088                cat.building(name)
11089            } else {
11090                cat.live(name)
11091            }
11092            .ok_or_else(|| MongrelError::NotFound(format!("table {name:?} not found")))?;
11093            if building
11094                && !matches!(
11095                    &src.state,
11096                    TableState::Building { intended_name, .. } if intended_name == new_name
11097                )
11098            {
11099                return Err(MongrelError::InvalidArgument(format!(
11100                    "building table {name:?} is not reserved for {new_name:?}"
11101                )));
11102            }
11103            // Target must be free. Checked under ddl_lock, which every other
11104            // DDL (create/rename/drop) also holds, so a concurrent operation
11105            // cannot claim `new_name` between this check and the catalog write.
11106            if cat.live(new_name).is_some() || (!building && cat.building_for(new_name).is_some()) {
11107                return Err(MongrelError::InvalidArgument(format!(
11108                    "rename_table: a table named {new_name:?} already exists"
11109                )));
11110            }
11111            src.table_id
11112        };
11113
11114        let commit_lock = Arc::clone(&self.commit_lock);
11115        let _c = commit_lock.lock();
11116        let epoch = self.epoch.bump_assigned();
11117        let mut _epoch_guard = EpochGuard::new(self.epoch.as_ref(), epoch);
11118        let txn_id = self.alloc_txn_id()?;
11119        if let Some(definition) = materialized_view.as_mut() {
11120            definition.last_refresh_epoch = epoch.0;
11121        }
11122        let materialized_view_json = materialized_view
11123            .as_ref()
11124            .map(DdlOp::encode_materialized_view)
11125            .transpose()?;
11126        let mut next_catalog = self.catalog.read().clone();
11127        let entry = next_catalog
11128            .tables
11129            .iter_mut()
11130            .find(|t| t.table_id == table_id)
11131            .ok_or_else(|| MongrelError::NotFound(format!("table {name:?} not found")))?;
11132        entry.name = new_name.to_string();
11133        if building {
11134            entry.state = TableState::Live;
11135        }
11136        for trigger in &mut next_catalog.triggers {
11137            if matches!(
11138                &trigger.trigger.target,
11139                TriggerTarget::Table(target) if target == name
11140            ) {
11141                trigger.trigger = trigger.trigger.retarget_table(new_name, epoch.0)?;
11142            }
11143        }
11144        if let Some(definition) = next_catalog
11145            .materialized_views
11146            .iter_mut()
11147            .find(|definition| definition.name == name)
11148        {
11149            definition.name = new_name.to_string();
11150        }
11151        if let Some(definition) = materialized_view.take() {
11152            next_catalog.materialized_views.push(definition);
11153        }
11154        next_catalog.db_epoch = next_catalog.db_epoch.max(epoch.0);
11155        for table in &mut next_catalog.security.rls_tables {
11156            if table == name {
11157                *table = new_name.to_string();
11158            }
11159        }
11160        for policy in &mut next_catalog.security.policies {
11161            if policy.table == name {
11162                policy.table = new_name.to_string();
11163            }
11164        }
11165        for mask in &mut next_catalog.security.masks {
11166            if mask.table == name {
11167                mask.table = new_name.to_string();
11168            }
11169        }
11170        for role in &mut next_catalog.roles {
11171            for permission in &mut role.permissions {
11172                rename_permission_table(permission, name, new_name);
11173            }
11174        }
11175        advance_security_version(&mut next_catalog)?;
11176        let ddl = if building {
11177            DdlOp::PublishBuildingTable {
11178                table_id,
11179                new_name: new_name.to_string(),
11180            }
11181        } else {
11182            DdlOp::RenameTable {
11183                table_id,
11184                new_name: new_name.to_string(),
11185            }
11186        };
11187        let commit_seq = {
11188            let mut wal = self.shared_wal.lock();
11189            if let Some(before_commit) = before_commit {
11190                before_commit()?;
11191            }
11192            let append: Result<u64> = (|| {
11193                wal.append(txn_id, table_id, crate::wal::Op::Ddl(ddl))?;
11194                if let Some(definition_json) = materialized_view_json {
11195                    wal.append(
11196                        txn_id,
11197                        table_id,
11198                        crate::wal::Op::Ddl(DdlOp::SetMaterializedView {
11199                            name: new_name.to_string(),
11200                            definition_json,
11201                        }),
11202                    )?;
11203                }
11204                append_catalog_snapshot(&mut wal, txn_id, &next_catalog)?;
11205                wal.append_commit(txn_id, epoch, &[])
11206            })();
11207            append.map_err(|error| self.commit_outcome_unknown(epoch, error))?
11208        };
11209        self.await_durable_commit(commit_seq, epoch)?;
11210
11211        let checkpoint = self.checkpoint_catalog_after_durable(next_catalog);
11212        // The in-memory table object is keyed by table_id, not name, so it does
11213        // not move and live TableHandles remain valid.
11214        if let Some(table) = self.tables.read().get(&table_id) {
11215            table.lock().set_catalog_name(new_name.to_string());
11216        }
11217        self.finish_durable_publish(epoch, &mut _epoch_guard, checkpoint)?;
11218        Ok(epoch)
11219    }
11220
11221    pub fn alter_column(
11222        &self,
11223        table_name: &str,
11224        column_name: &str,
11225        change: AlterColumn,
11226    ) -> Result<ColumnDef> {
11227        self.alter_column_with_epoch(table_name, column_name, change)
11228            .map(|(column, _)| column)
11229    }
11230
11231    pub fn alter_column_with_epoch(
11232        &self,
11233        table_name: &str,
11234        column_name: &str,
11235        change: AlterColumn,
11236    ) -> Result<(ColumnDef, Option<Epoch>)> {
11237        self.alter_column_with_epoch_inner(table_name, column_name, change, None, None, None)
11238    }
11239
11240    /// Cooperatively prepare an ALTER and fence each durable commit separately.
11241    /// `after_commit(Some(epoch))` follows an exact durable outcome;
11242    /// `after_commit(None)` follows an uncertain WAL attempt. It is called once
11243    /// for every successful `before_commit` callback.
11244    pub fn alter_column_with_epoch_controlled<B, A>(
11245        &self,
11246        table_name: &str,
11247        column_name: &str,
11248        change: AlterColumn,
11249        control: &crate::ExecutionControl,
11250        mut before_commit: B,
11251        mut after_commit: A,
11252    ) -> Result<(ColumnDef, Option<Epoch>)>
11253    where
11254        B: FnMut() -> Result<()>,
11255        A: FnMut(Option<Epoch>) -> Result<()>,
11256    {
11257        self.alter_column_with_epoch_inner(
11258            table_name,
11259            column_name,
11260            change,
11261            Some(control),
11262            Some(&mut before_commit),
11263            Some(&mut after_commit),
11264        )
11265    }
11266
11267    #[allow(clippy::too_many_arguments)]
11268    fn alter_column_with_epoch_inner(
11269        &self,
11270        table_name: &str,
11271        column_name: &str,
11272        change: AlterColumn,
11273        control: Option<&crate::ExecutionControl>,
11274        mut before_commit: Option<&mut dyn FnMut() -> Result<()>>,
11275        mut after_commit: Option<&mut dyn FnMut(Option<Epoch>) -> Result<()>>,
11276    ) -> Result<(ColumnDef, Option<Epoch>)> {
11277        use crate::wal::DdlOp;
11278        use std::sync::atomic::Ordering;
11279
11280        self.require(&crate::auth::Permission::Ddl)?;
11281        commit_prepare_checkpoint(control, 0)?;
11282        if self.poisoned.load(Ordering::Relaxed) {
11283            return Err(MongrelError::Other(
11284                "database poisoned by fsync error".into(),
11285            ));
11286        }
11287
11288        let _g = self.ddl_lock.lock();
11289        let table_id = {
11290            let cat = self.catalog.read();
11291            cat.live(table_name)
11292                .ok_or_else(|| MongrelError::NotFound(format!("table {table_name:?} not found")))?
11293                .table_id
11294        };
11295        let handle =
11296            self.tables.read().get(&table_id).cloned().ok_or_else(|| {
11297                MongrelError::NotFound(format!("table {table_name:?} not mounted"))
11298            })?;
11299
11300        // Legitimate online-ALTER slice: when nullable -> NOT NULL has a
11301        // declared default, backfill existing NULL/absent cells as one durable
11302        // transaction before logging the metadata change. A crash between the
11303        // two commits leaves a harmless nullable-but-filled column; retry is
11304        // idempotent because only remaining NULLs are touched.
11305        let backfill = {
11306            let table = handle.lock();
11307            let old = table
11308                .schema()
11309                .column(column_name)
11310                .cloned()
11311                .ok_or_else(|| MongrelError::Schema(format!("unknown column {column_name}")))?;
11312            let next_flags = change.flags.unwrap_or(old.flags);
11313            if old.flags.contains(crate::schema::ColumnFlags::NULLABLE)
11314                && !next_flags.contains(crate::schema::ColumnFlags::NULLABLE)
11315                && old.default_value.is_some()
11316            {
11317                let snapshot = Snapshot::at(self.epoch.visible());
11318                let mut updates = Vec::new();
11319                let rows = match control {
11320                    Some(control) => table.visible_rows_controlled(snapshot, control)?,
11321                    None => table.visible_rows(snapshot)?,
11322                };
11323                for (row_index, row) in rows.into_iter().enumerate() {
11324                    commit_prepare_checkpoint(control, row_index)?;
11325                    if row
11326                        .columns
11327                        .get(&old.id)
11328                        .is_some_and(|value| !matches!(value, Value::Null))
11329                    {
11330                        continue;
11331                    }
11332                    let mut cells: Vec<(u16, Value)> = row.columns.into_iter().collect();
11333                    table.apply_defaults(&mut cells)?;
11334                    updates.push((
11335                        table_id,
11336                        crate::txn::Staged::Update {
11337                            row_id: row.row_id,
11338                            new_row: cells,
11339                            changed_columns: vec![old.id],
11340                        },
11341                    ));
11342                }
11343                updates
11344            } else {
11345                Vec::new()
11346            }
11347        };
11348        let durable_epoch = std::cell::Cell::new(None);
11349        let backfill_epoch = if backfill.is_empty() {
11350            None
11351        } else {
11352            let (principal, catalog_bound) = self.transaction_principal_snapshot();
11353            let txn_id = self.alloc_txn_id()?;
11354            let mut entered_fence = false;
11355            let commit_result = match (control, before_commit.as_deref_mut()) {
11356                (Some(control), Some(before_commit)) => self
11357                    .commit_transaction_with_external_states_controlled(
11358                        txn_id,
11359                        self.epoch.visible(),
11360                        backfill,
11361                        Vec::new(),
11362                        Vec::new(),
11363                        principal.clone(),
11364                        catalog_bound,
11365                        None,
11366                        control,
11367                        &mut || {
11368                            before_commit()?;
11369                            entered_fence = true;
11370                            Ok(())
11371                        },
11372                    )
11373                    .map(|(epoch, _)| epoch),
11374                _ => self
11375                    .commit_transaction_with_external_states(
11376                        txn_id,
11377                        self.epoch.visible(),
11378                        backfill,
11379                        Vec::new(),
11380                        Vec::new(),
11381                        principal,
11382                        catalog_bound,
11383                        None,
11384                    )
11385                    .map(|(epoch, _)| epoch),
11386            };
11387            let commit_result = if entered_fence {
11388                finish_controlled_commit_attempt(commit_result, &mut after_commit)
11389            } else {
11390                commit_result
11391            };
11392            match &commit_result {
11393                Ok(epoch) => durable_epoch.set(Some(*epoch)),
11394                Err(MongrelError::DurableCommit { epoch, .. }) => {
11395                    durable_epoch.set(Some(Epoch(*epoch)));
11396                }
11397                Err(_) => {}
11398            }
11399            Some(commit_result?)
11400        };
11401        let result: Result<(ColumnDef, Option<Epoch>)> = (|| {
11402            let _security_write = self.security_write()?;
11403            self.require(&crate::auth::Permission::Ddl)?;
11404            if self
11405                .catalog
11406                .read()
11407                .live(table_name)
11408                .is_none_or(|entry| entry.table_id != table_id)
11409            {
11410                return Err(MongrelError::Conflict(format!(
11411                    "table {table_name:?} changed during ALTER"
11412                )));
11413            }
11414            let mut table = handle.lock();
11415            let (column, prepared_schema) = table.prepare_alter_column(column_name, &change)?;
11416            let renamed_column = (column.name != column_name).then(|| column.name.clone());
11417            let Some(prepared_schema) = prepared_schema else {
11418                return Ok((column, backfill_epoch));
11419            };
11420
11421            let commit_lock = Arc::clone(&self.commit_lock);
11422            let _c = commit_lock.lock();
11423            let epoch = self.epoch.bump_assigned();
11424            let mut _epoch_guard = EpochGuard::new(self.epoch.as_ref(), epoch);
11425            let txn_id = self.alloc_txn_id()?;
11426            let column_json = DdlOp::encode_column(&column)?;
11427            let mut next_catalog = self.catalog.read().clone();
11428            let catalog_entry_index = next_catalog
11429                .tables
11430                .iter()
11431                .position(|entry| entry.table_id == table_id)
11432                .ok_or_else(|| MongrelError::NotFound(format!("table {table_name:?} not found")))?;
11433            if let Some(new_column_name) = &renamed_column {
11434                for (trigger_index, trigger) in next_catalog.triggers.iter_mut().enumerate() {
11435                    commit_prepare_checkpoint(control, trigger_index)?;
11436                    if matches!(
11437                        &trigger.trigger.target,
11438                        TriggerTarget::Table(target) if target == table_name
11439                    ) {
11440                        trigger.trigger = trigger.trigger.renamed_update_column(
11441                            column_name,
11442                            new_column_name.clone(),
11443                            epoch.0,
11444                        )?;
11445                    }
11446                }
11447                for (role_index, role) in next_catalog.roles.iter_mut().enumerate() {
11448                    commit_prepare_checkpoint(control, role_index)?;
11449                    for (permission_index, permission) in role.permissions.iter_mut().enumerate() {
11450                        commit_prepare_checkpoint(control, permission_index)?;
11451                        rename_permission_column(
11452                            permission,
11453                            table_name,
11454                            column_name,
11455                            new_column_name,
11456                        );
11457                    }
11458                }
11459                advance_security_version(&mut next_catalog)?;
11460            }
11461            next_catalog.tables[catalog_entry_index].schema = prepared_schema.clone();
11462            next_catalog.db_epoch = next_catalog.db_epoch.max(epoch.0);
11463            commit_prepare_checkpoint(control, 0)?;
11464            let mut entered_fence = false;
11465            if let Some(before_commit) = before_commit.as_deref_mut() {
11466                before_commit()?;
11467                entered_fence = true;
11468            }
11469            let commit_result: Result<Epoch> = (|| {
11470                let commit_seq = {
11471                    let mut wal = self.shared_wal.lock();
11472                    let append: Result<u64> = (|| {
11473                        wal.append(
11474                            txn_id,
11475                            table_id,
11476                            crate::wal::Op::Ddl(DdlOp::AlterTable {
11477                                table_id,
11478                                column_json,
11479                            }),
11480                        )?;
11481                        append_catalog_snapshot(&mut wal, txn_id, &next_catalog)?;
11482                        wal.append_commit(txn_id, epoch, &[])
11483                    })();
11484                    append.map_err(|error| self.commit_outcome_unknown(epoch, error))?
11485                };
11486                self.await_durable_commit(commit_seq, epoch)?;
11487                durable_epoch.set(Some(epoch));
11488
11489                table.apply_altered_schema_prepared(prepared_schema);
11490                let schema = table.schema().clone();
11491                let table_checkpoint = table.checkpoint_altered_schema();
11492                drop(table);
11493                next_catalog.tables[catalog_entry_index].schema = schema;
11494                next_catalog.db_epoch = next_catalog.db_epoch.max(epoch.0);
11495                let catalog_result =
11496                    catalog::write_atomic(&self.root, &next_catalog, self.meta_dek.as_ref());
11497                let security_version = next_catalog.security_version;
11498                *self.catalog.write() = next_catalog;
11499                if renamed_column.is_some() {
11500                    self.security_coordinator
11501                        .version
11502                        .store(security_version, Ordering::Release);
11503                }
11504                self.epoch.publish_in_order(epoch);
11505                _epoch_guard.disarm();
11506                if let Err(error) = table_checkpoint.and(catalog_result) {
11507                    self.poisoned.store(true, Ordering::Relaxed);
11508                    return Err(MongrelError::DurableCommit {
11509                        epoch: epoch.0,
11510                        message: error.to_string(),
11511                    });
11512                }
11513                Ok(epoch)
11514            })();
11515            let commit_result = if entered_fence {
11516                finish_controlled_commit_attempt(commit_result, &mut after_commit)
11517            } else {
11518                commit_result
11519            };
11520            let epoch = commit_result?;
11521            Ok((column, Some(epoch)))
11522        })();
11523        result.map_err(|error| match (durable_epoch.get(), error) {
11524            (_, error @ MongrelError::DurableCommit { .. }) => error,
11525            (Some(epoch), error) => MongrelError::DurableCommit {
11526                epoch: epoch.0,
11527                message: error.to_string(),
11528            },
11529            (None, error) => error,
11530        })
11531    }
11532
11533    /// Set a timestamp-column TTL policy and WAL-log it for crash recovery and
11534    /// replication. Duration is in nanoseconds.
11535    pub fn set_table_ttl(
11536        &self,
11537        table_name: &str,
11538        column_name: &str,
11539        duration_nanos: u64,
11540    ) -> Result<crate::manifest::TtlPolicy> {
11541        let policy = self.replace_table_ttl(table_name, Some((column_name, duration_nanos)))?;
11542        policy.ok_or_else(|| MongrelError::Other("set TTL produced no policy".into()))
11543    }
11544
11545    /// Set TTL metadata on a hidden build before it is published.
11546    #[doc(hidden)]
11547    pub fn set_building_table_ttl(
11548        &self,
11549        table_name: &str,
11550        column_name: &str,
11551        duration_nanos: u64,
11552    ) -> Result<crate::manifest::TtlPolicy> {
11553        let policy = self.replace_table_ttl_with_state(
11554            table_name,
11555            Some((column_name, duration_nanos)),
11556            true,
11557        )?;
11558        policy
11559            .ok_or_else(|| MongrelError::Other("set building-table TTL produced no policy".into()))
11560    }
11561
11562    pub fn clear_table_ttl(&self, table_name: &str) -> Result<()> {
11563        self.replace_table_ttl(table_name, None)?;
11564        Ok(())
11565    }
11566
11567    fn replace_table_ttl(
11568        &self,
11569        table_name: &str,
11570        requested: Option<(&str, u64)>,
11571    ) -> Result<Option<crate::manifest::TtlPolicy>> {
11572        self.replace_table_ttl_with_state(table_name, requested, false)
11573    }
11574
11575    fn replace_table_ttl_with_state(
11576        &self,
11577        table_name: &str,
11578        requested: Option<(&str, u64)>,
11579        building: bool,
11580    ) -> Result<Option<crate::manifest::TtlPolicy>> {
11581        use crate::wal::DdlOp;
11582        use std::sync::atomic::Ordering;
11583
11584        self.require(&crate::auth::Permission::Ddl)?;
11585        if self.poisoned.load(Ordering::Relaxed) {
11586            return Err(MongrelError::Other(
11587                "database poisoned by fsync error".into(),
11588            ));
11589        }
11590
11591        let _g = self.ddl_lock.lock();
11592        let _security_write = self.security_write()?;
11593        self.require(&crate::auth::Permission::Ddl)?;
11594        let table_id = {
11595            let cat = self.catalog.read();
11596            if building {
11597                cat.building(table_name)
11598            } else {
11599                cat.live(table_name)
11600            }
11601            .ok_or_else(|| MongrelError::NotFound(format!("table {table_name:?} not found")))?
11602            .table_id
11603        };
11604        let handle =
11605            self.tables.read().get(&table_id).cloned().ok_or_else(|| {
11606                MongrelError::NotFound(format!("table {table_name:?} not mounted"))
11607            })?;
11608        let mut table = handle.lock();
11609        let policy = match requested {
11610            Some((column, duration)) => Some(table.prepare_ttl_policy(column, duration)?),
11611            None => None,
11612        };
11613        if table.ttl() == policy {
11614            return Ok(policy);
11615        }
11616
11617        let commit_lock = Arc::clone(&self.commit_lock);
11618        let _c = commit_lock.lock();
11619        let epoch = self.epoch.bump_assigned();
11620        let mut _epoch_guard = EpochGuard::new(self.epoch.as_ref(), epoch);
11621        let txn_id = self.alloc_txn_id()?;
11622        let policy_json = DdlOp::encode_ttl(policy)?;
11623        let mut next_catalog = self.catalog.read().clone();
11624        next_catalog.db_epoch = next_catalog.db_epoch.max(epoch.0);
11625        let commit_seq = {
11626            let mut wal = self.shared_wal.lock();
11627            let append: Result<u64> = (|| {
11628                wal.append(
11629                    txn_id,
11630                    table_id,
11631                    crate::wal::Op::Ddl(DdlOp::SetTtl {
11632                        table_id,
11633                        policy_json,
11634                    }),
11635                )?;
11636                append_catalog_snapshot(&mut wal, txn_id, &next_catalog)?;
11637                wal.append_commit(txn_id, epoch, &[])
11638            })();
11639            append.map_err(|error| self.commit_outcome_unknown(epoch, error))?
11640        };
11641        self.await_durable_commit(commit_seq, epoch)?;
11642
11643        let mut publish_error = table.apply_ttl_policy_at(policy, epoch).err();
11644        drop(table);
11645        if let Err(error) = self.checkpoint_catalog_after_durable(next_catalog) {
11646            publish_error.get_or_insert(error);
11647        }
11648        self.finish_durable_publish(epoch, &mut _epoch_guard, publish_error.map_or(Ok(()), Err))?;
11649        Ok(policy)
11650    }
11651
11652    /// Retention-gated garbage collection (spec §6.4, §7.4, §16). Deletes:
11653    /// - Dropped-table subdirs whose `at_epoch < min_active_snapshot`.
11654    /// - Stale `_txn/` dirs (aborted/crashed large-txn pending runs).
11655    ///
11656    /// Returns the number of items reclaimed.
11657    pub fn gc(&self) -> Result<usize> {
11658        let control = crate::ExecutionControl::new(None);
11659        self.gc_controlled(&control, || true)
11660    }
11661
11662    /// Discover reclaimable state cooperatively, then cross one publication
11663    /// boundary immediately before the first irreversible deletion.
11664    #[doc(hidden)]
11665    pub fn gc_controlled<F>(
11666        &self,
11667        control: &crate::ExecutionControl,
11668        before_publish: F,
11669    ) -> Result<usize>
11670    where
11671        F: FnOnce() -> bool,
11672    {
11673        self.gc_controlled_with_receipt(control, before_publish)
11674            .map(|(reclaimed, _)| reclaimed)
11675    }
11676
11677    /// Discover reclaimable state from one exact catalog/epoch snapshot, then
11678    /// return that snapshot if an irreversible deletion was attempted.
11679    #[doc(hidden)]
11680    pub fn gc_controlled_with_receipt<F>(
11681        &self,
11682        control: &crate::ExecutionControl,
11683        before_publish: F,
11684    ) -> Result<(usize, Option<MaintenanceReceipt>)>
11685    where
11686        F: FnOnce() -> bool,
11687    {
11688        enum Candidate {
11689            Directory(PathBuf),
11690            File(PathBuf),
11691        }
11692
11693        self.require(&crate::auth::Permission::Ddl)?;
11694        let _ddl = self.ddl_lock.lock();
11695        self.require(&crate::auth::Permission::Ddl)?;
11696        control.checkpoint()?;
11697        let maintenance_epoch = self.epoch.visible();
11698        let min_active = self.snapshots.min_active(maintenance_epoch).0;
11699        let mut candidates = Vec::new();
11700
11701        // Reclaim dropped-table dirs where no pinned snapshot still needs them.
11702        let cat = self.catalog.read();
11703        for (entry_index, entry) in cat.tables.iter().enumerate() {
11704            if entry_index % 256 == 0 {
11705                control.checkpoint()?;
11706            }
11707            if let TableState::Dropped { at_epoch } = entry.state {
11708                if at_epoch <= min_active {
11709                    let tdir = self.root.join(TABLES_DIR).join(entry.table_id.to_string());
11710                    if tdir.exists() {
11711                        candidates.push(Candidate::Directory(tdir));
11712                    }
11713                }
11714            }
11715        }
11716        drop(cat);
11717
11718        // Sweep stale _txn/<id>/ dirs on remaining live tables — but NEVER an
11719        // in-flight spill's dir (deleting it would lose the pending run and fail
11720        // the commit, review fix #14). Each `_txn/` subdir is named by its txn id;
11721        // skip any id still registered in `active_spills`.
11722        let cat = self.catalog.read();
11723        for (entry_index, entry) in cat.tables.iter().enumerate() {
11724            if entry_index % 256 == 0 {
11725                control.checkpoint()?;
11726            }
11727            if !matches!(entry.state, TableState::Live) {
11728                continue;
11729            }
11730            let txn_dir = self
11731                .root
11732                .join(TABLES_DIR)
11733                .join(entry.table_id.to_string())
11734                .join("_txn");
11735            if !txn_dir.exists() {
11736                continue;
11737            }
11738            for (sub_index, sub) in std::fs::read_dir(&txn_dir)?.enumerate() {
11739                if sub_index % 256 == 0 {
11740                    control.checkpoint()?;
11741                }
11742                let sub = sub?;
11743                let name = sub.file_name();
11744                let Some(name) = name.to_str() else { continue };
11745                // A non-numeric entry can't belong to a live txn — sweep it.
11746                let is_active = name
11747                    .parse::<u64>()
11748                    .map(|id| self.active_spills.is_active(id))
11749                    .unwrap_or(false);
11750                if is_active {
11751                    continue;
11752                }
11753                candidates.push(Candidate::Directory(sub.path()));
11754            }
11755        }
11756        drop(cat);
11757
11758        let external_names = {
11759            let cat = self.catalog.read();
11760            cat.external_tables
11761                .iter()
11762                .map(|entry| entry.name.clone())
11763                .collect::<std::collections::HashSet<_>>()
11764        };
11765        let vtab_dir = self.root.join(VTAB_DIR);
11766        if vtab_dir.exists() {
11767            for (entry_index, entry) in std::fs::read_dir(&vtab_dir)?.enumerate() {
11768                if entry_index % 256 == 0 {
11769                    control.checkpoint()?;
11770                }
11771                let entry = entry?;
11772                let name = entry.file_name();
11773                let Some(name) = name.to_str() else { continue };
11774                if external_names.contains(name) {
11775                    continue;
11776                }
11777                let path = entry.path();
11778                if path.is_dir() {
11779                    candidates.push(Candidate::Directory(path));
11780                } else {
11781                    candidates.push(Candidate::File(path));
11782                }
11783            }
11784        }
11785
11786        // Reap compaction-superseded runs whose retire epoch no pinned snapshot
11787        // can still need (spec §6.4). Each table deletes its own retired files
11788        // gated on `min_active` and persists its manifest.
11789        let tables = self
11790            .tables
11791            .read()
11792            .iter()
11793            .map(|(table_id, handle)| (*table_id, handle.clone()))
11794            .collect::<Vec<_>>();
11795        let mut retiring = Vec::new();
11796        for (table_index, (table_id, handle)) in tables.iter().enumerate() {
11797            if table_index % 256 == 0 {
11798                control.checkpoint()?;
11799            }
11800            let backup_pinned: HashSet<u128> = self
11801                .backup_pins
11802                .lock()
11803                .keys()
11804                .filter_map(|(pinned_table, run_id)| {
11805                    (*pinned_table == *table_id).then_some(*run_id)
11806                })
11807                .collect();
11808            if handle
11809                .lock()
11810                .has_reapable_retiring(Epoch(min_active), &backup_pinned)
11811            {
11812                retiring.push((handle.clone(), backup_pinned));
11813            }
11814        }
11815
11816        // WAL-segment GC (spec §6.4/§16). `SharedWal::open` mints a fresh active
11817        // segment on every reopen without truncating the prior ones, so rotated
11818        // segments accumulate. Once every live table's committed data is durable
11819        // in runs (no in-memory rows) and no in-flight spill is open, all rotated
11820        // (non-active) segments are redundant for recovery and safe to delete —
11821        // an in-flight txn only ever appends to the active segment, which is
11822        // never deleted.
11823        let all_durable = self.active_spills.is_idle()
11824            && tables.iter().all(|(_, handle)| {
11825                let g = handle.lock();
11826                g.memtable_len() == 0 && g.mutable_run_len() == 0
11827            });
11828        let retain = self
11829            .replication_wal_retention_segments
11830            .load(std::sync::atomic::Ordering::Relaxed);
11831        let reap_wal = all_durable
11832            && self
11833                .shared_wal
11834                .lock()
11835                .has_gc_segments_retain_recent(retain)?;
11836
11837        if candidates.is_empty() && retiring.is_empty() && !reap_wal {
11838            return Ok((0, None));
11839        }
11840        control.checkpoint()?;
11841        if !before_publish() {
11842            return Err(MongrelError::Cancelled);
11843        }
11844
11845        let mut reclaimed = 0;
11846        for candidate in candidates {
11847            match candidate {
11848                Candidate::Directory(path) => std::fs::remove_dir_all(path)?,
11849                Candidate::File(path) => std::fs::remove_file(path)?,
11850            }
11851            reclaimed += 1;
11852        }
11853        for (handle, backup_pinned) in retiring {
11854            reclaimed += handle
11855                .lock()
11856                .reap_retiring(Epoch(min_active), &backup_pinned)?;
11857        }
11858        if reap_wal {
11859            reclaimed += self
11860                .shared_wal
11861                .lock()
11862                .gc_segments_retain_recent(u64::MAX, retain)?;
11863        }
11864
11865        Ok((
11866            reclaimed,
11867            Some(MaintenanceReceipt {
11868                epoch: maintenance_epoch,
11869            }),
11870        ))
11871    }
11872
11873    /// Produce a deterministic-stable byte image of the database directory.
11874    ///
11875    /// After `checkpoint()`:
11876    ///   - All pending writes are flushed to sorted runs (no memtable data).
11877    ///   - Each table is compacted to a single sorted run (no run fragmentation).
11878    ///   - All non-active WAL segments are deleted (data is durable in runs).
11879    ///   - The active WAL segment is rotated to a fresh empty segment.
11880    ///   - Dropped-table directories are removed.
11881    ///   - All manifests + catalog are persisted.
11882    ///
11883    /// The resulting directory is byte-stable: `git add` captures a snapshot
11884    /// that `git checkout` restores deterministically. No stale WAL tail bytes,
11885    /// no unbounded segment growth, no mutable-run spill files.
11886    ///
11887    /// This is the engine primitive behind `mongreldb snapshot <dir>` (CLI).
11888    /// It does NOT clear the exclusive lock — the caller still owns the
11889    /// database handle.
11890    pub fn checkpoint(&self) -> Result<()> {
11891        self.checkpoint_controlled(|| Ok(()))
11892    }
11893
11894    /// Strict checkpoint with a deterministic test hook after every table is
11895    /// flushed/compacted but before WAL replacement.
11896    #[doc(hidden)]
11897    pub fn checkpoint_controlled<F>(&self, before_wal_reset: F) -> Result<()>
11898    where
11899        F: FnOnce() -> Result<()>,
11900    {
11901        self.require(&crate::auth::Permission::Ddl)?;
11902        // Block cross-table commits and DDL for the full operation. Locking all
11903        // mounted handles also excludes direct `Table` commits, which do not
11904        // enter the database replication barrier.
11905        let _replication = self.replication_barrier.write();
11906        let _ddl = self.ddl_lock.lock();
11907        let _security = self.security_coordinator.gate.read();
11908        self.require(&crate::auth::Permission::Ddl)?;
11909
11910        let mut handles = self
11911            .tables
11912            .read()
11913            .iter()
11914            .map(|(table_id, handle)| (*table_id, handle.clone()))
11915            .collect::<Vec<_>>();
11916        handles.sort_by_key(|(table_id, _)| *table_id);
11917        let mut tables = handles
11918            .iter()
11919            .map(|(table_id, handle)| (*table_id, handle.lock()))
11920            .collect::<Vec<_>>();
11921
11922        // Strict flush. Any error leaves the old WAL recovery source intact.
11923        for (_, table) in &mut tables {
11924            if table.has_pending_writes() || table.memtable_len() > 0 || table.mutable_run_len() > 0
11925            {
11926                table.force_flush()?;
11927            }
11928        }
11929
11930        // Strict compaction. Checkpoint never reports a stable image after a
11931        // skipped failure.
11932        for (_, table) in &mut tables {
11933            if table.run_count() >= 2 || table.should_compact() {
11934                table.compact()?;
11935            }
11936        }
11937
11938        before_wal_reset()?;
11939
11940        // Reap table-local retired runs while every table remains quiesced.
11941        let maintenance_epoch = self.epoch.visible();
11942        let min_active = self.snapshots.min_active(maintenance_epoch);
11943        for (table_id, table) in &mut tables {
11944            let backup_pinned: HashSet<u128> = self
11945                .backup_pins
11946                .lock()
11947                .keys()
11948                .filter_map(|(pinned_table, run_id)| {
11949                    (*pinned_table == *table_id).then_some(*run_id)
11950                })
11951                .collect();
11952            table.reap_retiring(min_active, &backup_pinned)?;
11953        }
11954
11955        // Publish a fresh synced active WAL, then durably reap every older
11956        // segment. This point is reached only after every strict flush succeeds.
11957        self.shared_wal.lock().reset_after_checkpoint()?;
11958
11959        // Remove catalog-unreachable directories and stale transaction state.
11960        let catalog_snapshot = self.catalog.read().clone();
11961        for entry in &catalog_snapshot.tables {
11962            if matches!(entry.state, TableState::Dropped { at_epoch } if at_epoch <= min_active.0) {
11963                crate::durable_file::remove_directory_all(
11964                    &self.root.join(TABLES_DIR).join(entry.table_id.to_string()),
11965                )?;
11966            }
11967            if !matches!(entry.state, TableState::Live) {
11968                continue;
11969            }
11970            let transaction_dir = self
11971                .root
11972                .join(TABLES_DIR)
11973                .join(entry.table_id.to_string())
11974                .join("_txn");
11975            if transaction_dir.is_dir() {
11976                for child in std::fs::read_dir(&transaction_dir)? {
11977                    let child = child?;
11978                    let active = child
11979                        .file_name()
11980                        .to_str()
11981                        .and_then(|name| name.parse::<u64>().ok())
11982                        .is_some_and(|txn_id| self.active_spills.is_active(txn_id));
11983                    if !active {
11984                        crate::durable_file::remove_directory_all(&child.path())?;
11985                    }
11986                }
11987            }
11988        }
11989        let external_names = catalog_snapshot
11990            .external_tables
11991            .iter()
11992            .map(|entry| entry.name.as_str())
11993            .collect::<HashSet<_>>();
11994        let external_root = self.root.join(VTAB_DIR);
11995        if external_root.is_dir() {
11996            for entry in std::fs::read_dir(&external_root)? {
11997                let entry = entry?;
11998                let name = entry.file_name();
11999                if name
12000                    .to_str()
12001                    .is_some_and(|name| external_names.contains(name))
12002                {
12003                    continue;
12004                }
12005                if entry.file_type()?.is_dir() {
12006                    crate::durable_file::remove_directory_all(&entry.path())?;
12007                } else {
12008                    std::fs::remove_file(entry.path())?;
12009                    crate::durable_file::sync_directory(&external_root)?;
12010                }
12011            }
12012        }
12013
12014        // Final authoritative metadata checkpoint while all writers remain
12015        // excluded.
12016        catalog::write_atomic(&self.root, &catalog_snapshot, self.meta_dek.as_ref())?;
12017        let visible = self.epoch.visible();
12018        for (_, table) in &tables {
12019            table.persist_manifest(visible)?;
12020        }
12021
12022        Ok(())
12023    }
12024    fn alloc_txn_id(&self) -> Result<u64> {
12025        crate::txn::allocate_txn_id(&self.next_txn_id)
12026    }
12027
12028    /// Set the per-table spill threshold (bytes). When a transaction's staged
12029    /// bytes for a single table exceed this, the rows are written as a
12030    /// uniform-epoch pending run instead of streamed Put records (spec §8.5).
12031    pub fn set_spill_threshold(&self, bytes: u64) {
12032        self.spill_threshold
12033            .store(bytes, std::sync::atomic::Ordering::Relaxed);
12034    }
12035
12036    /// Test-only: install a hook invoked after a transaction writes its spill
12037    /// runs but before the sequencer, so a test can race `gc()` against an
12038    /// in-flight spill. Not part of the stable API.
12039    #[doc(hidden)]
12040    pub fn __set_spill_hook(&self, f: impl Fn() + Send + Sync + 'static) {
12041        *self.spill_hook.lock() = Some(Box::new(f));
12042    }
12043
12044    /// Test-only: install a hook invoked while a spilled commit holds the
12045    /// security read gate and before it appends to the WAL.
12046    #[doc(hidden)]
12047    pub fn __set_security_commit_hook(&self, f: impl Fn() + Send + Sync + 'static) {
12048        *self.security_commit_hook.lock() = Some(Box::new(f));
12049    }
12050
12051    /// Test-only: install a hook after transaction preparation and before the
12052    /// commit sequencer validates catalog generations.
12053    #[doc(hidden)]
12054    pub fn __set_catalog_commit_hook(&self, f: impl Fn() + Send + Sync + 'static) {
12055        *self.catalog_commit_hook.lock() = Some(Box::new(f));
12056    }
12057
12058    /// Test-only: pause an online backup after its consistent boundary is
12059    /// captured but before the pinned immutable runs are copied.
12060    #[doc(hidden)]
12061    pub fn __set_backup_hook(&self, f: impl Fn() + Send + Sync + 'static) {
12062        *self.backup_hook.lock() = Some(Box::new(f));
12063    }
12064
12065    /// Test-only: pause WAL extraction before its final principal recheck.
12066    #[doc(hidden)]
12067    pub fn __set_replication_hook(&self, f: impl Fn() + Send + Sync + 'static) {
12068        *self.replication_hook.lock() = Some(Box::new(f));
12069    }
12070
12071    /// Number of WAL fsyncs issued so far (test/diagnostic). With group commit
12072    /// this stays well below the number of committed transactions when commits
12073    /// are concurrent (one leader fsync covers a whole batch — spec §9.3).
12074    #[doc(hidden)]
12075    pub fn __wal_group_sync_count(&self) -> u64 {
12076        self.shared_wal.lock().group_sync_count()
12077    }
12078
12079    /// Force the poisoned state (test-only) to verify the §9.3e fail-fast
12080    /// contract that an fsync error would trigger in production.
12081    #[doc(hidden)]
12082    pub fn __poison(&self) {
12083        self.poisoned
12084            .store(true, std::sync::atomic::Ordering::Relaxed);
12085    }
12086
12087    /// Verify multi-table integrity (spec §16). For every live table this:
12088    /// authenticates the manifest; opens each `RunRef`'s file through
12089    /// [`RunReader`](crate::sorted_run::RunReader), which verifies the run footer
12090    /// checksum and — for encrypted DBs — the keyed run-metadata MAC; checks each
12091    /// run's physical row count against its `RunRef`; flags `RunRef`s whose file
12092    /// is missing (dangling) and `.sr` files on disk that no `RunRef` references
12093    /// (orphan); and verifies `flushed_epoch <= current_epoch`. Returns the list
12094    /// of issues found (empty = healthy). Orphans are `warning`-severity; all
12095    /// other findings are `error`-severity (so [`Self::doctor`] quarantines them).
12096    ///
12097    /// Cost: O(total run bytes) — the footer checksum is verified over each run's
12098    /// full body, so this is an integrity tool, not a hot path.
12099    pub fn check(&self) -> Vec<CheckIssue> {
12100        match self.check_inner(None) {
12101            Ok(issues) => issues,
12102            Err(error) => vec![CheckIssue {
12103                table_id: WAL_TABLE_ID,
12104                table_name: "shared WAL".into(),
12105                severity: "error".into(),
12106                description: error.to_string(),
12107            }],
12108        }
12109    }
12110
12111    /// Integrity check with cooperative cancellation between tables and runs.
12112    #[doc(hidden)]
12113    pub fn check_controlled(&self, control: &crate::ExecutionControl) -> Result<Vec<CheckIssue>> {
12114        self.check_inner(Some(control))
12115    }
12116
12117    fn check_inner(&self, control: Option<&crate::ExecutionControl>) -> Result<Vec<CheckIssue>> {
12118        let mut issues = Vec::new();
12119        let cat = self.catalog.read();
12120        let manifest_meta_dek = crate::encryption::meta_dek_for(self.kek.as_deref());
12121        for (table_index, entry) in cat.tables.iter().enumerate() {
12122            if table_index % 256 == 0 {
12123                if let Some(control) = control {
12124                    control.checkpoint()?;
12125                }
12126            }
12127            if !matches!(entry.state, TableState::Live) {
12128                continue;
12129            }
12130            let tdir = self.root.join(TABLES_DIR).join(entry.table_id.to_string());
12131            let mut err = |sev: &str, desc: String| {
12132                issues.push(CheckIssue {
12133                    table_id: entry.table_id,
12134                    table_name: entry.name.clone(),
12135                    severity: sev.into(),
12136                    description: desc,
12137                });
12138            };
12139            let m = match crate::manifest::read(&tdir, manifest_meta_dek.as_ref()) {
12140                Ok(m) => m,
12141                Err(e) => {
12142                    err("error", format!("manifest read failed: {e}"));
12143                    continue;
12144                }
12145            };
12146            if m.flushed_epoch > m.current_epoch {
12147                err(
12148                    "error",
12149                    format!(
12150                        "flushed_epoch {} exceeds current_epoch {} (impossible)",
12151                        m.flushed_epoch, m.current_epoch
12152                    ),
12153                );
12154            }
12155
12156            let runs_dir = tdir.join(crate::engine::RUNS_DIR);
12157            let mut referenced: std::collections::HashSet<u128> = std::collections::HashSet::new();
12158            for (run_index, rr) in m.runs.iter().enumerate() {
12159                if run_index % 256 == 0 {
12160                    if let Some(control) = control {
12161                        control.checkpoint()?;
12162                    }
12163                }
12164                referenced.insert(rr.run_id);
12165                let run_path = runs_dir.join(format!("r-{}.sr", rr.run_id));
12166                if !run_path.exists() {
12167                    err("error", format!("missing run file: r-{}.sr", rr.run_id));
12168                    continue;
12169                }
12170                match crate::sorted_run::RunReader::open(
12171                    &run_path,
12172                    entry.schema.clone(),
12173                    self.kek.clone(),
12174                ) {
12175                    Ok(reader) => {
12176                        if reader.row_count() as u64 != rr.row_count {
12177                            err(
12178                                "error",
12179                                format!(
12180                                    "run r-{} row count mismatch: manifest {} vs run {}",
12181                                    rr.run_id,
12182                                    rr.row_count,
12183                                    reader.row_count()
12184                                ),
12185                            );
12186                        }
12187                    }
12188                    Err(e) => {
12189                        err(
12190                            "error",
12191                            format!("run r-{} integrity check failed: {e}", rr.run_id),
12192                        );
12193                    }
12194                }
12195            }
12196
12197            // Compaction-superseded runs awaiting retention-gated deletion are
12198            // tracked in `retiring`; their files are expected on disk, so they
12199            // are not orphans.
12200            for r in &m.retiring {
12201                referenced.insert(r.run_id);
12202            }
12203
12204            // Orphan `.sr` files present on disk but absent from the manifest.
12205            if let Ok(rd) = std::fs::read_dir(&runs_dir) {
12206                for (entry_index, ent) in rd.flatten().enumerate() {
12207                    if entry_index % 256 == 0 {
12208                        if let Some(control) = control {
12209                            control.checkpoint()?;
12210                        }
12211                    }
12212                    let p = ent.path();
12213                    if p.extension().and_then(|s| s.to_str()) != Some("sr") {
12214                        continue;
12215                    }
12216                    let run_id = p
12217                        .file_stem()
12218                        .and_then(|s| s.to_str())
12219                        .and_then(|s| s.strip_prefix("r-"))
12220                        .and_then(|s| s.parse::<u128>().ok());
12221                    if let Some(id) = run_id {
12222                        if !referenced.contains(&id) {
12223                            err(
12224                                "warning",
12225                                format!("orphan run file r-{id}.sr not referenced by the manifest"),
12226                            );
12227                        }
12228                    }
12229                }
12230            }
12231        }
12232
12233        let external_names = cat
12234            .external_tables
12235            .iter()
12236            .map(|entry| entry.name.clone())
12237            .collect::<std::collections::HashSet<_>>();
12238        let vtab_dir = self.root.join(VTAB_DIR);
12239        if let Ok(entries) = std::fs::read_dir(&vtab_dir) {
12240            for (entry_index, entry) in entries.flatten().enumerate() {
12241                if entry_index % 256 == 0 {
12242                    if let Some(control) = control {
12243                        control.checkpoint()?;
12244                    }
12245                }
12246                let name = entry.file_name();
12247                let Some(name) = name.to_str() else { continue };
12248                if !external_names.contains(name) {
12249                    issues.push(CheckIssue {
12250                        table_id: EXTERNAL_TABLE_ID,
12251                        table_name: name.to_string(),
12252                        severity: "warning".into(),
12253                        description: format!(
12254                            "orphan external table state entry {:?} not referenced by the catalog",
12255                            entry.path()
12256                        ),
12257                    });
12258                }
12259            }
12260        }
12261
12262        // WAL retention / integrity invariant (spec §16): every on-disk WAL
12263        // segment must open (header magic + version, and the frame cipher must
12264        // be derivable for an encrypted WAL). A segment that won't open is
12265        // corrupt or truncated and would break crash recovery. `table_id` is
12266        // the reserved `WAL_TABLE_ID` sentinel (u64::MAX) so [`Self::doctor`]
12267        // never confuses a WAL issue with a real table.
12268        if let Some(control) = control {
12269            control.checkpoint()?;
12270        }
12271        for (seg, msg) in self.shared_wal.lock().verify_segments() {
12272            issues.push(CheckIssue {
12273                table_id: WAL_TABLE_ID,
12274                table_name: "<wal>".into(),
12275                severity: "error".into(),
12276                description: format!("WAL segment seg-{seg:06}.wal failed integrity check: {msg}"),
12277            });
12278        }
12279        Ok(issues)
12280    }
12281
12282    /// Quarantine unreadable tables (spec §16). Moves corrupt table dirs to
12283    /// `_quarantine/<table_id>/`, marks them dropped in the catalog, and
12284    /// unmounts them from the live table map so the DB still opens.
12285    pub fn doctor(&self) -> Result<Vec<u64>> {
12286        let control = crate::ExecutionControl::new(None);
12287        self.doctor_controlled(&control, || true)
12288    }
12289
12290    /// Check cancellably, then fence immediately before the first quarantine
12291    /// mutation. Returning `false` from `before_publish` leaves the database
12292    /// untouched.
12293    #[doc(hidden)]
12294    pub fn doctor_controlled<F>(
12295        &self,
12296        control: &crate::ExecutionControl,
12297        before_publish: F,
12298    ) -> Result<Vec<u64>>
12299    where
12300        F: FnOnce() -> bool,
12301    {
12302        self.doctor_controlled_with_receipt(control, before_publish)
12303            .map(|(quarantined, _)| quarantined)
12304    }
12305
12306    /// Check cancellably and return the exact catalog epoch used for a
12307    /// quarantine publication. No receipt is returned when nothing changes.
12308    #[doc(hidden)]
12309    pub fn doctor_controlled_with_receipt<F>(
12310        &self,
12311        control: &crate::ExecutionControl,
12312        before_publish: F,
12313    ) -> Result<(Vec<u64>, Option<MaintenanceReceipt>)>
12314    where
12315        F: FnOnce() -> bool,
12316    {
12317        // Hold the DDL lock for the whole operation to prevent concurrent
12318        // create_table/drop_table from racing the catalog/dir mutation.
12319        let _ddl = self.ddl_lock.lock();
12320        let _security_write = self.security_write()?;
12321        let issues = self.check_inner(Some(control))?;
12322        // A corrupt WAL segment is reported as an error but is NOT a table
12323        // problem — quarantining an innocent table cannot fix it (and the first
12324        // real table is id 0, so the WAL sentinel WAL_TABLE_ID = u64::MAX keeps
12325        // them disjoint). The admin must address WAL corruption manually.
12326        let bad_tables: std::collections::HashSet<u64> = issues
12327            .iter()
12328            .filter(|i| {
12329                i.severity == "error"
12330                    && i.table_id != WAL_TABLE_ID
12331                    && i.table_id != EXTERNAL_TABLE_ID
12332            })
12333            .map(|i| i.table_id)
12334            .collect();
12335        if bad_tables.is_empty() {
12336            return Ok((Vec::new(), None));
12337        }
12338        let _commit = self.commit_lock.lock();
12339        control.checkpoint()?;
12340        if !before_publish() {
12341            return Err(MongrelError::Cancelled);
12342        }
12343        let maintenance_epoch = self.epoch.bump_assigned();
12344        let mut epoch_guard = EpochGuard::new(self.epoch.as_ref(), maintenance_epoch);
12345
12346        let qdir = self.root.join("_quarantine");
12347        crate::durable_file::create_directory(&qdir)?;
12348        let mut bad_tables = bad_tables.into_iter().collect::<Vec<_>>();
12349        bad_tables.sort_unstable();
12350
12351        // Quiesce every mounted target before catalog publication. Existing
12352        // handle clones are marked unavailable in the publication callback so
12353        // they cannot append to the shared WAL after their catalog entry drops.
12354        let mut handles = self
12355            .tables
12356            .read()
12357            .iter()
12358            .filter(|(table_id, _)| bad_tables.binary_search(table_id).is_ok())
12359            .map(|(table_id, handle)| (*table_id, handle.clone()))
12360            .collect::<Vec<_>>();
12361        handles.sort_by_key(|(table_id, _)| *table_id);
12362        let mut table_guards = handles
12363            .iter()
12364            .map(|(table_id, handle)| (*table_id, handle.lock()))
12365            .collect::<Vec<_>>();
12366
12367        let mut next_catalog = self.catalog.read().clone();
12368        for table_id in &bad_tables {
12369            if let Some(entry) = next_catalog
12370                .tables
12371                .iter_mut()
12372                .find(|entry| entry.table_id == *table_id)
12373            {
12374                entry.state = TableState::Dropped {
12375                    at_epoch: maintenance_epoch.0,
12376                };
12377            }
12378        }
12379        next_catalog.db_epoch = next_catalog.db_epoch.max(maintenance_epoch.0);
12380
12381        let txn_id = self.alloc_txn_id()?;
12382        let commit_seq = {
12383            let mut wal = self.shared_wal.lock();
12384            let append: Result<u64> = (|| {
12385                for table_id in &bad_tables {
12386                    wal.append(
12387                        txn_id,
12388                        *table_id,
12389                        crate::wal::Op::Ddl(crate::wal::DdlOp::DropTable {
12390                            table_id: *table_id,
12391                        }),
12392                    )?;
12393                }
12394                append_catalog_snapshot(&mut wal, txn_id, &next_catalog)?;
12395                wal.append_commit(txn_id, maintenance_epoch, &[])
12396            })();
12397            append.map_err(|error| self.commit_outcome_unknown(maintenance_epoch, error))?
12398        };
12399        self.await_durable_commit(commit_seq, maintenance_epoch)?;
12400        for (_, table) in &mut table_guards {
12401            table.mark_unavailable_after_quarantine();
12402        }
12403        {
12404            let mut live_tables = self.tables.write();
12405            for table_id in &bad_tables {
12406                live_tables.remove(table_id);
12407            }
12408        }
12409        let checkpoint = self.checkpoint_catalog_after_durable(next_catalog);
12410        self.finish_durable_publish(maintenance_epoch, &mut epoch_guard, checkpoint)?;
12411
12412        // The catalog drop is durable. Directory placement is secondary but
12413        // still uses a write-through rename. A failure reports the known
12414        // catalog outcome and leaves a harmless orphan under `tables/`.
12415        for table_id in &bad_tables {
12416            let source = self.root.join(TABLES_DIR).join(table_id.to_string());
12417            if source.exists() {
12418                let destination = qdir.join(table_id.to_string());
12419                if let Err(error) = crate::durable_file::rename(&source, &destination) {
12420                    return Err(MongrelError::DurableCommit {
12421                        epoch: maintenance_epoch.0,
12422                        message: format!(
12423                            "DOCTOR dropped table {table_id} but quarantine move failed: {error}"
12424                        ),
12425                    });
12426                }
12427            }
12428        }
12429        Ok((
12430            bad_tables,
12431            Some(MaintenanceReceipt {
12432                epoch: maintenance_epoch,
12433            }),
12434        ))
12435    }
12436
12437    /// The DB-wide KEK (if encrypted).
12438    #[allow(dead_code)]
12439    pub(crate) fn kek(&self) -> Option<&Arc<crate::encryption::Kek>> {
12440        self.kek.as_ref()
12441    }
12442
12443    /// Shared epoch authority (used by the transaction layer in P2).
12444    #[allow(dead_code)]
12445    pub(crate) fn epoch_authority(&self) -> &Arc<EpochAuthority> {
12446        &self.epoch
12447    }
12448
12449    /// Shared snapshot registry (used by GC in P3.6).
12450    #[allow(dead_code)]
12451    pub(crate) fn snapshots(&self) -> &Arc<SnapshotRegistry> {
12452        &self.snapshots
12453    }
12454}
12455
12456fn external_state_dir(root: &Path, name: &str) -> PathBuf {
12457    root.join(VTAB_DIR).join(name)
12458}
12459
12460fn append_catalog_snapshot(
12461    wal: &mut crate::wal::SharedWal,
12462    txn_id: u64,
12463    catalog: &Catalog,
12464) -> Result<()> {
12465    let catalog_json = crate::wal::DdlOp::encode_catalog(catalog)?;
12466    wal.append(
12467        txn_id,
12468        WAL_TABLE_ID,
12469        crate::wal::Op::Ddl(crate::wal::DdlOp::CatalogSnapshot { catalog_json }),
12470    )?;
12471    Ok(())
12472}
12473
12474fn filter_ignored_staging(
12475    staging: Vec<(u64, crate::txn::Staged)>,
12476    ignored_indices: &std::collections::BTreeSet<usize>,
12477) -> Vec<(u64, crate::txn::Staged)> {
12478    if ignored_indices.is_empty() {
12479        return staging;
12480    }
12481    staging
12482        .into_iter()
12483        .enumerate()
12484        .filter_map(|(idx, staged)| (!ignored_indices.contains(&idx)).then_some(staged))
12485        .collect()
12486}
12487
12488fn external_state_file(root: &Path, name: &str) -> PathBuf {
12489    external_state_dir(root, name).join("state.json")
12490}
12491
12492fn read_external_state_file(root: &Path, name: &str) -> Result<Vec<u8>> {
12493    let path = external_state_file(root, name);
12494    match std::fs::read(path) {
12495        Ok(bytes) => Ok(bytes),
12496        Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(Vec::new()),
12497        Err(e) => Err(e.into()),
12498    }
12499}
12500
12501fn current_external_state_bytes(
12502    root: &Path,
12503    external_states: &[(String, Vec<u8>)],
12504    name: &str,
12505) -> Result<Vec<u8>> {
12506    for (table, state) in external_states.iter().rev() {
12507        if table == name {
12508            return Ok(state.clone());
12509        }
12510    }
12511    read_external_state_file(root, name)
12512}
12513
12514fn dedup_external_states(external_states: Vec<(String, Vec<u8>)>) -> Vec<(String, Vec<u8>)> {
12515    let mut out = external_states;
12516    dedup_external_states_in_place(&mut out);
12517    out
12518}
12519
12520fn dedup_external_states_in_place(external_states: &mut Vec<(String, Vec<u8>)>) {
12521    let mut seen = std::collections::HashSet::new();
12522    let mut out = Vec::with_capacity(external_states.len());
12523    for (name, state) in std::mem::take(external_states).into_iter().rev() {
12524        if seen.insert(name.clone()) {
12525            out.push((name, state));
12526        }
12527    }
12528    out.reverse();
12529    *external_states = out;
12530}
12531
12532fn prepare_external_state_file(
12533    root: &Path,
12534    name: &str,
12535    state: &[u8],
12536    txn_id: u64,
12537) -> Result<PathBuf> {
12538    crate::durable_file::create_directory(&root.join(VTAB_DIR))?;
12539    let dir = external_state_dir(root, name);
12540    crate::durable_file::create_directory(&dir)?;
12541    let pending = dir.join(format!("state.json.{txn_id}.tmp"));
12542    {
12543        let mut file = std::fs::OpenOptions::new()
12544            .create_new(true)
12545            .write(true)
12546            .open(&pending)?;
12547        file.write_all(state)?;
12548        file.sync_all()?;
12549    }
12550    Ok(pending)
12551}
12552
12553fn publish_external_state_file(root: &Path, name: &str, pending: &Path) -> Result<()> {
12554    let path = external_state_file(root, name);
12555    crate::durable_file::replace(pending, &path)?;
12556    Ok(())
12557}
12558
12559fn write_external_state_file(
12560    durable: &crate::durable_file::DurableRoot,
12561    name: &str,
12562    state: &[u8],
12563) -> Result<()> {
12564    let directory = Path::new(VTAB_DIR).join(name);
12565    durable.create_directory_all(&directory)?;
12566    durable.write_atomic(directory.join("state.json"), state)?;
12567    Ok(())
12568}
12569
12570fn validate_recovered_data_table(
12571    catalog: &Catalog,
12572    tables: &HashMap<u64, TableHandle>,
12573    table_id: u64,
12574    commit_epoch: u64,
12575    offset: u64,
12576) -> Result<bool> {
12577    let entry = catalog
12578        .tables
12579        .iter()
12580        .find(|entry| entry.table_id == table_id)
12581        .ok_or_else(|| MongrelError::CorruptWal {
12582            offset,
12583            reason: format!("committed record references unknown table {table_id}"),
12584        })?;
12585    if commit_epoch < entry.created_epoch {
12586        return Err(MongrelError::CorruptWal {
12587            offset,
12588            reason: format!(
12589                "table {table_id} record epoch {commit_epoch} precedes creation epoch {}",
12590                entry.created_epoch
12591            ),
12592        });
12593    }
12594    match entry.state {
12595        TableState::Dropped { at_epoch } => {
12596            // Abandoned hidden builds are marked dropped at the last durable
12597            // boundary during open, so their final build commit may equal the
12598            // cleanup epoch. Ordinary table drops consume a new epoch and must
12599            // remain strictly later than every data commit.
12600            let abandoned_build_boundary =
12601                entry.name.starts_with(CTAS_BUILD_TABLE_PREFIX) && commit_epoch == at_epoch;
12602            if commit_epoch >= at_epoch && !abandoned_build_boundary {
12603                Err(MongrelError::CorruptWal {
12604                    offset,
12605                    reason: format!(
12606                        "table {table_id} record epoch {commit_epoch} is not before drop epoch {at_epoch}"
12607                    ),
12608                })
12609            } else {
12610                Ok(false)
12611            }
12612        }
12613        TableState::Live | TableState::Building { .. } => {
12614            if tables.contains_key(&table_id) {
12615                Ok(true)
12616            } else {
12617                Err(MongrelError::CorruptWal {
12618                    offset,
12619                    reason: format!("live table {table_id} has no mounted recovery handle"),
12620                })
12621            }
12622        }
12623    }
12624}
12625
12626type RecoveryTableStage = (
12627    Vec<crate::memtable::Row>,
12628    Vec<(crate::rowid::RowId, Epoch)>,
12629    Option<Epoch>,
12630    Epoch,
12631);
12632
12633#[derive(Clone)]
12634struct RecoveryValidationTable {
12635    schema: Schema,
12636    flushed_epoch: u64,
12637}
12638
12639fn validate_shared_wal_recovery_plan(
12640    durable_root: &crate::durable_file::DurableRoot,
12641    catalog: &Catalog,
12642    recovered_table_ids: &HashSet<u64>,
12643    reconciled_table_ids: &HashSet<u64>,
12644    meta_dek: Option<&[u8; META_DEK_LEN]>,
12645    kek: Option<Arc<crate::encryption::Kek>>,
12646    records: &[crate::wal::Record],
12647) -> Result<()> {
12648    use crate::wal::{DdlOp, Op};
12649
12650    let mut tables = HashMap::<u64, RecoveryValidationTable>::new();
12651    for entry in &catalog.tables {
12652        if !matches!(entry.state, TableState::Live) {
12653            continue;
12654        }
12655        let relative_dir = Path::new(TABLES_DIR).join(entry.table_id.to_string());
12656        let manifest = match crate::manifest::read_durable(durable_root, &relative_dir, meta_dek) {
12657            Ok(manifest) => Some(manifest),
12658            Err(MongrelError::Io(error)) if error.kind() == std::io::ErrorKind::NotFound => None,
12659            Err(error) => return Err(error),
12660        };
12661        let flushed_epoch = if let Some(manifest) = manifest {
12662            if manifest.table_id != entry.table_id {
12663                return Err(MongrelError::Conflict(format!(
12664                    "catalog table {} storage identity mismatch",
12665                    entry.table_id
12666                )));
12667            }
12668            if (manifest.schema_id != entry.schema.schema_id
12669                && !reconciled_table_ids.contains(&entry.table_id))
12670                || manifest.flushed_epoch > manifest.current_epoch
12671                || manifest.global_idx_epoch > manifest.current_epoch
12672                || manifest.next_row_id == u64::MAX
12673                || manifest.auto_inc_next < 0
12674                || manifest.auto_inc_next == i64::MAX
12675                || (entry.schema.auto_increment_column().is_none() && manifest.auto_inc_next != 0)
12676            {
12677                return Err(MongrelError::InvalidArgument(format!(
12678                    "table {} manifest counters or schema identity are invalid",
12679                    entry.table_id
12680                )));
12681            }
12682            #[cfg(feature = "encryption")]
12683            let idx_dek = kek.as_ref().map(|key| key.derive_idx_key());
12684            #[cfg(not(feature = "encryption"))]
12685            let idx_dek: Option<zeroize::Zeroizing<[u8; 32]>> = None;
12686            crate::global_idx::read_durable_for(
12687                durable_root,
12688                &relative_dir,
12689                entry.table_id,
12690                &entry.schema,
12691                idx_dek.as_deref(),
12692            )?;
12693            let mut run_ids = HashSet::new();
12694            let mut maximum_row_id = None::<u64>;
12695            for run in &manifest.runs {
12696                if run.run_id >= u64::MAX as u128
12697                    || run.epoch_created > manifest.current_epoch
12698                    || !run_ids.insert(run.run_id)
12699                {
12700                    return Err(MongrelError::InvalidArgument(format!(
12701                        "table {} manifest contains an invalid or duplicate run id",
12702                        entry.table_id
12703                    )));
12704                }
12705                let relative = relative_dir
12706                    .join(crate::engine::RUNS_DIR)
12707                    .join(format!("r-{}.sr", run.run_id as u64));
12708                let file = durable_root.open_regular(&relative)?;
12709                let mut reader = crate::sorted_run::RunReader::open_file(
12710                    file,
12711                    entry.schema.clone(),
12712                    kek.clone(),
12713                )?;
12714                let header = reader.header();
12715                if header.run_id != run.run_id
12716                    || header.level != run.level
12717                    || header.row_count != run.row_count
12718                    || !header.is_uniform_epoch() && header.epoch_created != run.epoch_created
12719                    || header.is_uniform_epoch() && header.epoch_created != 0
12720                    || header.schema_id > entry.schema.schema_id
12721                {
12722                    return Err(MongrelError::InvalidArgument(format!(
12723                        "table {} run {} differs from its manifest: header=(id {}, level {}, rows {}, epoch {}, schema {}), manifest=(id {}, level {}, rows {}, epoch {}, schema <= {})",
12724                        entry.table_id,
12725                        run.run_id,
12726                        header.run_id,
12727                        header.level,
12728                        header.row_count,
12729                        header.epoch_created,
12730                        header.schema_id,
12731                        run.run_id,
12732                        run.level,
12733                        run.row_count,
12734                        run.epoch_created,
12735                        entry.schema.schema_id,
12736                    )));
12737                }
12738                if header.row_count != 0 {
12739                    maximum_row_id = Some(
12740                        maximum_row_id
12741                            .map_or(header.max_row_id, |value| value.max(header.max_row_id)),
12742                    );
12743                }
12744                reader.validate_all_pages()?;
12745            }
12746            if maximum_row_id.is_some_and(|maximum| manifest.next_row_id <= maximum) {
12747                return Err(MongrelError::InvalidArgument(format!(
12748                    "table {} next_row_id does not advance beyond persisted rows",
12749                    entry.table_id
12750                )));
12751            }
12752            for run in &manifest.retiring {
12753                if run.run_id >= u64::MAX as u128
12754                    || run.retire_epoch > manifest.current_epoch
12755                    || !run_ids.insert(run.run_id)
12756                {
12757                    return Err(MongrelError::InvalidArgument(format!(
12758                        "table {} manifest contains an invalid or aliased retired run",
12759                        entry.table_id
12760                    )));
12761                }
12762            }
12763            manifest.flushed_epoch
12764        } else {
12765            if !recovered_table_ids.contains(&entry.table_id) {
12766                return Err(MongrelError::NotFound(format!(
12767                    "live table {} manifest is missing",
12768                    entry.table_id
12769                )));
12770            }
12771            0
12772        };
12773        tables.insert(
12774            entry.table_id,
12775            RecoveryValidationTable {
12776                schema: entry.schema.clone(),
12777                flushed_epoch,
12778            },
12779        );
12780    }
12781
12782    let committed = records
12783        .iter()
12784        .filter_map(|record| match record.op {
12785            Op::TxnCommit { epoch, .. } => Some((record.txn_id, epoch)),
12786            _ => None,
12787        })
12788        .collect::<HashMap<_, _>>();
12789    let mut run_ids = HashSet::new();
12790    let mut recovered_row_ids = HashMap::<u64, HashSet<u64>>::new();
12791    for record in records {
12792        let Some(&commit_epoch) = committed.get(&record.txn_id) else {
12793            continue;
12794        };
12795        match &record.op {
12796            Op::Put { table_id, rows } => {
12797                let table = validate_recovery_data_table_plan(
12798                    catalog,
12799                    &tables,
12800                    *table_id,
12801                    commit_epoch,
12802                    record.seq.0,
12803                )?;
12804                let decoded: Vec<crate::memtable::Row> =
12805                    bincode::deserialize(rows).map_err(|error| MongrelError::CorruptWal {
12806                        offset: record.seq.0,
12807                        reason: format!(
12808                            "committed Put payload for transaction {} could not be decoded: {error}",
12809                            record.txn_id
12810                        ),
12811                    })?;
12812                if let Some(table) = table {
12813                    for row in &decoded {
12814                        if !recovered_row_ids
12815                            .entry(*table_id)
12816                            .or_default()
12817                            .insert(row.row_id.0)
12818                        {
12819                            return Err(MongrelError::CorruptWal {
12820                                offset: record.seq.0,
12821                                reason: format!(
12822                                    "committed WAL repeats recovered row id {} for table {table_id}",
12823                                    row.row_id.0
12824                                ),
12825                            });
12826                        }
12827                        validate_recovered_row(&table.schema, row)?;
12828                    }
12829                }
12830            }
12831            Op::Delete { table_id, .. } | Op::TruncateTable { table_id } => {
12832                validate_recovery_data_table_plan(
12833                    catalog,
12834                    &tables,
12835                    *table_id,
12836                    commit_epoch,
12837                    record.seq.0,
12838                )?;
12839            }
12840            Op::ExternalTableState { name, .. } => validate_recovered_external_name(name)?,
12841            Op::Ddl(DdlOp::ResetExternalTableState {
12842                name,
12843                generation_epoch,
12844            }) => {
12845                if *generation_epoch != commit_epoch {
12846                    return Err(MongrelError::CorruptWal {
12847                        offset: record.seq.0,
12848                        reason: format!(
12849                            "external state reset epoch {generation_epoch} does not match WAL commit epoch {commit_epoch}"
12850                        ),
12851                    });
12852                }
12853                validate_recovered_external_name(name)?;
12854            }
12855            Op::TxnCommit { added_runs, .. } => {
12856                for added in added_runs {
12857                    let Some(table) = validate_recovery_data_table_plan(
12858                        catalog,
12859                        &tables,
12860                        added.table_id,
12861                        commit_epoch,
12862                        record.seq.0,
12863                    )?
12864                    else {
12865                        continue;
12866                    };
12867                    if added.run_id >= u64::MAX as u128
12868                        || !run_ids.insert((added.table_id, added.run_id))
12869                    {
12870                        return Err(MongrelError::CorruptWal {
12871                            offset: record.seq.0,
12872                            reason: format!(
12873                                "duplicate or invalid recovered run {} for table {}",
12874                                added.run_id, added.table_id
12875                            ),
12876                        });
12877                    }
12878                    if commit_epoch <= table.flushed_epoch {
12879                        continue;
12880                    }
12881                    validate_planned_spilled_run(
12882                        durable_root,
12883                        record.txn_id,
12884                        commit_epoch,
12885                        added,
12886                        &table.schema,
12887                        kek.clone(),
12888                    )?;
12889                }
12890            }
12891            _ => {}
12892        }
12893    }
12894    Ok(())
12895}
12896
12897fn validate_recovery_data_table_plan<'a>(
12898    catalog: &Catalog,
12899    tables: &'a HashMap<u64, RecoveryValidationTable>,
12900    table_id: u64,
12901    commit_epoch: u64,
12902    offset: u64,
12903) -> Result<Option<&'a RecoveryValidationTable>> {
12904    let entry = catalog
12905        .tables
12906        .iter()
12907        .find(|entry| entry.table_id == table_id)
12908        .ok_or_else(|| MongrelError::CorruptWal {
12909            offset,
12910            reason: format!("committed record references unknown table {table_id}"),
12911        })?;
12912    if commit_epoch < entry.created_epoch {
12913        return Err(MongrelError::CorruptWal {
12914            offset,
12915            reason: format!(
12916                "table {table_id} record epoch {commit_epoch} precedes creation epoch {}",
12917                entry.created_epoch
12918            ),
12919        });
12920    }
12921    match entry.state {
12922        TableState::Dropped { at_epoch } => {
12923            let abandoned =
12924                entry.name.starts_with(CTAS_BUILD_TABLE_PREFIX) && commit_epoch == at_epoch;
12925            if commit_epoch >= at_epoch && !abandoned {
12926                return Err(MongrelError::CorruptWal {
12927                    offset,
12928                    reason: format!(
12929                        "table {table_id} record epoch {commit_epoch} is not before drop epoch {at_epoch}"
12930                    ),
12931                });
12932            }
12933            Ok(None)
12934        }
12935        TableState::Live => {
12936            tables
12937                .get(&table_id)
12938                .map(Some)
12939                .ok_or_else(|| MongrelError::CorruptWal {
12940                    offset,
12941                    reason: format!("live table {table_id} has no recovery plan"),
12942                })
12943        }
12944        TableState::Building { .. } => Err(MongrelError::CorruptWal {
12945            offset,
12946            reason: format!("building table {table_id} was not normalized before recovery"),
12947        }),
12948    }
12949}
12950
12951fn validate_planned_spilled_run(
12952    root: &crate::durable_file::DurableRoot,
12953    txn_id: u64,
12954    commit_epoch: u64,
12955    added: &crate::wal::AddedRun,
12956    schema: &Schema,
12957    kek: Option<Arc<crate::encryption::Kek>>,
12958) -> Result<()> {
12959    let table = Path::new(TABLES_DIR).join(added.table_id.to_string());
12960    let destination = table
12961        .join(crate::engine::RUNS_DIR)
12962        .join(format!("r-{}.sr", added.run_id as u64));
12963    let pending = table
12964        .join("_txn")
12965        .join(txn_id.to_string())
12966        .join(format!("r-{}.sr", added.run_id as u64));
12967    let file = match root.open_regular(&destination) {
12968        Ok(file) => file,
12969        Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
12970            root.open_regular(&pending).map_err(|pending_error| {
12971                if pending_error.kind() == std::io::ErrorKind::NotFound {
12972                    MongrelError::CorruptWal {
12973                        offset: commit_epoch,
12974                        reason: format!(
12975                            "committed spilled run {} for transaction {txn_id} is missing",
12976                            added.run_id
12977                        ),
12978                    }
12979                } else {
12980                    pending_error.into()
12981                }
12982            })?
12983        }
12984        Err(error) => return Err(error.into()),
12985    };
12986    let mut reader = crate::sorted_run::RunReader::open_file(file, schema.clone(), kek)?;
12987    let header = reader.header();
12988    if header.run_id != added.run_id
12989        || header.content_hash != added.content_hash
12990        || header.row_count != added.row_count
12991        || header.level != added.level
12992        || header.min_row_id != added.min_row_id
12993        || header.max_row_id != added.max_row_id
12994        || header.schema_id != schema.schema_id
12995        || !header.is_uniform_epoch()
12996        || header.epoch_created != 0
12997    {
12998        return Err(MongrelError::CorruptWal {
12999            offset: commit_epoch,
13000            reason: format!(
13001                "committed spilled run {} metadata differs from WAL",
13002                added.run_id
13003            ),
13004        });
13005    }
13006    reader.validate_all_pages()?;
13007    Ok(())
13008}
13009
13010/// Two-pass, `flushed_epoch`-gated recovery of the shared WAL (spec §15).
13011///
13012/// Pass 1 scans every `TxnCommit` marker and records `txn_id → commit_epoch`
13013/// (the per-txn outcome; aborted / in-flight / torn-tail txns are absent). Pass
13014/// 2 applies each committed data record (Put/Delete) to its table at the commit
13015/// epoch, skipping records whose `commit_epoch <= table.flushed_epoch` (already
13016/// durable in a sorted run). Finally the shared epoch authority is raised to the
13017/// max committed epoch so the next commit continues monotonically.
13018fn recover_shared_wal(
13019    durable_root: &crate::durable_file::DurableRoot,
13020    tables: &HashMap<u64, TableHandle>,
13021    catalog: &Catalog,
13022    epoch: &EpochAuthority,
13023    records: &[crate::wal::Record],
13024) -> Result<()> {
13025    use crate::memtable::Row;
13026    use crate::wal::{DdlOp, Op};
13027
13028    // Pass 1: committed-txn outcomes + collect spilled-run info.
13029    let mut committed: HashMap<u64, u64> = HashMap::new();
13030    let mut spilled_to_link: Vec<(
13031        u64, /*txn_id*/
13032        u64, /*epoch*/
13033        Vec<crate::wal::AddedRun>,
13034    )> = Vec::new();
13035    for r in records {
13036        if let Op::TxnCommit {
13037            epoch: ce,
13038            ref added_runs,
13039        } = r.op
13040        {
13041            committed.insert(r.txn_id, ce);
13042            if !added_runs.is_empty() {
13043                spilled_to_link.push((r.txn_id, ce, added_runs.clone()));
13044            }
13045        }
13046    }
13047    for record in records {
13048        let Some(&commit_epoch) = committed.get(&record.txn_id) else {
13049            continue;
13050        };
13051        match &record.op {
13052            Op::Put { table_id, .. }
13053            | Op::Delete { table_id, .. }
13054            | Op::TruncateTable { table_id } => {
13055                validate_recovered_data_table(
13056                    catalog,
13057                    tables,
13058                    *table_id,
13059                    commit_epoch,
13060                    record.seq.0,
13061                )?;
13062            }
13063            Op::TxnCommit { added_runs, .. } => {
13064                for run in added_runs {
13065                    validate_recovered_data_table(
13066                        catalog,
13067                        tables,
13068                        run.table_id,
13069                        commit_epoch,
13070                        record.seq.0,
13071                    )?;
13072                }
13073            }
13074            _ => {}
13075        }
13076    }
13077    let truncated_transactions: HashSet<(u64, u64)> = records
13078        .iter()
13079        .filter_map(|record| {
13080            committed.get(&record.txn_id)?;
13081            match record.op {
13082                Op::TruncateTable { table_id } => Some((record.txn_id, table_id)),
13083                _ => None,
13084            }
13085        })
13086        .collect();
13087
13088    // Pass 2: stage data per table, gated by flushed_epoch.
13089    enum ExternalRecoveryAction {
13090        Write { name: String, state: Vec<u8> },
13091        Reset { name: String },
13092    }
13093    let mut stage: HashMap<u64, RecoveryTableStage> = HashMap::new();
13094    let mut external_actions = Vec::new();
13095    let mut max_epoch = epoch.visible().0;
13096    for r in records.iter().cloned() {
13097        let Some(&ce) = committed.get(&r.txn_id) else {
13098            continue; // aborted / in-flight — discard
13099        };
13100        let commit_epoch = Epoch(ce);
13101        max_epoch = max_epoch.max(ce);
13102        match r.op {
13103            Op::Put { table_id, rows } => {
13104                // Skip if this table already flushed past the commit epoch.
13105                let skip = tables
13106                    .get(&table_id)
13107                    .map(|h| h.lock().flushed_epoch() >= ce)
13108                    .unwrap_or(true);
13109                if skip {
13110                    continue;
13111                }
13112                let rows: Vec<Row> = bincode::deserialize(&rows).map_err(|error| {
13113                    MongrelError::CorruptWal {
13114                        offset: r.seq.0,
13115                        reason: format!(
13116                            "committed Put payload for transaction {} could not be decoded: {error}",
13117                            r.txn_id
13118                        ),
13119                    }
13120                })?;
13121                // Re-stamp each row at the txn commit epoch (rows are pre-stamped
13122                // at pending_epoch which equals the commit epoch, but be robust).
13123                let rows: Vec<Row> = rows
13124                    .into_iter()
13125                    .map(|mut row| {
13126                        row.committed_epoch = commit_epoch;
13127                        row
13128                    })
13129                    .collect();
13130                let entry = stage
13131                    .entry(table_id)
13132                    .or_insert_with(|| (Vec::new(), Vec::new(), None, commit_epoch));
13133                entry.0.extend(rows);
13134                entry.3 = commit_epoch;
13135            }
13136            Op::Delete { table_id, row_ids } => {
13137                let skip = tables
13138                    .get(&table_id)
13139                    .map(|h| h.lock().flushed_epoch() >= ce)
13140                    .unwrap_or(true);
13141                if skip {
13142                    continue;
13143                }
13144                let dels = row_ids.into_iter().map(|rid| (rid, commit_epoch));
13145                let entry = stage
13146                    .entry(table_id)
13147                    .or_insert_with(|| (Vec::new(), Vec::new(), None, commit_epoch));
13148                entry.1.extend(dels);
13149                entry.3 = commit_epoch;
13150            }
13151            Op::TruncateTable { table_id } => {
13152                let skip = tables
13153                    .get(&table_id)
13154                    .map(|h| h.lock().flushed_epoch() >= ce)
13155                    .unwrap_or(true);
13156                if skip {
13157                    continue;
13158                }
13159                stage.insert(
13160                    table_id,
13161                    (Vec::new(), Vec::new(), Some(commit_epoch), commit_epoch),
13162                );
13163            }
13164            Op::ExternalTableState { name, state } => {
13165                let current_generation = catalog
13166                    .external_tables
13167                    .iter()
13168                    .find(|entry| entry.name == name)
13169                    .map(|entry| entry.created_epoch);
13170                if current_generation.is_some_and(|created_epoch| ce >= created_epoch) {
13171                    validate_recovered_external_name(&name)?;
13172                    external_actions.push(ExternalRecoveryAction::Write { name, state });
13173                }
13174            }
13175            Op::Ddl(DdlOp::ResetExternalTableState {
13176                name,
13177                generation_epoch,
13178            }) => {
13179                if generation_epoch != ce {
13180                    return Err(MongrelError::CorruptWal {
13181                        offset: r.seq.0,
13182                        reason: format!(
13183                        "external state reset epoch {generation_epoch} does not match WAL commit epoch {ce}"
13184                    ),
13185                    });
13186                }
13187                validate_recovered_external_name(&name)?;
13188                external_actions.push(ExternalRecoveryAction::Reset { name });
13189            }
13190            Op::Flush { .. }
13191            | Op::TxnCommit { .. }
13192            | Op::TxnAbort
13193            | Op::Ddl(_)
13194            | Op::BeforeImage { .. }
13195            | Op::CommitTimestamp { .. }
13196            | Op::SpilledRows { .. } => {}
13197        }
13198    }
13199    for (_, commit_epoch, added_runs) in &mut spilled_to_link {
13200        added_runs.retain(|added| {
13201            tables
13202                .get(&added.table_id)
13203                .is_some_and(|table| table.lock().flushed_epoch() < *commit_epoch)
13204        });
13205    }
13206    spilled_to_link.retain(|(_, _, added_runs)| !added_runs.is_empty());
13207    validate_recovery_table_stages(tables, &stage)?;
13208    validate_recovery_spilled_runs(durable_root, tables, &spilled_to_link)?;
13209
13210    // All WAL payloads, catalog generations, table stages, and immutable run
13211    // identities have now been validated. Only this application phase mutates
13212    // the database tree.
13213    for action in external_actions {
13214        match action {
13215            ExternalRecoveryAction::Write { name, state } => {
13216                write_external_state_file(durable_root, &name, &state)?;
13217            }
13218            ExternalRecoveryAction::Reset { name } => {
13219                durable_root.create_directory_all(VTAB_DIR)?;
13220                durable_root.remove_directory_all(Path::new(VTAB_DIR).join(name))?;
13221            }
13222        }
13223    }
13224    for (table_id, (rows, deletes, truncate_epoch, table_epoch)) in stage {
13225        let Some(handle) = tables.get(&table_id) else {
13226            continue;
13227        };
13228        let mut t = handle.lock();
13229        if let Some(epoch) = truncate_epoch {
13230            t.apply_truncate(epoch);
13231        }
13232        t.recover_apply(rows, deletes)?;
13233        // The WAL can be newer than the copied/persisted manifest after a
13234        // crash or replication apply. Rebuild O(1) count metadata from the
13235        // recovered state before endorsing the commit epoch in the manifest.
13236        let rows = t.visible_rows(Snapshot::at(Epoch(u64::MAX)))?;
13237        t.live_count = rows.len() as u64;
13238        // Recovery can replay older row commits while a newer spilled run is
13239        // already linked by the copied manifest. Never move that manifest's
13240        // epoch behind its existing run references.
13241        t.persist_manifest(table_epoch.max(epoch.visible()))?;
13242    }
13243
13244    // Pass 3: link spilled runs from committed txns (spec §8.5). A crash
13245    // between TxnCommit sync and the publish phase leaves the run in
13246    // `_txn/<txn_id>/`. Move it to `_runs/` and add the RunRef.
13247    for (txn_id, ce, added_runs) in &spilled_to_link {
13248        for ar in added_runs {
13249            let Some(handle) = tables.get(&ar.table_id) else {
13250                continue;
13251            };
13252            let mut t = handle.lock();
13253            let table_dir = Path::new(TABLES_DIR).join(ar.table_id.to_string());
13254            let destination = table_dir
13255                .join(crate::engine::RUNS_DIR)
13256                .join(format!("r-{}.sr", ar.run_id));
13257            match durable_root.open_regular(&destination) {
13258                Ok(_) => {}
13259                Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
13260                    let pending = table_dir
13261                        .join("_txn")
13262                        .join(txn_id.to_string())
13263                        .join(format!("r-{}.sr", ar.run_id));
13264                    durable_root.rename_file_new(&pending, &destination)?;
13265                }
13266                Err(error) => return Err(error.into()),
13267            }
13268            // Only link a run whose file is actually present, and never re-link
13269            // one the publish phase already persisted into the manifest (which is
13270            // the common clean-reopen case, since the `TxnCommit` lives in the WAL
13271            // until segment GC). `recover_spilled_run` is idempotent + reconciles
13272            // `live_count`/indexes only when the run is genuinely new.
13273            let linked = t.recover_spilled_run(crate::manifest::RunRef {
13274                run_id: ar.run_id,
13275                level: ar.level,
13276                epoch_created: *ce,
13277                row_count: ar.row_count,
13278            });
13279            let replaced = truncated_transactions.contains(&(*txn_id, ar.table_id));
13280            if replaced {
13281                t.set_flushed_epoch(Epoch(*ce));
13282            }
13283            if linked || replaced {
13284                t.persist_manifest(Epoch(*ce).max(epoch.visible()))?;
13285            }
13286        }
13287    }
13288
13289    epoch.advance_recovered(Epoch(max_epoch));
13290    Ok(())
13291}
13292
13293fn reconcile_recovered_table_metadata(
13294    tables: &HashMap<u64, TableHandle>,
13295    epoch: Epoch,
13296) -> Result<()> {
13297    let mut table_ids = tables.keys().copied().collect::<Vec<_>>();
13298    table_ids.sort_unstable();
13299    let mut plans = Vec::with_capacity(table_ids.len());
13300    for table_id in &table_ids {
13301        let handle = tables.get(table_id).ok_or_else(|| {
13302            MongrelError::Other(format!("mounted table {table_id} vanished during recovery"))
13303        })?;
13304        plans.push((*table_id, handle.lock().plan_recovered_metadata()?));
13305    }
13306    // Every table's data and metadata have been decoded successfully. Publish
13307    // repairs only after the complete database-wide plan is known valid.
13308    for (table_id, plan) in plans {
13309        let handle = tables.get(&table_id).ok_or_else(|| {
13310            MongrelError::Other(format!("mounted table {table_id} vanished during recovery"))
13311        })?;
13312        handle.lock().apply_recovered_metadata(plan, epoch)?;
13313    }
13314    Ok(())
13315}
13316
13317fn validate_recovered_external_name(name: &str) -> Result<()> {
13318    if name.is_empty()
13319        || !name.chars().all(|character| {
13320            character.is_ascii_alphanumeric() || character == '_' || character == '-'
13321        })
13322    {
13323        return Err(MongrelError::CorruptWal {
13324            offset: 0,
13325            reason: format!("unsafe recovered external-table name {name:?}"),
13326        });
13327    }
13328    Ok(())
13329}
13330
13331fn validate_recovery_table_stages(
13332    tables: &HashMap<u64, TableHandle>,
13333    stages: &HashMap<u64, RecoveryTableStage>,
13334) -> Result<()> {
13335    for (table_id, (rows, _, _, _)) in stages {
13336        let handle = tables
13337            .get(table_id)
13338            .ok_or_else(|| MongrelError::CorruptWal {
13339                offset: *table_id,
13340                reason: format!("recovery stage references unmounted table {table_id}"),
13341            })?;
13342        let table = handle.lock();
13343        // Force all existing immutable runs through their integrity/decode path
13344        // before any other table manifest can be changed.
13345        table.visible_rows(Snapshot::at(Epoch(u64::MAX)))?;
13346        for row in rows {
13347            validate_recovered_row(table.schema(), row)?;
13348        }
13349    }
13350    Ok(())
13351}
13352
13353fn validate_recovered_row(schema: &Schema, row: &crate::memtable::Row) -> Result<()> {
13354    if row.deleted || row.row_id.0 == u64::MAX {
13355        return Err(MongrelError::CorruptWal {
13356            offset: row.row_id.0,
13357            reason: "committed Put payload contains a tombstone or exhausted row id".into(),
13358        });
13359    }
13360    let cells = row
13361        .columns
13362        .iter()
13363        .map(|(column, value)| (*column, value.clone()))
13364        .collect::<Vec<_>>();
13365    schema
13366        .validate_persisted_values(&cells)
13367        .map_err(|error| MongrelError::CorruptWal {
13368            offset: row.row_id.0,
13369            reason: format!("recovered row violates table schema: {error}"),
13370        })?;
13371    if schema.auto_increment_column().is_some_and(|column| {
13372        matches!(row.columns.get(&column.id), Some(Value::Int64(value)) if *value == i64::MAX)
13373    }) {
13374        return Err(MongrelError::CorruptWal {
13375            offset: row.row_id.0,
13376            reason: "recovered AUTO_INCREMENT value exhausts i64".into(),
13377        });
13378    }
13379    Ok(())
13380}
13381
13382fn validate_recovery_spilled_runs(
13383    root: &crate::durable_file::DurableRoot,
13384    tables: &HashMap<u64, TableHandle>,
13385    spilled: &[(u64, u64, Vec<crate::wal::AddedRun>)],
13386) -> Result<()> {
13387    let mut identities = HashSet::new();
13388    for (txn_id, commit_epoch, added_runs) in spilled {
13389        for added in added_runs {
13390            if added.run_id >= u64::MAX as u128 {
13391                return Err(MongrelError::CorruptWal {
13392                    offset: *commit_epoch,
13393                    reason: format!(
13394                        "recovered run id {} exceeds the on-disk namespace",
13395                        added.run_id
13396                    ),
13397                });
13398            }
13399            let Some(handle) = tables.get(&added.table_id) else {
13400                continue;
13401            };
13402            if !identities.insert((added.table_id, added.run_id)) {
13403                return Err(MongrelError::CorruptWal {
13404                    offset: *commit_epoch,
13405                    reason: format!(
13406                        "duplicate recovered run {} for table {}",
13407                        added.run_id, added.table_id
13408                    ),
13409                });
13410            }
13411            let table = handle.lock();
13412            validate_planned_spilled_run(
13413                root,
13414                *txn_id,
13415                *commit_epoch,
13416                added,
13417                table.schema(),
13418                table.kek(),
13419            )?;
13420        }
13421    }
13422    Ok(())
13423}
13424
13425fn validate_condition_columns(condition: &ProcedureCondition, schema: &Schema) -> Result<()> {
13426    match condition {
13427        ProcedureCondition::Pk { .. } => {
13428            if schema.primary_key().is_none() {
13429                return Err(MongrelError::InvalidArgument(
13430                    "procedure condition Pk references a table without a primary key".into(),
13431                ));
13432            }
13433        }
13434        ProcedureCondition::BitmapEq { column_id, .. }
13435        | ProcedureCondition::BitmapIn { column_id, .. }
13436        | ProcedureCondition::Range { column_id, .. }
13437        | ProcedureCondition::RangeF64 { column_id, .. }
13438        | ProcedureCondition::IsNull { column_id }
13439        | ProcedureCondition::IsNotNull { column_id }
13440        | ProcedureCondition::FmContains { column_id, .. } => {
13441            validate_column_id(*column_id, schema)?;
13442        }
13443    }
13444    Ok(())
13445}
13446
13447fn bind_procedure_args(
13448    procedure: &StoredProcedure,
13449    mut args: HashMap<String, crate::Value>,
13450) -> Result<HashMap<String, crate::Value>> {
13451    let mut out = HashMap::new();
13452    for param in &procedure.params {
13453        let value = match args.remove(&param.name) {
13454            Some(value) => value,
13455            None => param.default.clone().ok_or_else(|| {
13456                MongrelError::InvalidArgument(format!(
13457                    "missing required procedure parameter {:?}",
13458                    param.name
13459                ))
13460            })?,
13461        };
13462        if !param.nullable && matches!(value, crate::Value::Null) {
13463            return Err(MongrelError::InvalidArgument(format!(
13464                "procedure parameter {:?} must not be NULL",
13465                param.name
13466            )));
13467        }
13468        if !matches!(value, crate::Value::Null) && !value_matches_type(&value, param.ty.clone()) {
13469            return Err(MongrelError::InvalidArgument(format!(
13470                "procedure parameter {:?} has wrong type",
13471                param.name
13472            )));
13473        }
13474        out.insert(param.name.clone(), value);
13475    }
13476    if let Some(extra) = args.keys().next() {
13477        return Err(MongrelError::InvalidArgument(format!(
13478            "unknown procedure parameter {extra:?}"
13479        )));
13480    }
13481    Ok(out)
13482}
13483
13484fn value_matches_type(value: &crate::Value, ty: crate::TypeId) -> bool {
13485    matches!(
13486        (value, ty),
13487        (crate::Value::Bool(_), crate::TypeId::Bool)
13488            | (crate::Value::Int64(_), crate::TypeId::Int8)
13489            | (crate::Value::Int64(_), crate::TypeId::Int16)
13490            | (crate::Value::Int64(_), crate::TypeId::Int32)
13491            | (crate::Value::Int64(_), crate::TypeId::Int64)
13492            | (crate::Value::Int64(_), crate::TypeId::UInt8)
13493            | (crate::Value::Int64(_), crate::TypeId::UInt16)
13494            | (crate::Value::Int64(_), crate::TypeId::UInt32)
13495            | (crate::Value::Int64(_), crate::TypeId::UInt64)
13496            | (crate::Value::Int64(_), crate::TypeId::TimestampNanos)
13497            | (crate::Value::Int64(_), crate::TypeId::Date32)
13498            | (crate::Value::Float64(_), crate::TypeId::Float32)
13499            | (crate::Value::Float64(_), crate::TypeId::Float64)
13500            | (crate::Value::Bytes(_), crate::TypeId::Bytes)
13501            | (crate::Value::Embedding(_), crate::TypeId::Embedding { .. })
13502    )
13503}
13504
13505fn eval_cells(
13506    cells: &[crate::procedure::ProcedureCell],
13507    args: &HashMap<String, crate::Value>,
13508    outputs: &HashMap<String, ProcedureCallOutput>,
13509) -> Result<Vec<(u16, crate::Value)>> {
13510    cells
13511        .iter()
13512        .map(|cell| Ok((cell.column_id, eval_value(&cell.value, args, outputs)?)))
13513        .collect()
13514}
13515
13516fn eval_condition(
13517    condition: &ProcedureCondition,
13518    args: &HashMap<String, crate::Value>,
13519    outputs: &HashMap<String, ProcedureCallOutput>,
13520) -> Result<crate::Condition> {
13521    Ok(match condition {
13522        ProcedureCondition::Pk { value } => {
13523            crate::Condition::Pk(eval_value(value, args, outputs)?.encode_key())
13524        }
13525        ProcedureCondition::BitmapEq { column_id, value } => crate::Condition::BitmapEq {
13526            column_id: *column_id,
13527            value: eval_value(value, args, outputs)?.encode_key(),
13528        },
13529        ProcedureCondition::BitmapIn { column_id, values } => crate::Condition::BitmapIn {
13530            column_id: *column_id,
13531            values: values
13532                .iter()
13533                .map(|value| Ok(eval_value(value, args, outputs)?.encode_key()))
13534                .collect::<Result<Vec<_>>>()?,
13535        },
13536        ProcedureCondition::Range { column_id, lo, hi } => crate::Condition::Range {
13537            column_id: *column_id,
13538            lo: expect_i64(eval_value(lo, args, outputs)?)?,
13539            hi: expect_i64(eval_value(hi, args, outputs)?)?,
13540        },
13541        ProcedureCondition::RangeF64 {
13542            column_id,
13543            lo,
13544            lo_inclusive,
13545            hi,
13546            hi_inclusive,
13547        } => crate::Condition::RangeF64 {
13548            column_id: *column_id,
13549            lo: expect_f64(eval_value(lo, args, outputs)?)?,
13550            lo_inclusive: *lo_inclusive,
13551            hi: expect_f64(eval_value(hi, args, outputs)?)?,
13552            hi_inclusive: *hi_inclusive,
13553        },
13554        ProcedureCondition::IsNull { column_id } => crate::Condition::IsNull {
13555            column_id: *column_id,
13556        },
13557        ProcedureCondition::IsNotNull { column_id } => crate::Condition::IsNotNull {
13558            column_id: *column_id,
13559        },
13560        ProcedureCondition::FmContains { column_id, pattern } => crate::Condition::FmContains {
13561            column_id: *column_id,
13562            pattern: expect_bytes(eval_value(pattern, args, outputs)?)?,
13563        },
13564    })
13565}
13566
13567fn eval_value(
13568    value: &ProcedureValue,
13569    args: &HashMap<String, crate::Value>,
13570    outputs: &HashMap<String, ProcedureCallOutput>,
13571) -> Result<crate::Value> {
13572    match value {
13573        ProcedureValue::Literal(value) => Ok(value.clone()),
13574        ProcedureValue::Param(name) => args.get(name).cloned().ok_or_else(|| {
13575            MongrelError::InvalidArgument(format!("unknown procedure parameter {name:?}"))
13576        }),
13577        ProcedureValue::StepScalar(id) => match outputs.get(id) {
13578            Some(ProcedureCallOutput::Scalar(value)) => Ok(value.clone()),
13579            _ => Err(MongrelError::InvalidArgument(format!(
13580                "procedure step {id:?} did not return a scalar"
13581            ))),
13582        },
13583        ProcedureValue::StepRows(_) | ProcedureValue::StepRow(_) => {
13584            Err(MongrelError::InvalidArgument(
13585                "row-valued procedure reference cannot be used as a scalar".into(),
13586            ))
13587        }
13588        ProcedureValue::Object(_) | ProcedureValue::Array(_) => Err(MongrelError::InvalidArgument(
13589            "structured procedure value cannot be used as a scalar cell".into(),
13590        )),
13591    }
13592}
13593
13594fn eval_return_output(
13595    value: &ProcedureValue,
13596    args: &HashMap<String, crate::Value>,
13597    outputs: &HashMap<String, ProcedureCallOutput>,
13598) -> Result<ProcedureCallOutput> {
13599    match value {
13600        ProcedureValue::Literal(value) => Ok(ProcedureCallOutput::Scalar(value.clone())),
13601        ProcedureValue::Param(name) => Ok(ProcedureCallOutput::Scalar(
13602            args.get(name).cloned().ok_or_else(|| {
13603                MongrelError::InvalidArgument(format!("unknown procedure parameter {name:?}"))
13604            })?,
13605        )),
13606        ProcedureValue::StepRows(id)
13607        | ProcedureValue::StepRow(id)
13608        | ProcedureValue::StepScalar(id) => outputs.get(id).cloned().ok_or_else(|| {
13609            MongrelError::InvalidArgument(format!("unknown procedure step output {id:?}"))
13610        }),
13611        ProcedureValue::Object(fields) => {
13612            let mut out = Vec::with_capacity(fields.len());
13613            for (name, value) in fields {
13614                out.push((name.clone(), eval_return_output(value, args, outputs)?));
13615            }
13616            Ok(ProcedureCallOutput::Object(out))
13617        }
13618        ProcedureValue::Array(values) => {
13619            let mut out = Vec::with_capacity(values.len());
13620            for value in values {
13621                out.push(eval_return_output(value, args, outputs)?);
13622            }
13623            Ok(ProcedureCallOutput::Array(out))
13624        }
13625    }
13626}
13627
13628fn expect_i64(value: crate::Value) -> Result<i64> {
13629    match value {
13630        crate::Value::Int64(value) => Ok(value),
13631        _ => Err(MongrelError::InvalidArgument(
13632            "procedure value must be Int64".into(),
13633        )),
13634    }
13635}
13636
13637fn expect_f64(value: crate::Value) -> Result<f64> {
13638    match value {
13639        crate::Value::Float64(value) => Ok(value),
13640        _ => Err(MongrelError::InvalidArgument(
13641            "procedure value must be Float64".into(),
13642        )),
13643    }
13644}
13645
13646fn expect_bytes(value: crate::Value) -> Result<Vec<u8>> {
13647    match value {
13648        crate::Value::Bytes(value) => Ok(value),
13649        _ => Err(MongrelError::InvalidArgument(
13650            "procedure value must be Bytes".into(),
13651        )),
13652    }
13653}
13654
13655fn validate_column_id(column_id: u16, schema: &Schema) -> Result<()> {
13656    if schema.columns.iter().any(|c| c.id == column_id) {
13657        Ok(())
13658    } else {
13659        Err(MongrelError::InvalidArgument(format!(
13660            "unknown column id {column_id}"
13661        )))
13662    }
13663}
13664
13665fn trigger_matches_event(
13666    trigger: &StoredTrigger,
13667    event: &WriteEvent,
13668    cat: &Catalog,
13669) -> Result<bool> {
13670    if trigger.event != event.kind {
13671        return Ok(false);
13672    }
13673    let TriggerTarget::Table(target) = &trigger.target else {
13674        return Ok(false);
13675    };
13676    if target != &event.table {
13677        return Ok(false);
13678    }
13679    if trigger.event == TriggerEvent::Update && !trigger.update_of.is_empty() {
13680        let schema = &cat
13681            .live(target)
13682            .ok_or_else(|| {
13683                MongrelError::InvalidArgument(format!(
13684                    "trigger {:?} references unknown table {target:?}",
13685                    trigger.name
13686                ))
13687            })?
13688            .schema;
13689        let mut watched = Vec::with_capacity(trigger.update_of.len());
13690        for name in &trigger.update_of {
13691            let col = schema.column(name).ok_or_else(|| {
13692                MongrelError::InvalidArgument(format!(
13693                    "trigger {:?} references unknown UPDATE OF column {name:?}",
13694                    trigger.name
13695                ))
13696            })?;
13697            watched.push(col.id);
13698        }
13699        if !event
13700            .changed_columns
13701            .iter()
13702            .any(|column_id| watched.contains(column_id))
13703        {
13704            return Ok(false);
13705        }
13706    }
13707    Ok(true)
13708}
13709
13710fn changed_columns(old: Option<&TriggerRowImage>, new: Option<&TriggerRowImage>) -> Vec<u16> {
13711    let mut ids = std::collections::BTreeSet::new();
13712    if let Some(old) = old {
13713        ids.extend(old.columns.keys().copied());
13714    }
13715    if let Some(new) = new {
13716        ids.extend(new.columns.keys().copied());
13717    }
13718    ids.into_iter()
13719        .filter(|id| {
13720            old.and_then(|row| row.columns.get(id)) != new.and_then(|row| row.columns.get(id))
13721        })
13722        .collect()
13723}
13724
13725fn eval_trigger_cells(
13726    cells: &[crate::trigger::TriggerCell],
13727    event: &WriteEvent,
13728    selected: Option<&TriggerRowImage>,
13729) -> Result<Vec<(u16, Value)>> {
13730    cells
13731        .iter()
13732        .map(|cell| {
13733            Ok((
13734                cell.column_id,
13735                eval_trigger_value(&cell.value, event, selected)?,
13736            ))
13737        })
13738        .collect()
13739}
13740
13741fn eval_trigger_expr(expr: &TriggerExpr, event: &WriteEvent) -> Result<bool> {
13742    match expr {
13743        TriggerExpr::Value(value) => match eval_trigger_value(value, event, None)? {
13744            Value::Bool(value) => Ok(value),
13745            Value::Null => Ok(false),
13746            other => Err(MongrelError::InvalidArgument(format!(
13747                "trigger WHEN value must be boolean, got {other:?}"
13748            ))),
13749        },
13750        TriggerExpr::Eq { left, right } => Ok(values_equal(
13751            &eval_trigger_value(left, event, None)?,
13752            &eval_trigger_value(right, event, None)?,
13753        )),
13754        TriggerExpr::NotEq { left, right } => Ok(!values_equal(
13755            &eval_trigger_value(left, event, None)?,
13756            &eval_trigger_value(right, event, None)?,
13757        )),
13758        TriggerExpr::Lt { left, right } => match value_order(
13759            &eval_trigger_value(left, event, None)?,
13760            &eval_trigger_value(right, event, None)?,
13761        ) {
13762            Some(ordering) => Ok(ordering == std::cmp::Ordering::Less),
13763            None => Ok(false),
13764        },
13765        TriggerExpr::Lte { left, right } => match value_order(
13766            &eval_trigger_value(left, event, None)?,
13767            &eval_trigger_value(right, event, None)?,
13768        ) {
13769            Some(ordering) => Ok(ordering != std::cmp::Ordering::Greater),
13770            None => Ok(false),
13771        },
13772        TriggerExpr::Gt { left, right } => match value_order(
13773            &eval_trigger_value(left, event, None)?,
13774            &eval_trigger_value(right, event, None)?,
13775        ) {
13776            Some(ordering) => Ok(ordering == std::cmp::Ordering::Greater),
13777            None => Ok(false),
13778        },
13779        TriggerExpr::Gte { left, right } => match value_order(
13780            &eval_trigger_value(left, event, None)?,
13781            &eval_trigger_value(right, event, None)?,
13782        ) {
13783            Some(ordering) => Ok(ordering != std::cmp::Ordering::Less),
13784            None => Ok(false),
13785        },
13786        TriggerExpr::IsNull(value) => Ok(matches!(
13787            eval_trigger_value(value, event, None)?,
13788            Value::Null
13789        )),
13790        TriggerExpr::IsNotNull(value) => Ok(!matches!(
13791            eval_trigger_value(value, event, None)?,
13792            Value::Null
13793        )),
13794        TriggerExpr::And { left, right } => {
13795            if !eval_trigger_expr(left, event)? {
13796                Ok(false)
13797            } else {
13798                Ok(eval_trigger_expr(right, event)?)
13799            }
13800        }
13801        TriggerExpr::Or { left, right } => {
13802            if eval_trigger_expr(left, event)? {
13803                Ok(true)
13804            } else {
13805                Ok(eval_trigger_expr(right, event)?)
13806            }
13807        }
13808        TriggerExpr::Not(expr) => Ok(!eval_trigger_expr(expr, event)?),
13809    }
13810}
13811
13812fn eval_trigger_condition(
13813    condition: &TriggerCondition,
13814    event: &WriteEvent,
13815    selected: &TriggerRowImage,
13816    schema: &Schema,
13817) -> Result<bool> {
13818    match condition {
13819        TriggerCondition::Pk { value } => {
13820            let pk = schema.primary_key().ok_or_else(|| {
13821                MongrelError::InvalidArgument(
13822                    "trigger condition Pk references a table without a primary key".into(),
13823                )
13824            })?;
13825            let lhs = eval_trigger_value(value, event, Some(selected))?;
13826            Ok(values_equal(
13827                &lhs,
13828                selected.columns.get(&pk.id).unwrap_or(&Value::Null),
13829            ))
13830        }
13831        TriggerCondition::Eq { column_id, value } => Ok(values_equal(
13832            selected.columns.get(column_id).unwrap_or(&Value::Null),
13833            &eval_trigger_value(value, event, Some(selected))?,
13834        )),
13835        TriggerCondition::NotEq { column_id, value } => Ok(!values_equal(
13836            selected.columns.get(column_id).unwrap_or(&Value::Null),
13837            &eval_trigger_value(value, event, Some(selected))?,
13838        )),
13839        TriggerCondition::Lt { column_id, value } => match value_order(
13840            selected.columns.get(column_id).unwrap_or(&Value::Null),
13841            &eval_trigger_value(value, event, Some(selected))?,
13842        ) {
13843            Some(ordering) => Ok(ordering == std::cmp::Ordering::Less),
13844            None => Ok(false),
13845        },
13846        TriggerCondition::Lte { column_id, value } => match value_order(
13847            selected.columns.get(column_id).unwrap_or(&Value::Null),
13848            &eval_trigger_value(value, event, Some(selected))?,
13849        ) {
13850            Some(ordering) => Ok(ordering != std::cmp::Ordering::Greater),
13851            None => Ok(false),
13852        },
13853        TriggerCondition::Gt { column_id, value } => match value_order(
13854            selected.columns.get(column_id).unwrap_or(&Value::Null),
13855            &eval_trigger_value(value, event, Some(selected))?,
13856        ) {
13857            Some(ordering) => Ok(ordering == std::cmp::Ordering::Greater),
13858            None => Ok(false),
13859        },
13860        TriggerCondition::Gte { column_id, value } => match value_order(
13861            selected.columns.get(column_id).unwrap_or(&Value::Null),
13862            &eval_trigger_value(value, event, Some(selected))?,
13863        ) {
13864            Some(ordering) => Ok(ordering != std::cmp::Ordering::Less),
13865            None => Ok(false),
13866        },
13867        TriggerCondition::IsNull { column_id } => Ok(matches!(
13868            selected.columns.get(column_id),
13869            None | Some(Value::Null)
13870        )),
13871        TriggerCondition::IsNotNull { column_id } => Ok(!matches!(
13872            selected.columns.get(column_id),
13873            None | Some(Value::Null)
13874        )),
13875        TriggerCondition::And { left, right } => {
13876            if !eval_trigger_condition(left, event, selected, schema)? {
13877                Ok(false)
13878            } else {
13879                Ok(eval_trigger_condition(right, event, selected, schema)?)
13880            }
13881        }
13882        TriggerCondition::Or { left, right } => {
13883            if eval_trigger_condition(left, event, selected, schema)? {
13884                Ok(true)
13885            } else {
13886                Ok(eval_trigger_condition(right, event, selected, schema)?)
13887            }
13888        }
13889        TriggerCondition::Not(condition) => {
13890            Ok(!eval_trigger_condition(condition, event, selected, schema)?)
13891        }
13892    }
13893}
13894
13895fn eval_trigger_value(
13896    value: &TriggerValue,
13897    event: &WriteEvent,
13898    selected: Option<&TriggerRowImage>,
13899) -> Result<Value> {
13900    match value {
13901        TriggerValue::Literal(value) => Ok(value.clone()),
13902        TriggerValue::NewColumn(column_id) => event
13903            .new
13904            .as_ref()
13905            .and_then(|row| row.columns.get(column_id))
13906            .cloned()
13907            .ok_or_else(|| MongrelError::InvalidArgument("NEW column is not available".into())),
13908        TriggerValue::OldColumn(column_id) => event
13909            .old
13910            .as_ref()
13911            .and_then(|row| row.columns.get(column_id))
13912            .cloned()
13913            .ok_or_else(|| MongrelError::InvalidArgument("OLD column is not available".into())),
13914        TriggerValue::SelectedColumn(column_id) => selected
13915            .and_then(|row| row.columns.get(column_id))
13916            .cloned()
13917            .ok_or_else(|| {
13918                MongrelError::InvalidArgument("SELECTED column is not available".into())
13919            }),
13920    }
13921}
13922
13923fn values_equal(left: &Value, right: &Value) -> bool {
13924    match (left, right) {
13925        (Value::Null, Value::Null) => true,
13926        (Value::Bool(a), Value::Bool(b)) => a == b,
13927        (Value::Int64(a), Value::Int64(b)) => a == b,
13928        (Value::Float64(a), Value::Float64(b)) => a.to_bits() == b.to_bits(),
13929        (Value::Bytes(a), Value::Bytes(b)) => a == b,
13930        (Value::Embedding(a), Value::Embedding(b)) => {
13931            a.len() == b.len()
13932                && a.iter()
13933                    .zip(b.iter())
13934                    .all(|(a, b)| a.to_bits() == b.to_bits())
13935        }
13936        _ => false,
13937    }
13938}
13939
13940fn value_order(left: &Value, right: &Value) -> Option<std::cmp::Ordering> {
13941    match (left, right) {
13942        (Value::Null, _) | (_, Value::Null) => None,
13943        (Value::Bool(a), Value::Bool(b)) => Some(a.cmp(b)),
13944        (Value::Int64(a), Value::Int64(b)) => Some(a.cmp(b)),
13945        // Cross-type Int64/Float64 comparison coerces the integer to f64.
13946        // This matches the spec but can lose precision for i64 values above 2^53.
13947        (Value::Int64(a), Value::Float64(b)) => {
13948            let af = *a as f64;
13949            Some(af.total_cmp(b))
13950        }
13951        // Cross-type Int64/Float64 comparison coerces the integer to f64.
13952        // This matches the spec but can lose precision for i64 values above 2^53.
13953        (Value::Float64(a), Value::Int64(b)) => {
13954            let bf = *b as f64;
13955            Some(a.total_cmp(&bf))
13956        }
13957        (Value::Float64(a), Value::Float64(b)) => Some(a.total_cmp(b)),
13958        (Value::Bytes(a), Value::Bytes(b)) => Some(a.cmp(b)),
13959        (Value::Embedding(_), Value::Embedding(_)) => None,
13960        _ => None,
13961    }
13962}
13963
13964fn trigger_message(value: Value) -> String {
13965    match value {
13966        Value::Null => "NULL".into(),
13967        Value::Bool(value) => value.to_string(),
13968        Value::Int64(value) => value.to_string(),
13969        Value::Float64(value) => value.to_string(),
13970        Value::Bytes(value) => String::from_utf8_lossy(&value).into_owned(),
13971        Value::Embedding(value) => format!("{value:?}"),
13972        Value::Decimal(value) => value.to_string(),
13973        Value::Interval {
13974            months,
13975            days,
13976            nanos,
13977        } => format!("{months}m {days}d {nanos}ns"),
13978        Value::Uuid(b) => b.iter().map(|x| format!("{x:02x}")).collect(),
13979        Value::Json(b) => String::from_utf8_lossy(&b).into_owned(),
13980    }
13981}
13982
13983fn validate_trigger_step<'a>(
13984    step: &TriggerStep,
13985    cat: &'a Catalog,
13986    target_schema: &Schema,
13987    event: TriggerEvent,
13988    select_schemas: &mut HashMap<String, &'a Schema>,
13989) -> Result<()> {
13990    match step {
13991        TriggerStep::SetNew { cells } => {
13992            if event == TriggerEvent::Delete {
13993                return Err(MongrelError::InvalidArgument(
13994                    "SetNew trigger step is not valid for DELETE triggers".into(),
13995                ));
13996            }
13997            for cell in cells {
13998                validate_column_id(cell.column_id, target_schema)?;
13999                validate_trigger_value(&cell.value, target_schema, event)?;
14000            }
14001        }
14002        TriggerStep::Insert { table, cells } => {
14003            let schema = trigger_write_schema(cat, table, "insert")?;
14004            for cell in cells {
14005                validate_column_id(cell.column_id, schema)?;
14006                validate_trigger_value(&cell.value, target_schema, event)?;
14007            }
14008        }
14009        TriggerStep::UpdateByPk { table, pk, cells } => {
14010            let schema = trigger_write_schema(cat, table, "update")?;
14011            if schema.primary_key().is_none() {
14012                return Err(MongrelError::InvalidArgument(format!(
14013                    "trigger update_by_pk references table {table:?} without a primary key"
14014                )));
14015            }
14016            validate_trigger_value(pk, target_schema, event)?;
14017            for cell in cells {
14018                validate_column_id(cell.column_id, schema)?;
14019                validate_trigger_value(&cell.value, target_schema, event)?;
14020            }
14021        }
14022        TriggerStep::DeleteByPk { table, pk } => {
14023            let schema = trigger_write_schema(cat, table, "delete")?;
14024            if schema.primary_key().is_none() {
14025                return Err(MongrelError::InvalidArgument(format!(
14026                    "trigger delete_by_pk references table {table:?} without a primary key"
14027                )));
14028            }
14029            validate_trigger_value(pk, target_schema, event)?;
14030        }
14031        TriggerStep::Select {
14032            id,
14033            table,
14034            conditions,
14035        } => {
14036            let schema = trigger_read_schema(cat, table)?;
14037            for condition in conditions {
14038                validate_trigger_condition(condition, schema, target_schema, event)?;
14039            }
14040            if select_schemas.contains_key(id) {
14041                return Err(MongrelError::InvalidArgument(format!(
14042                    "duplicate select id {id:?} in trigger program"
14043                )));
14044            }
14045            select_schemas.insert(id.clone(), schema);
14046        }
14047        TriggerStep::Foreach { id, steps } => {
14048            if !select_schemas.contains_key(id) {
14049                return Err(MongrelError::InvalidArgument(format!(
14050                    "foreach references unknown select id {id:?}"
14051                )));
14052            }
14053            let mut inner_select_schemas = select_schemas.clone();
14054            for step in steps {
14055                validate_trigger_step(step, cat, target_schema, event, &mut inner_select_schemas)?;
14056            }
14057        }
14058        TriggerStep::DeleteWhere { table, conditions } => {
14059            let schema = trigger_write_schema(cat, table, "delete")?;
14060            for condition in conditions {
14061                validate_trigger_condition(condition, schema, target_schema, event)?;
14062            }
14063        }
14064        TriggerStep::UpdateWhere {
14065            table,
14066            conditions,
14067            cells,
14068        } => {
14069            let schema = trigger_write_schema(cat, table, "update")?;
14070            for condition in conditions {
14071                validate_trigger_condition(condition, schema, target_schema, event)?;
14072            }
14073            for cell in cells {
14074                validate_column_id(cell.column_id, schema)?;
14075                validate_trigger_value(&cell.value, target_schema, event)?;
14076            }
14077        }
14078        TriggerStep::Raise { message, .. } => {
14079            validate_trigger_value(message, target_schema, event)?
14080        }
14081    }
14082    Ok(())
14083}
14084
14085fn trigger_validation_error(error: MongrelError) -> MongrelError {
14086    match error {
14087        MongrelError::TriggerValidation(_) => error,
14088        MongrelError::InvalidArgument(message)
14089        | MongrelError::Conflict(message)
14090        | MongrelError::NotFound(message) => MongrelError::TriggerValidation(message),
14091        error => error,
14092    }
14093}
14094
14095fn trigger_write_schema<'a>(cat: &'a Catalog, table: &str, op: &str) -> Result<&'a Schema> {
14096    if let Some(entry) = cat.live(table) {
14097        return Ok(&entry.schema);
14098    }
14099    if let Some(entry) = cat.external_tables.iter().find(|entry| entry.name == table) {
14100        let allowed = match op {
14101            "insert" => entry.capabilities.writable || entry.capabilities.insert_only,
14102            "update" | "delete" => entry.capabilities.writable,
14103            _ => false,
14104        };
14105        if !allowed {
14106            return Err(MongrelError::InvalidArgument(format!(
14107                "trigger {op} references external table {table:?}, but module {:?} is not writable for that operation",
14108                entry.module
14109            )));
14110        }
14111        if !entry.capabilities.transaction_safe {
14112            return Err(MongrelError::InvalidArgument(format!(
14113                "trigger {op} references external table {table:?}, but module {:?} is not transaction-safe",
14114                entry.module
14115            )));
14116        }
14117        return Ok(&entry.declared_schema);
14118    }
14119    Err(MongrelError::InvalidArgument(format!(
14120        "trigger references unknown table {table:?}"
14121    )))
14122}
14123
14124fn trigger_read_schema<'a>(cat: &'a Catalog, table: &str) -> Result<&'a Schema> {
14125    if let Some(entry) = cat.live(table) {
14126        return Ok(&entry.schema);
14127    }
14128    if let Some(entry) = cat.external_tables.iter().find(|entry| entry.name == table) {
14129        if entry.capabilities.trigger_safe {
14130            return Ok(&entry.declared_schema);
14131        }
14132        return Err(MongrelError::InvalidArgument(format!(
14133            "trigger reads external table {table:?}, but module {:?} is not trigger-safe",
14134            entry.module
14135        )));
14136    }
14137    Err(MongrelError::InvalidArgument(format!(
14138        "trigger references unknown table {table:?}"
14139    )))
14140}
14141
14142fn validate_trigger_condition(
14143    condition: &TriggerCondition,
14144    schema: &Schema,
14145    target_schema: &Schema,
14146    event: TriggerEvent,
14147) -> Result<()> {
14148    match condition {
14149        TriggerCondition::Pk { value } => {
14150            if schema.primary_key().is_none() {
14151                return Err(MongrelError::InvalidArgument(
14152                    "trigger condition Pk references a table without a primary key".into(),
14153                ));
14154            }
14155            validate_trigger_value(value, target_schema, event)
14156        }
14157        TriggerCondition::Eq { column_id, value }
14158        | TriggerCondition::NotEq { column_id, value }
14159        | TriggerCondition::Lt { column_id, value }
14160        | TriggerCondition::Lte { column_id, value }
14161        | TriggerCondition::Gt { column_id, value }
14162        | TriggerCondition::Gte { column_id, value } => {
14163            validate_column_id(*column_id, schema)?;
14164            validate_trigger_value(value, target_schema, event)
14165        }
14166        TriggerCondition::IsNull { column_id } | TriggerCondition::IsNotNull { column_id } => {
14167            validate_column_id(*column_id, schema)
14168        }
14169        TriggerCondition::And { left, right } | TriggerCondition::Or { left, right } => {
14170            validate_trigger_condition(left, schema, target_schema, event)?;
14171            validate_trigger_condition(right, schema, target_schema, event)
14172        }
14173        TriggerCondition::Not(condition) => {
14174            validate_trigger_condition(condition, schema, target_schema, event)
14175        }
14176    }
14177}
14178
14179fn validate_trigger_expr(expr: &TriggerExpr, schema: &Schema, event: TriggerEvent) -> Result<()> {
14180    match expr {
14181        TriggerExpr::Value(value) | TriggerExpr::IsNull(value) | TriggerExpr::IsNotNull(value) => {
14182            validate_trigger_value(value, schema, event)
14183        }
14184        TriggerExpr::Eq { left, right }
14185        | TriggerExpr::NotEq { left, right }
14186        | TriggerExpr::Lt { left, right }
14187        | TriggerExpr::Lte { left, right }
14188        | TriggerExpr::Gt { left, right }
14189        | TriggerExpr::Gte { left, right } => {
14190            validate_trigger_value(left, schema, event)?;
14191            validate_trigger_value(right, schema, event)
14192        }
14193        TriggerExpr::And { left, right } | TriggerExpr::Or { left, right } => {
14194            validate_trigger_expr(left, schema, event)?;
14195            validate_trigger_expr(right, schema, event)
14196        }
14197        TriggerExpr::Not(expr) => validate_trigger_expr(expr, schema, event),
14198    }
14199}
14200
14201fn validate_trigger_value(
14202    value: &TriggerValue,
14203    schema: &Schema,
14204    event: TriggerEvent,
14205) -> Result<()> {
14206    match value {
14207        TriggerValue::Literal(_) => Ok(()),
14208        TriggerValue::NewColumn(id) => {
14209            if event == TriggerEvent::Delete {
14210                return Err(MongrelError::InvalidArgument(
14211                    "DELETE triggers cannot reference NEW".into(),
14212                ));
14213            }
14214            validate_column_id(*id, schema)
14215        }
14216        TriggerValue::OldColumn(id) => {
14217            if event == TriggerEvent::Insert {
14218                return Err(MongrelError::InvalidArgument(
14219                    "INSERT triggers cannot reference OLD".into(),
14220                ));
14221            }
14222            validate_column_id(*id, schema)
14223        }
14224        // SELECTED column references are only meaningful inside a foreach loop.
14225        // Strict loop-scope validation is deferred to runtime; the executor raises
14226        // an error if a selected row is not available.
14227        TriggerValue::SelectedColumn(_) => Ok(()),
14228    }
14229}
14230
14231/// Replay committed `Op::Ddl` records from the shared WAL into the catalog
14232/// (spec §15, review fix #16). A crash between WAL group-sync and the catalog
14233/// checkpoint leaves DDL durable in the WAL but absent from the on-disk
14234/// catalog. This pass closes that window by reconstructing missing entries
14235/// (and marking committed drops) before tables are mounted.
14236fn recover_ddl_from_wal(
14237    root: &Path,
14238    durable_root: Option<&crate::durable_file::DurableRoot>,
14239    target_catalog: &mut Catalog,
14240    meta_dek: Option<&[u8; META_DEK_LEN]>,
14241    wal_dek: Option<&zeroize::Zeroizing<[u8; 32]>>,
14242    apply: bool,
14243    table_roots: Option<&HashMap<u64, Arc<crate::durable_file::DurableRoot>>>,
14244) -> Result<()> {
14245    use crate::wal::SharedWal;
14246    let records = match durable_root {
14247        Some(root) => SharedWal::replay_durable_with_dek(root, wal_dek)?,
14248        None => SharedWal::replay_with_dek(root, wal_dek)?,
14249    };
14250    recover_ddl_from_records(
14251        root,
14252        durable_root,
14253        target_catalog,
14254        meta_dek,
14255        apply,
14256        table_roots,
14257        &records,
14258    )
14259}
14260
14261fn recover_ddl_from_records(
14262    root: &Path,
14263    durable_root: Option<&crate::durable_file::DurableRoot>,
14264    target_catalog: &mut Catalog,
14265    meta_dek: Option<&[u8; META_DEK_LEN]>,
14266    apply: bool,
14267    table_roots: Option<&HashMap<u64, Arc<crate::durable_file::DurableRoot>>>,
14268    records: &[crate::wal::Record],
14269) -> Result<()> {
14270    use crate::wal::{DdlOp, Op};
14271
14272    let original_catalog = target_catalog.clone();
14273    let mut recovered_catalog = original_catalog.clone();
14274    let cat = &mut recovered_catalog;
14275    let mut created_table_ids = HashSet::<u64>::new();
14276    let mut ttl_updates = HashMap::<u64, (Option<crate::manifest::TtlPolicy>, u64)>::new();
14277
14278    let mut committed: HashMap<u64, u64> = HashMap::new();
14279    for r in records {
14280        if let Op::TxnCommit { epoch: ce, .. } = r.op {
14281            committed.insert(r.txn_id, ce);
14282        }
14283    }
14284    let catalog_snapshot_txns = records
14285        .iter()
14286        .filter_map(|record| {
14287            (committed.contains_key(&record.txn_id)
14288                && matches!(&record.op, Op::Ddl(DdlOp::CatalogSnapshot { .. })))
14289            .then_some(record.txn_id)
14290        })
14291        .collect::<HashSet<_>>();
14292
14293    let mut changed = false;
14294    let mut applied_catalog_epoch = cat.db_epoch;
14295    let max_committed_epoch = committed.values().copied().max().unwrap_or(cat.db_epoch);
14296    for r in records.iter().cloned() {
14297        let Some(&ce) = committed.get(&r.txn_id) else {
14298            continue;
14299        };
14300        let txn_id = r.txn_id;
14301        match r.op {
14302            Op::Ddl(DdlOp::CreateTable {
14303                table_id,
14304                ref name,
14305                ref schema_json,
14306            }) => {
14307                if cat.tables.iter().any(|t| t.table_id == table_id) {
14308                    continue;
14309                }
14310                let schema = DdlOp::decode_schema(schema_json)?;
14311                validate_recovered_schema(&schema)?;
14312                created_table_ids.insert(table_id);
14313                cat.tables.push(CatalogEntry {
14314                    table_id,
14315                    name: name.clone(),
14316                    schema,
14317                    state: TableState::Live,
14318                    created_epoch: ce,
14319                });
14320                cat.next_table_id =
14321                    cat.next_table_id
14322                        .max(table_id.checked_add(1).ok_or_else(|| {
14323                            MongrelError::Full("table id namespace exhausted".into())
14324                        })?);
14325                changed = true;
14326            }
14327            Op::Ddl(DdlOp::CreateBuildingTable {
14328                table_id,
14329                ref build_name,
14330                ref intended_name,
14331                ref query_id,
14332                created_at_unix_nanos,
14333                ref schema_json,
14334            }) => {
14335                if cat.tables.iter().any(|table| table.table_id == table_id) {
14336                    continue;
14337                }
14338                let schema = DdlOp::decode_schema(schema_json)?;
14339                validate_recovered_schema(&schema)?;
14340                created_table_ids.insert(table_id);
14341                cat.tables.push(CatalogEntry {
14342                    table_id,
14343                    name: build_name.clone(),
14344                    schema,
14345                    state: TableState::Building {
14346                        intended_name: intended_name.clone(),
14347                        query_id: query_id.clone(),
14348                        created_at_unix_nanos,
14349                        replaces_table_id: None,
14350                    },
14351                    created_epoch: ce,
14352                });
14353                cat.next_table_id =
14354                    cat.next_table_id
14355                        .max(table_id.checked_add(1).ok_or_else(|| {
14356                            MongrelError::Full("table id namespace exhausted".into())
14357                        })?);
14358                changed = true;
14359            }
14360            Op::Ddl(DdlOp::CreateRebuildingTable {
14361                table_id,
14362                ref build_name,
14363                ref intended_name,
14364                ref query_id,
14365                created_at_unix_nanos,
14366                replaces_table_id,
14367                ref schema_json,
14368            }) => {
14369                if cat.tables.iter().any(|table| table.table_id == table_id) {
14370                    continue;
14371                }
14372                let schema = DdlOp::decode_schema(schema_json)?;
14373                validate_recovered_schema(&schema)?;
14374                created_table_ids.insert(table_id);
14375                cat.tables.push(CatalogEntry {
14376                    table_id,
14377                    name: build_name.clone(),
14378                    schema,
14379                    state: TableState::Building {
14380                        intended_name: intended_name.clone(),
14381                        query_id: query_id.clone(),
14382                        created_at_unix_nanos,
14383                        replaces_table_id: Some(replaces_table_id),
14384                    },
14385                    created_epoch: ce,
14386                });
14387                cat.next_table_id =
14388                    cat.next_table_id
14389                        .max(table_id.checked_add(1).ok_or_else(|| {
14390                            MongrelError::Full("table id namespace exhausted".into())
14391                        })?);
14392                changed = true;
14393            }
14394            Op::Ddl(DdlOp::DropTable { table_id }) => {
14395                let mut dropped_name = None;
14396                if let Some(entry) = cat.tables.iter_mut().find(|t| t.table_id == table_id) {
14397                    if matches!(entry.state, TableState::Live | TableState::Building { .. }) {
14398                        dropped_name = Some(entry.name.clone());
14399                        entry.state = TableState::Dropped { at_epoch: ce };
14400                        changed = true;
14401                    }
14402                }
14403                if let Some(name) = dropped_name {
14404                    let before = cat.materialized_views.len();
14405                    cat.materialized_views
14406                        .retain(|definition| definition.name != name);
14407                    changed |= before != cat.materialized_views.len();
14408                    cat.security.rls_tables.retain(|table| table != &name);
14409                    cat.security.policies.retain(|policy| policy.table != name);
14410                    cat.security.masks.retain(|mask| mask.table != name);
14411                    for role in &mut cat.roles {
14412                        role.permissions
14413                            .retain(|permission| permission_table(permission) != Some(&name));
14414                    }
14415                    if !catalog_snapshot_txns.contains(&txn_id) {
14416                        advance_security_version(cat)?;
14417                    }
14418                }
14419            }
14420            Op::Ddl(DdlOp::PublishBuildingTable {
14421                table_id,
14422                ref new_name,
14423            }) => {
14424                if let Some(entry) = cat
14425                    .tables
14426                    .iter_mut()
14427                    .find(|table| table.table_id == table_id)
14428                {
14429                    if entry.name != *new_name || !matches!(entry.state, TableState::Live) {
14430                        entry.name = new_name.clone();
14431                        entry.state = TableState::Live;
14432                        changed = true;
14433                    }
14434                }
14435            }
14436            Op::Ddl(DdlOp::ReplaceBuildingTable {
14437                table_id,
14438                replaced_table_id,
14439                ref new_name,
14440            }) => {
14441                changed |=
14442                    apply_rebuilding_publish(cat, table_id, replaced_table_id, new_name, ce)?;
14443            }
14444            Op::Ddl(DdlOp::RenameTable {
14445                table_id,
14446                ref new_name,
14447            }) => {
14448                let mut old_name = None;
14449                if let Some(entry) = cat.tables.iter_mut().find(|t| t.table_id == table_id) {
14450                    if entry.name != *new_name {
14451                        old_name = Some(entry.name.clone());
14452                        entry.name = new_name.clone();
14453                        changed = true;
14454                    }
14455                }
14456                if let Some(old_name) = old_name {
14457                    if let Some(definition) = cat
14458                        .materialized_views
14459                        .iter_mut()
14460                        .find(|definition| definition.name == old_name)
14461                    {
14462                        definition.name = new_name.clone();
14463                    }
14464                    for table in &mut cat.security.rls_tables {
14465                        if *table == old_name {
14466                            *table = new_name.clone();
14467                        }
14468                    }
14469                    for policy in &mut cat.security.policies {
14470                        if policy.table == old_name {
14471                            policy.table = new_name.clone();
14472                        }
14473                    }
14474                    for mask in &mut cat.security.masks {
14475                        if mask.table == old_name {
14476                            mask.table = new_name.clone();
14477                        }
14478                    }
14479                    for role in &mut cat.roles {
14480                        for permission in &mut role.permissions {
14481                            rename_permission_table(permission, &old_name, new_name);
14482                        }
14483                    }
14484                    if !catalog_snapshot_txns.contains(&txn_id) {
14485                        advance_security_version(cat)?;
14486                    }
14487                }
14488                // If the entry is absent, its CreateTable was already
14489                // checkpointed carrying the post-rename name, so there is
14490                // nothing to apply — a no-op, not an error.
14491            }
14492            Op::Ddl(DdlOp::AlterTable {
14493                table_id,
14494                ref column_json,
14495            }) => {
14496                let column = DdlOp::decode_column(column_json)?;
14497                let mut renamed = None;
14498                if let Some(entry) = cat.tables.iter_mut().find(|t| t.table_id == table_id) {
14499                    renamed = entry
14500                        .schema
14501                        .columns
14502                        .iter()
14503                        .find(|existing| existing.id == column.id && existing.name != column.name)
14504                        .map(|existing| {
14505                            (
14506                                entry.name.clone(),
14507                                existing.name.clone(),
14508                                column.name.clone(),
14509                            )
14510                        });
14511                    if apply_recovered_column_def(&mut entry.schema, column)? {
14512                        validate_recovered_schema(&entry.schema)?;
14513                        changed = true;
14514                    }
14515                }
14516                if let Some((table, old_name, new_name)) = renamed {
14517                    for role in &mut cat.roles {
14518                        for permission in &mut role.permissions {
14519                            rename_permission_column(permission, &table, &old_name, &new_name);
14520                        }
14521                    }
14522                    if !catalog_snapshot_txns.contains(&txn_id) {
14523                        advance_security_version(cat)?;
14524                    }
14525                }
14526            }
14527            Op::Ddl(DdlOp::SetTtl {
14528                table_id,
14529                ref policy_json,
14530            }) => {
14531                let policy = DdlOp::decode_ttl(policy_json)?;
14532                let entry = cat
14533                    .tables
14534                    .iter()
14535                    .find(|entry| entry.table_id == table_id)
14536                    .ok_or_else(|| {
14537                        MongrelError::Schema(format!(
14538                            "recovered TTL references unknown table id {table_id}"
14539                        ))
14540                    })?;
14541                if let Some(policy) = policy {
14542                    let valid = entry
14543                        .schema
14544                        .columns
14545                        .iter()
14546                        .find(|column| column.id == policy.column_id)
14547                        .is_some_and(|column| {
14548                            column.ty == TypeId::TimestampNanos
14549                                && policy.duration_nanos > 0
14550                                && policy.duration_nanos <= i64::MAX as u64
14551                        });
14552                    if !valid {
14553                        return Err(MongrelError::Schema(format!(
14554                            "invalid recovered TTL policy for table id {table_id}"
14555                        )));
14556                    }
14557                }
14558                ttl_updates.insert(table_id, (policy, ce));
14559            }
14560            Op::Ddl(DdlOp::SetMaterializedView {
14561                ref name,
14562                ref definition_json,
14563            }) => {
14564                let definition = DdlOp::decode_materialized_view(definition_json)?;
14565                if definition.name != *name {
14566                    return Err(MongrelError::Schema(format!(
14567                        "materialized view WAL name mismatch: {name:?}"
14568                    )));
14569                }
14570                if cat.live(name).is_some() {
14571                    if let Some(existing) = cat
14572                        .materialized_views
14573                        .iter_mut()
14574                        .find(|existing| existing.name == *name)
14575                    {
14576                        if *existing != definition {
14577                            *existing = definition;
14578                            changed = true;
14579                        }
14580                    } else {
14581                        cat.materialized_views.push(definition);
14582                        changed = true;
14583                    }
14584                }
14585            }
14586            Op::Ddl(DdlOp::SetSecurityCatalog { ref security_json }) => {
14587                let security = DdlOp::decode_security(security_json)?;
14588                validate_security_catalog(cat, &security)?;
14589                if cat.security != security {
14590                    cat.security = security;
14591                    if !catalog_snapshot_txns.contains(&txn_id) {
14592                        advance_security_version(cat)?;
14593                    }
14594                    changed = true;
14595                }
14596            }
14597            Op::Ddl(DdlOp::SetSqlPragma { ref key, value }) => {
14598                let target = match key.as_str() {
14599                    "user_version" => &mut cat.user_version,
14600                    "application_id" => &mut cat.application_id,
14601                    _ => {
14602                        return Err(MongrelError::InvalidArgument(format!(
14603                            "unsupported recovered SQL pragma {key:?}"
14604                        )))
14605                    }
14606                };
14607                if *target != Some(value) {
14608                    *target = Some(value);
14609                    cat.db_epoch = cat.db_epoch.max(ce);
14610                    changed = true;
14611                }
14612            }
14613            Op::Ddl(DdlOp::CatalogSnapshot { ref catalog_json }) => {
14614                if ce <= applied_catalog_epoch {
14615                    continue;
14616                }
14617                let snapshot = DdlOp::decode_catalog(catalog_json)?;
14618                if snapshot.db_epoch != ce {
14619                    return Err(MongrelError::Schema(format!(
14620                        "catalog snapshot epoch {} does not match WAL commit epoch {ce}",
14621                        snapshot.db_epoch
14622                    )));
14623                }
14624                validate_recovered_catalog(&snapshot)?;
14625                validate_catalog_transition(cat, &snapshot)?;
14626                *cat = snapshot;
14627                applied_catalog_epoch = ce;
14628                changed = true;
14629            }
14630            _ => {}
14631        }
14632    }
14633
14634    if cat.db_epoch < max_committed_epoch {
14635        cat.db_epoch = max_committed_epoch;
14636        changed = true;
14637    }
14638    changed |= repair_catalog_allocator_counters(cat)?;
14639
14640    validate_recovered_catalog(cat)?;
14641    let storage_reconciliation = validate_recovered_storage_plan(
14642        root,
14643        durable_root,
14644        cat,
14645        &created_table_ids,
14646        &ttl_updates,
14647        meta_dek,
14648    )?;
14649
14650    let needs_storage_apply = !storage_reconciliation.is_empty() || !ttl_updates.is_empty();
14651    if apply && (changed || needs_storage_apply) {
14652        for table_id in storage_reconciliation {
14653            let entry = cat
14654                .tables
14655                .iter()
14656                .find(|entry| entry.table_id == table_id)
14657                .ok_or_else(|| MongrelError::CorruptWal {
14658                    offset: table_id,
14659                    reason: "recovery storage plan lost its catalog table".into(),
14660                })?;
14661            ensure_recovered_table_storage(
14662                table_roots
14663                    .and_then(|roots| roots.get(&table_id))
14664                    .map(Arc::as_ref),
14665                durable_root,
14666                &root.join(TABLES_DIR).join(table_id.to_string()),
14667                table_id,
14668                &entry.schema,
14669                meta_dek,
14670            )?;
14671        }
14672        for (table_id, (policy, ttl_epoch)) in ttl_updates {
14673            let Some(entry) = cat.tables.iter().find(|entry| {
14674                entry.table_id == table_id
14675                    && matches!(entry.state, TableState::Live | TableState::Building { .. })
14676            }) else {
14677                continue;
14678            };
14679            let table_root = if let Some(root) = table_roots.and_then(|roots| roots.get(&table_id))
14680            {
14681                root.try_clone()?
14682            } else if let Some(root) = durable_root {
14683                root.open_directory(Path::new(TABLES_DIR).join(table_id.to_string()))?
14684            } else {
14685                crate::durable_file::DurableRoot::open(
14686                    root.join(TABLES_DIR).join(table_id.to_string()),
14687                )?
14688            };
14689            let table_dir = table_root.io_path()?;
14690            let mut manifest = crate::manifest::read_durable(&table_root, "", meta_dek)?;
14691            if manifest.ttl != policy || manifest.current_epoch < ttl_epoch {
14692                manifest.ttl = policy;
14693                manifest.current_epoch = manifest.current_epoch.max(ttl_epoch);
14694                manifest.schema_id = entry.schema.schema_id;
14695                crate::manifest::write_atomic(&table_dir, &mut manifest, meta_dek)?;
14696            }
14697        }
14698        if changed {
14699            match durable_root {
14700                Some(root) => catalog::write_durable(root, cat, meta_dek)?,
14701                None => catalog::write_atomic(root, cat, meta_dek)?,
14702            }
14703        }
14704    }
14705    *target_catalog = recovered_catalog;
14706    Ok(())
14707}
14708
14709fn ensure_recovered_table_storage(
14710    pinned_table: Option<&crate::durable_file::DurableRoot>,
14711    durable_root: Option<&crate::durable_file::DurableRoot>,
14712    fallback_table_dir: &Path,
14713    table_id: u64,
14714    schema: &Schema,
14715    meta_dek: Option<&[u8; META_DEK_LEN]>,
14716) -> Result<()> {
14717    let table_root = if let Some(root) = pinned_table {
14718        root.try_clone()?
14719    } else if let Some(root) = durable_root {
14720        let relative = Path::new(TABLES_DIR).join(table_id.to_string());
14721        match root.open_directory(&relative) {
14722            Ok(table) => table,
14723            Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
14724                root.create_directory_all_pinned(relative)?
14725            }
14726            Err(error) => return Err(error.into()),
14727        }
14728    } else {
14729        crate::durable_file::create_directory_all(fallback_table_dir)?;
14730        crate::durable_file::DurableRoot::open(fallback_table_dir)?
14731    };
14732    let table_dir = table_root.io_path()?;
14733    let mut existing_manifest = match crate::manifest::read_durable(&table_root, "", meta_dek) {
14734        Ok(manifest) => {
14735            if manifest.table_id != table_id {
14736                return Err(MongrelError::Conflict(format!(
14737                    "recovered table directory id mismatch: expected {table_id}, found {}",
14738                    manifest.table_id
14739                )));
14740            }
14741            Some(manifest)
14742        }
14743        Err(MongrelError::Io(error)) if error.kind() == std::io::ErrorKind::NotFound => None,
14744        Err(error) => return Err(error),
14745    };
14746
14747    table_root.create_directory_all(crate::engine::WAL_DIR)?;
14748    table_root.create_directory_all(crate::engine::RUNS_DIR)?;
14749    crate::engine::write_schema(&table_dir, schema)?;
14750
14751    if let Some(mut manifest) = existing_manifest.take() {
14752        if manifest.schema_id != schema.schema_id {
14753            manifest.schema_id = schema.schema_id;
14754            crate::manifest::write_atomic(&table_dir, &mut manifest, meta_dek)?;
14755        }
14756    } else {
14757        // The DB-wide meta DEK is also the per-table manifest meta DEK.
14758        let mut manifest = crate::manifest::Manifest::new(table_id, schema.schema_id);
14759        crate::manifest::write_atomic(&table_dir, &mut manifest, meta_dek)?;
14760    }
14761    Ok(())
14762}
14763
14764fn validate_recovered_schema(schema: &Schema) -> Result<()> {
14765    schema.validate_auto_increment()?;
14766    schema.validate_defaults()?;
14767    schema.validate_ai()?;
14768    let mut column_ids = HashSet::new();
14769    let mut column_names = HashSet::new();
14770    for column in &schema.columns {
14771        if !column_ids.insert(column.id) || !column_names.insert(column.name.as_str()) {
14772            return Err(MongrelError::Schema(
14773                "recovered schema contains duplicate columns".into(),
14774            ));
14775        }
14776        match &column.ty {
14777            TypeId::Decimal128 { precision, scale }
14778                if *precision == 0 || *precision > 38 || scale.unsigned_abs() > *precision =>
14779            {
14780                return Err(MongrelError::Schema(format!(
14781                    "column {:?} has invalid decimal precision or scale",
14782                    column.name
14783                )));
14784            }
14785            TypeId::Enum { variants }
14786                if variants.is_empty()
14787                    || variants.iter().any(String::is_empty)
14788                    || variants.iter().collect::<HashSet<_>>().len() != variants.len() =>
14789            {
14790                return Err(MongrelError::Schema(format!(
14791                    "column {:?} has invalid enum variants",
14792                    column.name
14793                )));
14794            }
14795            _ => {}
14796        }
14797    }
14798    let mut index_names = HashSet::new();
14799    for index in &schema.indexes {
14800        index.validate_options()?;
14801        if index.name.is_empty()
14802            || !index_names.insert(index.name.as_str())
14803            || schema
14804                .columns
14805                .iter()
14806                .all(|column| column.id != index.column_id)
14807        {
14808            return Err(MongrelError::Schema(format!(
14809                "recovered index {:?} references missing column {}",
14810                index.name, index.column_id
14811            )));
14812        }
14813    }
14814    let mut colocated = HashSet::new();
14815    for group in &schema.colocation {
14816        if group.is_empty()
14817            || group.iter().any(|id| !column_ids.contains(id))
14818            || group.iter().any(|id| !colocated.insert(*id))
14819        {
14820            return Err(MongrelError::Schema(
14821                "recovered schema contains invalid column co-location groups".into(),
14822            ));
14823        }
14824    }
14825
14826    let mut constraint_ids = HashSet::new();
14827    let mut constraint_names = HashSet::<String>::new();
14828    let mut validate_constraint_identity = |id: u16, name: &str| -> Result<()> {
14829        if name.is_empty()
14830            || !constraint_ids.insert(id)
14831            || !constraint_names.insert(name.to_owned())
14832        {
14833            return Err(MongrelError::Schema(
14834                "recovered schema contains duplicate or empty constraint identities".into(),
14835            ));
14836        }
14837        Ok(())
14838    };
14839    for unique in &schema.constraints.uniques {
14840        validate_constraint_identity(unique.id, &unique.name)?;
14841        if unique.columns.is_empty()
14842            || unique.columns.iter().any(|id| !column_ids.contains(id))
14843            || unique.columns.iter().collect::<HashSet<_>>().len() != unique.columns.len()
14844        {
14845            return Err(MongrelError::Schema(format!(
14846                "unique constraint {:?} has invalid columns",
14847                unique.name
14848            )));
14849        }
14850    }
14851    for foreign_key in &schema.constraints.foreign_keys {
14852        validate_constraint_identity(foreign_key.id, &foreign_key.name)?;
14853        if foreign_key.ref_table.is_empty()
14854            || foreign_key.columns.is_empty()
14855            || foreign_key.columns.len() != foreign_key.ref_columns.len()
14856            || foreign_key
14857                .columns
14858                .iter()
14859                .any(|id| !column_ids.contains(id))
14860            || foreign_key.columns.iter().collect::<HashSet<_>>().len() != foreign_key.columns.len()
14861            || foreign_key.ref_columns.iter().collect::<HashSet<_>>().len()
14862                != foreign_key.ref_columns.len()
14863        {
14864            return Err(MongrelError::Schema(format!(
14865                "foreign key {:?} has invalid columns",
14866                foreign_key.name
14867            )));
14868        }
14869        if (matches!(foreign_key.on_delete, crate::constraint::FkAction::SetNull)
14870            || matches!(foreign_key.on_update, crate::constraint::FkAction::SetNull))
14871            && foreign_key.columns.iter().any(|id| {
14872                schema
14873                    .columns
14874                    .iter()
14875                    .find(|column| column.id == *id)
14876                    .is_none_or(|column| {
14877                        !column.flags.contains(crate::schema::ColumnFlags::NULLABLE)
14878                    })
14879            })
14880        {
14881            return Err(MongrelError::Schema(format!(
14882                "foreign key {:?} uses SET NULL on a non-nullable column",
14883                foreign_key.name
14884            )));
14885        }
14886    }
14887    for check in &schema.constraints.checks {
14888        validate_constraint_identity(check.id, &check.name)?;
14889        check.expr.validate()?;
14890        validate_check_columns(&check.expr, &column_ids)?;
14891    }
14892    Ok(())
14893}
14894
14895fn validate_check_columns(
14896    expression: &crate::constraint::CheckExpr,
14897    column_ids: &HashSet<u16>,
14898) -> Result<()> {
14899    use crate::constraint::CheckExpr;
14900    match expression {
14901        CheckExpr::Col(id) | CheckExpr::IsNull(id) | CheckExpr::IsNotNull(id) => {
14902            if column_ids.contains(id) {
14903                Ok(())
14904            } else {
14905                Err(MongrelError::Schema(format!(
14906                    "check constraint references unknown column {id}"
14907                )))
14908            }
14909        }
14910        CheckExpr::Regex { col, .. } => {
14911            if column_ids.contains(col) {
14912                Ok(())
14913            } else {
14914                Err(MongrelError::Schema(format!(
14915                    "check constraint references unknown column {col}"
14916                )))
14917            }
14918        }
14919        CheckExpr::Add(left, right)
14920        | CheckExpr::Sub(left, right)
14921        | CheckExpr::Mul(left, right)
14922        | CheckExpr::Div(left, right)
14923        | CheckExpr::Mod(left, right)
14924        | CheckExpr::Eq(left, right)
14925        | CheckExpr::Ne(left, right)
14926        | CheckExpr::Lt(left, right)
14927        | CheckExpr::Le(left, right)
14928        | CheckExpr::Gt(left, right)
14929        | CheckExpr::Ge(left, right)
14930        | CheckExpr::And(left, right)
14931        | CheckExpr::Or(left, right) => {
14932            validate_check_columns(left, column_ids)?;
14933            validate_check_columns(right, column_ids)
14934        }
14935        CheckExpr::Not(inner) => validate_check_columns(inner, column_ids),
14936        CheckExpr::True | CheckExpr::Lit(_) => Ok(()),
14937    }
14938}
14939
14940fn validate_catalog_transition(current: &Catalog, next: &Catalog) -> Result<()> {
14941    for (name, prior, candidate) in [
14942        ("db_epoch", current.db_epoch, next.db_epoch),
14943        ("next_table_id", current.next_table_id, next.next_table_id),
14944        (
14945            "next_segment_no",
14946            current.next_segment_no,
14947            next.next_segment_no,
14948        ),
14949        ("next_user_id", current.next_user_id, next.next_user_id),
14950        (
14951            "security_version",
14952            current.security_version,
14953            next.security_version,
14954        ),
14955    ] {
14956        if candidate < prior {
14957            return Err(MongrelError::Schema(format!(
14958                "catalog snapshot rolls back {name} from {prior} to {candidate}"
14959            )));
14960        }
14961    }
14962    for prior in &current.tables {
14963        let Some(candidate) = next
14964            .tables
14965            .iter()
14966            .find(|entry| entry.table_id == prior.table_id)
14967        else {
14968            return Err(MongrelError::Schema(format!(
14969                "catalog snapshot removes table identity {}",
14970                prior.table_id
14971            )));
14972        };
14973        if candidate.created_epoch != prior.created_epoch
14974            || candidate.schema.schema_id < prior.schema.schema_id
14975            || matches!(prior.state, TableState::Dropped { .. })
14976                && !matches!(candidate.state, TableState::Dropped { .. })
14977        {
14978            return Err(MongrelError::Schema(format!(
14979                "catalog snapshot rolls back table identity {}",
14980                prior.table_id
14981            )));
14982        }
14983    }
14984    for prior in &current.users {
14985        if let Some(candidate) = next.users.iter().find(|user| user.id == prior.id) {
14986            if candidate.username != prior.username
14987                || candidate.created_epoch != prior.created_epoch
14988            {
14989                return Err(MongrelError::Schema(format!(
14990                    "catalog snapshot reuses user identity {}",
14991                    prior.id
14992                )));
14993            }
14994        }
14995    }
14996    Ok(())
14997}
14998
14999fn validate_recovered_catalog(catalog: &Catalog) -> Result<()> {
15000    let mut table_ids = HashSet::new();
15001    let mut active_names = HashSet::new();
15002    let mut max_table_id = None::<u64>;
15003    for entry in &catalog.tables {
15004        if !table_ids.insert(entry.table_id) {
15005            return Err(MongrelError::Schema(format!(
15006                "catalog contains duplicate table id {}",
15007                entry.table_id
15008            )));
15009        }
15010        max_table_id = Some(max_table_id.map_or(entry.table_id, |value| value.max(entry.table_id)));
15011        if entry.name.is_empty() || entry.created_epoch > catalog.db_epoch {
15012            return Err(MongrelError::Schema(format!(
15013                "catalog table {} has invalid name or creation epoch",
15014                entry.table_id
15015            )));
15016        }
15017        validate_recovered_schema(&entry.schema)?;
15018        match &entry.state {
15019            TableState::Live => {
15020                if !active_names.insert(entry.name.as_str()) {
15021                    return Err(MongrelError::Schema(format!(
15022                        "catalog contains duplicate active table name {:?}",
15023                        entry.name
15024                    )));
15025                }
15026            }
15027            TableState::Dropped { at_epoch } => {
15028                if *at_epoch < entry.created_epoch || *at_epoch > catalog.db_epoch {
15029                    return Err(MongrelError::Schema(format!(
15030                        "catalog table {} has invalid drop epoch {at_epoch}",
15031                        entry.table_id
15032                    )));
15033                }
15034            }
15035            TableState::Building {
15036                intended_name,
15037                query_id,
15038                replaces_table_id,
15039                ..
15040            } => {
15041                if intended_name.is_empty() || query_id.is_empty() {
15042                    return Err(MongrelError::Schema(format!(
15043                        "building table {} has empty identity fields",
15044                        entry.table_id
15045                    )));
15046                }
15047                if !active_names.insert(entry.name.as_str()) {
15048                    return Err(MongrelError::Schema(format!(
15049                        "catalog contains duplicate active/building table name {:?}",
15050                        entry.name
15051                    )));
15052                }
15053                if replaces_table_id.is_some_and(|id| id == entry.table_id) {
15054                    return Err(MongrelError::Schema(
15055                        "building table cannot replace itself".into(),
15056                    ));
15057                }
15058            }
15059        }
15060    }
15061    if let Some(maximum) = max_table_id {
15062        let required = maximum
15063            .checked_add(1)
15064            .ok_or_else(|| MongrelError::Full("table id namespace exhausted".into()))?;
15065        if catalog.next_table_id < required {
15066            return Err(MongrelError::Schema(format!(
15067                "catalog next_table_id {} precedes required {required}",
15068                catalog.next_table_id
15069            )));
15070        }
15071    }
15072    for entry in &catalog.tables {
15073        if let TableState::Building {
15074            replaces_table_id: Some(replaced),
15075            ..
15076        } = entry.state
15077        {
15078            if !table_ids.contains(&replaced) {
15079                return Err(MongrelError::Schema(format!(
15080                    "building table {} replaces unknown table {replaced}",
15081                    entry.table_id
15082                )));
15083            }
15084        }
15085    }
15086    for entry in &catalog.tables {
15087        if matches!(entry.state, TableState::Live | TableState::Building { .. }) {
15088            validate_foreign_key_targets(catalog, &entry.schema)?;
15089        }
15090    }
15091
15092    let mut external_names = HashSet::new();
15093    for entry in &catalog.external_tables {
15094        entry.validate()?;
15095        validate_recovered_schema(&entry.declared_schema)?;
15096        if !entry.declared_schema.constraints.is_empty() {
15097            return Err(MongrelError::Schema(format!(
15098                "external table {:?} cannot carry engine-enforced constraints",
15099                entry.name
15100            )));
15101        }
15102        if entry.created_epoch > catalog.db_epoch
15103            || !external_names.insert(entry.name.as_str())
15104            || active_names.contains(entry.name.as_str())
15105        {
15106            return Err(MongrelError::Schema(format!(
15107                "invalid or duplicate external table {:?}",
15108                entry.name
15109            )));
15110        }
15111    }
15112
15113    let mut procedure_names = HashSet::new();
15114    for entry in &catalog.procedures {
15115        entry.procedure.validate()?;
15116        if entry.procedure.created_epoch > entry.procedure.updated_epoch
15117            || entry.procedure.updated_epoch > catalog.db_epoch
15118            || !procedure_names.insert(entry.procedure.name.as_str())
15119        {
15120            return Err(MongrelError::Schema(format!(
15121                "invalid or duplicate procedure {:?}",
15122                entry.procedure.name
15123            )));
15124        }
15125        validate_recovered_procedure_references(catalog, &entry.procedure)?;
15126    }
15127
15128    let mut trigger_names = HashSet::new();
15129    for entry in &catalog.triggers {
15130        entry.trigger.validate()?;
15131        if entry.trigger.created_epoch > entry.trigger.updated_epoch
15132            || entry.trigger.updated_epoch > catalog.db_epoch
15133            || !trigger_names.insert(entry.trigger.name.as_str())
15134        {
15135            return Err(MongrelError::Schema(format!(
15136                "invalid or duplicate trigger {:?}",
15137                entry.trigger.name
15138            )));
15139        }
15140        validate_recovered_trigger_references(catalog, &entry.trigger)?;
15141    }
15142
15143    let mut views = HashSet::new();
15144    for view in &catalog.materialized_views {
15145        let target = catalog.live(&view.name).ok_or_else(|| {
15146            MongrelError::Schema(format!(
15147                "materialized view {:?} has no live table",
15148                view.name
15149            ))
15150        })?;
15151        if view.name.is_empty()
15152            || view.query.trim().is_empty()
15153            || view.last_refresh_epoch > catalog.db_epoch
15154            || !views.insert(view.name.as_str())
15155        {
15156            return Err(MongrelError::Schema(format!(
15157                "materialized view {:?} has no unique live table",
15158                view.name
15159            )));
15160        }
15161        if let Some(incremental) = &view.incremental {
15162            let source = catalog.live(&incremental.source_table).ok_or_else(|| {
15163                MongrelError::Schema(format!(
15164                    "materialized view {:?} references missing source {:?}",
15165                    view.name, incremental.source_table
15166                ))
15167            })?;
15168            if source.table_id != incremental.source_table_id
15169                || source
15170                    .schema
15171                    .columns
15172                    .iter()
15173                    .all(|column| column.id != incremental.group_column)
15174            {
15175                return Err(MongrelError::Schema(format!(
15176                    "materialized view {:?} has invalid incremental source",
15177                    view.name
15178                )));
15179            }
15180            let target_ids = target
15181                .schema
15182                .columns
15183                .iter()
15184                .map(|column| column.id)
15185                .collect::<HashSet<_>>();
15186            let mut output_ids = HashSet::new();
15187            let count_outputs = incremental
15188                .outputs
15189                .iter()
15190                .filter(|output| {
15191                    matches!(output.kind, crate::catalog::IncrementalAggregateKind::Count)
15192                })
15193                .count();
15194            if incremental.checkpoint_event_id.is_empty()
15195                || !target_ids.contains(&incremental.group_output_column)
15196                || !target_ids.contains(&incremental.count_output_column)
15197                || incremental.outputs.is_empty()
15198                || count_outputs != 1
15199                || incremental.outputs.iter().any(|output| {
15200                    !target_ids.contains(&output.output_column)
15201                        || output.output_column == incremental.group_output_column
15202                        || !output_ids.insert(output.output_column)
15203                        || matches!(output.kind, crate::catalog::IncrementalAggregateKind::Count)
15204                            && output.output_column != incremental.count_output_column
15205                        || match output.kind {
15206                            crate::catalog::IncrementalAggregateKind::Sum { source_column } => {
15207                                source
15208                                    .schema
15209                                    .columns
15210                                    .iter()
15211                                    .all(|column| column.id != source_column)
15212                            }
15213                            crate::catalog::IncrementalAggregateKind::Count => false,
15214                        }
15215                })
15216            {
15217                return Err(MongrelError::Schema(format!(
15218                    "materialized view {:?} has invalid incremental outputs",
15219                    view.name
15220                )));
15221            }
15222        }
15223    }
15224
15225    validate_security_catalog(catalog, &catalog.security)?;
15226    validate_recovered_auth_catalog(catalog)?;
15227    Ok(())
15228}
15229
15230fn repair_catalog_allocator_counters(catalog: &mut Catalog) -> Result<bool> {
15231    let mut changed = false;
15232    if let Some(maximum) = catalog.tables.iter().map(|entry| entry.table_id).max() {
15233        let required = maximum
15234            .checked_add(1)
15235            .ok_or_else(|| MongrelError::Full("table id namespace exhausted".into()))?;
15236        if catalog.next_table_id < required {
15237            catalog.next_table_id = required;
15238            changed = true;
15239        }
15240    }
15241    if let Some(maximum) = catalog.users.iter().map(|user| user.id).max() {
15242        let required = maximum
15243            .checked_add(1)
15244            .ok_or_else(|| MongrelError::Full("user id namespace exhausted".into()))?;
15245        if catalog.next_user_id < required {
15246            catalog.next_user_id = required;
15247            changed = true;
15248        }
15249    }
15250    Ok(changed)
15251}
15252
15253fn validate_foreign_key_targets(catalog: &Catalog, schema: &Schema) -> Result<()> {
15254    for foreign_key in &schema.constraints.foreign_keys {
15255        let parent = catalog.live(&foreign_key.ref_table).ok_or_else(|| {
15256            MongrelError::Schema(format!(
15257                "foreign key {:?} references unknown live table {:?}",
15258                foreign_key.name, foreign_key.ref_table
15259            ))
15260        })?;
15261        let referenced_unique = parent
15262            .schema
15263            .constraints
15264            .uniques
15265            .iter()
15266            .any(|unique| unique.columns == foreign_key.ref_columns)
15267            || foreign_key.ref_columns.len() == 1
15268                && parent
15269                    .schema
15270                    .primary_key()
15271                    .is_some_and(|column| column.id == foreign_key.ref_columns[0]);
15272        if !referenced_unique {
15273            return Err(MongrelError::Schema(format!(
15274                "foreign key {:?} does not reference a unique key",
15275                foreign_key.name
15276            )));
15277        }
15278        for (local_id, parent_id) in foreign_key.columns.iter().zip(&foreign_key.ref_columns) {
15279            let local = schema.columns.iter().find(|column| column.id == *local_id);
15280            let referenced = parent
15281                .schema
15282                .columns
15283                .iter()
15284                .find(|column| column.id == *parent_id);
15285            if local
15286                .zip(referenced)
15287                .is_none_or(|(local, referenced)| local.ty != referenced.ty)
15288            {
15289                return Err(MongrelError::Schema(format!(
15290                    "foreign key {:?} has missing or incompatible columns",
15291                    foreign_key.name
15292                )));
15293            }
15294        }
15295    }
15296    Ok(())
15297}
15298
15299fn validate_recovered_procedure_references(
15300    catalog: &Catalog,
15301    procedure: &StoredProcedure,
15302) -> Result<()> {
15303    for step in &procedure.body.steps {
15304        let Some(table_name) = step.table() else {
15305            continue;
15306        };
15307        let schema = &catalog
15308            .live(table_name)
15309            .ok_or_else(|| {
15310                MongrelError::Schema(format!(
15311                    "procedure {:?} references unknown table {table_name:?}",
15312                    procedure.name
15313                ))
15314            })?
15315            .schema;
15316        match step {
15317            ProcedureStep::NativeQuery {
15318                conditions,
15319                projection,
15320                ..
15321            } => {
15322                for condition in conditions {
15323                    validate_condition_columns(condition, schema)?;
15324                }
15325                for id in projection.iter().flatten() {
15326                    validate_column_id(*id, schema)?;
15327                }
15328            }
15329            ProcedureStep::Put { cells, .. } => {
15330                for cell in cells {
15331                    validate_column_id(cell.column_id, schema)?;
15332                }
15333            }
15334            ProcedureStep::Upsert {
15335                cells,
15336                update_cells,
15337                ..
15338            } => {
15339                for cell in cells.iter().chain(update_cells.iter().flatten()) {
15340                    validate_column_id(cell.column_id, schema)?;
15341                }
15342            }
15343            ProcedureStep::DeleteByPk { .. } if schema.primary_key().is_none() => {
15344                return Err(MongrelError::Schema(format!(
15345                    "procedure {:?} deletes by primary key on table without one",
15346                    procedure.name
15347                )));
15348            }
15349            ProcedureStep::DeleteByPk { .. }
15350            | ProcedureStep::DeleteRows { .. }
15351            | ProcedureStep::SqlQuery { .. } => {}
15352        }
15353    }
15354    Ok(())
15355}
15356
15357fn validate_recovered_trigger_references(catalog: &Catalog, trigger: &StoredTrigger) -> Result<()> {
15358    let target_schema = match &trigger.target {
15359        TriggerTarget::Table(name) => catalog
15360            .live(name)
15361            .ok_or_else(|| {
15362                MongrelError::Schema(format!(
15363                    "trigger {:?} references unknown table {name:?}",
15364                    trigger.name
15365                ))
15366            })?
15367            .schema
15368            .clone(),
15369        TriggerTarget::View(_) => Schema {
15370            columns: trigger.target_columns.clone(),
15371            ..Schema::default()
15372        },
15373    };
15374    for column in &trigger.update_of {
15375        if target_schema.column(column).is_none() {
15376            return Err(MongrelError::Schema(format!(
15377                "trigger {:?} references unknown UPDATE OF column {column:?}",
15378                trigger.name
15379            )));
15380        }
15381    }
15382    if let Some(expr) = &trigger.when {
15383        validate_trigger_expr(expr, &target_schema, trigger.event)?;
15384    }
15385    let mut selects = HashMap::new();
15386    for step in &trigger.program.steps {
15387        if matches!(step, TriggerStep::SetNew { .. }) && trigger.timing != TriggerTiming::Before {
15388            return Err(MongrelError::Schema(
15389                "SetNew is only valid in BEFORE triggers".into(),
15390            ));
15391        }
15392        validate_trigger_step(step, catalog, &target_schema, trigger.event, &mut selects)?;
15393    }
15394    Ok(())
15395}
15396
15397fn validate_recovered_auth_catalog(catalog: &Catalog) -> Result<()> {
15398    let mut role_names = HashSet::new();
15399    for role in &catalog.roles {
15400        if role.name.is_empty()
15401            || role.created_epoch > catalog.db_epoch
15402            || !role_names.insert(role.name.as_str())
15403        {
15404            return Err(MongrelError::Schema(format!(
15405                "invalid or duplicate role {:?}",
15406                role.name
15407            )));
15408        }
15409        for permission in &role.permissions {
15410            if let Some(table) = permission_table(permission) {
15411                let schema = catalog
15412                    .live(table)
15413                    .map(|entry| &entry.schema)
15414                    .or_else(|| {
15415                        catalog
15416                            .external_tables
15417                            .iter()
15418                            .find(|entry| entry.name == table)
15419                            .map(|entry| &entry.declared_schema)
15420                    })
15421                    .ok_or_else(|| {
15422                        MongrelError::Schema(format!(
15423                            "role {:?} references unknown table {table:?}",
15424                            role.name
15425                        ))
15426                    })?;
15427                let columns = match permission {
15428                    crate::auth::Permission::SelectColumns { columns, .. }
15429                    | crate::auth::Permission::InsertColumns { columns, .. }
15430                    | crate::auth::Permission::UpdateColumns { columns, .. } => Some(columns),
15431                    _ => None,
15432                };
15433                if columns.is_some_and(|columns| {
15434                    columns.is_empty()
15435                        || columns.iter().any(|column| schema.column(column).is_none())
15436                }) {
15437                    return Err(MongrelError::Schema(format!(
15438                        "role {:?} contains invalid column permissions",
15439                        role.name
15440                    )));
15441                }
15442            }
15443        }
15444    }
15445    let mut user_ids = HashSet::new();
15446    let mut usernames = HashSet::new();
15447    let mut maximum_user_id = 0;
15448    for user in &catalog.users {
15449        maximum_user_id = maximum_user_id.max(user.id);
15450        if user.id == 0
15451            || user.username.is_empty()
15452            || user.password_hash.is_empty()
15453            || user.created_epoch > catalog.db_epoch
15454            || !user_ids.insert(user.id)
15455            || !usernames.insert(user.username.as_str())
15456            || user
15457                .roles
15458                .iter()
15459                .any(|role| !role_names.contains(role.as_str()))
15460        {
15461            return Err(MongrelError::Schema(format!(
15462                "invalid or duplicate user {:?}",
15463                user.username
15464            )));
15465        }
15466    }
15467    if !catalog.users.is_empty() && catalog.next_user_id <= maximum_user_id {
15468        return Err(MongrelError::Schema(
15469            "catalog next_user_id does not advance beyond existing user ids".into(),
15470        ));
15471    }
15472    if catalog.require_auth && !catalog.users.iter().any(|user| user.is_admin) {
15473        return Err(MongrelError::Schema(
15474            "authenticated catalog has no administrator".into(),
15475        ));
15476    }
15477    Ok(())
15478}
15479
15480fn validate_recovered_storage_plan(
15481    root: &Path,
15482    durable_root: Option<&crate::durable_file::DurableRoot>,
15483    catalog: &Catalog,
15484    created_table_ids: &HashSet<u64>,
15485    ttl_updates: &HashMap<u64, (Option<crate::manifest::TtlPolicy>, u64)>,
15486    meta_dek: Option<&[u8; META_DEK_LEN]>,
15487) -> Result<Vec<u64>> {
15488    const MAX_SCHEMA_BYTES: u64 = 16 * 1024 * 1024;
15489    let mut reconcile = Vec::new();
15490    for entry in &catalog.tables {
15491        if !matches!(entry.state, TableState::Live | TableState::Building { .. }) {
15492            continue;
15493        }
15494        let relative_dir = Path::new(TABLES_DIR).join(entry.table_id.to_string());
15495        let table_dir = root.join(TABLES_DIR).join(entry.table_id.to_string());
15496        let table_exists = match durable_root {
15497            Some(root) => match root.open_directory(&relative_dir) {
15498                Ok(_) => true,
15499                Err(error) if error.kind() == std::io::ErrorKind::NotFound => false,
15500                Err(error) => return Err(error.into()),
15501            },
15502            None => table_dir.is_dir(),
15503        };
15504        if !table_exists {
15505            if created_table_ids.contains(&entry.table_id) {
15506                reconcile.push(entry.table_id);
15507                continue;
15508            }
15509            return Err(MongrelError::NotFound(format!(
15510                "catalog table {} storage is missing",
15511                entry.table_id
15512            )));
15513        }
15514        let manifest_result = match durable_root {
15515            Some(root) => crate::manifest::read_durable(root, &relative_dir, meta_dek),
15516            None => crate::manifest::read(&table_dir, meta_dek),
15517        };
15518        let manifest = match manifest_result {
15519            Ok(manifest) => manifest,
15520            Err(MongrelError::Io(error))
15521                if created_table_ids.contains(&entry.table_id)
15522                    && error.kind() == std::io::ErrorKind::NotFound =>
15523            {
15524                reconcile.push(entry.table_id);
15525                continue;
15526            }
15527            Err(error) => return Err(error),
15528        };
15529        if manifest.table_id != entry.table_id {
15530            return Err(MongrelError::Conflict(format!(
15531                "catalog table {} storage identity mismatch",
15532                entry.table_id
15533            )));
15534        }
15535        let schema_result = match durable_root {
15536            Some(root) => root
15537                .open_regular(relative_dir.join(crate::engine::SCHEMA_FILENAME))
15538                .map_err(MongrelError::from),
15539            None => crate::durable_file::open_regular_nofollow(
15540                &table_dir.join(crate::engine::SCHEMA_FILENAME),
15541            ),
15542        };
15543        let file = match schema_result {
15544            Ok(file) => file,
15545            Err(MongrelError::Io(error))
15546                if created_table_ids.contains(&entry.table_id)
15547                    && error.kind() == std::io::ErrorKind::NotFound =>
15548            {
15549                reconcile.push(entry.table_id);
15550                continue;
15551            }
15552            Err(error) => return Err(error),
15553        };
15554        let length = file.metadata()?.len();
15555        if length > MAX_SCHEMA_BYTES {
15556            return Err(MongrelError::ResourceLimitExceeded {
15557                resource: "recovered schema bytes",
15558                requested: usize::try_from(length).unwrap_or(usize::MAX),
15559                limit: MAX_SCHEMA_BYTES as usize,
15560            });
15561        }
15562        let disk_schema: Schema = serde_json::from_reader(file.take(MAX_SCHEMA_BYTES + 1))
15563            .map_err(|error| MongrelError::Schema(format!("decode recovered schema: {error}")))?;
15564        if manifest.schema_id != entry.schema.schema_id
15565            || crate::wal::DdlOp::encode_schema(&disk_schema)?
15566                != crate::wal::DdlOp::encode_schema(&entry.schema)?
15567        {
15568            reconcile.push(entry.table_id);
15569        }
15570    }
15571    for table_id in ttl_updates.keys() {
15572        if !catalog.tables.iter().any(|entry| {
15573            entry.table_id == *table_id
15574                && matches!(entry.state, TableState::Live | TableState::Building { .. })
15575        }) {
15576            continue;
15577        }
15578        let relative_dir = Path::new(TABLES_DIR).join(table_id.to_string());
15579        let table_exists = match durable_root {
15580            Some(root) => match root.open_directory(&relative_dir) {
15581                Ok(_) => true,
15582                Err(error) if error.kind() == std::io::ErrorKind::NotFound => false,
15583                Err(error) => return Err(error.into()),
15584            },
15585            None => root.join(&relative_dir).is_dir(),
15586        };
15587        if !table_exists && !created_table_ids.contains(table_id) {
15588            return Err(MongrelError::NotFound(format!(
15589                "TTL recovery table {table_id} storage is missing"
15590            )));
15591        }
15592    }
15593    reconcile.sort_unstable();
15594    reconcile.dedup();
15595    Ok(reconcile)
15596}
15597
15598fn validate_catalog_table_storage(
15599    root: &crate::durable_file::DurableRoot,
15600    catalog: &Catalog,
15601    meta_dek: Option<&[u8; META_DEK_LEN]>,
15602) -> Result<()> {
15603    for entry in &catalog.tables {
15604        if !matches!(entry.state, TableState::Live | TableState::Building { .. }) {
15605            continue;
15606        }
15607        let table_dir = Path::new(TABLES_DIR).join(entry.table_id.to_string());
15608        let manifest = crate::manifest::read_durable(root, &table_dir, meta_dek)?;
15609        if manifest.table_id != entry.table_id || manifest.schema_id != entry.schema.schema_id {
15610            return Err(MongrelError::Conflict(format!(
15611                "catalog table {} storage identity mismatch",
15612                entry.table_id
15613            )));
15614        }
15615        root.open_regular(table_dir.join(crate::engine::SCHEMA_FILENAME))?;
15616    }
15617    Ok(())
15618}
15619
15620fn apply_recovered_column_def(schema: &mut Schema, column: ColumnDef) -> Result<bool> {
15621    match schema.columns.iter_mut().find(|c| c.id == column.id) {
15622        Some(existing) if *existing == column => Ok(false),
15623        Some(existing) => {
15624            *existing = column;
15625            schema.schema_id = schema
15626                .schema_id
15627                .checked_add(1)
15628                .ok_or_else(|| MongrelError::Schema("schema id space exhausted".into()))?;
15629            Ok(true)
15630        }
15631        None => {
15632            schema.columns.push(column);
15633            schema.schema_id = schema
15634                .schema_id
15635                .checked_add(1)
15636                .ok_or_else(|| MongrelError::Schema("schema id space exhausted".into()))?;
15637            Ok(true)
15638        }
15639    }
15640}
15641
15642fn permission_table(permission: &crate::auth::Permission) -> Option<&str> {
15643    use crate::auth::Permission;
15644    match permission {
15645        Permission::Select { table }
15646        | Permission::Insert { table }
15647        | Permission::Update { table }
15648        | Permission::Delete { table }
15649        | Permission::SelectColumns { table, .. }
15650        | Permission::InsertColumns { table, .. }
15651        | Permission::UpdateColumns { table, .. } => Some(table),
15652        Permission::All | Permission::Ddl | Permission::Admin => None,
15653    }
15654}
15655
15656fn apply_rebuilding_publish(
15657    catalog: &mut Catalog,
15658    table_id: u64,
15659    replaced_table_id: u64,
15660    new_name: &str,
15661    epoch: u64,
15662) -> Result<bool> {
15663    let already_published = catalog.tables.iter().any(|entry| {
15664        entry.table_id == table_id
15665            && entry.name == new_name
15666            && matches!(entry.state, TableState::Live)
15667    }) && catalog.tables.iter().any(|entry| {
15668        entry.table_id == replaced_table_id && matches!(entry.state, TableState::Dropped { .. })
15669    });
15670    if already_published {
15671        return Ok(false);
15672    }
15673    let schema = catalog
15674        .tables
15675        .iter()
15676        .find(|entry| entry.table_id == table_id)
15677        .ok_or_else(|| MongrelError::NotFound(format!("table id {table_id} not found")))?
15678        .schema
15679        .clone();
15680    let replaced = catalog
15681        .tables
15682        .iter_mut()
15683        .find(|entry| entry.table_id == replaced_table_id)
15684        .ok_or_else(|| MongrelError::NotFound(format!("table id {replaced_table_id} not found")))?;
15685    replaced.state = TableState::Dropped { at_epoch: epoch };
15686    let replacement = catalog
15687        .tables
15688        .iter_mut()
15689        .find(|entry| entry.table_id == table_id)
15690        .ok_or_else(|| MongrelError::NotFound(format!("table id {table_id} not found")))?;
15691    replacement.name = new_name.to_string();
15692    replacement.state = TableState::Live;
15693
15694    for role in &mut catalog.roles {
15695        role.permissions.retain_mut(|permission| {
15696            retain_rebuilt_permission_columns(permission, new_name, &schema)
15697        });
15698    }
15699    for definition in &mut catalog.materialized_views {
15700        if let Some(incremental) = definition.incremental.as_mut() {
15701            if incremental.source_table == new_name
15702                && incremental.source_table_id == replaced_table_id
15703            {
15704                incremental.source_table_id = table_id;
15705            }
15706        }
15707    }
15708    advance_security_version(catalog)?;
15709    Ok(true)
15710}
15711
15712fn retain_rebuilt_permission_columns(
15713    permission: &mut crate::auth::Permission,
15714    target_table: &str,
15715    schema: &Schema,
15716) -> bool {
15717    use crate::auth::Permission;
15718    let columns = match permission {
15719        Permission::SelectColumns { table, columns }
15720        | Permission::InsertColumns { table, columns }
15721        | Permission::UpdateColumns { table, columns }
15722            if table == target_table =>
15723        {
15724            Some(columns)
15725        }
15726        _ => None,
15727    };
15728    if let Some(columns) = columns {
15729        columns.retain(|column| schema.column(column).is_some());
15730        !columns.is_empty()
15731    } else {
15732        true
15733    }
15734}
15735
15736fn rename_permission_table(permission: &mut crate::auth::Permission, old: &str, new: &str) {
15737    use crate::auth::Permission;
15738    let table = match permission {
15739        Permission::Select { table }
15740        | Permission::Insert { table }
15741        | Permission::Update { table }
15742        | Permission::Delete { table }
15743        | Permission::SelectColumns { table, .. }
15744        | Permission::InsertColumns { table, .. }
15745        | Permission::UpdateColumns { table, .. } => Some(table),
15746        Permission::All | Permission::Ddl | Permission::Admin => None,
15747    };
15748    if let Some(table) = table.filter(|table| table.as_str() == old) {
15749        *table = new.to_string();
15750    }
15751}
15752
15753fn rename_permission_column(
15754    permission: &mut crate::auth::Permission,
15755    target_table: &str,
15756    old: &str,
15757    new: &str,
15758) {
15759    use crate::auth::Permission;
15760    let columns = match permission {
15761        Permission::SelectColumns { table, columns }
15762        | Permission::InsertColumns { table, columns }
15763        | Permission::UpdateColumns { table, columns }
15764            if table == target_table =>
15765        {
15766            Some(columns)
15767        }
15768        _ => None,
15769    };
15770    if let Some(column) = columns
15771        .into_iter()
15772        .flatten()
15773        .find(|column| column.as_str() == old)
15774    {
15775        *column = new.to_string();
15776    }
15777}
15778
15779fn merge_permission(
15780    permissions: &mut Vec<crate::auth::Permission>,
15781    permission: crate::auth::Permission,
15782) {
15783    use crate::auth::Permission;
15784    let (kind, table, mut columns) = match permission {
15785        Permission::SelectColumns { table, columns } => (0, table, columns),
15786        Permission::InsertColumns { table, columns } => (1, table, columns),
15787        Permission::UpdateColumns { table, columns } => (2, table, columns),
15788        permission if !permissions.contains(&permission) => {
15789            permissions.push(permission);
15790            return;
15791        }
15792        _ => return,
15793    };
15794    for permission in permissions.iter_mut() {
15795        let existing = match permission {
15796            Permission::SelectColumns {
15797                table: existing_table,
15798                columns,
15799            } if kind == 0 && existing_table == &table => Some(columns),
15800            Permission::InsertColumns {
15801                table: existing_table,
15802                columns,
15803            } if kind == 1 && existing_table == &table => Some(columns),
15804            Permission::UpdateColumns {
15805                table: existing_table,
15806                columns,
15807            } if kind == 2 && existing_table == &table => Some(columns),
15808            _ => None,
15809        };
15810        if let Some(existing) = existing {
15811            existing.append(&mut columns);
15812            existing.sort();
15813            existing.dedup();
15814            return;
15815        }
15816    }
15817    columns.sort();
15818    columns.dedup();
15819    let permission = if kind == 0 {
15820        Permission::SelectColumns { table, columns }
15821    } else if kind == 1 {
15822        Permission::InsertColumns { table, columns }
15823    } else {
15824        Permission::UpdateColumns { table, columns }
15825    };
15826    permissions.push(permission);
15827}
15828
15829fn revoke_permission_from(
15830    permissions: &mut Vec<crate::auth::Permission>,
15831    revoked: &crate::auth::Permission,
15832) {
15833    use crate::auth::Permission;
15834    let revoked_columns = match revoked {
15835        Permission::SelectColumns { table, columns } => Some((0, table, columns)),
15836        Permission::InsertColumns { table, columns } => Some((1, table, columns)),
15837        Permission::UpdateColumns { table, columns } => Some((2, table, columns)),
15838        _ => None,
15839    };
15840    let Some((kind, table, columns)) = revoked_columns else {
15841        permissions.retain(|permission| permission != revoked);
15842        return;
15843    };
15844    for permission in permissions.iter_mut() {
15845        let current = match permission {
15846            Permission::SelectColumns {
15847                table: current_table,
15848                columns,
15849            } if kind == 0 && current_table == table => Some(columns),
15850            Permission::InsertColumns {
15851                table: current_table,
15852                columns,
15853            } if kind == 1 && current_table == table => Some(columns),
15854            Permission::UpdateColumns {
15855                table: current_table,
15856                columns,
15857            } if kind == 2 && current_table == table => Some(columns),
15858            _ => None,
15859        };
15860        if let Some(current) = current {
15861            current.retain(|column| !columns.contains(column));
15862        }
15863    }
15864    permissions.retain(|permission| match permission {
15865        Permission::SelectColumns { columns, .. }
15866        | Permission::InsertColumns { columns, .. }
15867        | Permission::UpdateColumns { columns, .. } => !columns.is_empty(),
15868        _ => true,
15869    });
15870}
15871
15872fn validate_security_catalog(
15873    catalog: &Catalog,
15874    security: &crate::security::SecurityCatalog,
15875) -> Result<()> {
15876    let mut policy_names = HashSet::new();
15877    for table in &security.rls_tables {
15878        if catalog.live(table).is_none() {
15879            return Err(MongrelError::NotFound(format!(
15880                "RLS table {table:?} not found"
15881            )));
15882        }
15883    }
15884    for policy in &security.policies {
15885        if !policy_names.insert((policy.table.clone(), policy.name.clone())) {
15886            return Err(MongrelError::InvalidArgument(format!(
15887                "duplicate policy {:?} on {:?}",
15888                policy.name, policy.table
15889            )));
15890        }
15891        let schema = &catalog
15892            .live(&policy.table)
15893            .ok_or_else(|| {
15894                MongrelError::NotFound(format!("policy table {:?} not found", policy.table))
15895            })?
15896            .schema;
15897        if let Some(expression) = &policy.using {
15898            validate_security_expression(expression, schema)?;
15899        }
15900        if let Some(expression) = &policy.with_check {
15901            validate_security_expression(expression, schema)?;
15902        }
15903    }
15904    let mut mask_names = HashSet::new();
15905    for mask in &security.masks {
15906        if !mask_names.insert((mask.table.clone(), mask.name.clone())) {
15907            return Err(MongrelError::InvalidArgument(format!(
15908                "duplicate mask {:?} on {:?}",
15909                mask.name, mask.table
15910            )));
15911        }
15912        let column = catalog
15913            .live(&mask.table)
15914            .and_then(|entry| {
15915                entry
15916                    .schema
15917                    .columns
15918                    .iter()
15919                    .find(|column| column.id == mask.column)
15920            })
15921            .ok_or_else(|| {
15922                MongrelError::NotFound(format!(
15923                    "mask column {} on {:?} not found",
15924                    mask.column, mask.table
15925                ))
15926            })?;
15927        if matches!(
15928            mask.strategy,
15929            crate::security::MaskStrategy::Redact { .. } | crate::security::MaskStrategy::Sha256
15930        ) && !matches!(column.ty, TypeId::Bytes | TypeId::Enum { .. })
15931        {
15932            return Err(MongrelError::InvalidArgument(format!(
15933                "mask {:?} requires a string/bytes column",
15934                mask.name
15935            )));
15936        }
15937    }
15938    Ok(())
15939}
15940
15941fn validate_security_expression(
15942    expression: &crate::security::SecurityExpr,
15943    schema: &Schema,
15944) -> Result<()> {
15945    use crate::security::SecurityExpr;
15946    match expression {
15947        SecurityExpr::True => Ok(()),
15948        SecurityExpr::ColumnEqCurrentUser { column }
15949        | SecurityExpr::ColumnEqValue { column, .. } => {
15950            if schema
15951                .columns
15952                .iter()
15953                .any(|candidate| candidate.id == *column)
15954            {
15955                Ok(())
15956            } else {
15957                Err(MongrelError::InvalidArgument(format!(
15958                    "security expression references unknown column id {column}"
15959                )))
15960            }
15961        }
15962        SecurityExpr::And { left, right } | SecurityExpr::Or { left, right } => {
15963            validate_security_expression(left, schema)?;
15964            validate_security_expression(right, schema)
15965        }
15966        SecurityExpr::Not { expression } => validate_security_expression(expression, schema),
15967    }
15968}
15969
15970/// Remove canonical numeric table directories that no catalog generation owns.
15971fn sweep_unreferenced_table_dirs(root: &Path, cat: &Catalog) -> Result<()> {
15972    let referenced = cat
15973        .tables
15974        .iter()
15975        .filter(|entry| matches!(entry.state, TableState::Live | TableState::Building { .. }))
15976        .map(|entry| entry.table_id)
15977        .collect::<HashSet<_>>();
15978    let tables_dir = root.join(TABLES_DIR);
15979    let entries = match std::fs::read_dir(&tables_dir) {
15980        Ok(entries) => entries,
15981        Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(()),
15982        Err(error) => return Err(error.into()),
15983    };
15984    for entry in entries {
15985        let entry = entry?;
15986        if !entry.file_type()?.is_dir() {
15987            continue;
15988        }
15989        let file_name = entry.file_name();
15990        let Some(name) = file_name.to_str() else {
15991            continue;
15992        };
15993        let Ok(table_id) = name.parse::<u64>() else {
15994            continue;
15995        };
15996        if name != table_id.to_string() {
15997            continue;
15998        }
15999        if !referenced.contains(&table_id) {
16000            crate::durable_file::remove_directory_all(&entry.path())?;
16001        }
16002    }
16003    Ok(())
16004}
16005
16006/// Sweep stale `_txn/<txn_id>/` dirs from every table (spec §8.5, review fix
16007/// #14). These dirs hold pending uniform-epoch runs from large transactions
16008/// that were aborted or crashed before commit. On open, all such dirs are safe
16009/// to remove because committed txns moved their runs to `_runs/` at publish.
16010fn sweep_pending_txn_dirs(root: &Path, cat: &Catalog) {
16011    for entry in &cat.tables {
16012        let txn_dir = root
16013            .join(TABLES_DIR)
16014            .join(entry.table_id.to_string())
16015            .join("_txn");
16016        if txn_dir.exists() {
16017            let _ = std::fs::remove_dir_all(&txn_dir);
16018        }
16019    }
16020}
16021
16022#[cfg(test)]
16023mod write_permission_tests {
16024    use super::*;
16025    use crate::txn::Staged;
16026
16027    struct NoopExternalBridge;
16028
16029    impl ExternalTriggerBridge for NoopExternalBridge {
16030        fn apply_trigger_external_write(
16031            &self,
16032            _entry: &ExternalTableEntry,
16033            base_state: Vec<u8>,
16034            _op: ExternalTriggerWrite,
16035        ) -> Result<ExternalTriggerWriteResult> {
16036            Ok(ExternalTriggerWriteResult::new(base_state))
16037        }
16038    }
16039
16040    fn assert_txn_namespace_full<T>(result: Result<T>) {
16041        assert!(matches!(result, Err(MongrelError::Full(_))));
16042    }
16043
16044    #[test]
16045    fn every_begin_api_preserves_transaction_id_exhaustion_without_wal_mutation() {
16046        let directory = tempfile::tempdir().unwrap();
16047        let database = Database::create(directory.path()).unwrap();
16048        let generation = (*database.next_txn_id.lock() >> 32).saturating_add(1);
16049        *database.next_txn_id.lock() = generation << 32;
16050        let before = crate::wal::SharedWal::replay(directory.path())
16051            .unwrap()
16052            .len();
16053        let bridge = NoopExternalBridge;
16054
16055        assert_txn_namespace_full(database.begin().commit());
16056        assert_txn_namespace_full(database.begin_as(None).commit_with_row_ids());
16057        assert_txn_namespace_full(
16058            database
16059                .begin_with_isolation(crate::txn::IsolationLevel::Serializable)
16060                .commit(),
16061        );
16062        assert_txn_namespace_full(
16063            database
16064                .begin_with_external_trigger_bridge(&bridge)
16065                .commit(),
16066        );
16067        assert_txn_namespace_full(
16068            database
16069                .begin_with_external_trigger_bridge_as(&bridge, None)
16070                .commit_controlled(&crate::ExecutionControl::new(None), || Ok(())),
16071        );
16072
16073        assert_eq!(
16074            crate::wal::SharedWal::replay(directory.path())
16075                .unwrap()
16076                .len(),
16077            before
16078        );
16079        drop(database);
16080        Database::open(directory.path()).unwrap();
16081    }
16082
16083    #[test]
16084    fn recovered_storage_identity_mismatch_does_not_mutate_directory() {
16085        let directory = tempfile::tempdir().unwrap();
16086        let table_dir = directory.path().join("7");
16087        crate::durable_file::create_directory_all(&table_dir).unwrap();
16088        let original_schema = test_schema();
16089        crate::engine::write_schema(&table_dir, &original_schema).unwrap();
16090        let mut manifest = crate::manifest::Manifest::new(8, original_schema.schema_id);
16091        crate::manifest::write_atomic(&table_dir, &mut manifest, None).unwrap();
16092        let schema_path = table_dir.join(crate::engine::SCHEMA_FILENAME);
16093        let original_bytes = std::fs::read(&schema_path).unwrap();
16094
16095        let mut replacement_schema = original_schema;
16096        replacement_schema.schema_id += 1;
16097        assert!(matches!(
16098            ensure_recovered_table_storage(None, None, &table_dir, 7, &replacement_schema, None,),
16099            Err(MongrelError::Conflict(_))
16100        ));
16101
16102        assert_eq!(std::fs::read(schema_path).unwrap(), original_bytes);
16103        assert!(!table_dir.join(crate::engine::WAL_DIR).exists());
16104        assert!(!table_dir.join(crate::engine::RUNS_DIR).exists());
16105        assert_eq!(crate::manifest::read(&table_dir, None).unwrap().table_id, 8);
16106    }
16107
16108    #[test]
16109    fn catalog_table_missing_storage_fails_without_recreating_it() {
16110        let directory = tempfile::tempdir().unwrap();
16111        let table_dir = {
16112            let database = Database::create(directory.path()).unwrap();
16113            database.create_table("docs", test_schema()).unwrap();
16114            directory
16115                .path()
16116                .join(TABLES_DIR)
16117                .join(database.table_id("docs").unwrap().to_string())
16118        };
16119        std::fs::remove_dir_all(&table_dir).unwrap();
16120
16121        assert!(matches!(
16122            Database::open(directory.path()),
16123            Err(MongrelError::NotFound(_))
16124        ));
16125        assert!(!table_dir.exists());
16126    }
16127
16128    #[test]
16129    fn authentication_and_principal_resolution_share_one_catalog_snapshot() {
16130        let directory = tempfile::tempdir().unwrap();
16131        let database = std::sync::Arc::new(
16132            Database::create_with_credentials(directory.path(), "admin", "admin-password").unwrap(),
16133        );
16134        database.create_user("alice", "old-password").unwrap();
16135        let old_identity = database.user_identity("alice").unwrap();
16136        let (verified_tx, verified_rx) = std::sync::mpsc::channel();
16137        let (resume_tx, resume_rx) = std::sync::mpsc::channel();
16138        let (mutation_started_tx, mutation_started_rx) = std::sync::mpsc::channel();
16139        let (mutation_done_tx, mutation_done_rx) = std::sync::mpsc::channel();
16140
16141        std::thread::scope(|scope| {
16142            let authenticate = {
16143                let database = std::sync::Arc::clone(&database);
16144                scope.spawn(move || {
16145                    database.authenticate_principal_inner("alice", "old-password", || {
16146                        verified_tx.send(()).unwrap();
16147                        resume_rx.recv().unwrap();
16148                    })
16149                })
16150            };
16151            verified_rx.recv().unwrap();
16152            let mutate = {
16153                let database = std::sync::Arc::clone(&database);
16154                scope.spawn(move || {
16155                    mutation_started_tx.send(()).unwrap();
16156                    database.drop_user("alice").unwrap();
16157                    database.create_user("alice", "new-password").unwrap();
16158                    mutation_done_tx.send(()).unwrap();
16159                })
16160            };
16161            mutation_started_rx.recv().unwrap();
16162            assert!(mutation_done_rx
16163                .recv_timeout(std::time::Duration::from_millis(50))
16164                .is_err());
16165            resume_tx.send(()).unwrap();
16166            let principal = authenticate.join().unwrap().unwrap().unwrap();
16167            assert_eq!((principal.user_id, principal.created_epoch), old_identity);
16168            mutate.join().unwrap();
16169        });
16170
16171        assert_ne!(database.user_identity("alice").unwrap(), old_identity);
16172        assert!(database
16173            .authenticate_principal("alice", "old-password")
16174            .unwrap()
16175            .is_none());
16176        assert!(database
16177            .authenticate_principal("alice", "new-password")
16178            .unwrap()
16179            .is_some());
16180    }
16181
16182    #[test]
16183    fn homogeneous_batch_summarizes_to_one_permission_decision() {
16184        let staging = (0..10_050)
16185            .map(|_| {
16186                (
16187                    7,
16188                    Staged::Put(vec![(2, Value::Int64(2)), (1, Value::Int64(1))]),
16189                )
16190            })
16191            .collect::<Vec<_>>();
16192
16193        let needs = summarize_write_permissions(&staging);
16194        let table = needs.get(&7).unwrap();
16195        assert_eq!(needs.len(), 1);
16196        assert!(table.insert);
16197        assert_eq!(table.insert_columns, [1, 2]);
16198        assert!(!table.update);
16199        assert!(!table.delete);
16200        assert!(!table.truncate);
16201    }
16202
16203    #[test]
16204    fn mixed_writes_union_columns_and_preserve_empty_operations() {
16205        let staging = vec![
16206            (7, Staged::Put(vec![(2, Value::Int64(2))])),
16207            (7, Staged::Put(vec![(1, Value::Int64(1))])),
16208            (
16209                7,
16210                Staged::Update {
16211                    row_id: RowId(1),
16212                    new_row: vec![(1, Value::Int64(1)), (2, Value::Int64(2))],
16213                    changed_columns: vec![2],
16214                },
16215            ),
16216            (7, Staged::Delete(RowId(2))),
16217            (8, Staged::Truncate),
16218        ];
16219
16220        let needs = summarize_write_permissions(&staging);
16221        let table = needs.get(&7).unwrap();
16222        assert_eq!(table.insert_columns, [1, 2]);
16223        assert!(table.update);
16224        assert_eq!(table.update_columns, [2]);
16225        assert!(table.delete);
16226        assert!(needs.get(&8).unwrap().truncate);
16227    }
16228
16229    #[test]
16230    fn final_permission_decisions_do_not_scale_with_rows() {
16231        let credentialless_dir = tempfile::tempdir().unwrap();
16232        let credentialless = Database::create(credentialless_dir.path()).unwrap();
16233        credentialless.create_table("docs", test_schema()).unwrap();
16234        WRITE_PERMISSION_DECISIONS.with(|decisions| decisions.set(0));
16235        credentialless
16236            .validate_write_permissions(&puts(credentialless.table_id("docs").unwrap()), None, None)
16237            .unwrap();
16238        WRITE_PERMISSION_DECISIONS.with(|decisions| assert_eq!(decisions.get(), 0));
16239
16240        let authenticated_dir = tempfile::tempdir().unwrap();
16241        let authenticated =
16242            Database::create_with_credentials(authenticated_dir.path(), "admin", "admin-password")
16243                .unwrap();
16244        authenticated.create_table("docs", test_schema()).unwrap();
16245        let admin = authenticated.resolve_principal("admin").unwrap();
16246        WRITE_PERMISSION_DECISIONS.with(|decisions| decisions.set(0));
16247        authenticated
16248            .validate_write_permissions(
16249                &puts(authenticated.table_id("docs").unwrap()),
16250                Some(&admin),
16251                None,
16252            )
16253            .unwrap();
16254        WRITE_PERMISSION_DECISIONS.with(|decisions| assert_eq!(decisions.get(), 1));
16255    }
16256
16257    #[test]
16258    fn delete_batch_checks_permission_once_when_staged_and_once_when_committed() {
16259        let dir = tempfile::tempdir().unwrap();
16260        let db = Database::create_with_credentials(dir.path(), "admin", "admin-password").unwrap();
16261        db.create_table("docs", test_schema()).unwrap();
16262        let admin = db.resolve_principal("admin").unwrap();
16263        TABLE_PERMISSION_DECISIONS.with(|decisions| decisions.set(0));
16264
16265        let mut transaction = db.begin_as(Some(admin));
16266        transaction
16267            .delete_batch("docs", (0..100).map(RowId).collect())
16268            .unwrap();
16269        transaction.commit().unwrap();
16270
16271        TABLE_PERMISSION_DECISIONS.with(|decisions| assert_eq!(decisions.get(), 2));
16272    }
16273
16274    #[test]
16275    fn truncate_validation_checks_admin_once_for_all_tables() {
16276        let dir = tempfile::tempdir().unwrap();
16277        let db = Database::create_with_credentials(dir.path(), "admin", "admin-password").unwrap();
16278        db.create_table("first", test_schema()).unwrap();
16279        db.create_table("second", test_schema()).unwrap();
16280        let admin = db.resolve_principal("admin").unwrap();
16281        let staging = vec![
16282            (db.table_id("first").unwrap(), Staged::Truncate),
16283            (db.table_id("second").unwrap(), Staged::Truncate),
16284        ];
16285
16286        TABLE_PERMISSION_DECISIONS.with(|decisions| decisions.set(0));
16287        db.validate_write_permissions(&staging, Some(&admin), None)
16288            .unwrap();
16289        TABLE_PERMISSION_DECISIONS.with(|decisions| assert_eq!(decisions.get(), 1));
16290    }
16291
16292    #[test]
16293    fn one_table_commit_batches_structural_work() {
16294        let dir = tempfile::tempdir().unwrap();
16295        let db = Database::create(dir.path()).unwrap();
16296        db.create_table("docs", test_schema()).unwrap();
16297        let table_id = db.table_id("docs").unwrap();
16298
16299        AUTO_INCREMENT_TABLE_LOCKS.with(|count| count.set(0));
16300        PREBUILD_TABLE_LOCKS.with(|count| count.set(0));
16301        PUBLISH_TABLE_LOCKS.with(|count| count.set(0));
16302        COMMIT_MANIFEST_WRITES.with(|count| count.set(0));
16303        db.transaction(|transaction| {
16304            for id in 0..100 {
16305                transaction.put("docs", vec![(1, Value::Int64(id))])?;
16306            }
16307            Ok(())
16308        })
16309        .unwrap();
16310
16311        AUTO_INCREMENT_TABLE_LOCKS.with(|count| assert_eq!(count.get(), 2));
16312        PREBUILD_TABLE_LOCKS.with(|count| assert_eq!(count.get(), 1));
16313        PUBLISH_TABLE_LOCKS.with(|count| assert_eq!(count.get(), 1));
16314        COMMIT_MANIFEST_WRITES.with(|count| assert_eq!(count.get(), 1));
16315
16316        let puts = crate::wal::SharedWal::replay(dir.path())
16317            .unwrap()
16318            .into_iter()
16319            .filter_map(|record| match record.op {
16320                crate::wal::Op::Put { table_id: id, rows } if id == table_id => Some(
16321                    bincode::deserialize::<Vec<crate::memtable::Row>>(&rows)
16322                        .unwrap()
16323                        .len(),
16324                ),
16325                _ => None,
16326            })
16327            .collect::<Vec<_>>();
16328        assert_eq!(puts, [100]);
16329
16330        let row_ids = db
16331            .table("docs")
16332            .unwrap()
16333            .lock()
16334            .visible_rows(db.snapshot().0)
16335            .unwrap()
16336            .into_iter()
16337            .take(2)
16338            .map(|row| row.row_id)
16339            .collect::<Vec<_>>();
16340        PREBUILD_TABLE_LOCKS.with(|count| count.set(0));
16341        PUBLISH_TABLE_LOCKS.with(|count| count.set(0));
16342        COMMIT_MANIFEST_WRITES.with(|count| count.set(0));
16343        db.transaction(|transaction| {
16344            for row_id in row_ids {
16345                transaction.delete("docs", row_id)?;
16346            }
16347            Ok(())
16348        })
16349        .unwrap();
16350        PREBUILD_TABLE_LOCKS.with(|count| assert_eq!(count.get(), 1));
16351        PUBLISH_TABLE_LOCKS.with(|count| assert_eq!(count.get(), 1));
16352        COMMIT_MANIFEST_WRITES.with(|count| assert_eq!(count.get(), 1));
16353
16354        let deletes = crate::wal::SharedWal::replay(dir.path())
16355            .unwrap()
16356            .into_iter()
16357            .filter_map(|record| match record.op {
16358                crate::wal::Op::Delete {
16359                    table_id: id,
16360                    row_ids,
16361                } if id == table_id => Some(row_ids.len()),
16362                _ => None,
16363            })
16364            .collect::<Vec<_>>();
16365        assert_eq!(deletes, [2]);
16366    }
16367
16368    fn puts(table_id: u64) -> Vec<(u64, Staged)> {
16369        (0..10_050)
16370            .map(|id| (table_id, Staged::Put(vec![(1, Value::Int64(id))])))
16371            .collect()
16372    }
16373
16374    fn test_schema() -> Schema {
16375        Schema {
16376            columns: vec![ColumnDef {
16377                id: 1,
16378                name: "id".into(),
16379                ty: TypeId::Int64,
16380                flags: crate::schema::ColumnFlags::empty()
16381                    .with(crate::schema::ColumnFlags::PRIMARY_KEY),
16382                default_value: None,
16383            }],
16384            ..Schema::default()
16385        }
16386    }
16387}
16388
16389#[cfg(test)]
16390mod cdc_bounds_tests {
16391    use super::*;
16392
16393    #[test]
16394    fn retained_byte_limit_rejects_without_allocating_payload() {
16395        let mut retained = 0;
16396        let error = charge_cdc_bytes(
16397            &mut retained,
16398            CDC_MAX_RETAINED_BYTES.saturating_add(1),
16399            "CDC retained bytes",
16400        )
16401        .unwrap_err();
16402        assert!(matches!(
16403            error,
16404            MongrelError::ResourceLimitExceeded {
16405                resource: "CDC retained bytes",
16406                ..
16407            }
16408        ));
16409    }
16410
16411    #[test]
16412    fn row_json_estimate_accounts_for_byte_array_expansion() {
16413        let row = crate::memtable::Row::new(RowId(1), Epoch(1))
16414            .with_column(1, Value::Bytes(vec![0; 1024]));
16415        assert!(cdc_row_json_bytes(&row) >= 1024 * std::mem::size_of::<serde_json::Value>());
16416    }
16417}
16418
16419#[cfg(test)]
16420mod generation_metrics_tests {
16421    use super::*;
16422    use crate::schema::{ColumnDef, ColumnFlags, Schema, TypeId};
16423
16424    #[test]
16425    fn legacy_cow_fallback_is_measured() {
16426        let dir = tempfile::tempdir().unwrap();
16427        let table = Table::create(
16428            dir.path(),
16429            Schema {
16430                columns: vec![ColumnDef {
16431                    id: 1,
16432                    name: "id".into(),
16433                    ty: TypeId::Int64,
16434                    flags: ColumnFlags::empty().with(ColumnFlags::PRIMARY_KEY),
16435                    default_value: None,
16436                }],
16437                ..Schema::default()
16438            },
16439            1,
16440        )
16441        .unwrap();
16442        let handle = TableHandle::from_table(table);
16443        let held = match &handle.inner {
16444            TableHandleInner::CopyOnWrite(slot) => Arc::clone(&slot.read()),
16445            TableHandleInner::Direct(_) => unreachable!(),
16446        };
16447
16448        handle.lock().set_sync_byte_threshold(1);
16449
16450        let stats = handle.generation_stats();
16451        assert_eq!(stats.cow_clone_count, 1);
16452        assert!(stats.estimated_cow_clone_bytes > 0);
16453        drop(held);
16454    }
16455}
16456
16457#[cfg(test)]
16458mod trigger_engine_tests {
16459    use super::*;
16460
16461    fn event_with(new_cells: &[(u16, Value)], old_cells: &[(u16, Value)]) -> WriteEvent {
16462        WriteEvent {
16463            table: "test".into(),
16464            kind: TriggerEvent::Insert,
16465            new: Some(TriggerRowImage {
16466                columns: new_cells.iter().cloned().collect(),
16467            }),
16468            old: Some(TriggerRowImage {
16469                columns: old_cells.iter().cloned().collect(),
16470            }),
16471            changed_columns: Vec::new(),
16472            op_indices: Vec::new(),
16473            put_idx: None,
16474            trigger_stack: Vec::new(),
16475        }
16476    }
16477
16478    fn event_insert(new_cells: &[(u16, Value)]) -> WriteEvent {
16479        WriteEvent {
16480            table: "test".into(),
16481            kind: TriggerEvent::Insert,
16482            new: Some(TriggerRowImage {
16483                columns: new_cells.iter().cloned().collect(),
16484            }),
16485            old: None,
16486            changed_columns: Vec::new(),
16487            op_indices: Vec::new(),
16488            put_idx: None,
16489            trigger_stack: Vec::new(),
16490        }
16491    }
16492
16493    #[test]
16494    fn value_order_int64_vs_float64() {
16495        assert_eq!(
16496            value_order(&Value::Int64(5), &Value::Float64(5.0)),
16497            Some(std::cmp::Ordering::Equal)
16498        );
16499        assert_eq!(
16500            value_order(&Value::Int64(5), &Value::Float64(3.0)),
16501            Some(std::cmp::Ordering::Greater)
16502        );
16503        assert_eq!(
16504            value_order(&Value::Int64(2), &Value::Float64(3.0)),
16505            Some(std::cmp::Ordering::Less)
16506        );
16507    }
16508
16509    #[test]
16510    fn value_order_null_returns_none() {
16511        assert_eq!(value_order(&Value::Int64(5), &Value::Null), None);
16512        assert_eq!(value_order(&Value::Null, &Value::Int64(5)), None);
16513        assert_eq!(value_order(&Value::Null, &Value::Null), None);
16514    }
16515
16516    #[test]
16517    fn value_order_cross_group_returns_none() {
16518        assert_eq!(
16519            value_order(&Value::Int64(5), &Value::Bytes(b"x".to_vec())),
16520            None
16521        );
16522        assert_eq!(value_order(&Value::Bool(true), &Value::Int64(1)), None);
16523        assert_eq!(
16524            value_order(
16525                &Value::Embedding(vec![1.0, 2.0]),
16526                &Value::Embedding(vec![1.0, 2.0])
16527            ),
16528            None
16529        );
16530    }
16531
16532    #[test]
16533    fn eval_trigger_expr_ranges_and_booleans() {
16534        let expr = TriggerExpr::And {
16535            left: Box::new(TriggerExpr::Gt {
16536                left: TriggerValue::NewColumn(1),
16537                right: TriggerValue::Literal(Value::Int64(0)),
16538            }),
16539            right: Box::new(TriggerExpr::Lte {
16540                left: TriggerValue::NewColumn(1),
16541                right: TriggerValue::Literal(Value::Int64(100)),
16542            }),
16543        };
16544        assert!(eval_trigger_expr(&expr, &event_insert(&[(1, Value::Int64(50))])).unwrap());
16545        assert!(!eval_trigger_expr(&expr, &event_insert(&[(1, Value::Int64(200))])).unwrap());
16546        assert!(!eval_trigger_expr(&expr, &event_insert(&[(1, Value::Null)])).unwrap());
16547
16548        let or_expr = TriggerExpr::Or {
16549            left: Box::new(TriggerExpr::Lt {
16550                left: TriggerValue::NewColumn(1),
16551                right: TriggerValue::Literal(Value::Int64(0)),
16552            }),
16553            right: Box::new(TriggerExpr::Not(Box::new(TriggerExpr::IsNull(
16554                TriggerValue::OldColumn(2),
16555            )))),
16556        };
16557        assert!(eval_trigger_expr(
16558            &or_expr,
16559            &event_with(&[(1, Value::Int64(5))], &[(2, Value::Int64(99))])
16560        )
16561        .unwrap());
16562        assert!(!eval_trigger_expr(
16563            &or_expr,
16564            &event_with(&[(1, Value::Int64(5))], &[(2, Value::Null)])
16565        )
16566        .unwrap());
16567
16568        assert!(eval_trigger_expr(
16569            &TriggerExpr::Value(TriggerValue::Literal(Value::Bool(true))),
16570            &event_insert(&[])
16571        )
16572        .unwrap());
16573        assert!(!eval_trigger_expr(
16574            &TriggerExpr::Value(TriggerValue::Literal(Value::Bool(false))),
16575            &event_insert(&[])
16576        )
16577        .unwrap());
16578        assert!(!eval_trigger_expr(
16579            &TriggerExpr::Value(TriggerValue::Literal(Value::Null)),
16580            &event_insert(&[])
16581        )
16582        .unwrap());
16583    }
16584}