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 durable_root = crate::durable_file::DurableRoot::open(root.as_ref())?;
1597        Self::begin_open_durable(durable_root, lock_timeout_ms)
1598    }
1599
1600    fn begin_open_durable(
1601        durable_root: crate::durable_file::DurableRoot,
1602        lock_timeout_ms: u32,
1603    ) -> Result<(PathBuf, DatabaseFileLock)> {
1604        let io_root = durable_root.io_path()?;
1605        let current_root = io_root.canonicalize()?;
1606        let mut lock = Self::acquire_database_lock(&current_root, lock_timeout_ms)?;
1607        lock.durable_root = Some(Arc::new(durable_root));
1608        let io_root = lock
1609            .durable_root
1610            .as_ref()
1611            .ok_or_else(|| MongrelError::Other("database root descriptor was not pinned".into()))?
1612            .io_path()?;
1613        if lock
1614            .durable_root
1615            .as_ref()
1616            .ok_or_else(|| MongrelError::Other("database root descriptor was not pinned".into()))?
1617            .open_directory(META_DIR)
1618            .is_err()
1619        {
1620            return Err(MongrelError::NotFound(format!(
1621                "no database metadata found at {:?}",
1622                current_root
1623            )));
1624        }
1625        Self::acquire_legacy_database_lock(&mut lock, &io_root, lock_timeout_ms)?;
1626        Ok((io_root, lock))
1627    }
1628
1629    /// Create a fresh plaintext database at `root`.
1630    pub fn create(root: impl AsRef<Path>) -> Result<Self> {
1631        let (root, lock) = Self::begin_create(root)?;
1632        Self::create_inner(root, None, lock)
1633    }
1634
1635    /// Create a fresh encrypted database, deriving the DB-wide KEK from a
1636    /// passphrase (Argon2id + HKDF). The salt is persisted at `_meta/keys`.
1637    #[cfg(feature = "encryption")]
1638    pub fn create_encrypted(root: impl AsRef<Path>, passphrase: &str) -> Result<Self> {
1639        let (root, lock) = Self::begin_create(root)?;
1640        let salt = crate::encryption::random_salt()?;
1641        crate::durable_file::write_atomic(&root.join(META_DIR).join(KEYS_FILENAME), &salt)?;
1642        let kek = Arc::new(crate::encryption::Kek::derive(passphrase, &salt)?);
1643        Self::create_inner(root, Some(kek), lock)
1644    }
1645
1646    /// Create a fresh encrypted database, deriving the DB-wide KEK from a raw
1647    /// high-entropy key via HKDF. The salt is persisted at `_meta/keys`.
1648    #[cfg(feature = "encryption")]
1649    pub fn create_with_key(root: impl AsRef<Path>, key: &[u8]) -> Result<Self> {
1650        let (root, lock) = Self::begin_create(root)?;
1651        let salt = crate::encryption::random_salt()?;
1652        crate::durable_file::write_atomic(&root.join(META_DIR).join(KEYS_FILENAME), &salt)?;
1653        let kek = Arc::new(crate::encryption::Kek::from_raw_key(key, &salt)?);
1654        Self::create_inner(root, Some(kek), lock)
1655    }
1656
1657    fn create_inner(
1658        root: PathBuf,
1659        kek: Option<Arc<crate::encryption::Kek>>,
1660        lock: DatabaseFileLock,
1661    ) -> Result<Self> {
1662        crate::durable_file::create_directory_all(&root.join(TABLES_DIR))?;
1663        let meta_dek = crate::encryption::meta_dek_for(kek.as_deref());
1664        let cat = Catalog::empty();
1665        catalog::write_atomic(&root, &cat, meta_dek.as_ref())?;
1666        Self::finish_open(root, cat, kek, meta_dek, false, None, None, None, lock)
1667    }
1668
1669    /// Open an existing plaintext database.
1670    pub fn open(root: impl AsRef<Path>) -> Result<Self> {
1671        Self::open_inner(root, None, None)
1672    }
1673
1674    /// Open an existing encrypted database with a passphrase.
1675    #[cfg(feature = "encryption")]
1676    pub fn open_encrypted(root: impl AsRef<Path>, passphrase: &str) -> Result<Self> {
1677        let (root, lock) = Self::begin_open(root, 0)?;
1678        let salt = read_encryption_salt(lock.durable_root.as_deref().ok_or_else(|| {
1679            MongrelError::Other("database root descriptor was not pinned".into())
1680        })?)?;
1681        let kek = Arc::new(crate::encryption::Kek::derive(passphrase, &salt)?);
1682        Self::open_inner_locked(root, Some(kek), lock)
1683    }
1684
1685    /// Open an existing encrypted database with a configurable cross-process
1686    /// lock timeout. Mirrors [`open_with_options`](Self::open_with_options).
1687    #[cfg(feature = "encryption")]
1688    pub fn open_encrypted_with_options(
1689        root: impl AsRef<Path>,
1690        passphrase: &str,
1691        options: OpenOptions,
1692    ) -> Result<Self> {
1693        let (root, lock) = Self::begin_open(root, options.lock_timeout_ms)?;
1694        let salt = read_encryption_salt(lock.durable_root.as_deref().ok_or_else(|| {
1695            MongrelError::Other("database root descriptor was not pinned".into())
1696        })?)?;
1697        let kek = Arc::new(crate::encryption::Kek::derive(passphrase, &salt)?);
1698        Self::open_inner_locked(root, Some(kek), lock)
1699    }
1700
1701    /// Open an existing encrypted database using a raw high-entropy key.
1702    #[cfg(feature = "encryption")]
1703    pub fn open_with_key(root: impl AsRef<Path>, key: &[u8]) -> Result<Self> {
1704        let (root, lock) = Self::begin_open(root, 0)?;
1705        let salt = read_encryption_salt(lock.durable_root.as_deref().ok_or_else(|| {
1706            MongrelError::Other("database root descriptor was not pinned".into())
1707        })?)?;
1708        let kek = Arc::new(crate::encryption::Kek::from_raw_key(key, &salt)?);
1709        Self::open_inner_locked(root, Some(kek), lock)
1710    }
1711
1712    /// Open an existing plaintext database that has `require_auth = true`,
1713    /// verifying the supplied credentials up front and caching the resolved
1714    /// [`Principal`] on the returned handle. Every subsequent operation will
1715    /// be checked against that principal.
1716    ///
1717    /// Returns [`MongrelError::AuthNotRequired`] if the database does not have
1718    /// `require_auth` enabled — callers must pick the matching constructor for
1719    /// the database's mode. Returns [`MongrelError::InvalidCredentials`] on a
1720    /// bad username/password.
1721    ///
1722    /// See `docs/15-credential-enforcement.md`.
1723    pub fn open_with_credentials(
1724        root: impl AsRef<Path>,
1725        username: &str,
1726        password: &str,
1727    ) -> Result<Self> {
1728        Self::open_inner_with_credentials(root, None, username, password)
1729    }
1730
1731    /// Open with credentials and a configurable cross-process lock timeout.
1732    /// Mirrors [`open_with_options`](Self::open_with_options) for the
1733    /// credentialed path.
1734    pub fn open_with_credentials_and_options(
1735        root: impl AsRef<Path>,
1736        username: &str,
1737        password: &str,
1738        options: OpenOptions,
1739    ) -> Result<Self> {
1740        Self::open_inner_with_credentials_and_lock_timeout(
1741            root,
1742            None,
1743            username,
1744            password,
1745            options.lock_timeout_ms,
1746        )
1747    }
1748
1749    /// Open an existing encrypted database that has `require_auth = true`,
1750    /// combining the encryption passphrase flow with credential verification.
1751    #[cfg(feature = "encryption")]
1752    pub fn open_encrypted_with_credentials(
1753        root: impl AsRef<Path>,
1754        passphrase: &str,
1755        username: &str,
1756        password: &str,
1757    ) -> Result<Self> {
1758        let (root, lock) = Self::begin_open(root, 0)?;
1759        let salt = read_encryption_salt(lock.durable_root.as_deref().ok_or_else(|| {
1760            MongrelError::Other("database root descriptor was not pinned".into())
1761        })?)?;
1762        let kek = Arc::new(crate::encryption::Kek::derive(passphrase, &salt)?);
1763        Self::open_inner_with_credentials_locked(root, Some(kek), username, password, lock)
1764    }
1765
1766    /// Open an encrypted + credentialed database with a configurable
1767    /// cross-process lock timeout. Mirrors
1768    /// [`open_encrypted_with_options`](Self::open_encrypted_with_options).
1769    #[cfg(feature = "encryption")]
1770    pub fn open_encrypted_with_credentials_and_options(
1771        root: impl AsRef<Path>,
1772        passphrase: &str,
1773        username: &str,
1774        password: &str,
1775        options: OpenOptions,
1776    ) -> Result<Self> {
1777        let (root, lock) = Self::begin_open(root, options.lock_timeout_ms)?;
1778        let salt = read_encryption_salt(lock.durable_root.as_deref().ok_or_else(|| {
1779            MongrelError::Other("database root descriptor was not pinned".into())
1780        })?)?;
1781        let kek = Arc::new(crate::encryption::Kek::derive(passphrase, &salt)?);
1782        Self::open_inner_with_credentials_locked(root, Some(kek), username, password, lock)
1783    }
1784
1785    /// Open an existing database with non-default [`OpenOptions`].
1786    ///
1787    /// Use this when you need cross-process lock retries (`lock_timeout_ms`)
1788    /// rather than the fail-fast default. The other open constructors keep
1789    /// their previous defaults; use their `*_with_options` variants when they
1790    /// need the same timeout behavior.
1791    pub fn open_with_options(root: impl AsRef<Path>, options: OpenOptions) -> Result<Self> {
1792        // No encryption, no auth; encrypted and credentialed paths have their
1793        // own `*_with_options` constructors.
1794        Self::open_inner_with_lock_timeout(root, None, None, options.lock_timeout_ms)
1795    }
1796
1797    fn open_inner_with_lock_timeout(
1798        root: impl AsRef<Path>,
1799        kek: Option<Arc<crate::encryption::Kek>>,
1800        _meta_dek_override: Option<[u8; META_DEK_LEN]>,
1801        lock_timeout_ms: u32,
1802    ) -> Result<Self> {
1803        let (root, lock) = Self::begin_open(root, lock_timeout_ms)?;
1804        Self::open_inner_locked(root, kek, lock)
1805    }
1806
1807    fn open_inner_locked(
1808        root: PathBuf,
1809        kek: Option<Arc<crate::encryption::Kek>>,
1810        lock: DatabaseFileLock,
1811    ) -> Result<Self> {
1812        let meta_dek = crate::encryption::meta_dek_for(kek.as_deref());
1813        let mut cat = catalog::read_durable(
1814            lock.durable_root.as_deref().ok_or_else(|| {
1815                MongrelError::Other("database root descriptor was not pinned".into())
1816            })?,
1817            meta_dek.as_ref(),
1818        )?
1819        .ok_or_else(|| MongrelError::NotFound(format!("no catalog found at {:?}", root)))?;
1820        let recovery_checkpoint = cat.clone();
1821
1822        // CATALOG is only a checkpoint. Authentication must use the
1823        // authoritative catalog after committed WAL DDL/security replay.
1824        let wal_dek = crate::encryption::wal_dek_for(kek.as_deref());
1825        let recovery_records = crate::wal::SharedWal::replay_durable_with_dek(
1826            lock.durable_root.as_deref().ok_or_else(|| {
1827                MongrelError::Other("database root descriptor was not pinned".into())
1828            })?,
1829            wal_dek.as_ref(),
1830        )?;
1831        recover_ddl_from_records(
1832            &root,
1833            Some(lock.durable_root.as_deref().ok_or_else(|| {
1834                MongrelError::Other("database root descriptor was not pinned".into())
1835            })?),
1836            &mut cat,
1837            meta_dek.as_ref(),
1838            false,
1839            None,
1840            &recovery_records,
1841        )?;
1842        Self::finish_open(
1843            root,
1844            cat,
1845            kek,
1846            meta_dek,
1847            true,
1848            Some(recovery_checkpoint),
1849            Some(recovery_records),
1850            None,
1851            lock,
1852        )
1853    }
1854
1855    /// Shared credentialed-open inner: read the catalog, verify the database
1856    /// requires auth, verify the password, resolve the principal, and pass
1857    /// everything to `finish_open` in one shot. This avoids the chicken-and-egg
1858    /// problem where `finish_open`'s fail-closed check (`require_auth &&
1859    /// principal.is_none()`) would fire before a post-open `authenticate()`
1860    /// could supply the principal.
1861    fn open_inner_with_credentials(
1862        root: impl AsRef<Path>,
1863        kek: Option<Arc<crate::encryption::Kek>>,
1864        username: &str,
1865        password: &str,
1866    ) -> Result<Self> {
1867        Self::open_inner_with_credentials_and_lock_timeout(root, kek, username, password, 0)
1868    }
1869
1870    /// Credentialed-open with an explicit cross-process lock timeout. The
1871    /// timeout is opt-in: callers that don't pass `OpenOptions` keep the
1872    /// historical fail-fast behavior via the wrapper above.
1873    fn open_inner_with_credentials_and_lock_timeout(
1874        root: impl AsRef<Path>,
1875        kek: Option<Arc<crate::encryption::Kek>>,
1876        username: &str,
1877        password: &str,
1878        lock_timeout_ms: u32,
1879    ) -> Result<Self> {
1880        let (root, lock) = Self::begin_open(root, lock_timeout_ms)?;
1881        Self::open_inner_with_credentials_locked(root, kek, username, password, lock)
1882    }
1883
1884    fn open_inner_with_credentials_locked(
1885        root: PathBuf,
1886        kek: Option<Arc<crate::encryption::Kek>>,
1887        username: &str,
1888        password: &str,
1889        lock: DatabaseFileLock,
1890    ) -> Result<Self> {
1891        let meta_dek = crate::encryption::meta_dek_for(kek.as_deref());
1892        let mut cat = catalog::read_durable(
1893            lock.durable_root.as_deref().ok_or_else(|| {
1894                MongrelError::Other("database root descriptor was not pinned".into())
1895            })?,
1896            meta_dek.as_ref(),
1897        )?
1898        .ok_or_else(|| MongrelError::NotFound(format!("no catalog found at {:?}", root)))?;
1899        let recovery_checkpoint = cat.clone();
1900
1901        // Never verify against a stale checkpoint. A committed password,
1902        // user, role, or auth-mode change in WAL is authoritative.
1903        let wal_dek = crate::encryption::wal_dek_for(kek.as_deref());
1904        let recovery_records = crate::wal::SharedWal::replay_durable_with_dek(
1905            lock.durable_root.as_deref().ok_or_else(|| {
1906                MongrelError::Other("database root descriptor was not pinned".into())
1907            })?,
1908            wal_dek.as_ref(),
1909        )?;
1910        recover_ddl_from_records(
1911            &root,
1912            Some(lock.durable_root.as_deref().ok_or_else(|| {
1913                MongrelError::Other("database root descriptor was not pinned".into())
1914            })?),
1915            &mut cat,
1916            meta_dek.as_ref(),
1917            false,
1918            None,
1919            &recovery_records,
1920        )?;
1921
1922        // Fail early if the database is not in require_auth mode — the caller
1923        // picked the wrong constructor.
1924        if !cat.require_auth {
1925            return Err(MongrelError::AuthNotRequired);
1926        }
1927
1928        // Verify credentials against the on-disk catalog before constructing
1929        // the full Database handle. This reads users/hashes directly from the
1930        // loaded catalog rather than going through the Database::verify_user
1931        // method (which requires a constructed Database).
1932        let user = cat
1933            .users
1934            .iter()
1935            .find(|u| u.username == username)
1936            .filter(|u| !u.password_hash.is_empty())
1937            .ok_or_else(|| MongrelError::InvalidCredentials {
1938                username: username.to_string(),
1939            })?;
1940        let password_ok = crate::auth::verify_password(password, &user.password_hash)
1941            .map_err(MongrelError::Other)?;
1942        if !password_ok {
1943            return Err(MongrelError::InvalidCredentials {
1944                username: username.to_string(),
1945            });
1946        }
1947
1948        // Resolve the principal from the catalog (roles + permissions).
1949        let principal =
1950            Self::resolve_principal_from_catalog(&cat, &user.username).ok_or_else(|| {
1951                MongrelError::InvalidCredentials {
1952                    username: username.to_string(),
1953                }
1954            })?;
1955
1956        Self::finish_open(
1957            root,
1958            cat,
1959            kek,
1960            meta_dek,
1961            true,
1962            Some(recovery_checkpoint),
1963            Some(recovery_records),
1964            Some(principal),
1965            lock,
1966        )
1967    }
1968
1969    /// Create a fresh plaintext database with `require_auth = true` and a
1970    /// single admin user. The returned handle is already authenticated as
1971    /// that admin — every subsequent operation is checked against the admin
1972    /// principal (which bypasses all permission checks via `is_admin`).
1973    ///
1974    /// This is the bootstrap path: there is no window where the database
1975    /// requires auth but has no users.
1976    ///
1977    /// See `docs/15-credential-enforcement.md`.
1978    pub fn create_with_credentials(
1979        root: impl AsRef<Path>,
1980        admin_username: &str,
1981        admin_password: &str,
1982    ) -> Result<Self> {
1983        let (root, lock) = Self::begin_create(root)?;
1984        Self::create_inner_with_credentials(root, None, admin_username, admin_password, lock)
1985    }
1986
1987    /// Create a fresh encrypted database with `require_auth = true` and a
1988    /// single admin user. Composes encryption-at-rest with credential
1989    /// enforcement.
1990    #[cfg(feature = "encryption")]
1991    pub fn create_encrypted_with_credentials(
1992        root: impl AsRef<Path>,
1993        passphrase: &str,
1994        admin_username: &str,
1995        admin_password: &str,
1996    ) -> Result<Self> {
1997        let (root, lock) = Self::begin_create(root)?;
1998        let salt = crate::encryption::random_salt()?;
1999        crate::durable_file::write_atomic(&root.join(META_DIR).join(KEYS_FILENAME), &salt)?;
2000        let kek = Arc::new(crate::encryption::Kek::derive(passphrase, &salt)?);
2001        Self::create_inner_with_credentials(root, Some(kek), admin_username, admin_password, lock)
2002    }
2003
2004    fn create_inner_with_credentials(
2005        root: PathBuf,
2006        kek: Option<Arc<crate::encryption::Kek>>,
2007        admin_username: &str,
2008        admin_password: &str,
2009        lock: DatabaseFileLock,
2010    ) -> Result<Self> {
2011        crate::durable_file::create_directory_all(&root.join(TABLES_DIR))?;
2012        let meta_dek = crate::encryption::meta_dek_for(kek.as_deref());
2013
2014        // Build the initial catalog with require_auth = true and one admin user.
2015        let password_hash =
2016            crate::auth::hash_password(admin_password).map_err(MongrelError::Other)?;
2017        let mut cat = Catalog::empty();
2018        cat.require_auth = true;
2019        cat.next_user_id = 2;
2020        cat.users.push(crate::auth::UserEntry {
2021            id: 1,
2022            username: admin_username.to_string(),
2023            password_hash,
2024            roles: Vec::new(),
2025            is_admin: true,
2026            created_epoch: 0,
2027        });
2028        catalog::write_atomic(&root, &cat, meta_dek.as_ref())?;
2029
2030        // The handle is constructed already authenticated as the admin user
2031        // it just created — no separate verify step needed.
2032        let admin_principal = crate::auth::Principal {
2033            user_id: 1,
2034            created_epoch: 0,
2035            username: admin_username.to_string(),
2036            is_admin: true,
2037            roles: Vec::new(),
2038            permissions: Vec::new(),
2039        };
2040        Self::finish_open(
2041            root,
2042            cat,
2043            kek,
2044            meta_dek,
2045            false,
2046            None,
2047            None,
2048            Some(admin_principal),
2049            lock,
2050        )
2051    }
2052
2053    fn reject_existing_database(root: &Path) -> Result<()> {
2054        // Refuse to overwrite an existing database. If CATALOG exists, the
2055        // directory already contains a real database; replacing it destroys data.
2056        if root.join(catalog::CATALOG_FILENAME).exists() {
2057            return Err(MongrelError::InvalidArgument(format!(
2058                "database already exists at {}; use Database::open() to open it, \
2059                 or remove the directory first",
2060                root.display()
2061            )));
2062        }
2063        Ok(())
2064    }
2065
2066    fn open_inner(
2067        root: impl AsRef<Path>,
2068        kek: Option<Arc<crate::encryption::Kek>>,
2069        _meta_dek_override: Option<[u8; META_DEK_LEN]>,
2070    ) -> Result<Self> {
2071        Self::open_inner_with_lock_timeout(root, kek, None, 0)
2072    }
2073
2074    /// Internal recovery open for a staging directory explicitly marked as a
2075    /// read-only replica. It bypasses user authentication only so PITR can
2076    /// replay auth-mode and password transitions; it is not public API.
2077    pub(crate) fn open_replica_recovery_durable(
2078        root: &crate::durable_file::DurableRoot,
2079    ) -> Result<Self> {
2080        let (root, lock) = Self::begin_open_durable(root.try_clone()?, 0)?;
2081        Self::open_replica_recovery_inner(root, None, lock)
2082    }
2083
2084    #[cfg(feature = "encryption")]
2085    pub(crate) fn open_encrypted_replica_recovery_durable(
2086        root: &crate::durable_file::DurableRoot,
2087        passphrase: &str,
2088    ) -> Result<Self> {
2089        let (root_path, lock) = Self::begin_open_durable(root.try_clone()?, 0)?;
2090        let salt = read_encryption_salt(root)?;
2091        let kek = Arc::new(crate::encryption::Kek::derive(passphrase, &salt)?);
2092        Self::open_replica_recovery_inner(root_path, Some(kek), lock)
2093    }
2094
2095    fn open_replica_recovery_inner(
2096        root: PathBuf,
2097        kek: Option<Arc<crate::encryption::Kek>>,
2098        lock: DatabaseFileLock,
2099    ) -> Result<Self> {
2100        if !root.join(META_DIR).join("replica").is_file() {
2101            return Err(MongrelError::InvalidArgument(
2102                "recovery auth bypass requires a marked replica staging directory".into(),
2103            ));
2104        }
2105        let meta_dek = crate::encryption::meta_dek_for(kek.as_deref());
2106        let mut cat = catalog::read_durable(
2107            lock.durable_root.as_deref().ok_or_else(|| {
2108                MongrelError::Other("database root descriptor was not pinned".into())
2109            })?,
2110            meta_dek.as_ref(),
2111        )?
2112        .ok_or_else(|| MongrelError::NotFound(format!("no catalog found at {:?}", root)))?;
2113        let recovery_checkpoint = cat.clone();
2114        let wal_dek = crate::encryption::wal_dek_for(kek.as_deref());
2115        let recovery_records = crate::wal::SharedWal::replay_durable_with_dek(
2116            lock.durable_root.as_deref().ok_or_else(|| {
2117                MongrelError::Other("database root descriptor was not pinned".into())
2118            })?,
2119            wal_dek.as_ref(),
2120        )?;
2121        recover_ddl_from_records(
2122            &root,
2123            Some(lock.durable_root.as_deref().ok_or_else(|| {
2124                MongrelError::Other("database root descriptor was not pinned".into())
2125            })?),
2126            &mut cat,
2127            meta_dek.as_ref(),
2128            false,
2129            None,
2130            &recovery_records,
2131        )?;
2132        let principal = if cat.require_auth {
2133            cat.users
2134                .iter()
2135                .find(|user| user.is_admin)
2136                .and_then(|user| Self::resolve_principal_from_catalog(&cat, &user.username))
2137                .ok_or_else(|| {
2138                    MongrelError::Schema(
2139                        "authenticated replica catalog has no recoverable admin".into(),
2140                    )
2141                })?
2142                .into()
2143        } else {
2144            None
2145        };
2146        Self::finish_open(
2147            root,
2148            cat,
2149            kek,
2150            meta_dek,
2151            true,
2152            Some(recovery_checkpoint),
2153            Some(recovery_records),
2154            principal,
2155            lock,
2156        )
2157    }
2158
2159    /// Acquire an exclusive advisory lock on `f`, retrying on `EAGAIN`/`EWOULDBLOCK`
2160    /// until `timeout_ms` elapses, mirroring SQLite's `busy_timeout` semantics.
2161    ///
2162    /// `timeout_ms == 0` is the fail-fast path: a single `try_lock_exclusive` call,
2163    /// no retry, no sleep. Existing open paths rely on that fail-fast default for
2164    /// backwards compatibility — opt in with `OpenOptions::lock_timeout_ms`.
2165    ///
2166    /// Backoff schedule: 1ms → 10ms → 50ms → 50ms → ... until `timeout_ms`.
2167    /// Total elapsed (not just sleep time) is bounded by `timeout_ms`, so the
2168    /// caller never blocks past its budget even at the tail of a busy lock
2169    /// holder's lock-window.
2170    fn fs_lock_exclusive(f: &std::fs::File, timeout_ms: u32) -> std::io::Result<()> {
2171        use fs2::FileExt;
2172        if timeout_ms == 0 {
2173            return f.try_lock_exclusive();
2174        }
2175        // Per-call deadline so a single stray 50ms sleep can't overshoot the budget.
2176        let deadline =
2177            std::time::Instant::now() + std::time::Duration::from_millis(timeout_ms as u64);
2178        let mut next_sleep = std::time::Duration::from_millis(1);
2179        loop {
2180            match f.try_lock_exclusive() {
2181                Ok(()) => return Ok(()),
2182                Err(e) if e.kind() == std::io::ErrorKind::WouldBlock => {
2183                    let now = std::time::Instant::now();
2184                    if now >= deadline {
2185                        return Err(std::io::Error::new(
2186                            std::io::ErrorKind::WouldBlock,
2187                            format!("could not acquire database lock within {timeout_ms}ms"),
2188                        ));
2189                    }
2190                    let remaining = deadline - now;
2191                    let sleep = next_sleep.min(remaining);
2192                    std::thread::sleep(sleep);
2193                    // Cap the per-iteration sleep so a single back-off step
2194                    // never overshoots the remaining budget.
2195                    next_sleep = next_sleep
2196                        .saturating_mul(10)
2197                        .min(std::time::Duration::from_millis(50));
2198                }
2199                Err(e) => return Err(e),
2200            }
2201        }
2202    }
2203
2204    #[allow(clippy::too_many_arguments)]
2205    fn finish_open(
2206        root: PathBuf,
2207        cat: Catalog,
2208        kek: Option<Arc<crate::encryption::Kek>>,
2209        meta_dek: Option<[u8; META_DEK_LEN]>,
2210        existing: bool,
2211        recovery_checkpoint: Option<Catalog>,
2212        recovery_records: Option<Vec<crate::wal::Record>>,
2213        principal: Option<crate::auth::Principal>,
2214        lock: DatabaseFileLock,
2215    ) -> Result<Self> {
2216        let durable_root = Arc::clone(lock.durable_root.as_ref().ok_or_else(|| {
2217            MongrelError::Other("database root descriptor was not pinned".into())
2218        })?);
2219        let read_only = if existing {
2220            match durable_root.open_regular(Path::new(META_DIR).join("replica")) {
2221                Ok(_) => true,
2222                Err(error) if error.kind() == std::io::ErrorKind::NotFound => false,
2223                Err(error) => return Err(error.into()),
2224            }
2225        } else {
2226            false
2227        };
2228        let recovered_catalog = cat;
2229        let mut cat = recovered_catalog.clone();
2230        let abandoned = if existing && !read_only {
2231            let abandoned = cat
2232                .tables
2233                .iter()
2234                .filter(|entry| matches!(entry.state, TableState::Building { .. }))
2235                .map(|entry| entry.table_id)
2236                .collect::<Vec<_>>();
2237            for entry in &mut cat.tables {
2238                if abandoned.contains(&entry.table_id) {
2239                    entry.state = TableState::Dropped {
2240                        at_epoch: cat.db_epoch,
2241                    };
2242                }
2243            }
2244            abandoned
2245        } else {
2246            Vec::new()
2247        };
2248        let wal_dek = crate::encryption::wal_dek_for(kek.as_deref());
2249        let recovery_records = match (existing, recovery_records) {
2250            (true, Some(records)) => records,
2251            (true, None) => {
2252                return Err(MongrelError::Other(
2253                    "existing open has no validated WAL recovery plan".into(),
2254                ))
2255            }
2256            (false, _) => Vec::new(),
2257        };
2258        let (history_epochs, history_start) =
2259            read_history_retention(&durable_root, Epoch(cat.db_epoch))?;
2260        let open_generation = if existing {
2261            let checkpoint = recovery_checkpoint.as_ref().ok_or_else(|| {
2262                MongrelError::Other("existing open has no catalog recovery checkpoint".into())
2263            })?;
2264            let recovered_table_ids = cat
2265                .tables
2266                .iter()
2267                .filter(|entry| {
2268                    checkpoint
2269                        .tables
2270                        .iter()
2271                        .all(|checkpoint| checkpoint.table_id != entry.table_id)
2272                })
2273                .map(|entry| entry.table_id)
2274                .collect::<HashSet<_>>();
2275            let reconciled_table_ids = cat
2276                .tables
2277                .iter()
2278                .filter(|entry| {
2279                    checkpoint
2280                        .tables
2281                        .iter()
2282                        .find(|checkpoint| checkpoint.table_id == entry.table_id)
2283                        .is_some_and(|checkpoint| {
2284                            crate::wal::DdlOp::encode_schema(&checkpoint.schema).ok()
2285                                != crate::wal::DdlOp::encode_schema(&entry.schema).ok()
2286                        })
2287                })
2288                .map(|entry| entry.table_id)
2289                .collect::<HashSet<_>>();
2290            validate_shared_wal_recovery_plan(
2291                &durable_root,
2292                &cat,
2293                &recovered_table_ids,
2294                &reconciled_table_ids,
2295                meta_dek.as_ref(),
2296                kek.clone(),
2297                &recovery_records,
2298            )?;
2299            let retained_generation = recovery_records
2300                .iter()
2301                .filter(|record| record.txn_id != crate::wal::SYSTEM_TXN_ID)
2302                .map(|record| record.txn_id >> 32)
2303                .max()
2304                .unwrap_or(0);
2305            let head_generation =
2306                crate::wal::SharedWal::durable_open_generation(&durable_root, wal_dek.as_ref())?;
2307            let durable_floor = match head_generation {
2308                Some(head) if retained_generation > head => {
2309                    return Err(MongrelError::CorruptWal {
2310                        offset: retained_generation,
2311                        reason: format!(
2312                            "retained transaction generation {retained_generation} exceeds WAL head generation {head}"
2313                        ),
2314                    })
2315                }
2316                Some(head) => head,
2317                None => retained_generation,
2318            };
2319            let stored = catalog::read_generation(&durable_root)?;
2320            if stored.is_some_and(|generation| generation < durable_floor) {
2321                return Err(MongrelError::Other(format!(
2322                    "open-generation {stored:?} precedes durable WAL generation {durable_floor}"
2323                )));
2324            }
2325            let bumped = stored
2326                .unwrap_or(durable_floor)
2327                .max(durable_floor)
2328                .checked_add(1)
2329                .ok_or_else(|| MongrelError::Full("open-generation namespace exhausted".into()))?;
2330            if bumped > u32::MAX as u64 {
2331                return Err(MongrelError::Full(
2332                    "open-generation namespace exhausted".into(),
2333                ));
2334            }
2335            bumped
2336        } else {
2337            0
2338        };
2339        let principal = if cat.require_auth {
2340            let supplied = principal.as_ref().ok_or(MongrelError::AuthRequired)?;
2341            Some(
2342                Self::resolve_bound_principal_from_catalog(&cat, supplied)
2343                    .ok_or(MongrelError::AuthRequired)?,
2344            )
2345        } else {
2346            principal
2347        };
2348        let mut table_roots = HashMap::<u64, Arc<crate::durable_file::DurableRoot>>::new();
2349        if existing {
2350            for entry in &cat.tables {
2351                if !matches!(entry.state, TableState::Live) {
2352                    continue;
2353                }
2354                match durable_root
2355                    .open_directory(Path::new(TABLES_DIR).join(entry.table_id.to_string()))
2356                {
2357                    Ok(root) => {
2358                        table_roots.insert(entry.table_id, Arc::new(root));
2359                    }
2360                    Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
2361                    Err(error) => return Err(error.into()),
2362                }
2363            }
2364        }
2365
2366        // No database-tree mutation occurs above this point. DDL, row payloads,
2367        // immutable runs, auth state, retention, and generation state have all
2368        // been validated against the authoritative recovered catalog.
2369        if existing {
2370            let mut applied = recovery_checkpoint.ok_or_else(|| {
2371                MongrelError::Other("existing open has no catalog recovery checkpoint".into())
2372            })?;
2373            recover_ddl_from_records(
2374                &root,
2375                Some(&durable_root),
2376                &mut applied,
2377                meta_dek.as_ref(),
2378                true,
2379                Some(&table_roots),
2380                &recovery_records,
2381            )?;
2382            let catalog_value = |catalog: &Catalog| {
2383                serde_json::to_value(catalog)
2384                    .map_err(|error| MongrelError::Other(format!("catalog compare: {error}")))
2385            };
2386            if catalog_value(&applied)? != catalog_value(&recovered_catalog)? {
2387                return Err(MongrelError::CorruptWal {
2388                    offset: 0,
2389                    reason: "validated and applied DDL recovery plans differ".into(),
2390                });
2391            }
2392            if catalog_value(&cat)? != catalog_value(&applied)? {
2393                catalog::write_atomic(&root, &cat, meta_dek.as_ref())?;
2394            }
2395            validate_catalog_table_storage(&durable_root, &cat, meta_dek.as_ref())?;
2396            if !read_only {
2397                sweep_unreferenced_table_dirs(&root, &cat)?;
2398            }
2399            match durable_root.remove_directory_all(Path::new(META_DIR).join("backup-pins")) {
2400                Ok(()) => {}
2401                Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
2402                Err(error) => return Err(error.into()),
2403            }
2404        }
2405
2406        let epoch = Arc::new(EpochAuthority::new(cat.db_epoch));
2407        let snapshots = Arc::new(SnapshotRegistry::new());
2408        snapshots.configure_history(history_epochs, history_start);
2409        let page_cache = Arc::new(crate::cache::Sharded::new(
2410            crate::cache::CACHE_SHARDS,
2411            || {
2412                crate::cache::PageCache::new(
2413                    crate::engine::PAGE_CACHE_CAPACITY / crate::cache::CACHE_SHARDS as u64,
2414                )
2415            },
2416        ));
2417        let decoded_cache = Arc::new(crate::cache::Sharded::new(
2418            crate::cache::CACHE_SHARDS,
2419            || {
2420                crate::cache::DecodedPageCache::new(
2421                    crate::engine::DECODED_CACHE_CAPACITY / crate::cache::CACHE_SHARDS as u64,
2422                )
2423            },
2424        ));
2425        let commit_lock = Arc::new(Mutex::new(()));
2426        let shared_wal = Arc::new(Mutex::new(if existing {
2427            crate::wal::SharedWal::open_durable_root_validated(
2428                Arc::clone(&durable_root),
2429                Epoch(cat.db_epoch),
2430                wal_dek.clone(),
2431                Some(&recovery_records),
2432            )?
2433        } else {
2434            crate::wal::SharedWal::create_with_durable_root(
2435                Arc::clone(&durable_root),
2436                Epoch(cat.db_epoch),
2437                wal_dek.clone(),
2438            )?
2439        }));
2440        // Shared write-path state handed to every mounted table so single-table
2441        // `put`/`commit` writes route through the one shared WAL, the one group-
2442        // commit coordinator, and the one poison flag (B1).
2443        let poisoned = Arc::new(std::sync::atomic::AtomicBool::new(false));
2444        let group = Arc::new(crate::txn::GroupCommit::new(
2445            shared_wal.lock().durable_seq(),
2446        ));
2447        let (change_wake, _change_rx) = tokio::sync::broadcast::channel(256);
2448        // Final base value is set after the open-generation bump below; tables
2449        // only draw ids once the user issues a write (post-open), so the
2450        // placeholder is never observed.
2451        let txn_ids = Arc::new(Mutex::new(1u64));
2452        let _ = abandoned;
2453
2454        // Build the shared auth state early — it's cloned into every mounted
2455        // Table's SharedCtx so the Table layer can enforce permissions without
2456        // a reference back to Database. The `require_auth` flag is mirrored
2457        // from the catalog; `enable_auth` / `refresh_principal` update it live.
2458        let auth_state = crate::auth_state::AuthState::new(cat.require_auth, principal.clone());
2459        let security_coordinator = security_coordinator(&root, cat.security_version);
2460        let auth_checker: Option<Arc<dyn crate::auth_state::TableAuthChecker>> = Some(Arc::new(
2461            crate::auth_state::DefaultTableAuthChecker::new(auth_state.clone()),
2462        ));
2463
2464        // Open every live table against the shared context. Mounted tables have
2465        // no private WAL (B1) — `open_in` just loads the manifest/runs and
2466        // advances the shared epoch authority to its manifest epoch, so the
2467        // final shared watermark is the max across all tables. All of a mounted
2468        // table's committed records are replayed below from the shared WAL.
2469        let mut tables: HashMap<u64, TableHandle> = HashMap::new();
2470        for entry in &cat.tables {
2471            if !matches!(entry.state, TableState::Live) {
2472                continue;
2473            }
2474            let table_root = match table_roots.remove(&entry.table_id) {
2475                Some(root) => root,
2476                None => Arc::new(
2477                    durable_root
2478                        .open_directory(Path::new(TABLES_DIR).join(entry.table_id.to_string()))?,
2479                ),
2480            };
2481            let tdir = table_root.io_path()?;
2482            let ctx = SharedCtx {
2483                root_guard: Some(table_root),
2484                epoch: Arc::clone(&epoch),
2485                page_cache: Arc::clone(&page_cache),
2486                decoded_cache: Arc::clone(&decoded_cache),
2487                snapshots: Arc::clone(&snapshots),
2488                kek: kek.clone(),
2489                commit_lock: Arc::clone(&commit_lock),
2490                shared: Some(crate::engine::SharedWalCtx {
2491                    wal: Arc::clone(&shared_wal),
2492                    group: Arc::clone(&group),
2493                    poisoned: Arc::clone(&poisoned),
2494                    txn_ids: Arc::clone(&txn_ids),
2495                    change_wake: change_wake.clone(),
2496                }),
2497                table_name: Some(entry.name.clone()),
2498                auth: auth_checker.clone(),
2499                read_only,
2500            };
2501            let t = Table::open_in(&tdir, ctx)?;
2502            tables.insert(entry.table_id, TableHandle::new(t));
2503        }
2504
2505        // Recover transaction writes from the shared WAL (spec §15). This is the
2506        // single durability source for mounted tables: it applies every committed
2507        // record — both single-table `Table::commit` writes and cross-table
2508        // transactions — gated by each table's `flushed_epoch` (records already
2509        // durable in a run are not re-applied).
2510        if existing {
2511            recover_shared_wal(&durable_root, &tables, &cat, &epoch, &recovery_records)?;
2512            reconcile_recovered_table_metadata(&tables, epoch.visible())?;
2513            if read_only {
2514                crate::replication::reconcile_replica_epoch_durable(
2515                    &durable_root,
2516                    epoch.visible().0,
2517                )?;
2518            }
2519            // P3.4: sweep stale `_txn/<txn_id>/` dirs left by aborted/crashed
2520            // large transactions (spec §8.5, review fix #14).
2521            sweep_pending_txn_dirs(&root, &cat);
2522        }
2523
2524        // Persist only after all semantic recovery and table mounting succeeds.
2525        catalog::write_generation(&durable_root, open_generation)?;
2526        shared_wal.lock().seal_open_generation(open_generation)?;
2527        crate::replication::replication_identity_durable(&durable_root)?;
2528        let next_txn_id = (open_generation << 32) | 1;
2529        // Seed the shared txn-id allocator now that the generation is final.
2530        *txn_ids.lock() = next_txn_id;
2531
2532        Ok(Self {
2533            root,
2534            durable_root,
2535            read_only,
2536            catalog: RwLock::new(cat),
2537            security_coordinator,
2538            security_catalog_disk_reads: AtomicU64::new(0),
2539            rls_cache: Mutex::new(RlsCache::default()),
2540            epoch,
2541            snapshots,
2542            page_cache,
2543            decoded_cache,
2544            commit_lock,
2545            shared_wal,
2546            next_txn_id: txn_ids,
2547            tables: RwLock::new(tables),
2548            kek,
2549            ddl_lock: Mutex::new(()),
2550            meta_dek,
2551            conflicts: crate::txn::ConflictIndex::new(),
2552            active_txns: crate::txn::ActiveTxns::new(),
2553            poisoned,
2554            group,
2555            spill_threshold: std::sync::atomic::AtomicU64::new(64 * 1024 * 1024),
2556            active_spills: Arc::new(crate::retention::ActiveSpills::new()),
2557            replication_barrier: parking_lot::RwLock::new(()),
2558            replication_wal_retention_segments: AtomicUsize::new(0),
2559            backup_pins: Arc::new(Mutex::new(HashMap::new())),
2560            spill_hook: Mutex::new(None),
2561            security_commit_hook: Mutex::new(None),
2562            catalog_commit_hook: Mutex::new(None),
2563            backup_hook: Mutex::new(None),
2564            replication_hook: Mutex::new(None),
2565            trigger_recursive: AtomicBool::new(TriggerConfig::default().recursive_triggers),
2566            trigger_max_depth: AtomicU32::new(TriggerConfig::default().max_depth),
2567            trigger_max_loop_iterations: AtomicU32::new(
2568                TriggerConfig::default().max_loop_iterations,
2569            ),
2570            _lock: Some(lock),
2571            notify: {
2572                let (tx, _rx) = tokio::sync::broadcast::channel(256);
2573                tx
2574            },
2575            change_wake,
2576            principal: RwLock::new(principal),
2577            auth_state,
2578        })
2579    }
2580
2581    /// The current reader-visible epoch.
2582    pub fn visible_epoch(&self) -> Epoch {
2583        self.epoch.visible()
2584    }
2585
2586    /// Clone the in-memory catalog (for diagnostics / tests).
2587    pub fn catalog_snapshot(&self) -> Catalog {
2588        self.catalog.read().clone()
2589    }
2590
2591    /// Read SQLite-compatible application metadata persisted in the catalog.
2592    pub fn sql_pragma_i64(&self, key: &str) -> Result<Option<i64>> {
2593        let catalog = self.catalog.read();
2594        match key {
2595            "user_version" => Ok(catalog.user_version),
2596            "application_id" => Ok(catalog.application_id),
2597            _ => Err(MongrelError::InvalidArgument(format!(
2598                "unsupported persistent SQL pragma {key:?}"
2599            ))),
2600        }
2601    }
2602
2603    /// Persist SQLite-compatible application metadata and return its exact
2604    /// publication epoch. An unchanged value performs no durable write.
2605    pub fn set_sql_pragma_i64_with_epoch(&self, key: &str, value: i64) -> Result<Option<Epoch>> {
2606        self.set_sql_pragma_i64_with_epoch_inner(key, value, None)
2607    }
2608
2609    pub fn set_sql_pragma_i64_with_epoch_controlled<F>(
2610        &self,
2611        key: &str,
2612        value: i64,
2613        mut before_commit: F,
2614    ) -> Result<Option<Epoch>>
2615    where
2616        F: FnMut() -> Result<()>,
2617    {
2618        self.set_sql_pragma_i64_with_epoch_inner(key, value, Some(&mut before_commit))
2619    }
2620
2621    fn set_sql_pragma_i64_with_epoch_inner(
2622        &self,
2623        key: &str,
2624        value: i64,
2625        before_commit: Option<&mut dyn FnMut() -> Result<()>>,
2626    ) -> Result<Option<Epoch>> {
2627        use crate::wal::DdlOp;
2628
2629        self.require(&crate::auth::Permission::Ddl)?;
2630        if self.read_only {
2631            return Err(MongrelError::ReadOnlyReplica);
2632        }
2633        if self.poisoned.load(Ordering::Relaxed) {
2634            return Err(MongrelError::Other(
2635                "database poisoned by fsync error".into(),
2636            ));
2637        }
2638        let _ddl = self.ddl_lock.lock();
2639        let _security_write = self.security_write()?;
2640        self.require(&crate::auth::Permission::Ddl)?;
2641        let mut next_catalog = self.catalog.read().clone();
2642        let target = match key {
2643            "user_version" => &mut next_catalog.user_version,
2644            "application_id" => &mut next_catalog.application_id,
2645            _ => {
2646                return Err(MongrelError::InvalidArgument(format!(
2647                    "unsupported persistent SQL pragma {key:?}"
2648                )))
2649            }
2650        };
2651        if *target == Some(value) {
2652            return Ok(None);
2653        }
2654        *target = Some(value);
2655
2656        let _commit = self.commit_lock.lock();
2657        let epoch = self.epoch.bump_assigned();
2658        let mut epoch_guard = EpochGuard::new(self.epoch.as_ref(), epoch);
2659        let txn_id = self.alloc_txn_id()?;
2660        next_catalog.db_epoch = next_catalog.db_epoch.max(epoch.0);
2661        let commit_seq = {
2662            let mut wal = self.shared_wal.lock();
2663            if let Some(before_commit) = before_commit {
2664                before_commit()?;
2665            }
2666            let append: Result<u64> = (|| {
2667                wal.append(
2668                    txn_id,
2669                    WAL_TABLE_ID,
2670                    crate::wal::Op::Ddl(DdlOp::SetSqlPragma {
2671                        key: key.to_string(),
2672                        value,
2673                    }),
2674                )?;
2675                append_catalog_snapshot(&mut wal, txn_id, &next_catalog)?;
2676                wal.append_commit(txn_id, epoch, &[])
2677            })();
2678            append.map_err(|error| self.commit_outcome_unknown(epoch, error))?
2679        };
2680        self.await_durable_commit(commit_seq, epoch)?;
2681        let checkpoint = self.checkpoint_catalog_after_durable(next_catalog);
2682        self.finish_durable_publish(epoch, &mut epoch_guard, checkpoint)?;
2683        Ok(Some(epoch))
2684    }
2685
2686    pub fn materialized_view(&self, name: &str) -> Option<crate::catalog::MaterializedViewEntry> {
2687        self.catalog
2688            .read()
2689            .materialized_views
2690            .iter()
2691            .find(|definition| definition.name == name)
2692            .cloned()
2693    }
2694
2695    pub fn materialized_views(&self) -> Vec<crate::catalog::MaterializedViewEntry> {
2696        self.catalog.read().materialized_views.clone()
2697    }
2698
2699    pub fn security_catalog(&self) -> crate::security::SecurityCatalog {
2700        self.catalog.read().security.clone()
2701    }
2702
2703    pub fn security_active_for(&self, table: &str) -> bool {
2704        self.catalog.read().security.table_has_security(table)
2705    }
2706
2707    fn refresh_security_catalog_if_stale(&self, expected_version: u64) -> Result<()> {
2708        if self.catalog.read().security_version == expected_version {
2709            return Ok(());
2710        }
2711        self.security_catalog_disk_reads
2712            .fetch_add(1, Ordering::Relaxed);
2713        let fresh = catalog::read_durable(&self.durable_root, self.meta_dek.as_ref())?
2714            .ok_or_else(|| MongrelError::NotFound("catalog vanished during write".into()))?;
2715        let principal = self.principal.read().clone();
2716        let principal = if fresh.require_auth {
2717            principal
2718                .as_ref()
2719                .and_then(|principal| Self::resolve_bound_principal_from_catalog(&fresh, principal))
2720        } else {
2721            principal
2722        };
2723        self.auth_state.set_require_auth(fresh.require_auth);
2724        *self.catalog.write() = fresh;
2725        *self.principal.write() = principal.clone();
2726        self.auth_state.set_principal(principal);
2727        Ok(())
2728    }
2729
2730    fn security_write(&self) -> Result<parking_lot::RwLockWriteGuard<'_, ()>> {
2731        let guard = self.security_coordinator.gate.write();
2732        let version = self.security_coordinator.version.load(Ordering::Acquire);
2733        self.refresh_security_catalog_if_stale(version)?;
2734        Ok(guard)
2735    }
2736
2737    /// Commit an exact catalog image through the shared WAL, then checkpoint it.
2738    /// The WAL image is the authoritative PITR and replication delta; CATALOG is
2739    /// only its restart checkpoint.
2740    fn publish_catalog_candidate(
2741        &self,
2742        catalog: Catalog,
2743        epoch: Epoch,
2744        epoch_guard: &mut EpochGuard<'_>,
2745        before_publish: Option<&mut dyn FnMut() -> Result<()>>,
2746    ) -> Result<()> {
2747        self.publish_catalog_candidate_with_prelude(
2748            catalog,
2749            epoch,
2750            epoch_guard,
2751            before_publish,
2752            Vec::new(),
2753        )
2754    }
2755
2756    fn publish_catalog_candidate_with_prelude(
2757        &self,
2758        catalog: Catalog,
2759        epoch: Epoch,
2760        epoch_guard: &mut EpochGuard<'_>,
2761        mut before_publish: Option<&mut dyn FnMut() -> Result<()>>,
2762        prelude: Vec<(u64, crate::wal::Op)>,
2763    ) -> Result<()> {
2764        use crate::wal::DdlOp;
2765
2766        if self.read_only {
2767            return Err(MongrelError::ReadOnlyReplica);
2768        }
2769        if self.poisoned.load(Ordering::Relaxed) {
2770            return Err(MongrelError::Other(
2771                "database poisoned by fsync error".into(),
2772            ));
2773        }
2774        if let Some(before_publish) = before_publish.as_mut() {
2775            (**before_publish)()?;
2776        }
2777        if catalog.db_epoch != epoch.0 {
2778            return Err(MongrelError::InvalidArgument(format!(
2779                "catalog epoch {} does not match commit epoch {}",
2780                catalog.db_epoch, epoch.0
2781            )));
2782        }
2783        {
2784            let current = self.catalog.read();
2785            validate_catalog_transition(&current, &catalog)?;
2786        }
2787        validate_recovered_catalog(&catalog)?;
2788        let catalog_json = DdlOp::encode_catalog(&catalog)?;
2789        let txn_id = self.alloc_txn_id()?;
2790        let commit_seq = {
2791            let mut wal = self.shared_wal.lock();
2792            let append: Result<u64> = (|| {
2793                for (table_id, op) in prelude {
2794                    wal.append(txn_id, table_id, op)?;
2795                }
2796                wal.append(
2797                    txn_id,
2798                    WAL_TABLE_ID,
2799                    crate::wal::Op::Ddl(DdlOp::CatalogSnapshot { catalog_json }),
2800                )?;
2801                wal.append_commit(txn_id, epoch, &[])
2802            })();
2803            append.map_err(|error| self.commit_outcome_unknown(epoch, error))?
2804        };
2805        self.await_durable_commit(commit_seq, epoch)?;
2806        let checkpoint = self.checkpoint_catalog_after_durable(catalog);
2807        self.finish_durable_publish(epoch, epoch_guard, checkpoint)
2808    }
2809
2810    /// A WAL commit is already durable. Publish the matching catalog in memory
2811    /// even when its checkpoint rewrite fails; recovery can rebuild the file,
2812    /// while the live handle must never continue with pre-commit metadata.
2813    fn checkpoint_catalog_after_durable(&self, catalog: Catalog) -> Result<()> {
2814        let checkpoint = catalog::write_atomic(&self.root, &catalog, self.meta_dek.as_ref());
2815        let version = catalog.security_version;
2816        let principal = self.principal.read().clone();
2817        let principal = if catalog.require_auth {
2818            principal.as_ref().and_then(|principal| {
2819                Self::resolve_bound_principal_from_catalog(&catalog, principal)
2820            })
2821        } else {
2822            principal
2823        };
2824        *self.catalog.write() = catalog;
2825        self.security_coordinator
2826            .version
2827            .store(version, Ordering::Release);
2828        self.auth_state
2829            .set_require_auth(self.catalog.read().require_auth);
2830        *self.principal.write() = principal.clone();
2831        self.auth_state.set_principal(principal);
2832        checkpoint
2833    }
2834
2835    fn finish_durable_publish(
2836        &self,
2837        epoch: Epoch,
2838        epoch_guard: &mut EpochGuard<'_>,
2839        post_step: Result<()>,
2840    ) -> Result<()> {
2841        self.epoch.publish_in_order(epoch);
2842        epoch_guard.disarm();
2843        match post_step {
2844            Ok(()) => Ok(()),
2845            Err(error) => {
2846                self.poisoned.store(true, Ordering::Relaxed);
2847                Err(MongrelError::DurableCommit {
2848                    epoch: epoch.0,
2849                    message: error.to_string(),
2850                })
2851            }
2852        }
2853    }
2854
2855    /// Wait for a commit marker to reach stable storage. A failed append/fsync
2856    /// acknowledgement is ambiguous, so poison the live handle and preserve
2857    /// the assigned epoch in a structured unknown-outcome error.
2858    fn await_durable_commit(&self, commit_seq: u64, epoch: Epoch) -> Result<()> {
2859        match self.group.await_durable(&self.shared_wal, commit_seq) {
2860            Ok(()) => Ok(()),
2861            Err(error) => {
2862                self.poisoned.store(true, Ordering::Relaxed);
2863                Err(MongrelError::CommitOutcomeUnknown {
2864                    epoch: epoch.0,
2865                    message: error.to_string(),
2866                })
2867            }
2868        }
2869    }
2870
2871    fn commit_outcome_unknown(&self, epoch: Epoch, error: impl std::fmt::Display) -> MongrelError {
2872        self.poisoned.store(true, Ordering::Relaxed);
2873        MongrelError::CommitOutcomeUnknown {
2874            epoch: epoch.0,
2875            message: error.to_string(),
2876        }
2877    }
2878
2879    /// Persist a complete validated RLS/masking catalog through the WAL.
2880    pub fn set_security_catalog(&self, security: crate::security::SecurityCatalog) -> Result<()> {
2881        self.set_security_catalog_as_with_epoch(security, None)
2882            .map(|_| ())
2883    }
2884
2885    /// Persist security policy changes on behalf of an explicit request principal.
2886    pub fn set_security_catalog_as(
2887        &self,
2888        security: crate::security::SecurityCatalog,
2889        principal: Option<&crate::auth::Principal>,
2890    ) -> Result<()> {
2891        self.set_security_catalog_as_with_epoch(security, principal)
2892            .map(|_| ())
2893    }
2894
2895    /// Persist security policy changes and return the exact publication epoch.
2896    pub fn set_security_catalog_as_with_epoch(
2897        &self,
2898        security: crate::security::SecurityCatalog,
2899        principal: Option<&crate::auth::Principal>,
2900    ) -> Result<Epoch> {
2901        self.set_security_catalog_as_with_epoch_inner(security, principal, None)
2902    }
2903
2904    /// Persist security policy changes, entering the commit fence immediately
2905    /// before the first WAL record can become visible to recovery.
2906    pub fn set_security_catalog_as_with_epoch_controlled<F>(
2907        &self,
2908        security: crate::security::SecurityCatalog,
2909        principal: Option<&crate::auth::Principal>,
2910        mut before_commit: F,
2911    ) -> Result<Epoch>
2912    where
2913        F: FnMut() -> Result<()>,
2914    {
2915        self.set_security_catalog_as_with_epoch_inner(security, principal, Some(&mut before_commit))
2916    }
2917
2918    fn set_security_catalog_as_with_epoch_inner(
2919        &self,
2920        security: crate::security::SecurityCatalog,
2921        principal: Option<&crate::auth::Principal>,
2922        before_commit: Option<&mut dyn FnMut() -> Result<()>>,
2923    ) -> Result<Epoch> {
2924        use crate::wal::DdlOp;
2925        use std::sync::atomic::Ordering;
2926
2927        self.require_for(principal, &crate::auth::Permission::Admin)?;
2928        if self.poisoned.load(Ordering::Relaxed) {
2929            return Err(MongrelError::Other(
2930                "database poisoned by fsync error".into(),
2931            ));
2932        }
2933        let _ddl = self.ddl_lock.lock();
2934        // DDL serializes first; write-path order after that is security gate ->
2935        // commit lock -> shared WAL.
2936        let _security_write = self.security_write()?;
2937        self.require_for(principal, &crate::auth::Permission::Admin)?;
2938        let mut next_catalog = self.catalog.read().clone();
2939        validate_security_catalog(&next_catalog, &security)?;
2940        let payload = DdlOp::encode_security(&security)?;
2941        let _commit = self.commit_lock.lock();
2942        let epoch = self.epoch.bump_assigned();
2943        let mut epoch_guard = EpochGuard::new(self.epoch.as_ref(), epoch);
2944        let txn_id = self.alloc_txn_id()?;
2945        next_catalog.security = security;
2946        advance_security_version(&mut next_catalog)?;
2947        next_catalog.db_epoch = next_catalog.db_epoch.max(epoch.0);
2948        let commit_seq = {
2949            let mut wal = self.shared_wal.lock();
2950            if let Some(before_commit) = before_commit {
2951                before_commit()?;
2952            }
2953            let append: Result<u64> = (|| {
2954                wal.append(
2955                    txn_id,
2956                    WAL_TABLE_ID,
2957                    crate::wal::Op::Ddl(DdlOp::SetSecurityCatalog {
2958                        security_json: payload,
2959                    }),
2960                )?;
2961                append_catalog_snapshot(&mut wal, txn_id, &next_catalog)?;
2962                wal.append_commit(txn_id, epoch, &[])
2963            })();
2964            append.map_err(|error| self.commit_outcome_unknown(epoch, error))?
2965        };
2966        self.await_durable_commit(commit_seq, epoch)?;
2967        let checkpoint = self.checkpoint_catalog_after_durable(next_catalog);
2968        self.finish_durable_publish(epoch, &mut epoch_guard, checkpoint)?;
2969        Ok(epoch)
2970    }
2971
2972    pub fn require_for(
2973        &self,
2974        principal: Option<&crate::auth::Principal>,
2975        permission: &crate::auth::Permission,
2976    ) -> Result<()> {
2977        let Some(principal) = principal else {
2978            return self.require(permission);
2979        };
2980        let resolved;
2981        let principal = if self.auth_state.require_auth() || principal.user_id != 0 {
2982            resolved = Self::resolve_bound_principal_from_catalog(&self.catalog.read(), principal)
2983                .ok_or(MongrelError::AuthRequired)?;
2984            &resolved
2985        } else {
2986            principal
2987        };
2988        #[cfg(test)]
2989        TABLE_PERMISSION_DECISIONS.with(|decisions| decisions.set(decisions.get() + 1));
2990        if principal.has_permission(permission) {
2991            Ok(())
2992        } else {
2993            Err(MongrelError::PermissionDenied {
2994                required: permission.clone(),
2995                principal: principal.username.clone(),
2996            })
2997        }
2998    }
2999
3000    /// Recheck the exact operation principal while the caller holds the
3001    /// security gate. This deliberately performs no refresh or nested gate
3002    /// acquisition.
3003    fn require_exact_principal_current(
3004        &self,
3005        principal: Option<&crate::auth::Principal>,
3006        permission: &crate::auth::Permission,
3007    ) -> Result<()> {
3008        let catalog = self.catalog.read();
3009        if !catalog.require_auth {
3010            return Ok(());
3011        }
3012        let supplied = principal.ok_or(MongrelError::AuthRequired)?;
3013        let current = Self::resolve_bound_principal_from_catalog(&catalog, supplied)
3014            .ok_or(MongrelError::AuthRequired)?;
3015        if current.has_permission(permission) {
3016            Ok(())
3017        } else {
3018            Err(MongrelError::PermissionDenied {
3019                required: permission.clone(),
3020                principal: current.username,
3021            })
3022        }
3023    }
3024
3025    pub(crate) fn with_exact_principal_current<T, F>(
3026        &self,
3027        principal: Option<&crate::auth::Principal>,
3028        permission: &crate::auth::Permission,
3029        operation: F,
3030    ) -> Result<T>
3031    where
3032        F: FnOnce() -> Result<T>,
3033    {
3034        let _security = self.security_coordinator.gate.read();
3035        self.require_exact_principal_current(principal, permission)?;
3036        operation()
3037    }
3038
3039    pub fn principal_snapshot(&self) -> Option<crate::auth::Principal> {
3040        self.principal.read().clone()
3041    }
3042
3043    #[cfg(test)]
3044    pub(crate) fn set_cached_principal_for_test(&self, principal: Option<crate::auth::Principal>) {
3045        *self.principal.write() = principal.clone();
3046        self.auth_state.set_principal(principal);
3047    }
3048
3049    pub fn require_columns_for(
3050        &self,
3051        table: &str,
3052        operation: crate::auth::ColumnOperation,
3053        column_ids: &[u16],
3054        principal: Option<&crate::auth::Principal>,
3055    ) -> Result<()> {
3056        if principal.is_none() && !self.auth_state.require_auth() {
3057            return Ok(());
3058        }
3059        let cached = self.principal.read().clone();
3060        let principal = principal.or(cached.as_ref());
3061        let Some(principal) = principal else {
3062            let permission = match operation {
3063                crate::auth::ColumnOperation::Select => crate::auth::Permission::Select {
3064                    table: table.to_string(),
3065                },
3066                crate::auth::ColumnOperation::Insert => crate::auth::Permission::Insert {
3067                    table: table.to_string(),
3068                },
3069                crate::auth::ColumnOperation::Update => crate::auth::Permission::Update {
3070                    table: table.to_string(),
3071                },
3072            };
3073            return self.require(&permission);
3074        };
3075        let catalog = self.catalog.read();
3076        let resolved;
3077        let principal = if catalog.require_auth || principal.user_id != 0 {
3078            resolved = Self::resolve_bound_principal_from_catalog(&catalog, principal)
3079                .ok_or(MongrelError::AuthRequired)?;
3080            &resolved
3081        } else {
3082            principal
3083        };
3084        let schema = &catalog
3085            .live(table)
3086            .ok_or_else(|| MongrelError::NotFound(format!("table {table:?} not found")))?
3087            .schema;
3088        Self::require_columns_for_principal(table, schema, operation, column_ids, principal)
3089    }
3090
3091    fn require_columns_for_principal(
3092        table: &str,
3093        schema: &Schema,
3094        operation: crate::auth::ColumnOperation,
3095        column_ids: &[u16],
3096        principal: &crate::auth::Principal,
3097    ) -> Result<()> {
3098        #[cfg(test)]
3099        WRITE_PERMISSION_DECISIONS.with(|decisions| decisions.set(decisions.get() + 1));
3100        match principal.column_access(table, operation) {
3101            crate::auth::ColumnAccess::All => Ok(()),
3102            crate::auth::ColumnAccess::Columns(allowed) => {
3103                let denied = column_ids.iter().find_map(|column_id| {
3104                    schema
3105                        .columns
3106                        .iter()
3107                        .find(|column| column.id == *column_id)
3108                        .filter(|column| !allowed.contains(&column.name))
3109                });
3110                if denied.is_none() {
3111                    Ok(())
3112                } else {
3113                    Err(MongrelError::PermissionDenied {
3114                        required: match operation {
3115                            crate::auth::ColumnOperation::Select => {
3116                                crate::auth::Permission::SelectColumns {
3117                                    table: table.to_string(),
3118                                    columns: denied
3119                                        .into_iter()
3120                                        .map(|column| column.name.clone())
3121                                        .collect(),
3122                                }
3123                            }
3124                            crate::auth::ColumnOperation::Insert => {
3125                                crate::auth::Permission::InsertColumns {
3126                                    table: table.to_string(),
3127                                    columns: denied
3128                                        .into_iter()
3129                                        .map(|column| column.name.clone())
3130                                        .collect(),
3131                                }
3132                            }
3133                            crate::auth::ColumnOperation::Update => {
3134                                crate::auth::Permission::UpdateColumns {
3135                                    table: table.to_string(),
3136                                    columns: denied
3137                                        .into_iter()
3138                                        .map(|column| column.name.clone())
3139                                        .collect(),
3140                                }
3141                            }
3142                        },
3143                        principal: principal.username.clone(),
3144                    })
3145                }
3146            }
3147            crate::auth::ColumnAccess::Denied => Err(MongrelError::PermissionDenied {
3148                required: match operation {
3149                    crate::auth::ColumnOperation::Select => crate::auth::Permission::Select {
3150                        table: table.to_string(),
3151                    },
3152                    crate::auth::ColumnOperation::Insert => crate::auth::Permission::Insert {
3153                        table: table.to_string(),
3154                    },
3155                    crate::auth::ColumnOperation::Update => crate::auth::Permission::Update {
3156                        table: table.to_string(),
3157                    },
3158                },
3159                principal: principal.username.clone(),
3160            }),
3161        }
3162    }
3163
3164    pub fn select_column_ids_for(
3165        &self,
3166        table: &str,
3167        principal: Option<&crate::auth::Principal>,
3168    ) -> Result<Vec<u16>> {
3169        let catalog = self.catalog.read();
3170        let columns = catalog
3171            .live(table)
3172            .ok_or_else(|| MongrelError::NotFound(format!("table {table:?} not found")))?
3173            .schema
3174            .columns
3175            .iter()
3176            .map(|column| (column.id, column.name.clone()))
3177            .collect::<Vec<_>>();
3178        let principal = self.principal_for_authorized_read(&catalog, principal, false)?;
3179        drop(catalog);
3180        let Some(principal) = principal.as_ref() else {
3181            self.require(&crate::auth::Permission::Select {
3182                table: table.to_string(),
3183            })?;
3184            return Ok(columns.iter().map(|(id, _)| *id).collect());
3185        };
3186        match principal.column_access(table, crate::auth::ColumnOperation::Select) {
3187            crate::auth::ColumnAccess::All => Ok(columns.iter().map(|(id, _)| *id).collect()),
3188            crate::auth::ColumnAccess::Columns(allowed) => Ok(columns
3189                .iter()
3190                .filter(|(_, name)| allowed.contains(name))
3191                .map(|(id, _)| *id)
3192                .collect()),
3193            crate::auth::ColumnAccess::Denied => Err(MongrelError::PermissionDenied {
3194                required: crate::auth::Permission::Select {
3195                    table: table.to_string(),
3196                },
3197                principal: principal.username.clone(),
3198            }),
3199        }
3200    }
3201
3202    pub fn secure_rows_for(
3203        &self,
3204        table: &str,
3205        rows: Vec<crate::memtable::Row>,
3206        principal: Option<&crate::auth::Principal>,
3207    ) -> Result<Vec<crate::memtable::Row>> {
3208        self.secure_rows_for_with_context(table, rows, principal, None)
3209    }
3210
3211    pub fn secure_rows_for_with_context(
3212        &self,
3213        table: &str,
3214        rows: Vec<crate::memtable::Row>,
3215        principal: Option<&crate::auth::Principal>,
3216        context: Option<&crate::query::AiExecutionContext>,
3217    ) -> Result<Vec<crate::memtable::Row>> {
3218        let (security, principal) = {
3219            let catalog = self.catalog.read();
3220            (
3221                catalog.security.clone(),
3222                self.principal_for_authorized_read(&catalog, principal, false)?,
3223            )
3224        };
3225        if !security.table_has_security(table) {
3226            return Ok(rows);
3227        }
3228        let principal = principal.as_ref().ok_or(MongrelError::AuthRequired)?;
3229        let mut output = Vec::new();
3230        for mut row in rows {
3231            if let Some(context) = context {
3232                context.consume(1)?;
3233            }
3234            if security.row_allowed(
3235                table,
3236                crate::security::PolicyCommand::Select,
3237                &row,
3238                principal,
3239                false,
3240            ) {
3241                security.apply_masks(table, &mut row, principal);
3242                output.push(row);
3243            }
3244        }
3245        Ok(output)
3246    }
3247
3248    /// Apply column masks to already RLS-authorized scored hits without a
3249    /// second row gather or policy evaluation.
3250    pub fn mask_search_hits_for(
3251        &self,
3252        table: &str,
3253        hits: &mut [crate::query::SearchHit],
3254        principal: Option<&crate::auth::Principal>,
3255    ) -> Result<()> {
3256        let (security, principal) = {
3257            let catalog = self.catalog.read();
3258            (
3259                catalog.security.clone(),
3260                self.principal_for_authorized_read(&catalog, principal, false)?,
3261            )
3262        };
3263        if !security.table_has_security(table) {
3264            return Ok(());
3265        }
3266        let principal = principal.as_ref().ok_or(MongrelError::AuthRequired)?;
3267        for hit in hits {
3268            security.apply_masks_to_cells(table, &mut hit.cells, principal);
3269        }
3270        Ok(())
3271    }
3272
3273    /// Apply masks to rows already admitted by candidate-aware RLS.
3274    pub fn mask_rows_for(
3275        &self,
3276        table: &str,
3277        rows: &mut [crate::memtable::Row],
3278        principal: Option<&crate::auth::Principal>,
3279    ) -> Result<()> {
3280        let (security, principal) = {
3281            let catalog = self.catalog.read();
3282            (
3283                catalog.security.clone(),
3284                self.principal_for_authorized_read(&catalog, principal, false)?,
3285            )
3286        };
3287        if !security.table_has_security(table) {
3288            return Ok(());
3289        }
3290        let principal = principal.as_ref().ok_or(MongrelError::AuthRequired)?;
3291        for row in rows {
3292            security.apply_masks(table, row, principal);
3293        }
3294        Ok(())
3295    }
3296
3297    /// Row IDs allowed to enter scored ranking. `None` means no RLS filter.
3298    pub fn authorized_candidate_ids_for(
3299        &self,
3300        table: &str,
3301        principal: Option<&crate::auth::Principal>,
3302    ) -> Result<Option<std::collections::HashSet<RowId>>> {
3303        Ok(self
3304            .authorized_read_snapshot(table, principal)?
3305            .allowed_row_ids)
3306    }
3307
3308    fn allowed_row_ids_locked(
3309        &self,
3310        table_name: &str,
3311        table: &Table,
3312        table_snapshot: Snapshot,
3313        security_state: (&crate::security::SecurityCatalog, u64),
3314        principal: Option<&crate::auth::Principal>,
3315        context: Option<&crate::query::AiExecutionContext>,
3316    ) -> Result<Option<Arc<HashSet<RowId>>>> {
3317        let (security, security_version) = security_state;
3318        if !security.rls_enabled(table_name) {
3319            return Ok(None);
3320        }
3321        let authorization_started = std::time::Instant::now();
3322        let principal = principal.ok_or(MongrelError::AuthRequired)?;
3323        let mut roles = principal.roles.clone();
3324        roles.sort_unstable();
3325        let principal_key = format!(
3326            "{}:{}:{}:{}:{roles:?}",
3327            principal.user_id, principal.created_epoch, principal.username, principal.is_admin
3328        );
3329        let cache_key = (
3330            table_name.to_string(),
3331            table.data_generation(),
3332            security_version,
3333            principal_key,
3334        );
3335        if let Some(allowed) = self.rls_cache.lock().get(&cache_key) {
3336            crate::trace::QueryTrace::record(|trace| {
3337                trace.rls_cache_hit = true;
3338                trace.authorization_nanos = trace
3339                    .authorization_nanos
3340                    .saturating_add(authorization_started.elapsed().as_nanos() as u64);
3341            });
3342            return Ok(Some(allowed));
3343        }
3344        if let Some(context) = context {
3345            context.checkpoint()?;
3346        }
3347        // ponytail: full RLS universe scan; replace with policy-column candidate checks if RLS search throughput matters.
3348        let started = std::time::Instant::now();
3349        let rows = table.visible_rows(table_snapshot)?;
3350        let rows_evaluated = rows.len() as u64;
3351        let mut allowed = HashSet::new();
3352        for chunk in rows.chunks(256) {
3353            if let Some(context) = context {
3354                context.consume(chunk.len())?;
3355            }
3356            allowed.extend(chunk.iter().filter_map(|row| {
3357                security
3358                    .row_allowed(
3359                        table_name,
3360                        crate::security::PolicyCommand::Select,
3361                        row,
3362                        principal,
3363                        false,
3364                    )
3365                    .then_some(row.row_id)
3366            }));
3367        }
3368        let allowed = Arc::new(allowed);
3369        let mut cache = self.rls_cache.lock();
3370        cache.build_nanos = cache
3371            .build_nanos
3372            .saturating_add(started.elapsed().as_nanos() as u64);
3373        cache.rows_evaluated = cache.rows_evaluated.saturating_add(rows_evaluated);
3374        cache.insert(cache_key, Arc::clone(&allowed));
3375        crate::trace::QueryTrace::record(|trace| {
3376            trace.rls_rows_evaluated = trace
3377                .rls_rows_evaluated
3378                .saturating_add(rows_evaluated as usize);
3379            trace.authorization_nanos = trace
3380                .authorization_nanos
3381                .saturating_add(authorization_started.elapsed().as_nanos() as u64);
3382        });
3383        Ok(Some(allowed))
3384    }
3385
3386    fn principal_for_authorized_read(
3387        &self,
3388        catalog: &Catalog,
3389        principal: Option<&crate::auth::Principal>,
3390        catalog_bound: bool,
3391    ) -> Result<Option<crate::auth::Principal>> {
3392        let principal = principal.cloned().or_else(|| self.principal.read().clone());
3393        let Some(principal) = principal else {
3394            return Ok(None);
3395        };
3396        if catalog.require_auth || catalog_bound || principal.user_id != 0 {
3397            return Self::resolve_bound_principal_from_catalog(catalog, &principal)
3398                .map(Some)
3399                .ok_or(MongrelError::AuthRequired);
3400        }
3401        Ok(Some(principal))
3402    }
3403
3404    /// Run authorization, candidate generation, ranking, and materialization
3405    /// while holding one table generation. Security changes cause a bounded
3406    /// retry before any result is published.
3407    pub fn with_authorized_read<T, F>(
3408        &self,
3409        table_name: &str,
3410        principal: Option<&crate::auth::Principal>,
3411        catalog_bound: bool,
3412        read: F,
3413    ) -> Result<T>
3414    where
3415        F: FnMut(
3416            &mut Table,
3417            Snapshot,
3418            Option<&HashSet<RowId>>,
3419            Option<&crate::auth::Principal>,
3420        ) -> Result<T>,
3421    {
3422        self.with_authorized_read_context(
3423            table_name,
3424            principal,
3425            catalog_bound,
3426            None,
3427            None,
3428            None,
3429            read,
3430        )
3431    }
3432
3433    #[allow(clippy::too_many_arguments)]
3434    pub fn with_authorized_read_context<T, F>(
3435        &self,
3436        table_name: &str,
3437        principal: Option<&crate::auth::Principal>,
3438        catalog_bound: bool,
3439        authorization: Option<&ReadAuthorization>,
3440        context: Option<&crate::query::AiExecutionContext>,
3441        snapshot_override: Option<Snapshot>,
3442        read: F,
3443    ) -> Result<T>
3444    where
3445        F: FnMut(
3446            &mut Table,
3447            Snapshot,
3448            Option<&HashSet<RowId>>,
3449            Option<&crate::auth::Principal>,
3450        ) -> Result<T>,
3451    {
3452        self.with_authorized_read_context_stamped(
3453            table_name,
3454            principal,
3455            catalog_bound,
3456            authorization,
3457            context,
3458            snapshot_override,
3459            read,
3460        )
3461        .map(|(result, _)| result)
3462    }
3463
3464    #[allow(clippy::too_many_arguments)]
3465    pub fn with_authorized_read_context_stamped<T, F>(
3466        &self,
3467        table_name: &str,
3468        principal: Option<&crate::auth::Principal>,
3469        catalog_bound: bool,
3470        authorization: Option<&ReadAuthorization>,
3471        context: Option<&crate::query::AiExecutionContext>,
3472        snapshot_override: Option<Snapshot>,
3473        mut read: F,
3474    ) -> Result<(T, AuthorizedReadStamp)>
3475    where
3476        F: FnMut(
3477            &mut Table,
3478            Snapshot,
3479            Option<&HashSet<RowId>>,
3480            Option<&crate::auth::Principal>,
3481        ) -> Result<T>,
3482    {
3483        if principal.is_none() && self.principal.read().is_some() {
3484            self.refresh_principal()?;
3485        }
3486        const RETRIES: usize = 3;
3487        let handle = self.table(table_name)?;
3488        for attempt in 0..RETRIES {
3489            crate::trace::QueryTrace::record(|trace| {
3490                trace.authorization_retries = attempt;
3491            });
3492            let (security, security_version, effective_principal) = {
3493                let catalog = self.catalog.read();
3494                (
3495                    catalog.security.clone(),
3496                    catalog.security_version,
3497                    self.principal_for_authorized_read(&catalog, principal, catalog_bound)?,
3498                )
3499            };
3500            if let Some(authorization) = authorization {
3501                for permission in &authorization.permissions {
3502                    self.require_for(effective_principal.as_ref(), permission)?;
3503                }
3504                self.require_columns_for(
3505                    table_name,
3506                    authorization.operation,
3507                    &authorization.columns,
3508                    effective_principal.as_ref(),
3509                )?;
3510            }
3511            let result = {
3512                let mut table = lock_table_with_context(&handle, context)?;
3513                let snapshot = snapshot_override.unwrap_or_else(|| table.snapshot());
3514                let allowed = self.allowed_row_ids_locked(
3515                    table_name,
3516                    &table,
3517                    snapshot,
3518                    (&security, security_version),
3519                    effective_principal.as_ref(),
3520                    context,
3521                )?;
3522                let stamp = AuthorizedReadStamp {
3523                    table_id: table.table_id(),
3524                    schema_id: table.schema().schema_id,
3525                    data_generation: table.data_generation(),
3526                    security_version,
3527                    snapshot,
3528                };
3529                let result = read(
3530                    &mut table,
3531                    snapshot,
3532                    allowed.as_deref(),
3533                    effective_principal.as_ref(),
3534                )?;
3535                (result, stamp)
3536            };
3537            if let Some(context) = context {
3538                context.checkpoint()?;
3539            }
3540            if self.catalog.read().security_version == security_version {
3541                return Ok(result);
3542            }
3543            if attempt + 1 == RETRIES {
3544                return Err(MongrelError::Conflict(
3545                    "security policy changed during scored read".into(),
3546                ));
3547            }
3548        }
3549        Err(MongrelError::Conflict(
3550            "authorization retry loop exhausted".into(),
3551        ))
3552    }
3553
3554    fn with_authorized_aggregate_table<T, F>(
3555        &self,
3556        table_name: &str,
3557        columns: &[u16],
3558        principal: Option<&crate::auth::Principal>,
3559        catalog_bound: bool,
3560        allow_table_security: bool,
3561        mut aggregate: F,
3562    ) -> Result<T>
3563    where
3564        F: FnMut(
3565            &mut Table,
3566            Option<&crate::security::CandidateAuthorization<'_>>,
3567            Option<&crate::auth::Principal>,
3568            u64,
3569        ) -> Result<T>,
3570    {
3571        if principal.is_none() && self.principal.read().is_some() {
3572            self.refresh_principal()?;
3573        }
3574        const RETRIES: usize = 3;
3575        let handle = self.table(table_name)?;
3576        for attempt in 0..RETRIES {
3577            let (security, security_version, effective_principal) = {
3578                let catalog = self.catalog.read();
3579                (
3580                    catalog.security.clone(),
3581                    catalog.security_version,
3582                    self.principal_for_authorized_read(&catalog, principal, catalog_bound)?,
3583                )
3584            };
3585            self.require_columns_for(
3586                table_name,
3587                crate::auth::ColumnOperation::Select,
3588                columns,
3589                effective_principal.as_ref(),
3590            )?;
3591            if !allow_table_security && security.table_has_security(table_name) {
3592                return Err(MongrelError::InvalidArgument(
3593                    "incremental aggregate is unsupported while RLS or column masks are active"
3594                        .into(),
3595                ));
3596            }
3597            let result = {
3598                let mut table = handle.lock();
3599                let authorization = if security.rls_enabled(table_name) {
3600                    Some(crate::security::CandidateAuthorization {
3601                        table: table_name,
3602                        security: &security,
3603                        principal: effective_principal
3604                            .as_ref()
3605                            .ok_or(MongrelError::AuthRequired)?,
3606                    })
3607                } else {
3608                    None
3609                };
3610                aggregate(
3611                    &mut table,
3612                    authorization.as_ref(),
3613                    effective_principal.as_ref(),
3614                    security_version,
3615                )?
3616            };
3617            if self.catalog.read().security_version == security_version {
3618                return Ok(result);
3619            }
3620            if attempt + 1 == RETRIES {
3621                return Err(MongrelError::Conflict(
3622                    "security policy changed during aggregate read".into(),
3623                ));
3624            }
3625        }
3626        Err(MongrelError::Conflict(
3627            "aggregate authorization retry loop exhausted".into(),
3628        ))
3629    }
3630
3631    /// Scored-read authorization that evaluates RLS only for approximate
3632    /// candidates. This avoids a full-table policy scan on cache misses while
3633    /// preserving one table generation and security-version retry.
3634    pub fn with_authorized_scored_read_context<T, F>(
3635        &self,
3636        table_name: &str,
3637        principal: Option<&crate::auth::Principal>,
3638        catalog_bound: bool,
3639        authorization: Option<&ReadAuthorization>,
3640        context: Option<&crate::query::AiExecutionContext>,
3641        mut read: F,
3642    ) -> Result<T>
3643    where
3644        F: FnMut(
3645            &mut Table,
3646            Snapshot,
3647            Option<&crate::security::CandidateAuthorization<'_>>,
3648            Option<&crate::auth::Principal>,
3649        ) -> Result<T>,
3650    {
3651        self.with_authorized_scored_read_context_at(
3652            table_name,
3653            principal,
3654            catalog_bound,
3655            authorization,
3656            context,
3657            None,
3658            |table, snapshot, authorization, principal| {
3659                let mut table = table.clone();
3660                read(&mut table, snapshot, authorization, principal)
3661            },
3662        )
3663    }
3664
3665    #[allow(clippy::too_many_arguments)]
3666    pub fn with_authorized_scored_read_context_at<T, F>(
3667        &self,
3668        table_name: &str,
3669        principal: Option<&crate::auth::Principal>,
3670        catalog_bound: bool,
3671        authorization: Option<&ReadAuthorization>,
3672        context: Option<&crate::query::AiExecutionContext>,
3673        snapshot_override: Option<Snapshot>,
3674        read: F,
3675    ) -> Result<T>
3676    where
3677        F: FnMut(
3678            &Table,
3679            Snapshot,
3680            Option<&crate::security::CandidateAuthorization<'_>>,
3681            Option<&crate::auth::Principal>,
3682        ) -> Result<T>,
3683    {
3684        self.with_authorized_scored_read_context_at_stamped(
3685            table_name,
3686            principal,
3687            catalog_bound,
3688            authorization,
3689            context,
3690            snapshot_override,
3691            read,
3692        )
3693        .map(|(result, _)| result)
3694    }
3695
3696    #[allow(clippy::too_many_arguments)]
3697    pub fn with_authorized_scored_read_context_at_stamped<T, F>(
3698        &self,
3699        table_name: &str,
3700        principal: Option<&crate::auth::Principal>,
3701        catalog_bound: bool,
3702        authorization: Option<&ReadAuthorization>,
3703        context: Option<&crate::query::AiExecutionContext>,
3704        snapshot_override: Option<Snapshot>,
3705        mut read: F,
3706    ) -> Result<(T, AuthorizedReadStamp)>
3707    where
3708        F: FnMut(
3709            &Table,
3710            Snapshot,
3711            Option<&crate::security::CandidateAuthorization<'_>>,
3712            Option<&crate::auth::Principal>,
3713        ) -> Result<T>,
3714    {
3715        if principal.is_none() && self.principal.read().is_some() {
3716            self.refresh_principal()?;
3717        }
3718        const RETRIES: usize = 3;
3719        let handle = self.table(table_name)?;
3720        for attempt in 0..RETRIES {
3721            if let Some(context) = context {
3722                context.checkpoint()?;
3723            }
3724            crate::trace::QueryTrace::record(|trace| {
3725                trace.authorization_retries = attempt;
3726            });
3727            let (security, security_version, effective_principal) = {
3728                let catalog = self.catalog.read();
3729                (
3730                    catalog.security.clone(),
3731                    catalog.security_version,
3732                    self.principal_for_authorized_read(&catalog, principal, catalog_bound)?,
3733                )
3734            };
3735            if let Some(authorization) = authorization {
3736                for permission in &authorization.permissions {
3737                    self.require_for(effective_principal.as_ref(), permission)?;
3738                }
3739                self.require_columns_for(
3740                    table_name,
3741                    authorization.operation,
3742                    &authorization.columns,
3743                    effective_principal.as_ref(),
3744                )?;
3745            }
3746            let result = {
3747                let (table, snapshot, _snapshot_guard, _run_pins) =
3748                    self.scored_read_generation(&handle, context, snapshot_override)?;
3749                let candidate_authorization = if security.rls_enabled(table_name) {
3750                    Some(crate::security::CandidateAuthorization {
3751                        table: table_name,
3752                        security: &security,
3753                        principal: effective_principal
3754                            .as_ref()
3755                            .ok_or(MongrelError::AuthRequired)?,
3756                    })
3757                } else {
3758                    None
3759                };
3760                let stamp = AuthorizedReadStamp {
3761                    table_id: table.table_id(),
3762                    schema_id: table.schema().schema_id,
3763                    data_generation: table.data_generation(),
3764                    security_version,
3765                    snapshot,
3766                };
3767                let result = read(
3768                    table.as_ref(),
3769                    snapshot,
3770                    candidate_authorization.as_ref(),
3771                    effective_principal.as_ref(),
3772                )?;
3773                (result, stamp)
3774            };
3775            if let Some(context) = context {
3776                context.checkpoint()?;
3777            }
3778            if self.catalog.read().security_version == security_version {
3779                return Ok(result);
3780            }
3781            if attempt + 1 == RETRIES {
3782                return Err(MongrelError::Conflict(
3783                    "security policy changed during scored read".into(),
3784                ));
3785            }
3786        }
3787        Err(MongrelError::Conflict(
3788            "scored-read authorization retry loop exhausted".into(),
3789        ))
3790    }
3791
3792    fn scored_read_generation(
3793        &self,
3794        handle: &TableHandle,
3795        context: Option<&crate::query::AiExecutionContext>,
3796        snapshot_override: Option<Snapshot>,
3797    ) -> Result<(
3798        Arc<TableReadGeneration>,
3799        Snapshot,
3800        crate::retention::OwnedSnapshotGuard,
3801        RunPins,
3802    )> {
3803        let mut table = if let Some(context) = context {
3804            loop {
3805                context.checkpoint()?;
3806                let wait = context
3807                    .remaining_duration()
3808                    .unwrap_or(std::time::Duration::from_millis(5))
3809                    .min(std::time::Duration::from_millis(5));
3810                if let Some(table) = handle.try_lock_for(wait) {
3811                    break table;
3812                }
3813            }
3814        } else {
3815            handle.lock()
3816        };
3817        let (snapshot, snapshot_guard) = if let Some(snapshot) = snapshot_override {
3818            self.snapshot_at_owned(snapshot.epoch)?
3819        } else {
3820            let snapshot = table.snapshot();
3821            let guard = self.snapshots.register_owned(snapshot.epoch);
3822            (snapshot, guard)
3823        };
3824        let table_id = table.table_id();
3825        let run_keys: Vec<_> = table
3826            .active_run_ids()
3827            .map(|run_id| (table_id, run_id))
3828            .collect();
3829        let generation = handle
3830            .generation_metrics
3831            .activate(table.clone_read_generation()?);
3832        let run_pins = self.pin_runs(&run_keys);
3833        Ok((generation, snapshot, snapshot_guard, run_pins))
3834    }
3835
3836    fn pin_runs(&self, runs: &[(u64, u128)]) -> RunPins {
3837        let mut pins = self.backup_pins.lock();
3838        for run in runs {
3839            *pins.entry(*run).or_insert(0) += 1;
3840        }
3841        drop(pins);
3842        RunPins {
3843            pins: Arc::clone(&self.backup_pins),
3844            runs: runs.to_vec(),
3845        }
3846    }
3847
3848    /// Execute a native conjunctive read with the database principal's row
3849    /// policy, column grants, and masks applied. Raw [`Table`] methods remain
3850    /// policy-unaware; language bindings must use this boundary for reads.
3851    pub fn query_for_current_principal(
3852        &self,
3853        table_name: &str,
3854        query: &crate::query::Query,
3855        projection: Option<&[u16]>,
3856    ) -> Result<Vec<crate::memtable::Row>> {
3857        let condition_columns = crate::query::condition_columns(&query.conditions);
3858        self.with_authorized_read(
3859            table_name,
3860            None,
3861            true,
3862            |table, snapshot, allowed, principal| {
3863                let allowed_columns = self.select_column_ids_for(table_name, principal)?;
3864                self.require_columns_for(
3865                    table_name,
3866                    crate::auth::ColumnOperation::Select,
3867                    &condition_columns,
3868                    principal,
3869                )?;
3870                if let Some(projection) = projection {
3871                    self.require_columns_for(
3872                        table_name,
3873                        crate::auth::ColumnOperation::Select,
3874                        projection,
3875                        principal,
3876                    )?;
3877                }
3878                let mut rows = table.query_at_with_allowed(query, snapshot, allowed)?;
3879                let projection =
3880                    projection.map(|columns| columns.iter().copied().collect::<HashSet<_>>());
3881                for row in &mut rows {
3882                    row.columns.retain(|column, _| {
3883                        allowed_columns.contains(column)
3884                            && projection
3885                                .as_ref()
3886                                .is_none_or(|projection| projection.contains(column))
3887                    });
3888                }
3889                self.secure_rows_for(table_name, rows, principal)
3890            },
3891        )
3892    }
3893
3894    /// Reservoir aggregate with column grants, RLS, masks, and security-version
3895    /// retry applied at the database boundary.
3896    pub fn approx_aggregate_for_current_principal(
3897        &self,
3898        table_name: &str,
3899        conditions: &[crate::query::Condition],
3900        column: Option<u16>,
3901        agg: crate::engine::ApproxAgg,
3902        z: f64,
3903    ) -> Result<Option<crate::engine::ApproxResult>> {
3904        if !z.is_finite() || z <= 0.0 {
3905            return Err(MongrelError::InvalidArgument(
3906                "z must be finite and > 0".into(),
3907            ));
3908        }
3909        let mut columns = crate::query::condition_columns(conditions);
3910        columns.extend(column);
3911        columns.sort_unstable();
3912        columns.dedup();
3913        self.with_authorized_aggregate_table(
3914            table_name,
3915            &columns,
3916            None,
3917            true,
3918            true,
3919            |table, authorization, _, _| {
3920                table.approx_aggregate_with_candidate_authorization(
3921                    conditions,
3922                    column,
3923                    agg,
3924                    z,
3925                    authorization,
3926                )
3927            },
3928        )
3929    }
3930
3931    /// Incremental aggregate over an append-only table. Active RLS or masks are
3932    /// rejected because the table-global delta cache cannot safely represent a
3933    /// secured row universe.
3934    pub fn incremental_aggregate_for_current_principal(
3935        &self,
3936        table_name: &str,
3937        conditions: &[crate::query::Condition],
3938        column: Option<u16>,
3939        agg: crate::engine::NativeAgg,
3940    ) -> Result<crate::engine::IncrementalAggResult> {
3941        self.incremental_aggregate_for_principal(table_name, conditions, column, agg, None, true)
3942    }
3943
3944    /// Incremental aggregate using an explicit request principal. A
3945    /// catalog-bound principal is re-resolved on every retry so live grants,
3946    /// revocations, RLS, and masks cannot reuse a stale cache entry.
3947    pub fn incremental_aggregate_for_principal(
3948        &self,
3949        table_name: &str,
3950        conditions: &[crate::query::Condition],
3951        column: Option<u16>,
3952        agg: crate::engine::NativeAgg,
3953        principal: Option<&crate::auth::Principal>,
3954        catalog_bound: bool,
3955    ) -> Result<crate::engine::IncrementalAggResult> {
3956        let mut columns = crate::query::condition_columns(conditions);
3957        columns.extend(column);
3958        columns.sort_unstable();
3959        columns.dedup();
3960        self.with_authorized_aggregate_table(
3961            table_name,
3962            &columns,
3963            principal,
3964            catalog_bound,
3965            false,
3966            |table, _, principal, security_version| {
3967                let cache_key = incremental_aggregate_cache_key(
3968                    table_name,
3969                    conditions,
3970                    column,
3971                    agg,
3972                    principal,
3973                    security_version,
3974                );
3975                table.aggregate_incremental(cache_key, conditions, column, agg)
3976            },
3977        )
3978    }
3979
3980    /// Read one row with the database principal's row policy, column grants,
3981    /// and masks applied.
3982    pub fn get_for_current_principal(
3983        &self,
3984        table_name: &str,
3985        row_id: RowId,
3986    ) -> Result<Option<crate::memtable::Row>> {
3987        self.with_authorized_read(
3988            table_name,
3989            None,
3990            true,
3991            |table, snapshot, allowed, principal| {
3992                let allowed_columns = self.select_column_ids_for(table_name, principal)?;
3993                let Some(row) = table.get(row_id, snapshot) else {
3994                    return Ok(None);
3995                };
3996                if allowed.is_some_and(|allowed| !allowed.contains(&row.row_id)) {
3997                    return Ok(None);
3998                }
3999                let mut rows = self.secure_rows_for(table_name, vec![row], principal)?;
4000                if let Some(row) = rows.first_mut() {
4001                    row.columns
4002                        .retain(|column, _| allowed_columns.contains(column));
4003                }
4004                Ok(rows.pop())
4005            },
4006        )
4007    }
4008
4009    /// Run exact ANN reranking over only rows authorized for this database
4010    /// handle. The embedding column still requires normal column access.
4011    pub fn ann_rerank_for_current_principal(
4012        &self,
4013        table_name: &str,
4014        request: &crate::query::AnnRerankRequest,
4015    ) -> Result<Vec<crate::query::AnnRerankHit>> {
4016        self.with_authorized_scored_read_context_at(
4017            table_name,
4018            None,
4019            true,
4020            Some(&ReadAuthorization {
4021                operation: crate::auth::ColumnOperation::Select,
4022                columns: vec![request.column_id],
4023                permissions: Vec::new(),
4024            }),
4025            None,
4026            None,
4027            |table, snapshot, authorization, principal| {
4028                self.require_columns_for(
4029                    table_name,
4030                    crate::auth::ColumnOperation::Select,
4031                    &[request.column_id],
4032                    principal,
4033                )?;
4034                table.ann_rerank_at_with_candidate_authorization_on_generation(
4035                    request,
4036                    snapshot,
4037                    authorization,
4038                    None,
4039                )
4040            },
4041        )
4042    }
4043
4044    /// Capture one table snapshot and the security version used to authorize it.
4045    /// The caller must validate the returned version before publishing results.
4046    pub fn authorized_read_snapshot(
4047        &self,
4048        table: &str,
4049        principal: Option<&crate::auth::Principal>,
4050    ) -> Result<AuthorizedReadSnapshot> {
4051        let (security, security_version, effective_principal) = {
4052            let catalog = self.catalog.read();
4053            (
4054                catalog.security.clone(),
4055                catalog.security_version,
4056                self.principal_for_authorized_read(&catalog, principal, false)?,
4057            )
4058        };
4059        let handle = self.table(table)?;
4060        let (table_snapshot, data_generation, allowed_row_ids) = {
4061            let table_handle = handle.lock();
4062            let table_snapshot = table_handle.snapshot();
4063            let data_generation = table_handle.data_generation();
4064            let allowed = self.allowed_row_ids_locked(
4065                table,
4066                &table_handle,
4067                table_snapshot,
4068                (&security, security_version),
4069                effective_principal.as_ref(),
4070                None,
4071            )?;
4072            (
4073                table_snapshot,
4074                data_generation,
4075                allowed.map(|allowed| (*allowed).clone()),
4076            )
4077        };
4078        Ok(AuthorizedReadSnapshot {
4079            table: table.to_string(),
4080            table_snapshot,
4081            data_generation,
4082            security_version,
4083            allowed_row_ids,
4084        })
4085    }
4086
4087    pub fn authorized_read_snapshot_valid(&self, snapshot: &AuthorizedReadSnapshot) -> bool {
4088        if self.catalog.read().security_version != snapshot.security_version {
4089            return false;
4090        }
4091        self.table(&snapshot.table)
4092            .ok()
4093            .is_some_and(|table| table.lock().data_generation() == snapshot.data_generation)
4094    }
4095
4096    pub fn rls_cache_stats(&self) -> RlsCacheStats {
4097        self.rls_cache.lock().stats()
4098    }
4099
4100    /// Read visible rows with column authorization, RLS, and masks applied.
4101    pub fn rows_for(
4102        &self,
4103        table: &str,
4104        principal: Option<&crate::auth::Principal>,
4105    ) -> Result<Vec<crate::memtable::Row>> {
4106        if principal.is_none() && self.principal.read().is_some() {
4107            self.refresh_principal()?;
4108        }
4109        let allowed = self.select_column_ids_for(table, principal)?;
4110        let handle = self.table(table)?;
4111        let rows = {
4112            let table = handle.lock();
4113            table.visible_rows(table.snapshot())?
4114        };
4115        let mut rows = self.secure_rows_for(table, rows, principal)?;
4116        for row in &mut rows {
4117            row.columns.retain(|column, _| allowed.contains(column));
4118        }
4119        Ok(rows)
4120    }
4121
4122    /// Historical rows use the current principal and security catalog against
4123    /// the row values visible at the requested snapshot.
4124    pub fn rows_at_epoch_for_current_principal(
4125        &self,
4126        table_name: &str,
4127        snapshot: Snapshot,
4128    ) -> Result<Vec<crate::memtable::Row>> {
4129        self.with_authorized_read_context(
4130            table_name,
4131            None,
4132            true,
4133            Some(&ReadAuthorization {
4134                operation: crate::auth::ColumnOperation::Select,
4135                columns: Vec::new(),
4136                permissions: Vec::new(),
4137            }),
4138            None,
4139            Some(snapshot),
4140            |table, snapshot, allowed, principal| {
4141                let allowed_columns = self.select_column_ids_for(table_name, principal)?;
4142                let mut rows = table.visible_rows(snapshot)?;
4143                if let Some(allowed) = allowed {
4144                    rows.retain(|row| allowed.contains(&row.row_id));
4145                }
4146                rows = self.secure_rows_for(table_name, rows, principal)?;
4147                for row in &mut rows {
4148                    row.columns
4149                        .retain(|column, _| allowed_columns.contains(column));
4150                }
4151                Ok(rows)
4152            },
4153        )
4154    }
4155
4156    /// Count rows visible to a principal without bypassing RLS.
4157    pub fn count_for(
4158        &self,
4159        table: &str,
4160        principal: Option<&crate::auth::Principal>,
4161    ) -> Result<u64> {
4162        if principal.is_none() && self.principal.read().is_some() {
4163            self.refresh_principal()?;
4164        }
4165        self.select_column_ids_for(table, principal)?;
4166        if self.security_active_for(table) {
4167            Ok(self.rows_for(table, principal)?.len() as u64)
4168        } else {
4169            Ok(self.table(table)?.lock().count())
4170        }
4171    }
4172
4173    /// Authorize and write one native-API row for an explicit principal.
4174    pub fn put_for(
4175        &self,
4176        table: &str,
4177        mut cells: Vec<(u16, crate::memtable::Value)>,
4178        principal: Option<&crate::auth::Principal>,
4179    ) -> Result<RowId> {
4180        let columns = cells.iter().map(|(column, _)| *column).collect::<Vec<_>>();
4181        self.require_columns_for(
4182            table,
4183            crate::auth::ColumnOperation::Insert,
4184            &columns,
4185            principal,
4186        )?;
4187        let handle = self.table(table)?;
4188        let mut table_handle = handle.lock();
4189        table_handle.fill_auto_inc(&mut cells)?;
4190        table_handle.apply_defaults(&mut cells)?;
4191        let mut row = crate::memtable::Row::new(RowId(0), self.epoch.visible());
4192        row.columns.extend(cells.iter().cloned());
4193        self.check_row_policy_for(
4194            table,
4195            crate::security::PolicyCommand::Insert,
4196            &row,
4197            true,
4198            principal,
4199        )?;
4200        table_handle.put(cells)
4201    }
4202
4203    pub fn check_row_policy_for(
4204        &self,
4205        table: &str,
4206        command: crate::security::PolicyCommand,
4207        row: &crate::memtable::Row,
4208        check_new: bool,
4209        principal: Option<&crate::auth::Principal>,
4210    ) -> Result<()> {
4211        let security = self.catalog.read().security.clone();
4212        if !security.rls_enabled(table) {
4213            return Ok(());
4214        }
4215        let cached = self.principal.read().clone();
4216        let principal = principal
4217            .or(cached.as_ref())
4218            .ok_or(MongrelError::AuthRequired)?;
4219        if security.row_allowed(table, command, row, principal, check_new) {
4220            return Ok(());
4221        }
4222        let required = match command {
4223            crate::security::PolicyCommand::Insert => crate::auth::Permission::Insert {
4224                table: table.to_string(),
4225            },
4226            crate::security::PolicyCommand::Update => crate::auth::Permission::Update {
4227                table: table.to_string(),
4228            },
4229            crate::security::PolicyCommand::Select => crate::auth::Permission::Select {
4230                table: table.to_string(),
4231            },
4232            crate::security::PolicyCommand::Delete | crate::security::PolicyCommand::All => {
4233                crate::auth::Permission::Delete {
4234                    table: table.to_string(),
4235                }
4236            }
4237        };
4238        Err(MongrelError::PermissionDenied {
4239            required,
4240            principal: principal.username.clone(),
4241        })
4242    }
4243
4244    /// Durably create or replace a materialized-view definition after its
4245    /// physical table has been populated.
4246    pub fn set_materialized_view(
4247        &self,
4248        definition: crate::catalog::MaterializedViewEntry,
4249    ) -> Result<()> {
4250        self.set_materialized_view_with_epoch(definition)
4251            .map(|_| ())
4252    }
4253
4254    /// Durably create or replace a materialized-view definition and return its epoch.
4255    pub fn set_materialized_view_with_epoch(
4256        &self,
4257        definition: crate::catalog::MaterializedViewEntry,
4258    ) -> Result<Epoch> {
4259        use crate::wal::DdlOp;
4260        use std::sync::atomic::Ordering;
4261
4262        self.require(&crate::auth::Permission::Ddl)?;
4263        if self.poisoned.load(Ordering::Relaxed) {
4264            return Err(MongrelError::Other(
4265                "database poisoned by fsync error".into(),
4266            ));
4267        }
4268        if definition.name.is_empty() || definition.query.trim().is_empty() {
4269            return Err(MongrelError::InvalidArgument(
4270                "materialized view name and query must not be empty".into(),
4271            ));
4272        }
4273
4274        let _ddl = self.ddl_lock.lock();
4275        let _security_write = self.security_write()?;
4276        self.require(&crate::auth::Permission::Ddl)?;
4277        let table_id = self
4278            .catalog
4279            .read()
4280            .live(&definition.name)
4281            .ok_or_else(|| {
4282                MongrelError::NotFound(format!(
4283                    "materialized view table {:?} not found",
4284                    definition.name
4285                ))
4286            })?
4287            .table_id;
4288        let definition_json = DdlOp::encode_materialized_view(&definition)?;
4289        let _commit = self.commit_lock.lock();
4290        let epoch = self.epoch.bump_assigned();
4291        let mut epoch_guard = EpochGuard::new(self.epoch.as_ref(), epoch);
4292        let txn_id = self.alloc_txn_id()?;
4293        let mut next_catalog = self.catalog.read().clone();
4294        if let Some(existing) = next_catalog
4295            .materialized_views
4296            .iter_mut()
4297            .find(|existing| existing.name == definition.name)
4298        {
4299            *existing = definition.clone();
4300        } else {
4301            next_catalog.materialized_views.push(definition.clone());
4302        }
4303        next_catalog.db_epoch = next_catalog.db_epoch.max(epoch.0);
4304        let commit_seq = {
4305            let mut wal = self.shared_wal.lock();
4306            let append: Result<u64> = (|| {
4307                wal.append(
4308                    txn_id,
4309                    table_id,
4310                    crate::wal::Op::Ddl(DdlOp::SetMaterializedView {
4311                        name: definition.name.clone(),
4312                        definition_json,
4313                    }),
4314                )?;
4315                append_catalog_snapshot(&mut wal, txn_id, &next_catalog)?;
4316                wal.append_commit(txn_id, epoch, &[])
4317            })();
4318            append.map_err(|error| self.commit_outcome_unknown(epoch, error))?
4319        };
4320        self.await_durable_commit(commit_seq, epoch)?;
4321
4322        let checkpoint = self.checkpoint_catalog_after_durable(next_catalog);
4323        self.finish_durable_publish(epoch, &mut epoch_guard, checkpoint)?;
4324        Ok(epoch)
4325    }
4326
4327    /// The filesystem root this database was opened/created at.
4328    pub fn root(&self) -> &Path {
4329        self.durable_root.canonical_path()
4330    }
4331
4332    /// Open a descriptor-pinned view of this database root for durable
4333    /// extension state such as server idempotency receipts.
4334    pub fn durable_root(&self) -> Arc<crate::durable_file::DurableRoot> {
4335        Arc::clone(&self.durable_root)
4336    }
4337
4338    /// Domain-separated authentication key for server idempotency state.
4339    /// Encrypted databases derive it from the in-memory KEK. Plain databases
4340    /// return `None`; their server persists a random key under the pinned root.
4341    #[cfg(feature = "encryption")]
4342    pub fn derive_server_idempotency_key(&self) -> Option<zeroize::Zeroizing<[u8; 32]>> {
4343        self.kek
4344            .as_deref()
4345            .map(|kek| kek.derive_subkey(b"mongreldb/server/idempotency/v1"))
4346    }
4347
4348    #[cfg(not(feature = "encryption"))]
4349    pub fn derive_server_idempotency_key(&self) -> Option<zeroize::Zeroizing<[u8; 32]>> {
4350        None
4351    }
4352
4353    pub fn is_read_only_replica(&self) -> bool {
4354        self.read_only
4355    }
4356
4357    /// Reject reads whose backing state may require WAL recovery after a
4358    /// post-commit publication failure. Ordinary table/catalog state is made
4359    /// coherent before poison; file-backed external modules use this gate.
4360    pub fn ensure_consistent_read(&self) -> Result<()> {
4361        if self.poisoned.load(Ordering::Relaxed) {
4362            return Err(MongrelError::Other(
4363                "database poisoned by post-commit failure; reopen required".into(),
4364            ));
4365        }
4366        Ok(())
4367    }
4368
4369    pub fn set_replication_wal_retention_segments(&self, segments: usize) {
4370        self.replication_wal_retention_segments
4371            .store(segments, std::sync::atomic::Ordering::Relaxed);
4372    }
4373
4374    /// Capture a consistent bootstrap image. DDL, transaction spill/publish,
4375    /// direct table commits, compaction, and WAL append are quiesced while the
4376    /// file image is read. WAL records newer than manifests remain sufficient
4377    /// for recovery, so no flush or compaction is required.
4378    pub fn replication_snapshot(&self) -> Result<crate::replication::ReplicationSnapshot> {
4379        let admin = crate::auth::Permission::Admin;
4380        self.require(&admin)?;
4381        let operation_principal = self.principal_snapshot();
4382        let _barrier = self.replication_barrier.write();
4383        let _ddl = self.ddl_lock.lock();
4384        let _security = self.security_coordinator.gate.read();
4385        self.require_exact_principal_current(operation_principal.as_ref(), &admin)?;
4386        let mut handles: Vec<_> = self
4387            .tables
4388            .read()
4389            .iter()
4390            .map(|(id, handle)| (*id, handle.clone()))
4391            .collect();
4392        handles.sort_by_key(|(id, _)| *id);
4393        let _table_guards: Vec<_> = handles.iter().map(|(_, handle)| handle.lock()).collect();
4394        let _commit = self.commit_lock.lock();
4395        let mut wal = self.shared_wal.lock();
4396        wal.group_sync()?;
4397        let wal_dek = crate::encryption::wal_dek_for(self.kek.as_deref());
4398        let records = crate::wal::SharedWal::replay_with_dek(&self.root, wal_dek.as_ref())?;
4399        let epoch = records
4400            .iter()
4401            .filter_map(|record| match &record.op {
4402                crate::wal::Op::TxnCommit { epoch, .. } => Some(*epoch),
4403                _ => None,
4404            })
4405            .max()
4406            .unwrap_or(0)
4407            .max(self.epoch.committed().0);
4408        let files = crate::replication::capture_files(&self.root)?;
4409        let source_id = crate::replication::replication_identity_durable(&self.durable_root)?;
4410        drop(wal);
4411        Ok(crate::replication::ReplicationSnapshot::new(
4412            source_id, epoch, files,
4413        ))
4414    }
4415
4416    /// Create an online, directly-openable backup at `destination`.
4417    ///
4418    /// The short boundary phase quiesces commits/DDL, syncs the WAL, copies
4419    /// mutable metadata, and pins the exact immutable runs named by the copied
4420    /// manifests. Writers resume while those runs stream into a sibling staging
4421    /// directory. A checksummed backup manifest is written last, then the stage
4422    /// is atomically renamed into place.
4423    pub fn hot_backup(&self, destination: impl AsRef<Path>) -> Result<crate::backup::BackupReport> {
4424        let control = crate::ExecutionControl::new(None);
4425        self.hot_backup_controlled(destination, &control, || true)
4426    }
4427
4428    pub(crate) fn hot_backup_to_durable_child(
4429        &self,
4430        parent: &crate::durable_file::DurableRoot,
4431        child: &Path,
4432        control: &crate::ExecutionControl,
4433    ) -> Result<crate::backup::BackupReport> {
4434        let mut components = child.components();
4435        if !matches!(components.next(), Some(std::path::Component::Normal(_)))
4436            || components.next().is_some()
4437        {
4438            return Err(MongrelError::InvalidArgument(
4439                "durable backup child must be one relative path component".into(),
4440            ));
4441        }
4442        let destination_name = child.file_name().ok_or_else(|| {
4443            MongrelError::InvalidArgument("durable backup child has no filename".into())
4444        })?;
4445        let prepared = prepare_backup_destination_in(&self.root, parent, destination_name)?;
4446        self.hot_backup_prepared(prepared, control, || true)
4447    }
4448
4449    /// Build a backup cooperatively, then invoke `before_publish` immediately
4450    /// before the staging directory is atomically renamed into place.
4451    #[doc(hidden)]
4452    pub fn hot_backup_controlled<F>(
4453        &self,
4454        destination: impl AsRef<Path>,
4455        control: &crate::ExecutionControl,
4456        before_publish: F,
4457    ) -> Result<crate::backup::BackupReport>
4458    where
4459        F: FnOnce() -> bool,
4460    {
4461        let prepared = prepare_backup_destination(&self.root, destination.as_ref())?;
4462        self.hot_backup_prepared(prepared, control, before_publish)
4463    }
4464
4465    fn hot_backup_prepared<F>(
4466        &self,
4467        mut prepared: PreparedBackupDestination,
4468        control: &crate::ExecutionControl,
4469        before_publish: F,
4470    ) -> Result<crate::backup::BackupReport>
4471    where
4472        F: FnOnce() -> bool,
4473    {
4474        let admin = crate::auth::Permission::Admin;
4475        self.require(&admin)?;
4476        let operation_principal = self.principal_snapshot();
4477        control.checkpoint()?;
4478        let destination = prepared.destination_path.clone();
4479        let mut before_publish = Some(before_publish);
4480
4481        let outcome = (|| {
4482            control.checkpoint()?;
4483            let barrier = self.replication_barrier.write();
4484            let ddl = self.ddl_lock.lock();
4485            let security = self.security_coordinator.gate.read();
4486            self.require_exact_principal_current(operation_principal.as_ref(), &admin)?;
4487            let mut handles: Vec<_> = self
4488                .tables
4489                .read()
4490                .iter()
4491                .map(|(id, handle)| (*id, handle.clone()))
4492                .collect();
4493            handles.sort_by_key(|(id, _)| *id);
4494            let table_guards: Vec<_> = handles.iter().map(|(_, handle)| handle.lock()).collect();
4495            let commit = self.commit_lock.lock();
4496            let mut wal = self.shared_wal.lock();
4497            wal.group_sync()?;
4498            let epoch = self.epoch.committed().0;
4499            let boundary_unix_nanos = current_unix_nanos();
4500
4501            let pin_nonce = std::time::SystemTime::now()
4502                .duration_since(std::time::UNIX_EPOCH)
4503                .unwrap_or_default()
4504                .as_nanos();
4505            let file_pin_root = self
4506                .root
4507                .join(META_DIR)
4508                .join("backup-pins")
4509                .join(format!("{}-{pin_nonce}", std::process::id()));
4510            std::fs::create_dir_all(&file_pin_root)?;
4511            let _file_pins = BackupFilePins {
4512                root: file_pin_root.clone(),
4513            };
4514            let mut run_files = Vec::new();
4515            for (index, (table_id, _)) in handles.iter().enumerate() {
4516                if index % 256 == 0 {
4517                    control.checkpoint()?;
4518                }
4519                let table = &table_guards[index];
4520                for (run_index, run) in table.run_refs().iter().enumerate() {
4521                    if run_index % 256 == 0 {
4522                        control.checkpoint()?;
4523                    }
4524                    let source = table.run_path(run.run_id as u64);
4525                    let relative = Path::new(TABLES_DIR)
4526                        .join(table_id.to_string())
4527                        .join(crate::engine::RUNS_DIR)
4528                        .join(format!("r-{}.sr", run.run_id));
4529                    let pinned = file_pin_root.join(format!("{table_id}-{}.sr", run.run_id));
4530                    if std::fs::hard_link(&source, &pinned).is_err() {
4531                        crate::backup::copy_file_synced(&source, &pinned)?;
4532                    }
4533                    run_files.push(((*table_id, run.run_id), pinned, relative));
4534                }
4535            }
4536            crate::durable_file::sync_directory(&file_pin_root)?;
4537            let run_keys: Vec<_> = run_files.iter().map(|(key, _, _)| *key).collect();
4538            {
4539                let mut pins = self.backup_pins.lock();
4540                for key in &run_keys {
4541                    *pins.entry(*key).or_insert(0) += 1;
4542                }
4543            }
4544            let _run_pins = RunPins {
4545                pins: Arc::clone(&self.backup_pins),
4546                runs: run_keys,
4547            };
4548            let deferred: HashSet<_> = run_files
4549                .iter()
4550                .map(|(_, _, relative)| relative.clone())
4551                .collect();
4552            let mut copied = Vec::new();
4553            copy_backup_boundary(
4554                &self.root,
4555                prepared.stage.as_deref().ok_or_else(|| {
4556                    MongrelError::Other("backup staging root was already released".into())
4557                })?,
4558                &deferred,
4559                &mut copied,
4560                Some(control),
4561            )?;
4562
4563            drop(wal);
4564            drop(commit);
4565            drop(table_guards);
4566            drop(security);
4567            drop(ddl);
4568            drop(barrier);
4569
4570            if let Some(hook) = self.backup_hook.lock().as_ref() {
4571                hook();
4572            }
4573            for (index, (_, source, relative)) in run_files.into_iter().enumerate() {
4574                if index % 256 == 0 {
4575                    control.checkpoint()?;
4576                }
4577                let mut source = crate::durable_file::open_regular_nofollow(&source)?;
4578                prepared
4579                    .stage
4580                    .as_deref()
4581                    .ok_or_else(|| {
4582                        MongrelError::Other("backup staging root was already released".into())
4583                    })?
4584                    .copy_new_from(&relative, &mut source)?;
4585                copied.push(relative);
4586            }
4587
4588            let manifest = crate::backup::BackupManifest::create_controlled_durable(
4589                prepared.stage.as_deref().ok_or_else(|| {
4590                    MongrelError::Other("backup staging root was already released".into())
4591                })?,
4592                epoch,
4593                &copied,
4594                control,
4595            )?;
4596            manifest.write_to_durable(prepared.stage.as_deref().ok_or_else(|| {
4597                MongrelError::Other("backup staging root was already released".into())
4598            })?)?;
4599            control.checkpoint()?;
4600            let publish = before_publish.take().ok_or_else(|| {
4601                MongrelError::Other("backup publication callback already consumed".into())
4602            })?;
4603            if !publish() {
4604                return Err(MongrelError::Cancelled);
4605            }
4606            let final_security = self.security_coordinator.gate.read();
4607            self.require_exact_principal_current(operation_principal.as_ref(), &admin)?;
4608            // Windows pins directories without delete sharing. Release the
4609            // stage handle before renaming that directory, while the parent
4610            // remains descriptor-pinned for the no-replace publication.
4611            drop(prepared.stage.take().ok_or_else(|| {
4612                MongrelError::Other("backup staging root was already released".into())
4613            })?);
4614            let published = std::cell::Cell::new(false);
4615            if let Err(error) = prepared.parent.rename_directory_new_with_after(
4616                Path::new(&prepared.stage_name),
4617                &prepared.parent,
4618                Path::new(&prepared.destination_name),
4619                || published.set(true),
4620            ) {
4621                if published.get() {
4622                    return Err(MongrelError::CommitOutcomeUnknown {
4623                        epoch,
4624                        message: format!("backup publication was not durable: {error}"),
4625                    });
4626                }
4627                return Err(error.into());
4628            }
4629            drop(final_security);
4630            Ok(crate::backup::BackupReport {
4631                destination,
4632                epoch,
4633                boundary_unix_nanos,
4634                files: manifest.files.len(),
4635                bytes: manifest.total_bytes(),
4636            })
4637        })();
4638
4639        if outcome.is_err() {
4640            drop(prepared.stage.take());
4641            let _ = prepared
4642                .parent
4643                .remove_directory_all(Path::new(&prepared.stage_name));
4644        }
4645        outcome
4646    }
4647
4648    /// Return complete committed transactions after `since_epoch`. A gap or a
4649    /// transaction backed by a spilled run requires a fresh bootstrap image.
4650    pub fn replication_batch_since(
4651        &self,
4652        since_epoch: u64,
4653    ) -> Result<crate::replication::ReplicationBatch> {
4654        use crate::wal::Op;
4655
4656        let admin = crate::auth::Permission::Admin;
4657        self.require(&admin)?;
4658        let operation_principal = self.principal_snapshot();
4659
4660        let mut wal = self.shared_wal.lock();
4661        wal.group_sync()?;
4662        let wal_dek = crate::encryption::wal_dek_for(self.kek.as_deref());
4663        let records = crate::wal::SharedWal::replay_with_dek(&self.root, wal_dek.as_ref())?;
4664        drop(wal);
4665
4666        let commits: HashMap<u64, u64> = records
4667            .iter()
4668            .filter_map(|record| match &record.op {
4669                Op::TxnCommit { epoch, .. } => Some((record.txn_id, *epoch)),
4670                _ => None,
4671            })
4672            .collect();
4673        let earliest_epoch = commits.values().copied().min();
4674        let current_epoch = commits
4675            .values()
4676            .copied()
4677            .max()
4678            .unwrap_or(0)
4679            .max(self.epoch.committed().0);
4680        let selected: HashSet<u64> = commits
4681            .iter()
4682            .filter_map(|(txn_id, epoch)| (*epoch > since_epoch).then_some(*txn_id))
4683            .collect();
4684        let retention_gap = since_epoch < current_epoch
4685            && since_epoch < crate::replication::replication_wal_floor(&self.root)?;
4686        let spilled = records.iter().any(|record| {
4687            selected.contains(&record.txn_id)
4688                && matches!(
4689                    &record.op,
4690                    Op::TxnCommit { added_runs, .. } if !added_runs.is_empty()
4691                )
4692        });
4693        let records = records
4694            .into_iter()
4695            .filter(|record| record.txn_id != crate::wal::SYSTEM_TXN_ID)
4696            .filter(|record| selected.contains(&record.txn_id))
4697            .collect();
4698        let source_id = crate::replication::replication_identity_durable(&self.durable_root)?;
4699        let batch = crate::replication::ReplicationBatch::complete_for_source(
4700            source_id,
4701            since_epoch,
4702            current_epoch,
4703            earliest_epoch,
4704            retention_gap,
4705            spilled,
4706            records,
4707        )?;
4708        if let Some(hook) = self.replication_hook.lock().as_ref() {
4709            hook();
4710        }
4711        let _security = self.security_coordinator.gate.read();
4712        self.require_exact_principal_current(operation_principal.as_ref(), &admin)?;
4713        Ok(batch)
4714    }
4715
4716    /// Durably append a leader batch to a follower's local WAL and checkpoint
4717    /// its catalog metadata. Security changes apply to this live handle before
4718    /// success returns. The caller must reopen to mount new table state.
4719    pub fn append_replication_batch(
4720        &self,
4721        batch: &crate::replication::ReplicationBatch,
4722    ) -> Result<u64> {
4723        use crate::wal::Op;
4724
4725        if !self.read_only {
4726            return Err(MongrelError::InvalidArgument(
4727                "replication batches may only target a marked replica".into(),
4728            ));
4729        }
4730        let current = crate::replication::replica_epoch(&self.root)?;
4731        if batch.is_source_bound() {
4732            let source_id = crate::replication::replica_source_id_durable(&self.durable_root)?;
4733            if batch.source_id != source_id {
4734                return Err(MongrelError::Conflict(
4735                    "replication batch source does not match follower binding".into(),
4736                ));
4737            }
4738        }
4739        if batch.requires_snapshot {
4740            return Err(MongrelError::Conflict(
4741                "replication snapshot required for this batch".into(),
4742            ));
4743        }
4744        batch.validate_proof()?;
4745        if batch.from_epoch != current {
4746            if batch.from_epoch < current && batch.current_epoch == current {
4747                let wal_dek = crate::encryption::wal_dek_for(self.kek.as_deref());
4748                let _wal = self.shared_wal.lock();
4749                let existing: HashSet<(u64, u64)> =
4750                    crate::wal::SharedWal::replay_with_dek(&self.root, wal_dek.as_ref())?
4751                        .into_iter()
4752                        .filter_map(|record| match record.op {
4753                            Op::TxnCommit { epoch, .. } => Some((record.txn_id, epoch)),
4754                            _ => None,
4755                        })
4756                        .collect();
4757                let already_applied = batch.records.iter().all(|record| match &record.op {
4758                    Op::TxnCommit { epoch, .. } => existing.contains(&(record.txn_id, *epoch)),
4759                    _ => true,
4760                });
4761                if already_applied {
4762                    return Ok(current);
4763                }
4764            }
4765            return Err(MongrelError::Conflict(format!(
4766                "replication batch starts at epoch {}, follower is at epoch {current}",
4767                batch.from_epoch
4768            )));
4769        }
4770        if batch.current_epoch < current {
4771            return Err(MongrelError::InvalidArgument(format!(
4772                "replication batch current epoch {} precedes follower epoch {current}",
4773                batch.current_epoch
4774            )));
4775        }
4776        let records = &batch.records;
4777        let mut commits = HashMap::new();
4778        let mut commit_epochs = HashSet::new();
4779        let mut commit_timestamps = HashMap::new();
4780        for record in records {
4781            match &record.op {
4782                Op::TxnCommit { epoch, added_runs } => {
4783                    if !added_runs.is_empty() {
4784                        return Err(MongrelError::Conflict(
4785                            "replication snapshot required for spilled-run transaction".into(),
4786                        ));
4787                    }
4788                    if commits.insert(record.txn_id, *epoch).is_some() {
4789                        return Err(MongrelError::InvalidArgument(format!(
4790                            "duplicate commit for replication transaction {}",
4791                            record.txn_id
4792                        )));
4793                    }
4794                    if *epoch <= current || *epoch > batch.current_epoch {
4795                        return Err(MongrelError::InvalidArgument(format!(
4796                            "replication commit epoch {epoch} is outside ({current}, {}]",
4797                            batch.current_epoch
4798                        )));
4799                    }
4800                    if !commit_epochs.insert(*epoch) {
4801                        return Err(MongrelError::InvalidArgument(format!(
4802                            "duplicate replication commit epoch {epoch}"
4803                        )));
4804                    }
4805                }
4806                Op::CommitTimestamp { unix_nanos } => {
4807                    commit_timestamps.insert(record.txn_id, *unix_nanos);
4808                }
4809                _ => {}
4810            }
4811        }
4812        for record in records {
4813            if record.txn_id == crate::wal::SYSTEM_TXN_ID
4814                || matches!(&record.op, Op::TxnAbort | Op::Flush { .. })
4815            {
4816                return Err(MongrelError::InvalidArgument(
4817                    "replication batch contains a non-committed record".into(),
4818                ));
4819            }
4820            if !commits.contains_key(&record.txn_id) {
4821                return Err(MongrelError::InvalidArgument(format!(
4822                    "incomplete replication transaction {}",
4823                    record.txn_id
4824                )));
4825            }
4826        }
4827        let target_epoch = commits
4828            .values()
4829            .copied()
4830            .filter(|epoch| *epoch > current)
4831            .max()
4832            .unwrap_or(current);
4833        if target_epoch != batch.current_epoch {
4834            return Err(MongrelError::InvalidArgument(format!(
4835                "replication batch ends at epoch {target_epoch}, expected {}",
4836                batch.current_epoch
4837            )));
4838        }
4839        let mut selected: HashSet<u64> = commits
4840            .iter()
4841            .filter_map(|(txn_id, epoch)| (*epoch > current).then_some(*txn_id))
4842            .collect();
4843        if selected.is_empty() {
4844            return Ok(current);
4845        }
4846        let mut wal = self.shared_wal.lock();
4847        wal.group_sync()?;
4848        let wal_dek = crate::encryption::wal_dek_for(self.kek.as_deref());
4849        let existing: HashSet<(u64, u64)> =
4850            crate::wal::SharedWal::replay_with_dek(&self.root, wal_dek.as_ref())?
4851                .into_iter()
4852                .filter_map(|record| match record.op {
4853                    Op::TxnCommit { epoch, .. } => Some((record.txn_id, epoch)),
4854                    _ => None,
4855                })
4856                .collect();
4857        selected.retain(|txn_id| {
4858            commits
4859                .get(txn_id)
4860                .is_some_and(|epoch| !existing.contains(&(*txn_id, *epoch)))
4861        });
4862        for record in records {
4863            if !selected.contains(&record.txn_id) {
4864                continue;
4865            }
4866            match &record.op {
4867                Op::TxnCommit { epoch, added_runs } => {
4868                    let timestamp = commit_timestamps
4869                        .get(&record.txn_id)
4870                        .copied()
4871                        .unwrap_or_else(current_unix_nanos);
4872                    wal.append_commit_at(record.txn_id, Epoch(*epoch), added_runs, timestamp)?;
4873                }
4874                Op::TxnAbort | Op::Flush { .. } | Op::CommitTimestamp { .. } => {}
4875                op => {
4876                    wal.append(record.txn_id, 0, op.clone())?;
4877                }
4878            }
4879        }
4880        if !selected.is_empty() {
4881            wal.group_sync()?;
4882        }
4883        drop(wal);
4884
4885        // Auth mode is selected before `finish_open` replays the WAL. Make the
4886        // catalog transition durable and publish security state to this live
4887        // handle before reporting success.
4888        let mut recovered_catalog = self.catalog.read().clone();
4889        if let Err(error) = recover_ddl_from_wal(
4890            &self.root,
4891            Some(&self.durable_root),
4892            &mut recovered_catalog,
4893            self.meta_dek.as_ref(),
4894            wal_dek.as_ref(),
4895            true,
4896            None,
4897        ) {
4898            return Err(MongrelError::DurableCommit {
4899                epoch: target_epoch,
4900                message: format!(
4901                    "replication WAL is durable but catalog checkpoint failed: {error}"
4902                ),
4903            });
4904        }
4905        let _security = self.security_coordinator.gate.write();
4906        let old_security_version = self.catalog.read().security_version;
4907        let security_changed = old_security_version != recovered_catalog.security_version
4908            || self.catalog.read().require_auth != recovered_catalog.require_auth;
4909        let require_auth = recovered_catalog.require_auth;
4910        let principal = if security_changed {
4911            None
4912        } else {
4913            self.principal.read().as_ref().and_then(|principal| {
4914                Self::resolve_bound_principal_from_catalog(&recovered_catalog, principal)
4915            })
4916        };
4917        if require_auth {
4918            self.auth_state.set_require_auth(true);
4919        }
4920        self.auth_state.set_principal(principal.clone());
4921        *self.principal.write() = principal;
4922        let security_version = recovered_catalog.security_version;
4923        *self.catalog.write() = recovered_catalog;
4924        self.security_coordinator
4925            .version
4926            .store(security_version, Ordering::Release);
4927        if !require_auth {
4928            self.auth_state.set_require_auth(false);
4929        }
4930        if let Err(error) =
4931            crate::replication::reconcile_replica_epoch_durable(&self.durable_root, target_epoch)
4932        {
4933            return Err(MongrelError::DurableCommit {
4934                epoch: target_epoch,
4935                message: format!(
4936                    "replication WAL and catalog are durable but follower watermark failed: {error}"
4937                ),
4938            });
4939        }
4940        Ok(target_epoch)
4941    }
4942
4943    /// Resolve a table name → id (live tables only). pub(crate) so the
4944    /// transaction layer can stage by name.
4945    pub fn table_id(&self, name: &str) -> Result<u64> {
4946        let cat = self.catalog.read();
4947        cat.live(name)
4948            .map(|e| e.table_id)
4949            .ok_or_else(|| MongrelError::NotFound(format!("table {name:?} not found")))
4950    }
4951
4952    /// Return the stable table id and current schema generation from one
4953    /// catalog snapshot. Callers can bind retries to this identity so a table
4954    /// dropped and recreated under the same name is never mistaken for the
4955    /// original resource.
4956    pub fn table_identity(&self, name: &str) -> Result<(u64, u64)> {
4957        let catalog = self.catalog.read();
4958        catalog
4959            .live(name)
4960            .map(|entry| (entry.table_id, entry.schema.schema_id))
4961            .ok_or_else(|| MongrelError::NotFound(format!("table {name:?} not found")))
4962    }
4963
4964    pub(crate) fn building_table_id(&self, name: &str) -> Result<u64> {
4965        self.catalog
4966            .read()
4967            .building(name)
4968            .map(|entry| entry.table_id)
4969            .ok_or_else(|| MongrelError::NotFound(format!("building table {name:?} not found")))
4970    }
4971
4972    pub fn procedures(&self) -> Vec<StoredProcedure> {
4973        self.catalog
4974            .read()
4975            .procedures
4976            .iter()
4977            .map(|p| p.procedure.clone())
4978            .collect()
4979    }
4980
4981    pub fn procedure(&self, name: &str) -> Option<StoredProcedure> {
4982        self.catalog
4983            .read()
4984            .procedures
4985            .iter()
4986            .find(|p| p.procedure.name == name)
4987            .map(|p| p.procedure.clone())
4988    }
4989
4990    pub fn create_procedure(&self, procedure: StoredProcedure) -> Result<StoredProcedure> {
4991        self.create_procedure_inner(procedure, None)
4992    }
4993
4994    pub fn create_procedure_controlled<F>(
4995        &self,
4996        procedure: StoredProcedure,
4997        mut before_publish: F,
4998    ) -> Result<StoredProcedure>
4999    where
5000        F: FnMut() -> Result<()>,
5001    {
5002        self.create_procedure_inner(procedure, Some(&mut before_publish))
5003    }
5004
5005    fn create_procedure_inner(
5006        &self,
5007        mut procedure: StoredProcedure,
5008        before_publish: Option<&mut dyn FnMut() -> Result<()>>,
5009    ) -> Result<StoredProcedure> {
5010        self.require(&crate::auth::Permission::Ddl)?;
5011        let _g = self.ddl_lock.lock();
5012        let _security_write = self.security_write()?;
5013        self.require(&crate::auth::Permission::Ddl)?;
5014        procedure.validate()?;
5015        self.validate_procedure_references(&procedure)?;
5016        {
5017            let cat = self.catalog.read();
5018            if cat
5019                .procedures
5020                .iter()
5021                .any(|p| p.procedure.name == procedure.name)
5022            {
5023                return Err(MongrelError::InvalidArgument(format!(
5024                    "procedure {:?} already exists",
5025                    procedure.name
5026                )));
5027            }
5028        }
5029        let commit_lock = Arc::clone(&self.commit_lock);
5030        let _c = commit_lock.lock();
5031        let epoch = self.epoch.bump_assigned();
5032        let mut _epoch_guard = EpochGuard::new(self.epoch.as_ref(), epoch);
5033        procedure.created_epoch = epoch.0;
5034        procedure.updated_epoch = epoch.0;
5035        let mut next_catalog = self.catalog.read().clone();
5036        next_catalog
5037            .procedures
5038            .push(ProcedureEntry::from(procedure.clone()));
5039        next_catalog.db_epoch = epoch.0;
5040        self.publish_catalog_candidate(next_catalog, epoch, &mut _epoch_guard, before_publish)?;
5041        Ok(procedure)
5042    }
5043
5044    pub fn create_or_replace_procedure(
5045        &self,
5046        procedure: StoredProcedure,
5047    ) -> Result<StoredProcedure> {
5048        self.create_or_replace_procedure_inner(procedure, None)
5049    }
5050
5051    pub fn create_or_replace_procedure_controlled<F>(
5052        &self,
5053        procedure: StoredProcedure,
5054        mut before_publish: F,
5055    ) -> Result<StoredProcedure>
5056    where
5057        F: FnMut() -> Result<()>,
5058    {
5059        self.create_or_replace_procedure_inner(procedure, Some(&mut before_publish))
5060    }
5061
5062    fn create_or_replace_procedure_inner(
5063        &self,
5064        procedure: StoredProcedure,
5065        before_publish: Option<&mut dyn FnMut() -> Result<()>>,
5066    ) -> Result<StoredProcedure> {
5067        self.require(&crate::auth::Permission::Ddl)?;
5068        let _g = self.ddl_lock.lock();
5069        let _security_write = self.security_write()?;
5070        self.require(&crate::auth::Permission::Ddl)?;
5071        procedure.validate()?;
5072        self.validate_procedure_references(&procedure)?;
5073        let commit_lock = Arc::clone(&self.commit_lock);
5074        let _c = commit_lock.lock();
5075        let epoch = self.epoch.bump_assigned();
5076        let mut _epoch_guard = EpochGuard::new(self.epoch.as_ref(), epoch);
5077        let mut next_catalog = self.catalog.read().clone();
5078        let replaced = {
5079            let next = match next_catalog
5080                .procedures
5081                .iter()
5082                .position(|p| p.procedure.name == procedure.name)
5083            {
5084                Some(idx) => {
5085                    let next = next_catalog.procedures[idx]
5086                        .procedure
5087                        .replaced(procedure.clone(), epoch.0)?;
5088                    next_catalog.procedures[idx] = ProcedureEntry::from(next.clone());
5089                    next
5090                }
5091                None => {
5092                    let mut next = procedure;
5093                    next.created_epoch = epoch.0;
5094                    next.updated_epoch = epoch.0;
5095                    next_catalog
5096                        .procedures
5097                        .push(ProcedureEntry::from(next.clone()));
5098                    next
5099                }
5100            };
5101            next_catalog.db_epoch = epoch.0;
5102            next
5103        };
5104        self.publish_catalog_candidate(next_catalog, epoch, &mut _epoch_guard, before_publish)?;
5105        Ok(replaced)
5106    }
5107
5108    pub fn drop_procedure(&self, name: &str) -> Result<()> {
5109        self.drop_procedure_with_epoch(name).map(|_| ())
5110    }
5111
5112    pub fn drop_procedure_with_epoch(&self, name: &str) -> Result<Epoch> {
5113        self.drop_procedure_with_epoch_inner(name, None)
5114    }
5115
5116    pub fn drop_procedure_with_epoch_controlled<F>(
5117        &self,
5118        name: &str,
5119        mut before_publish: F,
5120    ) -> Result<Epoch>
5121    where
5122        F: FnMut() -> Result<()>,
5123    {
5124        self.drop_procedure_with_epoch_inner(name, Some(&mut before_publish))
5125    }
5126
5127    fn drop_procedure_with_epoch_inner(
5128        &self,
5129        name: &str,
5130        before_publish: Option<&mut dyn FnMut() -> Result<()>>,
5131    ) -> Result<Epoch> {
5132        self.require(&crate::auth::Permission::Ddl)?;
5133        let _g = self.ddl_lock.lock();
5134        let _security_write = self.security_write()?;
5135        self.require(&crate::auth::Permission::Ddl)?;
5136        let commit_lock = Arc::clone(&self.commit_lock);
5137        let _c = commit_lock.lock();
5138        let epoch = self.epoch.bump_assigned();
5139        let mut _epoch_guard = EpochGuard::new(self.epoch.as_ref(), epoch);
5140        let mut next_catalog = self.catalog.read().clone();
5141        let before = next_catalog.procedures.len();
5142        next_catalog.procedures.retain(|p| p.procedure.name != name);
5143        if next_catalog.procedures.len() == before {
5144            return Err(MongrelError::NotFound(format!(
5145                "procedure {name:?} not found"
5146            )));
5147        }
5148        next_catalog.db_epoch = epoch.0;
5149        self.publish_catalog_candidate(next_catalog, epoch, &mut _epoch_guard, before_publish)?;
5150        Ok(epoch)
5151    }
5152
5153    // ── User / role / credentials management ─────────────────────────────
5154
5155    /// List all catalog users (password hashes included — callers should not
5156    /// serialize them externally).
5157    pub fn users(&self) -> Vec<crate::auth::UserEntry> {
5158        self.catalog.read().users.clone()
5159    }
5160
5161    /// Resolve only the stable, non-secret identity fields needed to scope
5162    /// request receipts. Password hashes never leave the catalog lock.
5163    pub fn user_identity(&self, username: &str) -> Option<(u64, u64)> {
5164        self.catalog
5165            .read()
5166            .users
5167            .iter()
5168            .find(|user| user.username == username)
5169            .map(|user| (user.id, user.created_epoch))
5170    }
5171
5172    /// Current catalog authorization generation. Retry bindings can include
5173    /// this value to fail closed after roles, grants, or row policies change.
5174    pub fn security_version(&self) -> u64 {
5175        self.catalog.read().security_version
5176    }
5177
5178    /// List all catalog roles.
5179    pub fn roles(&self) -> Vec<crate::auth::RoleEntry> {
5180        self.catalog.read().roles.clone()
5181    }
5182
5183    /// Create a new user with an Argon2id-hashed password.
5184    pub fn create_user(&self, username: &str, password: &str) -> Result<crate::auth::UserEntry> {
5185        self.require(&crate::auth::Permission::Admin)?;
5186        let hash = crate::auth::hash_password(password).map_err(MongrelError::Other)?;
5187        self.create_user_with_password_hash(username, hash)
5188    }
5189
5190    /// Create a user from a password hash prepared before a commit fence.
5191    pub fn create_user_with_password_hash(
5192        &self,
5193        username: &str,
5194        hash: String,
5195    ) -> Result<crate::auth::UserEntry> {
5196        self.create_user_with_password_hash_inner(username, hash, None)
5197    }
5198
5199    pub fn create_user_with_password_hash_controlled<F>(
5200        &self,
5201        username: &str,
5202        hash: String,
5203        mut before_publish: F,
5204    ) -> Result<crate::auth::UserEntry>
5205    where
5206        F: FnMut() -> Result<()>,
5207    {
5208        self.create_user_with_password_hash_inner(username, hash, Some(&mut before_publish))
5209    }
5210
5211    fn create_user_with_password_hash_inner(
5212        &self,
5213        username: &str,
5214        hash: String,
5215        before_publish: Option<&mut dyn FnMut() -> Result<()>>,
5216    ) -> Result<crate::auth::UserEntry> {
5217        self.require(&crate::auth::Permission::Admin)?;
5218        let _ddl = self.ddl_lock.lock();
5219        let _security_write = self.security_write()?;
5220        self.require(&crate::auth::Permission::Admin)?;
5221        let _commit = self.commit_lock.lock();
5222        let epoch = self.epoch.bump_assigned();
5223        let mut _epoch_guard = EpochGuard::new(self.epoch.as_ref(), epoch);
5224        let mut next_catalog = self.catalog.read().clone();
5225        if next_catalog.users.iter().any(|u| u.username == username) {
5226            return Err(MongrelError::InvalidArgument(format!(
5227                "user {username:?} already exists"
5228            )));
5229        }
5230        next_catalog.next_user_id = next_catalog.next_user_id.max(1);
5231        let id = next_catalog.next_user_id;
5232        next_catalog.next_user_id = id
5233            .checked_add(1)
5234            .ok_or_else(|| MongrelError::Full("user-id namespace exhausted".into()))?;
5235        let entry = crate::auth::UserEntry {
5236            id,
5237            username: username.into(),
5238            password_hash: hash,
5239            roles: Vec::new(),
5240            is_admin: false,
5241            created_epoch: epoch.0,
5242        };
5243        next_catalog.users.push(entry.clone());
5244        advance_security_version(&mut next_catalog)?;
5245        next_catalog.db_epoch = epoch.0;
5246        self.publish_catalog_candidate(next_catalog, epoch, &mut _epoch_guard, before_publish)?;
5247        Ok(entry)
5248    }
5249
5250    /// Drop a user by username.
5251    pub fn drop_user(&self, username: &str) -> Result<()> {
5252        self.drop_user_with_epoch(username).map(|_| ())
5253    }
5254
5255    pub fn drop_user_with_epoch(&self, username: &str) -> Result<Epoch> {
5256        self.drop_user_with_epoch_inner(username, None)
5257    }
5258
5259    pub fn drop_user_with_epoch_controlled<F>(
5260        &self,
5261        username: &str,
5262        mut before_publish: F,
5263    ) -> Result<Epoch>
5264    where
5265        F: FnMut() -> Result<()>,
5266    {
5267        self.drop_user_with_epoch_inner(username, Some(&mut before_publish))
5268    }
5269
5270    fn drop_user_with_epoch_inner(
5271        &self,
5272        username: &str,
5273        before_publish: Option<&mut dyn FnMut() -> Result<()>>,
5274    ) -> Result<Epoch> {
5275        self.require(&crate::auth::Permission::Admin)?;
5276        let _ddl = self.ddl_lock.lock();
5277        let _security_write = self.security_write()?;
5278        self.require(&crate::auth::Permission::Admin)?;
5279        let _commit = self.commit_lock.lock();
5280        let epoch = self.epoch.bump_assigned();
5281        let mut _epoch_guard = EpochGuard::new(self.epoch.as_ref(), epoch);
5282        let mut next_catalog = self.catalog.read().clone();
5283        let before = next_catalog.users.len();
5284        next_catalog.users.retain(|u| u.username != username);
5285        if next_catalog.users.len() == before {
5286            return Err(MongrelError::NotFound(format!(
5287                "user {username:?} not found"
5288            )));
5289        }
5290        advance_security_version(&mut next_catalog)?;
5291        next_catalog.db_epoch = epoch.0;
5292        self.publish_catalog_candidate(next_catalog, epoch, &mut _epoch_guard, before_publish)?;
5293        Ok(epoch)
5294    }
5295
5296    /// Change a user's password.
5297    pub fn alter_user_password(&self, username: &str, new_password: &str) -> Result<()> {
5298        self.alter_user_password_with_epoch(username, new_password)
5299            .map(|_| ())
5300    }
5301
5302    pub fn alter_user_password_with_epoch(
5303        &self,
5304        username: &str,
5305        new_password: &str,
5306    ) -> Result<Epoch> {
5307        self.require(&crate::auth::Permission::Admin)?;
5308        let hash = crate::auth::hash_password(new_password).map_err(MongrelError::Other)?;
5309        self.alter_user_password_hash_with_epoch(username, hash)
5310    }
5311
5312    pub fn alter_user_password_hash_with_epoch(
5313        &self,
5314        username: &str,
5315        hash: String,
5316    ) -> Result<Epoch> {
5317        self.alter_user_password_hash_with_epoch_inner(username, hash, None)
5318    }
5319
5320    pub fn alter_user_password_hash_with_epoch_controlled<F>(
5321        &self,
5322        username: &str,
5323        hash: String,
5324        mut before_publish: F,
5325    ) -> Result<Epoch>
5326    where
5327        F: FnMut() -> Result<()>,
5328    {
5329        self.alter_user_password_hash_with_epoch_inner(username, hash, Some(&mut before_publish))
5330    }
5331
5332    fn alter_user_password_hash_with_epoch_inner(
5333        &self,
5334        username: &str,
5335        hash: String,
5336        before_publish: Option<&mut dyn FnMut() -> Result<()>>,
5337    ) -> Result<Epoch> {
5338        self.require(&crate::auth::Permission::Admin)?;
5339        let _ddl = self.ddl_lock.lock();
5340        let _security_write = self.security_write()?;
5341        self.require(&crate::auth::Permission::Admin)?;
5342        let _commit = self.commit_lock.lock();
5343        let epoch = self.epoch.bump_assigned();
5344        let mut _epoch_guard = EpochGuard::new(self.epoch.as_ref(), epoch);
5345        let mut next_catalog = self.catalog.read().clone();
5346        let user = next_catalog
5347            .users
5348            .iter_mut()
5349            .find(|u| u.username == username)
5350            .ok_or_else(|| MongrelError::NotFound(format!("user {username:?} not found")))?;
5351        user.password_hash = hash;
5352        advance_security_version(&mut next_catalog)?;
5353        next_catalog.db_epoch = epoch.0;
5354        self.publish_catalog_candidate(next_catalog, epoch, &mut _epoch_guard, before_publish)?;
5355        Ok(epoch)
5356    }
5357
5358    /// Verify credentials. Returns `Some(entry)` on success, `None` on
5359    /// mismatch, `Err` on engine error.
5360    pub fn verify_user(
5361        &self,
5362        username: &str,
5363        password: &str,
5364    ) -> Result<Option<crate::auth::UserEntry>> {
5365        let cat = self.catalog.read();
5366        let Some(user) = cat.users.iter().find(|u| u.username == username) else {
5367            return Ok(None);
5368        };
5369        if user.password_hash.is_empty() {
5370            return Ok(None);
5371        }
5372        let ok = crate::auth::verify_password(password, &user.password_hash)
5373            .map_err(MongrelError::Other)?;
5374        if ok {
5375            Ok(Some(user.clone()))
5376        } else {
5377            Ok(None)
5378        }
5379    }
5380
5381    /// Authenticate and resolve one immutable principal from the same catalog
5382    /// snapshot. Username reuse cannot bridge the password check and principal
5383    /// resolution.
5384    pub fn authenticate_principal(
5385        &self,
5386        username: &str,
5387        password: &str,
5388    ) -> Result<Option<crate::auth::Principal>> {
5389        self.authenticate_principal_inner(username, password, || {})
5390    }
5391
5392    fn authenticate_principal_inner<F>(
5393        &self,
5394        username: &str,
5395        password: &str,
5396        after_verify: F,
5397    ) -> Result<Option<crate::auth::Principal>>
5398    where
5399        F: FnOnce(),
5400    {
5401        let catalog = self.catalog.read();
5402        let Some(user) = catalog.users.iter().find(|user| user.username == username) else {
5403            return Ok(None);
5404        };
5405        if user.password_hash.is_empty()
5406            || !crate::auth::verify_password(password, &user.password_hash)
5407                .map_err(MongrelError::Other)?
5408        {
5409            return Ok(None);
5410        }
5411        after_verify();
5412        Ok(Self::resolve_user_principal_from_catalog(&catalog, user))
5413    }
5414
5415    /// Grant admin privileges to a user (bypasses all permission checks).
5416    pub fn set_user_admin(&self, username: &str, is_admin: bool) -> Result<()> {
5417        self.set_user_admin_with_epoch(username, is_admin)
5418            .map(|_| ())
5419    }
5420
5421    pub fn set_user_admin_with_epoch(
5422        &self,
5423        username: &str,
5424        is_admin: bool,
5425    ) -> Result<Option<Epoch>> {
5426        self.set_user_admin_with_epoch_inner(username, is_admin, None)
5427    }
5428
5429    pub fn set_user_admin_with_epoch_controlled<F>(
5430        &self,
5431        username: &str,
5432        is_admin: bool,
5433        mut before_publish: F,
5434    ) -> Result<Option<Epoch>>
5435    where
5436        F: FnMut() -> Result<()>,
5437    {
5438        self.set_user_admin_with_epoch_inner(username, is_admin, Some(&mut before_publish))
5439    }
5440
5441    fn set_user_admin_with_epoch_inner(
5442        &self,
5443        username: &str,
5444        is_admin: bool,
5445        before_publish: Option<&mut dyn FnMut() -> Result<()>>,
5446    ) -> Result<Option<Epoch>> {
5447        self.require(&crate::auth::Permission::Admin)?;
5448        let _ddl = self.ddl_lock.lock();
5449        let _security_write = self.security_write()?;
5450        self.require(&crate::auth::Permission::Admin)?;
5451        let _commit = self.commit_lock.lock();
5452        let mut next_catalog = self.catalog.read().clone();
5453        let user = next_catalog
5454            .users
5455            .iter_mut()
5456            .find(|u| u.username == username)
5457            .ok_or_else(|| MongrelError::NotFound(format!("user {username:?} not found")))?;
5458        if user.is_admin == is_admin {
5459            return Ok(None);
5460        }
5461        user.is_admin = is_admin;
5462        let epoch = self.epoch.bump_assigned();
5463        let mut _epoch_guard = EpochGuard::new(self.epoch.as_ref(), epoch);
5464        advance_security_version(&mut next_catalog)?;
5465        next_catalog.db_epoch = epoch.0;
5466        self.publish_catalog_candidate(next_catalog, epoch, &mut _epoch_guard, before_publish)?;
5467        Ok(Some(epoch))
5468    }
5469
5470    /// Create a new role.
5471    pub fn create_role(&self, name: &str) -> Result<crate::auth::RoleEntry> {
5472        self.create_role_inner(name, None)
5473    }
5474
5475    pub fn create_role_controlled<F>(
5476        &self,
5477        name: &str,
5478        mut before_publish: F,
5479    ) -> Result<crate::auth::RoleEntry>
5480    where
5481        F: FnMut() -> Result<()>,
5482    {
5483        self.create_role_inner(name, Some(&mut before_publish))
5484    }
5485
5486    fn create_role_inner(
5487        &self,
5488        name: &str,
5489        before_publish: Option<&mut dyn FnMut() -> Result<()>>,
5490    ) -> Result<crate::auth::RoleEntry> {
5491        self.require(&crate::auth::Permission::Admin)?;
5492        let _ddl = self.ddl_lock.lock();
5493        let _security_write = self.security_write()?;
5494        self.require(&crate::auth::Permission::Admin)?;
5495        let _commit = self.commit_lock.lock();
5496        let epoch = self.epoch.bump_assigned();
5497        let mut _epoch_guard = EpochGuard::new(self.epoch.as_ref(), epoch);
5498        let mut next_catalog = self.catalog.read().clone();
5499        if next_catalog.roles.iter().any(|r| r.name == name) {
5500            return Err(MongrelError::InvalidArgument(format!(
5501                "role {name:?} already exists"
5502            )));
5503        }
5504        let entry = crate::auth::RoleEntry {
5505            name: name.into(),
5506            permissions: Vec::new(),
5507            created_epoch: epoch.0,
5508        };
5509        next_catalog.roles.push(entry.clone());
5510        advance_security_version(&mut next_catalog)?;
5511        next_catalog.db_epoch = epoch.0;
5512        self.publish_catalog_candidate(next_catalog, epoch, &mut _epoch_guard, before_publish)?;
5513        Ok(entry)
5514    }
5515
5516    /// Drop a role by name.
5517    pub fn drop_role(&self, name: &str) -> Result<()> {
5518        self.drop_role_with_epoch(name).map(|_| ())
5519    }
5520
5521    pub fn drop_role_with_epoch(&self, name: &str) -> Result<Epoch> {
5522        self.drop_role_with_epoch_inner(name, None)
5523    }
5524
5525    pub fn drop_role_with_epoch_controlled<F>(
5526        &self,
5527        name: &str,
5528        mut before_publish: F,
5529    ) -> Result<Epoch>
5530    where
5531        F: FnMut() -> Result<()>,
5532    {
5533        self.drop_role_with_epoch_inner(name, Some(&mut before_publish))
5534    }
5535
5536    fn drop_role_with_epoch_inner(
5537        &self,
5538        name: &str,
5539        before_publish: Option<&mut dyn FnMut() -> Result<()>>,
5540    ) -> Result<Epoch> {
5541        self.require(&crate::auth::Permission::Admin)?;
5542        let _ddl = self.ddl_lock.lock();
5543        let _security_write = self.security_write()?;
5544        self.require(&crate::auth::Permission::Admin)?;
5545        let _commit = self.commit_lock.lock();
5546        let epoch = self.epoch.bump_assigned();
5547        let mut _epoch_guard = EpochGuard::new(self.epoch.as_ref(), epoch);
5548        let mut next_catalog = self.catalog.read().clone();
5549        let before = next_catalog.roles.len();
5550        next_catalog.roles.retain(|r| r.name != name);
5551        if next_catalog.roles.len() == before {
5552            return Err(MongrelError::NotFound(format!("role {name:?} not found")));
5553        }
5554        for user in &mut next_catalog.users {
5555            user.roles.retain(|r| r != name);
5556        }
5557        advance_security_version(&mut next_catalog)?;
5558        next_catalog.db_epoch = epoch.0;
5559        self.publish_catalog_candidate(next_catalog, epoch, &mut _epoch_guard, before_publish)?;
5560        Ok(epoch)
5561    }
5562
5563    /// Grant a role to a user.
5564    pub fn grant_role(&self, username: &str, role_name: &str) -> Result<()> {
5565        self.grant_role_with_epoch(username, role_name).map(|_| ())
5566    }
5567
5568    pub fn grant_role_with_epoch(&self, username: &str, role_name: &str) -> Result<Option<Epoch>> {
5569        self.grant_role_with_epoch_inner(username, role_name, None)
5570    }
5571
5572    pub fn grant_role_with_epoch_controlled<F>(
5573        &self,
5574        username: &str,
5575        role_name: &str,
5576        mut before_publish: F,
5577    ) -> Result<Option<Epoch>>
5578    where
5579        F: FnMut() -> Result<()>,
5580    {
5581        self.grant_role_with_epoch_inner(username, role_name, Some(&mut before_publish))
5582    }
5583
5584    fn grant_role_with_epoch_inner(
5585        &self,
5586        username: &str,
5587        role_name: &str,
5588        before_publish: Option<&mut dyn FnMut() -> Result<()>>,
5589    ) -> Result<Option<Epoch>> {
5590        self.require(&crate::auth::Permission::Admin)?;
5591        let _ddl = self.ddl_lock.lock();
5592        let _security_write = self.security_write()?;
5593        self.require(&crate::auth::Permission::Admin)?;
5594        let _commit = self.commit_lock.lock();
5595        let mut next_catalog = self.catalog.read().clone();
5596        if !next_catalog.roles.iter().any(|r| r.name == role_name) {
5597            return Err(MongrelError::NotFound(format!(
5598                "role {role_name:?} not found"
5599            )));
5600        }
5601        let user = next_catalog
5602            .users
5603            .iter_mut()
5604            .find(|u| u.username == username)
5605            .ok_or_else(|| MongrelError::NotFound(format!("user {username:?} not found")))?;
5606        if user.roles.iter().any(|role| role == role_name) {
5607            return Ok(None);
5608        }
5609        user.roles.push(role_name.into());
5610        let epoch = self.epoch.bump_assigned();
5611        let mut _epoch_guard = EpochGuard::new(self.epoch.as_ref(), epoch);
5612        advance_security_version(&mut next_catalog)?;
5613        next_catalog.db_epoch = epoch.0;
5614        self.publish_catalog_candidate(next_catalog, epoch, &mut _epoch_guard, before_publish)?;
5615        Ok(Some(epoch))
5616    }
5617
5618    /// Revoke a role from a user.
5619    pub fn revoke_role(&self, username: &str, role_name: &str) -> Result<()> {
5620        self.revoke_role_with_epoch(username, role_name).map(|_| ())
5621    }
5622
5623    pub fn revoke_role_with_epoch(&self, username: &str, role_name: &str) -> Result<Option<Epoch>> {
5624        self.revoke_role_with_epoch_inner(username, role_name, None)
5625    }
5626
5627    pub fn revoke_role_with_epoch_controlled<F>(
5628        &self,
5629        username: &str,
5630        role_name: &str,
5631        mut before_publish: F,
5632    ) -> Result<Option<Epoch>>
5633    where
5634        F: FnMut() -> Result<()>,
5635    {
5636        self.revoke_role_with_epoch_inner(username, role_name, Some(&mut before_publish))
5637    }
5638
5639    fn revoke_role_with_epoch_inner(
5640        &self,
5641        username: &str,
5642        role_name: &str,
5643        before_publish: Option<&mut dyn FnMut() -> Result<()>>,
5644    ) -> Result<Option<Epoch>> {
5645        self.require(&crate::auth::Permission::Admin)?;
5646        let _ddl = self.ddl_lock.lock();
5647        let _security_write = self.security_write()?;
5648        self.require(&crate::auth::Permission::Admin)?;
5649        let _commit = self.commit_lock.lock();
5650        let mut next_catalog = self.catalog.read().clone();
5651        let user = next_catalog
5652            .users
5653            .iter_mut()
5654            .find(|u| u.username == username)
5655            .ok_or_else(|| MongrelError::NotFound(format!("user {username:?} not found")))?;
5656        let before = user.roles.len();
5657        user.roles.retain(|r| r != role_name);
5658        if user.roles.len() == before {
5659            return Ok(None);
5660        }
5661        let epoch = self.epoch.bump_assigned();
5662        let mut _epoch_guard = EpochGuard::new(self.epoch.as_ref(), epoch);
5663        advance_security_version(&mut next_catalog)?;
5664        next_catalog.db_epoch = epoch.0;
5665        self.publish_catalog_candidate(next_catalog, epoch, &mut _epoch_guard, before_publish)?;
5666        Ok(Some(epoch))
5667    }
5668
5669    /// Grant a permission to a role.
5670    pub fn grant_permission(
5671        &self,
5672        role_name: &str,
5673        permission: crate::auth::Permission,
5674    ) -> Result<()> {
5675        self.grant_permission_with_epoch(role_name, permission)
5676            .map(|_| ())
5677    }
5678
5679    pub fn grant_permission_with_epoch(
5680        &self,
5681        role_name: &str,
5682        permission: crate::auth::Permission,
5683    ) -> Result<Option<Epoch>> {
5684        self.grant_permission_with_epoch_inner(role_name, permission, None)
5685    }
5686
5687    pub fn grant_permission_with_epoch_controlled<F>(
5688        &self,
5689        role_name: &str,
5690        permission: crate::auth::Permission,
5691        mut before_publish: F,
5692    ) -> Result<Option<Epoch>>
5693    where
5694        F: FnMut() -> Result<()>,
5695    {
5696        self.grant_permission_with_epoch_inner(role_name, permission, Some(&mut before_publish))
5697    }
5698
5699    fn grant_permission_with_epoch_inner(
5700        &self,
5701        role_name: &str,
5702        permission: crate::auth::Permission,
5703        before_publish: Option<&mut dyn FnMut() -> Result<()>>,
5704    ) -> Result<Option<Epoch>> {
5705        self.require(&crate::auth::Permission::Admin)?;
5706        let _ddl = self.ddl_lock.lock();
5707        let _security_write = self.security_write()?;
5708        self.require(&crate::auth::Permission::Admin)?;
5709        let _commit = self.commit_lock.lock();
5710        let mut next_catalog = self.catalog.read().clone();
5711        let role = next_catalog
5712            .roles
5713            .iter_mut()
5714            .find(|r| r.name == role_name)
5715            .ok_or_else(|| MongrelError::NotFound(format!("role {role_name:?} not found")))?;
5716        let before = role.permissions.clone();
5717        merge_permission(&mut role.permissions, permission);
5718        if role.permissions == before {
5719            return Ok(None);
5720        }
5721        let epoch = self.epoch.bump_assigned();
5722        let mut _epoch_guard = EpochGuard::new(self.epoch.as_ref(), epoch);
5723        advance_security_version(&mut next_catalog)?;
5724        next_catalog.db_epoch = epoch.0;
5725        self.publish_catalog_candidate(next_catalog, epoch, &mut _epoch_guard, before_publish)?;
5726        Ok(Some(epoch))
5727    }
5728
5729    /// Revoke a permission from a role.
5730    pub fn revoke_permission(
5731        &self,
5732        role_name: &str,
5733        permission: crate::auth::Permission,
5734    ) -> Result<()> {
5735        self.revoke_permission_with_epoch(role_name, permission)
5736            .map(|_| ())
5737    }
5738
5739    pub fn revoke_permission_with_epoch(
5740        &self,
5741        role_name: &str,
5742        permission: crate::auth::Permission,
5743    ) -> Result<Option<Epoch>> {
5744        self.revoke_permission_with_epoch_inner(role_name, permission, None)
5745    }
5746
5747    pub fn revoke_permission_with_epoch_controlled<F>(
5748        &self,
5749        role_name: &str,
5750        permission: crate::auth::Permission,
5751        mut before_publish: F,
5752    ) -> Result<Option<Epoch>>
5753    where
5754        F: FnMut() -> Result<()>,
5755    {
5756        self.revoke_permission_with_epoch_inner(role_name, permission, Some(&mut before_publish))
5757    }
5758
5759    fn revoke_permission_with_epoch_inner(
5760        &self,
5761        role_name: &str,
5762        permission: crate::auth::Permission,
5763        before_publish: Option<&mut dyn FnMut() -> Result<()>>,
5764    ) -> Result<Option<Epoch>> {
5765        self.require(&crate::auth::Permission::Admin)?;
5766        let _ddl = self.ddl_lock.lock();
5767        let _security_write = self.security_write()?;
5768        self.require(&crate::auth::Permission::Admin)?;
5769        let _commit = self.commit_lock.lock();
5770        let mut next_catalog = self.catalog.read().clone();
5771        let role = next_catalog
5772            .roles
5773            .iter_mut()
5774            .find(|r| r.name == role_name)
5775            .ok_or_else(|| MongrelError::NotFound(format!("role {role_name:?} not found")))?;
5776        let before = role.permissions.clone();
5777        revoke_permission_from(&mut role.permissions, &permission);
5778        if role.permissions == before {
5779            return Ok(None);
5780        }
5781        let epoch = self.epoch.bump_assigned();
5782        let mut _epoch_guard = EpochGuard::new(self.epoch.as_ref(), epoch);
5783        advance_security_version(&mut next_catalog)?;
5784        next_catalog.db_epoch = epoch.0;
5785        self.publish_catalog_candidate(next_catalog, epoch, &mut _epoch_guard, before_publish)?;
5786        Ok(Some(epoch))
5787    }
5788
5789    /// Resolve a user into a [`crate::auth::Principal`] by collecting all
5790    /// permissions from their roles. Returns `None` if the user doesn't exist.
5791    pub fn resolve_principal(&self, username: &str) -> Option<crate::auth::Principal> {
5792        let cat = self.catalog.read();
5793        Self::resolve_principal_from_catalog(&cat, username)
5794    }
5795
5796    /// Re-resolve only when the immutable user identity still exists. This is
5797    /// the server/session validation path; username reuse never matches.
5798    pub fn resolve_current_principal(
5799        &self,
5800        principal: &crate::auth::Principal,
5801    ) -> Option<crate::auth::Principal> {
5802        Self::resolve_bound_principal_from_catalog(&self.catalog.read(), principal)
5803    }
5804
5805    /// Resolve a username to a [`Principal`] directly from a catalog snapshot,
5806    /// without needing a constructed `Database`. Used by the credentialed open
5807    /// path (which must verify credentials before the `Database` exists) and
5808    /// by [`resolve_principal`](Self::resolve_principal).
5809    fn resolve_principal_from_catalog(
5810        cat: &Catalog,
5811        username: &str,
5812    ) -> Option<crate::auth::Principal> {
5813        let user = cat.users.iter().find(|u| u.username == username)?;
5814        Self::resolve_user_principal_from_catalog(cat, user)
5815    }
5816
5817    fn resolve_bound_principal_from_catalog(
5818        cat: &Catalog,
5819        principal: &crate::auth::Principal,
5820    ) -> Option<crate::auth::Principal> {
5821        let user = cat.users.iter().find(|user| {
5822            user.id == principal.user_id
5823                && user.created_epoch == principal.created_epoch
5824                && user.username == principal.username
5825        })?;
5826        Self::resolve_user_principal_from_catalog(cat, user)
5827    }
5828
5829    fn resolve_user_principal_from_catalog(
5830        cat: &Catalog,
5831        user: &crate::auth::UserEntry,
5832    ) -> Option<crate::auth::Principal> {
5833        let mut permissions = Vec::new();
5834        for role_name in &user.roles {
5835            if let Some(role) = cat.roles.iter().find(|r| &r.name == role_name) {
5836                permissions.extend(role.permissions.iter().cloned());
5837            }
5838        }
5839        Some(crate::auth::Principal {
5840            user_id: user.id,
5841            created_epoch: user.created_epoch,
5842            username: user.username.clone(),
5843            is_admin: user.is_admin,
5844            roles: user.roles.clone(),
5845            permissions,
5846        })
5847    }
5848
5849    /// Check whether a user has a specific permission (via their roles).
5850    pub fn check_permission(&self, username: &str, permission: &crate::auth::Permission) -> bool {
5851        match self.resolve_principal(username) {
5852            Some(p) => p.has_permission(permission),
5853            None => false,
5854        }
5855    }
5856
5857    /// Returns `true` if this database's catalog has `require_auth = true`.
5858    /// When true, every operation consults the cached [`Principal`] via
5859    /// [`require`](Self::require).
5860    pub fn require_auth_enabled(&self) -> bool {
5861        self.catalog.read().require_auth
5862    }
5863
5864    /// A snapshot of the cached principal for this handle, if any. `None` for
5865    /// databases opened without credentials (the default). Returns a clone
5866    /// because the principal lives behind an `RwLock`.
5867    pub fn principal(&self) -> Option<crate::auth::Principal> {
5868        self.principal.read().clone()
5869    }
5870
5871    /// Build a `TableAuthChecker` from the current auth state. Used when
5872    /// mounting a new table (`create_table`) so the table inherits the
5873    /// database's enforcement configuration. The checker reads the live
5874    /// `require_auth` flag and cached principal, so changes via `enable_auth`
5875    /// / `refresh_principal` propagate to already-mounted tables.
5876    fn table_auth_checker(&self) -> Option<Arc<dyn crate::auth_state::TableAuthChecker>> {
5877        Some(Arc::new(crate::auth_state::DefaultTableAuthChecker::new(
5878            self.auth_state.clone(),
5879        )))
5880    }
5881
5882    /// Re-resolve the cached principal from the shared current catalog.
5883    /// Long-lived
5884    /// handles (e.g. a daemon) call this after a `REVOKE` or role change —
5885    /// possibly made by a different handle to the same database — to pick up
5886    /// the new effective permissions without re-verifying the password.
5887    ///
5888    /// The process-wide security version reloads from disk only when another
5889    /// handle published a newer catalog. The username is taken from
5890    /// the existing cached principal; if the user has since been dropped,
5891    /// returns [`MongrelError::InvalidCredentials`].
5892    ///
5893    /// No-op (returns `Ok(())`) on a credentialless database, or on a
5894    /// credentialed database whose cached principal is `None`.
5895    pub fn refresh_principal(&self) -> Result<()> {
5896        let previous = match self.principal.read().clone() {
5897            Some(principal) => principal,
5898            None => return Ok(()),
5899        };
5900        let observed_version = self.security_coordinator.version.load(Ordering::Acquire);
5901        self.refresh_security_catalog_if_stale(observed_version)?;
5902        let cat = self.catalog.read();
5903        match Self::resolve_bound_principal_from_catalog(&cat, &previous) {
5904            Some(p) => {
5905                *self.principal.write() = Some(p.clone());
5906                // Update the shared auth state so mounted Tables see the new
5907                // permissions immediately (Tables read from AuthState, not from
5908                // self.principal).
5909                self.auth_state.set_principal(Some(p));
5910                Ok(())
5911            }
5912            None => Err(MongrelError::InvalidCredentials {
5913                username: previous.username,
5914            }),
5915        }
5916    }
5917
5918    /// Number of security-catalog disk reloads performed by this open handle.
5919    /// Initial open reads are excluded.
5920    pub fn security_catalog_disk_read_count(&self) -> u64 {
5921        self.security_catalog_disk_reads.load(Ordering::Relaxed)
5922    }
5923
5924    /// Convert a credentialless database to a credentialed one: create the
5925    /// first admin user, set `require_auth = true`, and cache the admin
5926    /// principal on this handle so subsequent operations on the same handle
5927    /// continue to work. After this call, the database can only be reopened
5928    /// via `open_with_credentials` / `open_encrypted_with_credentials`.
5929    ///
5930    /// Refuses if the database already has `require_auth = true`. This is
5931    /// the conversion path for existing databases; for fresh databases,
5932    /// `create_with_credentials` sets everything up atomically.
5933    ///
5934    /// See `docs/15-credential-enforcement.md`.
5935    pub fn enable_auth(&self, admin_username: &str, admin_password: &str) -> Result<()> {
5936        let password_hash =
5937            crate::auth::hash_password(admin_password).map_err(MongrelError::Other)?;
5938        let _ddl = self.ddl_lock.lock();
5939        let _security_write = self.security_write()?;
5940        let _commit = self.commit_lock.lock();
5941        let epoch = self.epoch.bump_assigned();
5942        let mut _epoch_guard = EpochGuard::new(self.epoch.as_ref(), epoch);
5943        let mut next_catalog = self.catalog.read().clone();
5944        if next_catalog.require_auth {
5945            return Err(MongrelError::InvalidArgument(
5946                "database already has require_auth enabled".into(),
5947            ));
5948        }
5949        if next_catalog
5950            .users
5951            .iter()
5952            .any(|u| u.username == admin_username)
5953        {
5954            return Err(MongrelError::InvalidArgument(format!(
5955                "user {admin_username:?} already exists"
5956            )));
5957        }
5958        next_catalog.next_user_id = next_catalog.next_user_id.max(1);
5959        let id = next_catalog.next_user_id;
5960        next_catalog.next_user_id = id
5961            .checked_add(1)
5962            .ok_or_else(|| MongrelError::Full("user-id namespace exhausted".into()))?;
5963        next_catalog.users.push(crate::auth::UserEntry {
5964            id,
5965            username: admin_username.to_string(),
5966            password_hash,
5967            roles: Vec::new(),
5968            is_admin: true,
5969            created_epoch: epoch.0,
5970        });
5971        next_catalog.require_auth = true;
5972        advance_security_version(&mut next_catalog)?;
5973        next_catalog.db_epoch = epoch.0;
5974        let publish = self.publish_catalog_candidate(next_catalog, epoch, &mut _epoch_guard, None);
5975        // Cache the admin principal on this handle + update the shared auth
5976        // state whenever rename published, even if directory fsync was
5977        // inconclusive.
5978        if publish.is_ok() || matches!(&publish, Err(MongrelError::CommitOutcomeUnknown { .. })) {
5979            let principal = crate::auth::Principal {
5980                user_id: id,
5981                created_epoch: epoch.0,
5982                username: admin_username.to_string(),
5983                is_admin: true,
5984                roles: Vec::new(),
5985                permissions: Vec::new(),
5986            };
5987            *self.principal.write() = Some(principal.clone());
5988            self.auth_state.set_principal(Some(principal));
5989        }
5990        publish
5991    }
5992
5993    /// Disable `require_auth` on this database, reverting it to credentialless
5994    /// mode. This is the **recovery** path — it requires the handle to already
5995    /// be open (and therefore already authenticated if `require_auth` was on).
5996    ///
5997    /// After this call, the database can be reopened with plain
5998    /// [`open`](Self::open) / [`open_encrypted`](Self::open_encrypted) without
5999    /// credentials. All existing users and roles are preserved in the catalog
6000    /// (so `require_auth` can be re-enabled without recreating them), but they
6001    /// are no longer consulted for enforcement.
6002    ///
6003    /// For true **offline** recovery (when credentials are lost and no
6004    /// authenticated handle is available), the caller opens the database
6005    /// directly via the catalog file (filesystem access required) and calls
6006    /// this method — see the CLI's `auth disable-offline` command.
6007    ///
6008    /// See `docs/15-credential-enforcement.md` §4.7.
6009    pub fn disable_auth(&self) -> Result<()> {
6010        let _ddl = self.ddl_lock.lock();
6011        let _security_write = self.security_write()?;
6012        let _commit = self.commit_lock.lock();
6013        let epoch = self.epoch.bump_assigned();
6014        let mut _epoch_guard = EpochGuard::new(self.epoch.as_ref(), epoch);
6015        let mut next_catalog = self.catalog.read().clone();
6016        if !next_catalog.require_auth {
6017            return Err(MongrelError::InvalidArgument(
6018                "database does not have require_auth enabled".into(),
6019            ));
6020        }
6021        next_catalog.require_auth = false;
6022        advance_security_version(&mut next_catalog)?;
6023        next_catalog.db_epoch = epoch.0;
6024        let publish = self.publish_catalog_candidate(next_catalog, epoch, &mut _epoch_guard, None);
6025        // Clear the cached principal — enforcement is now off.
6026        if publish.is_ok() || matches!(&publish, Err(MongrelError::CommitOutcomeUnknown { .. })) {
6027            *self.principal.write() = None;
6028        }
6029        publish
6030    }
6031
6032    /// Enforcement check: if the catalog has `require_auth = true`, verify
6033    /// that the cached principal satisfies `perm`. Called by every
6034    /// enforcement point (DDL, admin, maintenance, and — in Phase 2 —
6035    /// Table/Transaction/MongrelSession operations).
6036    ///
6037    /// On a credentialless database this is a no-op (`Ok(())`).
6038    pub fn require(&self, perm: &crate::auth::Permission) -> Result<()> {
6039        if self.read_only && !matches!(perm, crate::auth::Permission::Select { .. }) {
6040            return Err(MongrelError::ReadOnlyReplica);
6041        }
6042        if self.principal.read().is_some() {
6043            self.refresh_principal().map_err(|error| match error {
6044                MongrelError::InvalidCredentials { .. } => MongrelError::AuthRequired,
6045                error => error,
6046            })?;
6047        }
6048        if !self.catalog.read().require_auth {
6049            return Ok(());
6050        }
6051        let guard = self.principal.read();
6052        let p = guard.as_ref().ok_or(MongrelError::AuthRequired)?;
6053        if p.has_permission(perm) {
6054            Ok(())
6055        } else {
6056            Err(MongrelError::PermissionDenied {
6057                required: perm.clone(),
6058                principal: p.username.clone(),
6059            })
6060        }
6061    }
6062
6063    /// Convenience: enforce a table-level permission (`Select`/`Insert`/
6064    /// `Update`/`Delete`) by table name. Used by the Transaction layer and
6065    /// other callers that know the operation kind + table name but don't want
6066    /// to construct the full `Permission` enum value themselves.
6067    pub fn require_table(
6068        &self,
6069        table: &str,
6070        perm: crate::auth_state::RequiredPermission,
6071    ) -> Result<()> {
6072        self.require(&perm.into_permission(table))
6073    }
6074
6075    pub fn triggers(&self) -> Vec<StoredTrigger> {
6076        self.catalog
6077            .read()
6078            .triggers
6079            .iter()
6080            .map(|t| t.trigger.clone())
6081            .collect()
6082    }
6083
6084    pub fn trigger(&self, name: &str) -> Option<StoredTrigger> {
6085        self.catalog
6086            .read()
6087            .triggers
6088            .iter()
6089            .find(|t| t.trigger.name == name)
6090            .map(|t| t.trigger.clone())
6091    }
6092
6093    pub fn create_trigger(&self, trigger: StoredTrigger) -> Result<StoredTrigger> {
6094        self.create_trigger_inner(trigger, None, None)
6095    }
6096
6097    pub fn create_trigger_controlled<F>(
6098        &self,
6099        trigger: StoredTrigger,
6100        mut before_publish: F,
6101    ) -> Result<StoredTrigger>
6102    where
6103        F: FnMut() -> Result<()>,
6104    {
6105        self.create_trigger_inner(trigger, None, Some(&mut before_publish))
6106    }
6107
6108    pub fn create_trigger_as_controlled<F>(
6109        &self,
6110        trigger: StoredTrigger,
6111        principal: Option<&crate::auth::Principal>,
6112        mut before_publish: F,
6113    ) -> Result<StoredTrigger>
6114    where
6115        F: FnMut() -> Result<()>,
6116    {
6117        self.create_trigger_inner(trigger, principal, Some(&mut before_publish))
6118    }
6119
6120    fn create_trigger_inner(
6121        &self,
6122        mut trigger: StoredTrigger,
6123        principal: Option<&crate::auth::Principal>,
6124        before_publish: Option<&mut dyn FnMut() -> Result<()>>,
6125    ) -> Result<StoredTrigger> {
6126        self.require_for(principal, &crate::auth::Permission::Ddl)?;
6127        let _g = self.ddl_lock.lock();
6128        let _security_write = self.security_write()?;
6129        self.require_for(principal, &crate::auth::Permission::Ddl)?;
6130        trigger.validate()?;
6131        self.validate_trigger_references(&trigger)
6132            .map_err(trigger_validation_error)?;
6133        {
6134            let cat = self.catalog.read();
6135            if cat.triggers.iter().any(|t| t.trigger.name == trigger.name) {
6136                return Err(MongrelError::TriggerValidation(format!(
6137                    "trigger {:?} already exists",
6138                    trigger.name
6139                )));
6140            }
6141        }
6142        let commit_lock = Arc::clone(&self.commit_lock);
6143        let _c = commit_lock.lock();
6144        let epoch = self.epoch.bump_assigned();
6145        let mut _epoch_guard = EpochGuard::new(self.epoch.as_ref(), epoch);
6146        trigger.created_epoch = epoch.0;
6147        trigger.updated_epoch = epoch.0;
6148        let mut next_catalog = self.catalog.read().clone();
6149        next_catalog
6150            .triggers
6151            .push(TriggerEntry::from(trigger.clone()));
6152        next_catalog.db_epoch = epoch.0;
6153        self.publish_catalog_candidate(next_catalog, epoch, &mut _epoch_guard, before_publish)?;
6154        Ok(trigger)
6155    }
6156
6157    pub fn create_or_replace_trigger(&self, trigger: StoredTrigger) -> Result<StoredTrigger> {
6158        self.create_or_replace_trigger_inner(trigger, None, None)
6159    }
6160
6161    pub fn create_or_replace_trigger_controlled<F>(
6162        &self,
6163        trigger: StoredTrigger,
6164        mut before_publish: F,
6165    ) -> Result<StoredTrigger>
6166    where
6167        F: FnMut() -> Result<()>,
6168    {
6169        self.create_or_replace_trigger_inner(trigger, None, Some(&mut before_publish))
6170    }
6171
6172    pub fn create_or_replace_trigger_as_controlled<F>(
6173        &self,
6174        trigger: StoredTrigger,
6175        principal: Option<&crate::auth::Principal>,
6176        mut before_publish: F,
6177    ) -> Result<StoredTrigger>
6178    where
6179        F: FnMut() -> Result<()>,
6180    {
6181        self.create_or_replace_trigger_inner(trigger, principal, Some(&mut before_publish))
6182    }
6183
6184    fn create_or_replace_trigger_inner(
6185        &self,
6186        trigger: StoredTrigger,
6187        principal: Option<&crate::auth::Principal>,
6188        before_publish: Option<&mut dyn FnMut() -> Result<()>>,
6189    ) -> Result<StoredTrigger> {
6190        self.require_for(principal, &crate::auth::Permission::Ddl)?;
6191        let _g = self.ddl_lock.lock();
6192        let _security_write = self.security_write()?;
6193        self.require_for(principal, &crate::auth::Permission::Ddl)?;
6194        trigger.validate()?;
6195        self.validate_trigger_references(&trigger)
6196            .map_err(trigger_validation_error)?;
6197        let commit_lock = Arc::clone(&self.commit_lock);
6198        let _c = commit_lock.lock();
6199        let epoch = self.epoch.bump_assigned();
6200        let mut _epoch_guard = EpochGuard::new(self.epoch.as_ref(), epoch);
6201        let mut next_catalog = self.catalog.read().clone();
6202        let replaced = {
6203            let next = match next_catalog
6204                .triggers
6205                .iter()
6206                .position(|t| t.trigger.name == trigger.name)
6207            {
6208                Some(idx) => {
6209                    let next = next_catalog.triggers[idx]
6210                        .trigger
6211                        .replaced(trigger.clone(), epoch.0)?;
6212                    next_catalog.triggers[idx] = TriggerEntry::from(next.clone());
6213                    next
6214                }
6215                None => {
6216                    let mut next = trigger;
6217                    next.created_epoch = epoch.0;
6218                    next.updated_epoch = epoch.0;
6219                    next_catalog.triggers.push(TriggerEntry::from(next.clone()));
6220                    next
6221                }
6222            };
6223            next_catalog.db_epoch = epoch.0;
6224            next
6225        };
6226        self.publish_catalog_candidate(next_catalog, epoch, &mut _epoch_guard, before_publish)?;
6227        Ok(replaced)
6228    }
6229
6230    pub fn drop_trigger(&self, name: &str) -> Result<()> {
6231        self.drop_trigger_with_epoch(name).map(|_| ())
6232    }
6233
6234    /// Drop one trigger and return the exact catalog publication epoch.
6235    pub fn drop_trigger_with_epoch(&self, name: &str) -> Result<Epoch> {
6236        self.drop_triggers_with_epoch(&[name.to_string()])
6237    }
6238
6239    pub fn drop_trigger_with_epoch_controlled<F>(
6240        &self,
6241        name: &str,
6242        before_publish: F,
6243    ) -> Result<Epoch>
6244    where
6245        F: FnMut() -> Result<()>,
6246    {
6247        self.drop_triggers_with_epoch_controlled(&[name.to_string()], before_publish)
6248    }
6249
6250    /// Atomically drop several triggers in one catalog publication.
6251    pub fn drop_triggers_with_epoch(&self, names: &[String]) -> Result<Epoch> {
6252        self.drop_triggers_with_epoch_inner(names, None, None)
6253    }
6254
6255    pub fn drop_triggers_with_epoch_controlled<F>(
6256        &self,
6257        names: &[String],
6258        mut before_publish: F,
6259    ) -> Result<Epoch>
6260    where
6261        F: FnMut() -> Result<()>,
6262    {
6263        self.drop_triggers_with_epoch_inner(names, None, Some(&mut before_publish))
6264    }
6265
6266    pub fn drop_triggers_with_epoch_as_controlled<F>(
6267        &self,
6268        names: &[String],
6269        principal: Option<&crate::auth::Principal>,
6270        mut before_publish: F,
6271    ) -> Result<Epoch>
6272    where
6273        F: FnMut() -> Result<()>,
6274    {
6275        self.drop_triggers_with_epoch_inner(names, principal, Some(&mut before_publish))
6276    }
6277
6278    fn drop_triggers_with_epoch_inner(
6279        &self,
6280        names: &[String],
6281        principal: Option<&crate::auth::Principal>,
6282        before_publish: Option<&mut dyn FnMut() -> Result<()>>,
6283    ) -> Result<Epoch> {
6284        self.require_for(principal, &crate::auth::Permission::Ddl)?;
6285        if names.is_empty() {
6286            return Err(MongrelError::InvalidArgument(
6287                "at least one trigger name is required".into(),
6288            ));
6289        }
6290        let _g = self.ddl_lock.lock();
6291        let _security_write = self.security_write()?;
6292        self.require_for(principal, &crate::auth::Permission::Ddl)?;
6293        {
6294            let cat = self.catalog.read();
6295            for name in names {
6296                if !cat.triggers.iter().any(|t| t.trigger.name == *name) {
6297                    return Err(MongrelError::NotFound(format!(
6298                        "trigger {name:?} not found"
6299                    )));
6300                }
6301            }
6302        }
6303        let commit_lock = Arc::clone(&self.commit_lock);
6304        let _c = commit_lock.lock();
6305        let epoch = self.epoch.bump_assigned();
6306        let mut _epoch_guard = EpochGuard::new(self.epoch.as_ref(), epoch);
6307        let mut next_catalog = self.catalog.read().clone();
6308        next_catalog
6309            .triggers
6310            .retain(|trigger| !names.contains(&trigger.trigger.name));
6311        next_catalog.db_epoch = epoch.0;
6312        self.publish_catalog_candidate(next_catalog, epoch, &mut _epoch_guard, before_publish)?;
6313        Ok(epoch)
6314    }
6315
6316    pub fn external_tables(&self) -> Vec<ExternalTableEntry> {
6317        self.catalog.read().external_tables.clone()
6318    }
6319
6320    pub fn external_table(&self, name: &str) -> Option<ExternalTableEntry> {
6321        self.catalog
6322            .read()
6323            .external_tables
6324            .iter()
6325            .find(|t| t.name == name)
6326            .cloned()
6327    }
6328
6329    pub fn create_external_table(&self, entry: ExternalTableEntry) -> Result<ExternalTableEntry> {
6330        self.create_external_table_inner(entry, None)
6331    }
6332
6333    pub fn create_external_table_controlled<F>(
6334        &self,
6335        entry: ExternalTableEntry,
6336        mut before_publish: F,
6337    ) -> Result<ExternalTableEntry>
6338    where
6339        F: FnMut() -> Result<()>,
6340    {
6341        self.create_external_table_inner(entry, Some(&mut before_publish))
6342    }
6343
6344    fn create_external_table_inner(
6345        &self,
6346        mut entry: ExternalTableEntry,
6347        before_publish: Option<&mut dyn FnMut() -> Result<()>>,
6348    ) -> Result<ExternalTableEntry> {
6349        self.require(&crate::auth::Permission::Ddl)?;
6350        let _g = self.ddl_lock.lock();
6351        let _security_write = self.security_write()?;
6352        self.require(&crate::auth::Permission::Ddl)?;
6353        entry.validate()?;
6354        {
6355            let cat = self.catalog.read();
6356            if cat.live(&entry.name).is_some()
6357                || cat.external_tables.iter().any(|t| t.name == entry.name)
6358            {
6359                return Err(MongrelError::InvalidArgument(format!(
6360                    "table {:?} already exists",
6361                    entry.name
6362                )));
6363            }
6364        }
6365        let commit_lock = Arc::clone(&self.commit_lock);
6366        let _c = commit_lock.lock();
6367        // A prior durable drop may have left connector state behind if its
6368        // cleanup failed or the process crashed. Never let a new table with
6369        // the same name inherit that stale state.
6370        crate::durable_file::create_directory(&self.root.join(VTAB_DIR))?;
6371        crate::durable_file::remove_directory_all(&self.root.join(VTAB_DIR).join(&entry.name))?;
6372        let epoch = self.epoch.bump_assigned();
6373        let mut _epoch_guard = EpochGuard::new(self.epoch.as_ref(), epoch);
6374        entry.created_epoch = epoch.0;
6375        let mut next_catalog = self.catalog.read().clone();
6376        next_catalog.external_tables.push(entry.clone());
6377        next_catalog.db_epoch = epoch.0;
6378        self.publish_catalog_candidate_with_prelude(
6379            next_catalog,
6380            epoch,
6381            &mut _epoch_guard,
6382            before_publish,
6383            vec![(
6384                EXTERNAL_TABLE_ID,
6385                crate::wal::Op::Ddl(crate::wal::DdlOp::ResetExternalTableState {
6386                    name: entry.name.clone(),
6387                    generation_epoch: epoch.0,
6388                }),
6389            )],
6390        )?;
6391        Ok(entry)
6392    }
6393
6394    pub fn drop_external_table(&self, name: &str) -> Result<()> {
6395        self.drop_external_table_with_epoch(name).map(|_| ())
6396    }
6397
6398    /// Drop an external table and return the exact catalog publication epoch.
6399    pub fn drop_external_table_with_epoch(&self, name: &str) -> Result<Epoch> {
6400        self.drop_external_table_with_epoch_inner(name, None)
6401    }
6402
6403    pub fn drop_external_table_with_epoch_controlled<F>(
6404        &self,
6405        name: &str,
6406        mut before_publish: F,
6407    ) -> Result<Epoch>
6408    where
6409        F: FnMut() -> Result<()>,
6410    {
6411        self.drop_external_table_with_epoch_inner(name, Some(&mut before_publish))
6412    }
6413
6414    fn drop_external_table_with_epoch_inner(
6415        &self,
6416        name: &str,
6417        before_publish: Option<&mut dyn FnMut() -> Result<()>>,
6418    ) -> Result<Epoch> {
6419        self.require(&crate::auth::Permission::Ddl)?;
6420        let _g = self.ddl_lock.lock();
6421        let _security_write = self.security_write()?;
6422        self.require(&crate::auth::Permission::Ddl)?;
6423        let commit_lock = Arc::clone(&self.commit_lock);
6424        let _c = commit_lock.lock();
6425        let epoch = self.epoch.bump_assigned();
6426        let mut _epoch_guard = EpochGuard::new(self.epoch.as_ref(), epoch);
6427        let mut next_catalog = self.catalog.read().clone();
6428        let before = next_catalog.external_tables.len();
6429        next_catalog.external_tables.retain(|t| t.name != name);
6430        if next_catalog.external_tables.len() == before {
6431            return Err(MongrelError::NotFound(format!(
6432                "external table {name:?} not found"
6433            )));
6434        }
6435        next_catalog.db_epoch = epoch.0;
6436        self.publish_catalog_candidate_with_prelude(
6437            next_catalog,
6438            epoch,
6439            &mut _epoch_guard,
6440            before_publish,
6441            vec![(
6442                EXTERNAL_TABLE_ID,
6443                crate::wal::Op::Ddl(crate::wal::DdlOp::ResetExternalTableState {
6444                    name: name.to_string(),
6445                    generation_epoch: epoch.0,
6446                }),
6447            )],
6448        )?;
6449        let state_dir = self.root.join(VTAB_DIR).join(name);
6450        if let Err(error) = crate::durable_file::remove_directory_all(&state_dir) {
6451            return Err(MongrelError::DurableCommit {
6452                epoch: epoch.0,
6453                message: format!(
6454                    "external table was dropped but connector-state cleanup failed: {error}"
6455                ),
6456            });
6457        }
6458        Ok(epoch)
6459    }
6460
6461    pub fn commit_external_table_state(&self, name: &str, state: &[u8]) -> Result<Epoch> {
6462        let txn_id = self.alloc_txn_id()?;
6463        let (principal, catalog_bound) = self.transaction_principal_snapshot();
6464        self.commit_transaction_with_external_states(
6465            txn_id,
6466            self.epoch.visible(),
6467            Vec::new(),
6468            vec![(name.to_string(), state.to_vec())],
6469            Vec::new(),
6470            principal,
6471            catalog_bound,
6472            None,
6473        )
6474        .map(|(epoch, _)| epoch)
6475    }
6476
6477    pub fn trigger_config(&self) -> TriggerConfig {
6478        use std::sync::atomic::Ordering;
6479        TriggerConfig {
6480            recursive_triggers: self.trigger_recursive.load(Ordering::Relaxed),
6481            max_depth: self.trigger_max_depth.load(Ordering::Relaxed),
6482            max_loop_iterations: self.trigger_max_loop_iterations.load(Ordering::Relaxed),
6483        }
6484    }
6485
6486    pub fn set_trigger_config(&self, config: TriggerConfig) -> Result<()> {
6487        use std::sync::atomic::Ordering;
6488        if config.max_depth == 0 {
6489            return Err(MongrelError::InvalidArgument(
6490                "trigger max_depth must be greater than 0".into(),
6491            ));
6492        }
6493        self.trigger_recursive
6494            .store(config.recursive_triggers, Ordering::Relaxed);
6495        self.trigger_max_depth
6496            .store(config.max_depth, Ordering::Relaxed);
6497        self.trigger_max_loop_iterations
6498            .store(config.max_loop_iterations, Ordering::Relaxed);
6499        Ok(())
6500    }
6501
6502    pub fn set_recursive_triggers(&self, recursive: bool) {
6503        use std::sync::atomic::Ordering;
6504        self.trigger_recursive.store(recursive, Ordering::Relaxed);
6505    }
6506
6507    /// Subscribe to ephemeral SQL NOTIFY messages. Durable row changes use
6508    /// [`Self::change_events_since`], with [`Self::subscribe_change_commits`]
6509    /// as a low-latency wake-up.
6510    pub fn subscribe_changes(&self) -> tokio::sync::broadcast::Receiver<ChangeEvent> {
6511        self.notify.subscribe()
6512    }
6513
6514    pub fn subscribe_change_commits(&self) -> tokio::sync::broadcast::Receiver<()> {
6515        self.change_wake.subscribe()
6516    }
6517
6518    /// Reconstruct committed row changes from the retained shared WAL. Event
6519    /// ids are stable `<commit_epoch>:<operation_index>` pairs. A caller that
6520    /// resumes before the oldest retained commit receives `gap = true` and
6521    /// must rebootstrap instead of silently skipping changes.
6522    pub fn change_events_since(&self, last_event_id: Option<&str>) -> Result<CdcBatch> {
6523        let control = crate::ExecutionControl::new(None);
6524        self.change_events_since_controlled(last_event_id, &control)
6525    }
6526
6527    /// Reconstruct committed changes with cooperative cancellation and bounds.
6528    pub fn change_events_since_controlled(
6529        &self,
6530        last_event_id: Option<&str>,
6531        control: &crate::ExecutionControl,
6532    ) -> Result<CdcBatch> {
6533        use crate::wal::Op;
6534
6535        control.checkpoint()?;
6536        let resume = match last_event_id {
6537            Some(id) => {
6538                let (epoch, index) = id.split_once(':').ok_or_else(|| {
6539                    MongrelError::InvalidArgument(format!(
6540                        "invalid CDC event id {id:?}; expected <epoch>:<index>"
6541                    ))
6542                })?;
6543                Some((
6544                    epoch.parse::<u64>().map_err(|error| {
6545                        MongrelError::InvalidArgument(format!("invalid CDC epoch: {error}"))
6546                    })?,
6547                    index.parse::<u32>().map_err(|error| {
6548                        MongrelError::InvalidArgument(format!("invalid CDC index: {error}"))
6549                    })?,
6550                ))
6551            }
6552            None => None,
6553        };
6554
6555        let mut wal = self.shared_wal.lock();
6556        wal.group_sync()?;
6557        let wal_dek = crate::encryption::wal_dek_for(self.kek.as_deref());
6558        let records = crate::wal::SharedWal::replay_with_dek_controlled(
6559            &self.root,
6560            wal_dek.as_ref(),
6561            control,
6562            CDC_MAX_WAL_RECORDS,
6563            CDC_MAX_WAL_REPLAY_BYTES,
6564        )?;
6565        drop(wal);
6566        control.checkpoint()?;
6567
6568        let mut commits: HashMap<u64, (u64, Vec<crate::wal::AddedRun>)> = HashMap::new();
6569        let mut spilled_payloads: HashMap<(u64, u64), Vec<&[u8]>> = HashMap::new();
6570        for (index, record) in records.iter().enumerate() {
6571            if index % 256 == 0 {
6572                control.checkpoint()?;
6573            }
6574            if let Op::TxnCommit { epoch, added_runs } = &record.op {
6575                commits.insert(record.txn_id, (*epoch, added_runs.clone()));
6576            }
6577            if let Op::SpilledRows { table_id, rows } = &record.op {
6578                spilled_payloads
6579                    .entry((record.txn_id, *table_id))
6580                    .or_default()
6581                    .push(rows);
6582            }
6583        }
6584        let earliest_epoch = commits.values().map(|(epoch, _)| *epoch).min();
6585        let current_epoch = self.epoch.committed().0;
6586        let retention_floor = crate::replication::replication_wal_floor(&self.root)?;
6587        let gap = resume.is_some_and(|(epoch, _)| {
6588            retention_floor != 0 && epoch <= retention_floor && epoch <= current_epoch
6589        });
6590        if gap {
6591            return Ok(CdcBatch {
6592                events: Vec::new(),
6593                current_epoch,
6594                earliest_epoch,
6595                gap: true,
6596            });
6597        }
6598
6599        let table_names: HashMap<u64, String> = self
6600            .catalog
6601            .read()
6602            .tables
6603            .iter()
6604            .map(|entry| (entry.table_id, entry.name.clone()))
6605            .collect();
6606        let mut before_images: HashMap<(u64, u64, u64), crate::memtable::Row> = HashMap::new();
6607        let mut retained_bytes = 0_usize;
6608        for (index, record) in records.iter().enumerate() {
6609            if index % 256 == 0 {
6610                control.checkpoint()?;
6611            }
6612            if !commits.contains_key(&record.txn_id) {
6613                continue;
6614            }
6615            let Op::BeforeImage {
6616                table_id,
6617                row_id,
6618                row,
6619            } = &record.op
6620            else {
6621                continue;
6622            };
6623            if row.len() > CDC_MAX_INLINE_PAYLOAD_BYTES {
6624                return Err(MongrelError::ResourceLimitExceeded {
6625                    resource: "CDC before-image bytes",
6626                    requested: row.len(),
6627                    limit: CDC_MAX_INLINE_PAYLOAD_BYTES,
6628                });
6629            }
6630            let before: crate::memtable::Row = bincode::deserialize(row)?;
6631            if before_images.len() >= CDC_MAX_ROWS {
6632                return Err(MongrelError::ResourceLimitExceeded {
6633                    resource: "CDC before-image rows",
6634                    requested: before_images.len().saturating_add(1),
6635                    limit: CDC_MAX_ROWS,
6636                });
6637            }
6638            charge_cdc_bytes(
6639                &mut retained_bytes,
6640                cdc_row_storage_bytes(&before),
6641                "CDC retained bytes",
6642            )?;
6643            before_images.insert((record.txn_id, *table_id, row_id.0), before);
6644        }
6645        let mut operation_indices: HashMap<u64, u32> = HashMap::new();
6646        let mut events = Vec::new();
6647        let mut decoded_rows = before_images.len();
6648        for (record_index, record) in records.iter().enumerate() {
6649            if record_index % 256 == 0 {
6650                control.checkpoint()?;
6651            }
6652            let Some((commit_epoch, _)) = commits.get(&record.txn_id) else {
6653                continue;
6654            };
6655            let event = match &record.op {
6656                Op::Put { table_id, rows } => {
6657                    if rows.len() > CDC_MAX_INLINE_PAYLOAD_BYTES {
6658                        return Err(MongrelError::ResourceLimitExceeded {
6659                            resource: "CDC inline row bytes",
6660                            requested: rows.len(),
6661                            limit: CDC_MAX_INLINE_PAYLOAD_BYTES,
6662                        });
6663                    }
6664                    let rows: Vec<crate::memtable::Row> = bincode::deserialize(rows)?;
6665                    decoded_rows = decoded_rows.saturating_add(rows.len());
6666                    if decoded_rows > CDC_MAX_ROWS {
6667                        return Err(MongrelError::ResourceLimitExceeded {
6668                            resource: "CDC decoded rows",
6669                            requested: decoded_rows,
6670                            limit: CDC_MAX_ROWS,
6671                        });
6672                    }
6673                    let event_bytes = cdc_rows_json_bytes(&rows).saturating_add(512);
6674                    let mut peak_bytes = retained_bytes;
6675                    charge_cdc_bytes(&mut peak_bytes, event_bytes, "CDC retained event bytes")?;
6676                    let data = serde_json::to_value(rows)
6677                        .map_err(|error| MongrelError::Other(format!("CDC JSON: {error}")))?;
6678                    Some((*table_id, "put", data, event_bytes))
6679                }
6680                Op::Delete { table_id, row_ids } => {
6681                    let before = row_ids
6682                        .iter()
6683                        .filter_map(|row_id| {
6684                            before_images
6685                                .get(&(record.txn_id, *table_id, row_id.0))
6686                                .cloned()
6687                        })
6688                        .collect::<Vec<_>>();
6689                    let event_bytes = cdc_rows_json_bytes(&before)
6690                        .saturating_add(
6691                            row_ids
6692                                .len()
6693                                .saturating_mul(std::mem::size_of::<serde_json::Value>()),
6694                        )
6695                        .saturating_add(512);
6696                    let mut peak_bytes = retained_bytes;
6697                    charge_cdc_bytes(&mut peak_bytes, event_bytes, "CDC retained event bytes")?;
6698                    Some((
6699                        *table_id,
6700                        "delete",
6701                        serde_json::json!({
6702                            "row_ids": row_ids.iter().map(|row_id| row_id.0).collect::<Vec<_>>(),
6703                            "before": before,
6704                        }),
6705                        event_bytes,
6706                    ))
6707                }
6708                Op::TruncateTable { table_id } => {
6709                    Some((*table_id, "truncate", serde_json::Value::Null, 512))
6710                }
6711                _ => None,
6712            };
6713            if let Some((table_id, op, data, event_bytes)) = event {
6714                let index = operation_indices.entry(record.txn_id).or_insert(0);
6715                let event_position = (*commit_epoch, *index);
6716                *index = index.saturating_add(1);
6717                if resume.is_some_and(|position| event_position <= position) {
6718                    continue;
6719                }
6720                if events.len() >= CDC_MAX_EVENTS {
6721                    return Err(MongrelError::ResourceLimitExceeded {
6722                        resource: "CDC events",
6723                        requested: events.len().saturating_add(1),
6724                        limit: CDC_MAX_EVENTS,
6725                    });
6726                }
6727                charge_cdc_bytes(&mut retained_bytes, event_bytes, "CDC retained event bytes")?;
6728                events.push(ChangeEvent {
6729                    id: Some(format!("{}:{}", event_position.0, event_position.1)),
6730                    channel: "changes".into(),
6731                    table_id: Some(table_id),
6732                    table: table_names.get(&table_id).cloned().unwrap_or_default(),
6733                    op: op.into(),
6734                    epoch: *commit_epoch,
6735                    txn_id: Some(record.txn_id),
6736                    message: None,
6737                    data: Some(data),
6738                });
6739            }
6740            if let Op::TxnCommit { added_runs, .. } = &record.op {
6741                for run in added_runs {
6742                    control.checkpoint()?;
6743                    let index = operation_indices.entry(record.txn_id).or_insert(0);
6744                    let event_position = (*commit_epoch, *index);
6745                    *index = index.saturating_add(1);
6746                    if resume.is_some_and(|position| event_position <= position) {
6747                        continue;
6748                    }
6749                    let mut rows = if let Some(payloads) =
6750                        spilled_payloads.get(&(record.txn_id, run.table_id))
6751                    {
6752                        let mut rows = Vec::new();
6753                        for payload in payloads {
6754                            control.checkpoint()?;
6755                            if payload.len() > CDC_MAX_INLINE_PAYLOAD_BYTES {
6756                                return Err(MongrelError::ResourceLimitExceeded {
6757                                    resource: "CDC spilled row bytes",
6758                                    requested: payload.len(),
6759                                    limit: CDC_MAX_INLINE_PAYLOAD_BYTES,
6760                                });
6761                            }
6762                            let chunk: Vec<crate::memtable::Row> = bincode::deserialize(payload)?;
6763                            if decoded_rows
6764                                .saturating_add(rows.len())
6765                                .saturating_add(chunk.len())
6766                                > CDC_MAX_ROWS
6767                            {
6768                                return Err(MongrelError::ResourceLimitExceeded {
6769                                    resource: "CDC decoded rows",
6770                                    requested: decoded_rows
6771                                        .saturating_add(rows.len())
6772                                        .saturating_add(chunk.len()),
6773                                    limit: CDC_MAX_ROWS,
6774                                });
6775                            }
6776                            rows.extend(chunk);
6777                        }
6778                        rows
6779                    } else {
6780                        let Some(handle) = self.tables.read().get(&run.table_id).cloned() else {
6781                            return Ok(CdcBatch {
6782                                events: Vec::new(),
6783                                current_epoch,
6784                                earliest_epoch,
6785                                gap: true,
6786                            });
6787                        };
6788                        let table = handle.lock();
6789                        let mut reader = match table.open_reader(run.run_id) {
6790                            Ok(reader) => reader,
6791                            Err(_) => {
6792                                return Ok(CdcBatch {
6793                                    events: Vec::new(),
6794                                    current_epoch,
6795                                    earliest_epoch,
6796                                    gap: true,
6797                                })
6798                            }
6799                        };
6800                        let remaining = CDC_MAX_ROWS.saturating_sub(decoded_rows);
6801                        let rows = reader.all_rows_controlled(control, remaining)?;
6802                        drop(reader);
6803                        drop(table);
6804                        rows
6805                    };
6806                    for row in &mut rows {
6807                        row.committed_epoch = Epoch(*commit_epoch);
6808                    }
6809                    decoded_rows = decoded_rows.saturating_add(rows.len());
6810                    let event_bytes = cdc_rows_json_bytes(&rows).saturating_add(768);
6811                    charge_cdc_bytes(&mut retained_bytes, event_bytes, "CDC retained event bytes")?;
6812                    if events.len() >= CDC_MAX_EVENTS {
6813                        return Err(MongrelError::ResourceLimitExceeded {
6814                            resource: "CDC events",
6815                            requested: events.len().saturating_add(1),
6816                            limit: CDC_MAX_EVENTS,
6817                        });
6818                    }
6819                    events.push(ChangeEvent {
6820                        id: Some(format!("{}:{}", event_position.0, event_position.1)),
6821                        channel: "changes".into(),
6822                        table_id: Some(run.table_id),
6823                        table: table_names.get(&run.table_id).cloned().unwrap_or_default(),
6824                        op: "put_run".into(),
6825                        epoch: *commit_epoch,
6826                        txn_id: Some(record.txn_id),
6827                        message: None,
6828                        data: Some(serde_json::json!({
6829                            "run_id": run.run_id.to_string(),
6830                            "row_count": run.row_count,
6831                            "min_row_id": run.min_row_id,
6832                            "max_row_id": run.max_row_id,
6833                            "rows": rows,
6834                        })),
6835                    });
6836                }
6837            }
6838        }
6839        control.checkpoint()?;
6840        Ok(CdcBatch {
6841            events,
6842            current_epoch,
6843            earliest_epoch,
6844            gap: false,
6845        })
6846    }
6847
6848    /// Publish a notification message on a named channel. Reaches all active
6849    /// subscribers (daemon `/events`, application listeners).
6850    pub fn notify(&self, channel: &str, message: Option<String>) {
6851        let _ = self.notify.send(ChangeEvent {
6852            id: None,
6853            channel: channel.to_string(),
6854            table_id: None,
6855            table: String::new(),
6856            op: "notify".into(),
6857            epoch: self.epoch.visible().0,
6858            txn_id: None,
6859            message,
6860            data: None,
6861        });
6862    }
6863
6864    pub fn call_procedure(
6865        &self,
6866        name: &str,
6867        args: HashMap<String, crate::Value>,
6868    ) -> Result<ProcedureCallResult> {
6869        self.call_procedure_as(name, args, None)
6870    }
6871
6872    pub fn call_procedure_as(
6873        &self,
6874        name: &str,
6875        args: HashMap<String, crate::Value>,
6876        principal: Option<&crate::auth::Principal>,
6877    ) -> Result<ProcedureCallResult> {
6878        let control = crate::ExecutionControl::new(None);
6879        self.call_procedure_as_controlled(name, args, principal, &control, || true)
6880    }
6881
6882    /// Execute only the exact procedure revision previously authorized by the
6883    /// caller. A dropped or replaced definition fails closed.
6884    #[doc(hidden)]
6885    pub fn call_procedure_as_bound(
6886        &self,
6887        expected: &StoredProcedure,
6888        args: HashMap<String, crate::Value>,
6889        principal: Option<&crate::auth::Principal>,
6890    ) -> Result<ProcedureCallResult> {
6891        self.require_for(principal, &crate::auth::Permission::All)?;
6892        let procedure = self.procedure(&expected.name).ok_or_else(|| {
6893            MongrelError::NotFound(format!("procedure {:?} not found", expected.name))
6894        })?;
6895        if &procedure != expected {
6896            return Err(MongrelError::Conflict(format!(
6897                "procedure {:?} changed after request authorization",
6898                expected.name
6899            )));
6900        }
6901        let control = crate::ExecutionControl::new(None);
6902        self.execute_procedure_as_controlled(procedure, args, principal, &control, || true)
6903    }
6904
6905    /// Execute a procedure with cooperative cancellation during preparation.
6906    /// `before_commit` runs after every procedure step has succeeded and
6907    /// immediately before a write procedure commits. Returning `false` aborts
6908    /// the transaction without publishing it.
6909    #[doc(hidden)]
6910    pub fn call_procedure_as_controlled<F>(
6911        &self,
6912        name: &str,
6913        args: HashMap<String, crate::Value>,
6914        principal: Option<&crate::auth::Principal>,
6915        control: &crate::ExecutionControl,
6916        before_commit: F,
6917    ) -> Result<ProcedureCallResult>
6918    where
6919        F: FnOnce() -> bool,
6920    {
6921        // v1 requires ALL to call procedures on a require_auth database; a
6922        // finer SECURITY DEFINER-style marker is a future extension (spec §9
6923        // decision 1).
6924        self.require_for(principal, &crate::auth::Permission::All)?;
6925        let procedure = self
6926            .procedure(name)
6927            .ok_or_else(|| MongrelError::NotFound(format!("procedure {name:?} not found")))?;
6928        self.execute_procedure_as_controlled(procedure, args, principal, control, before_commit)
6929    }
6930
6931    fn execute_procedure_as_controlled<F>(
6932        &self,
6933        procedure: StoredProcedure,
6934        args: HashMap<String, crate::Value>,
6935        principal: Option<&crate::auth::Principal>,
6936        control: &crate::ExecutionControl,
6937        before_commit: F,
6938    ) -> Result<ProcedureCallResult>
6939    where
6940        F: FnOnce() -> bool,
6941    {
6942        let args = bind_procedure_args(&procedure, args)?;
6943        let has_writes = procedure.body.steps.iter().any(ProcedureStep::is_write);
6944        let mut outputs: HashMap<String, ProcedureCallOutput> = HashMap::new();
6945        if has_writes {
6946            let mut tx = self.begin_as(principal.cloned());
6947            let run = (|| {
6948                for (step_index, step) in procedure.body.steps.iter().enumerate() {
6949                    if step_index % 256 == 0 {
6950                        control.checkpoint()?;
6951                    }
6952                    let output = self.execute_procedure_step(
6953                        step,
6954                        &args,
6955                        &outputs,
6956                        Some(&mut tx),
6957                        principal,
6958                        Some(control),
6959                    )?;
6960                    outputs.insert(step.id().to_string(), output);
6961                }
6962                control.checkpoint()?;
6963                eval_return_output(&procedure.body.return_value, &args, &outputs)
6964            })();
6965            match run {
6966                Ok(output) => {
6967                    control.checkpoint()?;
6968                    if !before_commit() {
6969                        tx.rollback();
6970                        return Err(MongrelError::Cancelled);
6971                    }
6972                    let epoch = tx.commit()?.0;
6973                    Ok(ProcedureCallResult {
6974                        epoch: Some(epoch),
6975                        output,
6976                    })
6977                }
6978                Err(e) => {
6979                    tx.rollback();
6980                    Err(e)
6981                }
6982            }
6983        } else {
6984            for (step_index, step) in procedure.body.steps.iter().enumerate() {
6985                if step_index % 256 == 0 {
6986                    control.checkpoint()?;
6987                }
6988                let output = self.execute_procedure_step(
6989                    step,
6990                    &args,
6991                    &outputs,
6992                    None,
6993                    principal,
6994                    Some(control),
6995                )?;
6996                outputs.insert(step.id().to_string(), output);
6997            }
6998            control.checkpoint()?;
6999            Ok(ProcedureCallResult {
7000                epoch: None,
7001                output: eval_return_output(&procedure.body.return_value, &args, &outputs)?,
7002            })
7003        }
7004    }
7005
7006    fn execute_procedure_step(
7007        &self,
7008        step: &ProcedureStep,
7009        args: &HashMap<String, crate::Value>,
7010        outputs: &HashMap<String, ProcedureCallOutput>,
7011        tx: Option<&mut crate::txn::Transaction<'_>>,
7012        principal: Option<&crate::auth::Principal>,
7013        control: Option<&crate::ExecutionControl>,
7014    ) -> Result<ProcedureCallOutput> {
7015        if let Some(control) = control {
7016            control.checkpoint()?;
7017        }
7018        match step {
7019            ProcedureStep::NativeQuery {
7020                table,
7021                conditions,
7022                projection,
7023                limit,
7024                ..
7025            } => {
7026                let mut q = crate::Query::new();
7027                for condition in conditions {
7028                    q = q.and(eval_condition(condition, args, outputs)?);
7029                }
7030                let handle = self.table(table)?;
7031                let rows = handle.lock().query(&q)?;
7032                let mut rows = self.secure_rows_for(table, rows, principal)?;
7033                if let Some(limit) = limit {
7034                    rows.truncate(*limit);
7035                }
7036                let projection = projection.as_ref();
7037                let mut output = Vec::with_capacity(rows.len());
7038                for (row_index, row) in rows.into_iter().enumerate() {
7039                    if row_index % 256 == 0 {
7040                        if let Some(control) = control {
7041                            control.checkpoint()?;
7042                        }
7043                    }
7044                    output.push(ProcedureCallRow {
7045                        row_id: Some(row.row_id),
7046                        columns: match projection {
7047                            Some(ids) => row
7048                                .columns
7049                                .into_iter()
7050                                .filter(|(id, _)| ids.contains(id))
7051                                .collect(),
7052                            None => row.columns,
7053                        },
7054                    });
7055                }
7056                Ok(ProcedureCallOutput::Rows(output))
7057            }
7058            ProcedureStep::Put {
7059                table,
7060                cells,
7061                returning,
7062                ..
7063            } => {
7064                let tx = tx.ok_or_else(|| {
7065                    MongrelError::InvalidArgument(
7066                        "write procedure step requires a transaction".into(),
7067                    )
7068                })?;
7069                let cells = eval_cells(cells, args, outputs)?;
7070                if *returning {
7071                    let out = tx.put_returning(table, cells)?;
7072                    Ok(ProcedureCallOutput::Row(ProcedureCallRow {
7073                        row_id: None,
7074                        columns: out.row.columns.into_iter().collect(),
7075                    }))
7076                } else {
7077                    tx.put(table, cells)?;
7078                    Ok(ProcedureCallOutput::Null)
7079                }
7080            }
7081            ProcedureStep::Upsert {
7082                table,
7083                cells,
7084                update_cells,
7085                returning,
7086                ..
7087            } => {
7088                let tx = tx.ok_or_else(|| {
7089                    MongrelError::InvalidArgument(
7090                        "write procedure step requires a transaction".into(),
7091                    )
7092                })?;
7093                let cells = eval_cells(cells, args, outputs)?;
7094                let action = match update_cells {
7095                    Some(update_cells) => {
7096                        crate::UpsertAction::DoUpdate(eval_cells(update_cells, args, outputs)?)
7097                    }
7098                    None => crate::UpsertAction::DoNothing,
7099                };
7100                let out = tx.upsert(table, cells, action)?;
7101                if *returning {
7102                    Ok(ProcedureCallOutput::Row(ProcedureCallRow {
7103                        row_id: None,
7104                        columns: out.row.columns.into_iter().collect(),
7105                    }))
7106                } else {
7107                    Ok(ProcedureCallOutput::Null)
7108                }
7109            }
7110            ProcedureStep::DeleteByPk { table, pk, .. } => {
7111                let tx = tx.ok_or_else(|| {
7112                    MongrelError::InvalidArgument(
7113                        "write procedure step requires a transaction".into(),
7114                    )
7115                })?;
7116                let pk = eval_value(pk, args, outputs)?;
7117                let handle = self.table(table)?;
7118                let row_id = handle.lock().lookup_pk(&pk.encode_key()).ok_or_else(|| {
7119                    MongrelError::NotFound("procedure delete_by_pk target not found".into())
7120                })?;
7121                tx.delete(table, row_id)?;
7122                Ok(ProcedureCallOutput::Scalar(crate::Value::Bool(true)))
7123            }
7124            ProcedureStep::DeleteRows { .. } => Err(MongrelError::InvalidArgument(
7125                "DeleteRows procedure step is not supported by the core executor yet".into(),
7126            )),
7127            ProcedureStep::SqlQuery { .. } => Err(MongrelError::InvalidArgument(
7128                "SqlQuery procedure step must be executed by mongreldb-query".into(),
7129            )),
7130        }
7131    }
7132
7133    fn validate_procedure_references(&self, procedure: &StoredProcedure) -> Result<()> {
7134        let cat = self.catalog.read();
7135        for step in &procedure.body.steps {
7136            let Some(table_name) = step.table() else {
7137                continue;
7138            };
7139            let schema = &cat
7140                .live(table_name)
7141                .ok_or_else(|| {
7142                    MongrelError::InvalidArgument(format!(
7143                        "procedure {:?} references unknown table {table_name:?}",
7144                        procedure.name
7145                    ))
7146                })?
7147                .schema;
7148            match step {
7149                ProcedureStep::NativeQuery {
7150                    conditions,
7151                    projection,
7152                    ..
7153                } => {
7154                    for condition in conditions {
7155                        validate_condition_columns(condition, schema)?;
7156                    }
7157                    if let Some(projection) = projection {
7158                        for id in projection {
7159                            validate_column_id(*id, schema)?;
7160                        }
7161                    }
7162                }
7163                ProcedureStep::Put { cells, .. } => {
7164                    for cell in cells {
7165                        validate_column_id(cell.column_id, schema)?;
7166                    }
7167                }
7168                ProcedureStep::Upsert {
7169                    cells,
7170                    update_cells,
7171                    ..
7172                } => {
7173                    for cell in cells {
7174                        validate_column_id(cell.column_id, schema)?;
7175                    }
7176                    if let Some(update_cells) = update_cells {
7177                        for cell in update_cells {
7178                            validate_column_id(cell.column_id, schema)?;
7179                        }
7180                    }
7181                }
7182                ProcedureStep::DeleteByPk { .. } => {
7183                    if schema.primary_key().is_none() {
7184                        return Err(MongrelError::InvalidArgument(format!(
7185                            "procedure {:?} references DeleteByPk on table {table_name:?} without a primary key",
7186                            procedure.name
7187                        )));
7188                    }
7189                }
7190                ProcedureStep::DeleteRows { .. } | ProcedureStep::SqlQuery { .. } => {}
7191            }
7192        }
7193        Ok(())
7194    }
7195
7196    fn validate_trigger_references(&self, trigger: &StoredTrigger) -> Result<()> {
7197        let cat = self.catalog.read();
7198        let target_schema = match &trigger.target {
7199            TriggerTarget::Table(target_name) => cat
7200                .live(target_name)
7201                .ok_or_else(|| {
7202                    MongrelError::InvalidArgument(format!(
7203                        "trigger {:?} references unknown target table {target_name:?}",
7204                        trigger.name
7205                    ))
7206                })?
7207                .schema
7208                .clone(),
7209            TriggerTarget::View(_) => Schema {
7210                columns: trigger.target_columns.clone(),
7211                ..Schema::default()
7212            },
7213        };
7214        for col in &trigger.update_of {
7215            if target_schema.column(col).is_none() {
7216                return Err(MongrelError::InvalidArgument(format!(
7217                    "trigger {:?} UPDATE OF references unknown column {col:?}",
7218                    trigger.name
7219                )));
7220            }
7221        }
7222        if let Some(expr) = &trigger.when {
7223            validate_trigger_expr(expr, &target_schema, trigger.event)?;
7224        }
7225        let mut select_schemas: HashMap<String, &Schema> = HashMap::new();
7226        for step in &trigger.program.steps {
7227            if matches!(step, TriggerStep::SetNew { .. }) && trigger.timing != TriggerTiming::Before
7228            {
7229                return Err(MongrelError::InvalidArgument(
7230                    "SetNew trigger steps are only valid in BEFORE triggers".into(),
7231                ));
7232            }
7233            validate_trigger_step(
7234                step,
7235                &cat,
7236                &target_schema,
7237                trigger.event,
7238                &mut select_schemas,
7239            )?;
7240        }
7241        Ok(())
7242    }
7243
7244    /// Begin a new transaction reading at the current visible epoch.
7245    pub fn begin(&self) -> crate::txn::Transaction<'_> {
7246        self.begin_with_isolation(crate::txn::IsolationLevel::default())
7247    }
7248
7249    fn transaction_principal_snapshot(&self) -> (Option<crate::auth::Principal>, bool) {
7250        let principal = self.principal.read().clone();
7251        let catalog_bound = principal.as_ref().is_some_and(|principal| {
7252            let catalog = self.catalog.read();
7253            catalog.require_auth || principal.user_id != 0
7254        });
7255        (principal, catalog_bound)
7256    }
7257
7258    pub fn begin_as(
7259        &self,
7260        principal: Option<crate::auth::Principal>,
7261    ) -> crate::txn::Transaction<'_> {
7262        let catalog_bound = principal.as_ref().is_some_and(|principal| {
7263            let catalog = self.catalog.read();
7264            catalog.require_auth || principal.user_id != 0
7265        });
7266        let txn_id = self.alloc_txn_id();
7267        let read = Snapshot::at(self.epoch.visible());
7268        crate::txn::Transaction::new(self, txn_id, read).with_principal(principal, catalog_bound)
7269    }
7270
7271    /// Begin a transaction with a specific isolation level.
7272    pub fn begin_with_isolation(
7273        &self,
7274        level: crate::txn::IsolationLevel,
7275    ) -> crate::txn::Transaction<'_> {
7276        let txn_id = self.alloc_txn_id();
7277        let epoch = match level {
7278            crate::txn::IsolationLevel::ReadCommitted => self.epoch.visible(),
7279            _ => self.epoch.visible(),
7280        };
7281        let read = Snapshot::at(epoch);
7282        let (principal, catalog_bound) = self.transaction_principal_snapshot();
7283        crate::txn::Transaction::new(self, txn_id, read).with_principal(principal, catalog_bound)
7284    }
7285
7286    /// Begin a transaction whose trigger programs may route external-table DML
7287    /// through an application/query-layer module bridge.
7288    pub fn begin_with_external_trigger_bridge<'a>(
7289        &'a self,
7290        bridge: &'a dyn ExternalTriggerBridge,
7291    ) -> crate::txn::Transaction<'a> {
7292        let txn_id = self.alloc_txn_id();
7293        let read = Snapshot::at(self.epoch.visible());
7294        let (principal, catalog_bound) = self.transaction_principal_snapshot();
7295        crate::txn::Transaction::new(self, txn_id, read)
7296            .with_external_trigger_bridge(bridge)
7297            .with_principal(principal, catalog_bound)
7298    }
7299
7300    pub fn begin_with_external_trigger_bridge_as<'a>(
7301        &'a self,
7302        bridge: &'a dyn ExternalTriggerBridge,
7303        principal: Option<crate::auth::Principal>,
7304    ) -> crate::txn::Transaction<'a> {
7305        let catalog_bound = principal.as_ref().is_some_and(|principal| {
7306            let catalog = self.catalog.read();
7307            catalog.require_auth || principal.user_id != 0
7308        });
7309        let txn_id = self.alloc_txn_id();
7310        let read = Snapshot::at(self.epoch.visible());
7311        crate::txn::Transaction::new(self, txn_id, read)
7312            .with_external_trigger_bridge(bridge)
7313            .with_principal(principal, catalog_bound)
7314    }
7315
7316    /// Run `f` in a transaction; commit on `Ok`, rollback on `Err`.
7317    pub fn transaction<T>(
7318        &self,
7319        f: impl FnOnce(&mut crate::txn::Transaction) -> Result<T>,
7320    ) -> Result<T> {
7321        let mut tx = self.begin();
7322        match f(&mut tx) {
7323            Ok(out) => {
7324                tx.commit()?;
7325                Ok(out)
7326            }
7327            Err(e) => {
7328                tx.rollback();
7329                Err(e)
7330            }
7331        }
7332    }
7333
7334    pub fn transaction_with_row_ids<T>(
7335        &self,
7336        f: impl FnOnce(&mut crate::txn::Transaction) -> Result<T>,
7337    ) -> Result<(T, Vec<RowId>)> {
7338        let mut tx = self.begin();
7339        match f(&mut tx) {
7340            Ok(output) => {
7341                let (_, row_ids) = tx.commit_with_row_ids()?;
7342                Ok((output, row_ids))
7343            }
7344            Err(error) => {
7345                tx.rollback();
7346                Err(error)
7347            }
7348        }
7349    }
7350
7351    pub fn transaction_for_current_principal<T>(
7352        &self,
7353        f: impl FnOnce(&mut crate::txn::Transaction) -> Result<T>,
7354    ) -> Result<T> {
7355        if self.principal.read().is_some() {
7356            self.refresh_principal()?;
7357        }
7358        let mut transaction = self.begin_as(self.principal.read().clone());
7359        match f(&mut transaction) {
7360            Ok(output) => {
7361                transaction.commit()?;
7362                Ok(output)
7363            }
7364            Err(error) => {
7365                transaction.rollback();
7366                Err(error)
7367            }
7368        }
7369    }
7370
7371    pub fn transaction_for_current_principal_with_epoch<T>(
7372        &self,
7373        f: impl FnOnce(&mut crate::txn::Transaction) -> Result<T>,
7374    ) -> Result<(Epoch, T)> {
7375        if self.principal.read().is_some() {
7376            self.refresh_principal()?;
7377        }
7378        let mut transaction = self.begin_as(self.principal.read().clone());
7379        match f(&mut transaction) {
7380            Ok(output) => {
7381                let epoch = transaction.commit()?;
7382                Ok((epoch, output))
7383            }
7384            Err(error) => {
7385                transaction.rollback();
7386                Err(error)
7387            }
7388        }
7389    }
7390
7391    pub fn transaction_with_row_ids_for_current_principal<T>(
7392        &self,
7393        f: impl FnOnce(&mut crate::txn::Transaction) -> Result<T>,
7394    ) -> Result<(T, Vec<RowId>)> {
7395        if self.principal.read().is_some() {
7396            self.refresh_principal()?;
7397        }
7398        let mut transaction = self.begin_as(self.principal.read().clone());
7399        match f(&mut transaction) {
7400            Ok(output) => {
7401                let (_, row_ids) = transaction.commit_with_row_ids()?;
7402                Ok((output, row_ids))
7403            }
7404            Err(error) => {
7405                transaction.rollback();
7406                Err(error)
7407            }
7408        }
7409    }
7410
7411    /// Run `f` in a transaction with an external-trigger bridge; commit on
7412    /// `Ok`, rollback on `Err`.
7413    pub fn transaction_with_external_trigger_bridge<'a, T>(
7414        &'a self,
7415        bridge: &'a dyn ExternalTriggerBridge,
7416        f: impl FnOnce(&mut crate::txn::Transaction) -> Result<T>,
7417    ) -> Result<T> {
7418        let mut tx = self.begin_with_external_trigger_bridge(bridge);
7419        match f(&mut tx) {
7420            Ok(out) => {
7421                tx.commit()?;
7422                Ok(out)
7423            }
7424            Err(e) => {
7425                tx.rollback();
7426                Err(e)
7427            }
7428        }
7429    }
7430
7431    pub fn transaction_with_external_trigger_bridge_as<'a, T>(
7432        &'a self,
7433        bridge: &'a dyn ExternalTriggerBridge,
7434        principal: Option<crate::auth::Principal>,
7435        f: impl FnOnce(&mut crate::txn::Transaction) -> Result<T>,
7436    ) -> Result<T> {
7437        let mut tx = self.begin_with_external_trigger_bridge_as(bridge, principal);
7438        match f(&mut tx) {
7439            Ok(output) => {
7440                tx.commit()?;
7441                Ok(output)
7442            }
7443            Err(error) => {
7444                tx.rollback();
7445                Err(error)
7446            }
7447        }
7448    }
7449
7450    /// Register a txn in `ActiveTxns` (spec §9.2, review fix #12). Called from
7451    /// `Transaction::new` so registration happens **before** any read.
7452    pub(crate) fn register_active(&self, epoch: Epoch) -> crate::txn::ActiveTxnGuard<'_> {
7453        self.active_txns.register(epoch)
7454    }
7455
7456    fn fill_auto_increment_for_staging(
7457        &self,
7458        staging: &mut [(u64, crate::txn::Staged)],
7459        control: Option<&crate::ExecutionControl>,
7460    ) -> Result<()> {
7461        let mut puts_by_table: HashMap<u64, Vec<usize>> = HashMap::new();
7462        for (index, (table_id, staged)) in staging.iter().enumerate() {
7463            commit_prepare_checkpoint(control, index)?;
7464            if matches!(staged, crate::txn::Staged::Put(_)) {
7465                puts_by_table.entry(*table_id).or_default().push(index);
7466            }
7467        }
7468
7469        let tables = self.tables.read();
7470        for (table_index, (table_id, indexes)) in puts_by_table.into_iter().enumerate() {
7471            commit_prepare_checkpoint(control, table_index)?;
7472            if let Some(handle) = tables.get(&table_id) {
7473                #[cfg(test)]
7474                AUTO_INCREMENT_TABLE_LOCKS.with(|count| count.set(count.get() + 1));
7475                let mut t = handle.lock();
7476                for (fill_index, index) in indexes.into_iter().enumerate() {
7477                    commit_prepare_checkpoint(control, fill_index)?;
7478                    if let crate::txn::Staged::Put(cells) = &mut staging[index].1 {
7479                        t.fill_auto_inc(cells)?;
7480                    }
7481                }
7482            }
7483        }
7484        Ok(())
7485    }
7486
7487    fn expand_table_triggers(
7488        &self,
7489        staging: &mut Vec<(u64, crate::txn::Staged)>,
7490        read_epoch: Epoch,
7491        external_trigger_bridge: Option<&dyn ExternalTriggerBridge>,
7492        external_states: &mut Vec<(String, Vec<u8>)>,
7493        control: Option<&crate::ExecutionControl>,
7494    ) -> Result<()> {
7495        commit_prepare_checkpoint(control, 0)?;
7496        let mut external_writes = Vec::new();
7497        let config = self.trigger_config();
7498        if config.recursive_triggers {
7499            let chunk = std::mem::take(staging);
7500            let stacks = vec![Vec::new(); chunk.len()];
7501            *staging = self.expand_trigger_chunk(
7502                chunk,
7503                stacks,
7504                read_epoch,
7505                0,
7506                config.max_depth,
7507                &mut external_writes,
7508                &config,
7509                control,
7510            )?;
7511            self.apply_external_trigger_writes(
7512                external_writes,
7513                external_trigger_bridge,
7514                external_states,
7515                staging,
7516                control,
7517            )?;
7518            return Ok(());
7519        }
7520
7521        let mut expansion =
7522            self.expand_table_triggers_once(staging, read_epoch, None, &config, control)?;
7523        if !expansion.before.is_empty() {
7524            let mut final_staging = expansion.before;
7525            final_staging.extend(filter_ignored_staging(
7526                std::mem::take(staging),
7527                &expansion.ignored_indices,
7528            ));
7529            *staging = final_staging;
7530        } else if !expansion.ignored_indices.is_empty() {
7531            *staging = filter_ignored_staging(std::mem::take(staging), &expansion.ignored_indices);
7532        }
7533        staging.append(&mut expansion.after);
7534        external_writes.append(&mut expansion.before_external);
7535        external_writes.append(&mut expansion.after_external);
7536        self.apply_external_trigger_writes(
7537            external_writes,
7538            external_trigger_bridge,
7539            external_states,
7540            staging,
7541            control,
7542        )?;
7543        Ok(())
7544    }
7545
7546    #[allow(clippy::too_many_arguments)]
7547    fn expand_trigger_chunk(
7548        &self,
7549        mut chunk: Vec<(u64, crate::txn::Staged)>,
7550        stacks: Vec<Vec<String>>,
7551        read_epoch: Epoch,
7552        depth: u32,
7553        max_depth: u32,
7554        external_writes: &mut Vec<ExternalTriggerWrite>,
7555        config: &TriggerConfig,
7556        control: Option<&crate::ExecutionControl>,
7557    ) -> Result<Vec<(u64, crate::txn::Staged)>> {
7558        if chunk.is_empty() {
7559            return Ok(Vec::new());
7560        }
7561        commit_prepare_checkpoint(control, 0)?;
7562        self.fill_auto_increment_for_staging(&mut chunk, control)?;
7563        let expansion = self.expand_table_triggers_once(
7564            &mut chunk,
7565            read_epoch,
7566            Some(&stacks),
7567            config,
7568            control,
7569        )?;
7570        if depth >= max_depth && (!expansion.before.is_empty() || !expansion.after.is_empty()) {
7571            let stack = expansion
7572                .before_stacks
7573                .first()
7574                .or_else(|| expansion.after_stacks.first())
7575                .cloned()
7576                .unwrap_or_default();
7577            return Err(MongrelError::TriggerValidation(format!(
7578                "trigger recursion exceeded max depth {max_depth}; trigger stack: {}",
7579                Self::format_trigger_stack(&stack)
7580            )));
7581        }
7582
7583        let mut out = Vec::new();
7584        external_writes.extend(expansion.before_external);
7585        out.extend(self.expand_trigger_chunk(
7586            expansion.before,
7587            expansion.before_stacks,
7588            read_epoch,
7589            depth + 1,
7590            max_depth,
7591            external_writes,
7592            config,
7593            control,
7594        )?);
7595        out.extend(filter_ignored_staging(chunk, &expansion.ignored_indices));
7596        external_writes.extend(expansion.after_external);
7597        out.extend(self.expand_trigger_chunk(
7598            expansion.after,
7599            expansion.after_stacks,
7600            read_epoch,
7601            depth + 1,
7602            max_depth,
7603            external_writes,
7604            config,
7605            control,
7606        )?);
7607        Ok(out)
7608    }
7609
7610    fn apply_external_trigger_writes(
7611        &self,
7612        writes: Vec<ExternalTriggerWrite>,
7613        bridge: Option<&dyn ExternalTriggerBridge>,
7614        external_states: &mut Vec<(String, Vec<u8>)>,
7615        staging: &mut Vec<(u64, crate::txn::Staged)>,
7616        control: Option<&crate::ExecutionControl>,
7617    ) -> Result<()> {
7618        if writes.is_empty() {
7619            return Ok(());
7620        }
7621        let bridge = bridge.ok_or_else(|| {
7622            MongrelError::TriggerValidation(
7623                "trigger program wrote an external table, but this transaction has no external trigger bridge".into(),
7624            )
7625        })?;
7626        for (write_index, write) in writes.into_iter().enumerate() {
7627            commit_prepare_checkpoint(control, write_index)?;
7628            let table = write.table().to_string();
7629            let entry = self.external_table(&table).ok_or_else(|| {
7630                MongrelError::NotFound(format!("external table {table:?} not found"))
7631            })?;
7632            let base_state = current_external_state_bytes(&self.root, external_states, &table)?;
7633            let result = bridge.apply_trigger_external_write(&entry, base_state, write)?;
7634            external_states.push((table, result.state));
7635            for (base_index, base_write) in result.base_writes.into_iter().enumerate() {
7636                commit_prepare_checkpoint(control, base_index)?;
7637                match base_write {
7638                    ExternalTriggerBaseWrite::Put { table, cells } => {
7639                        let table_id = self.table_id(&table)?;
7640                        staging.push((table_id, crate::txn::Staged::Put(cells)));
7641                    }
7642                    ExternalTriggerBaseWrite::Delete { table, row_id } => {
7643                        let table_id = self.table_id(&table)?;
7644                        staging.push((table_id, crate::txn::Staged::Delete(row_id)));
7645                    }
7646                }
7647            }
7648        }
7649        dedup_external_states_in_place(external_states);
7650        Ok(())
7651    }
7652
7653    fn expand_table_triggers_once(
7654        &self,
7655        staging: &mut Vec<(u64, crate::txn::Staged)>,
7656        read_epoch: Epoch,
7657        trigger_stacks: Option<&[Vec<String>]>,
7658        config: &TriggerConfig,
7659        control: Option<&crate::ExecutionControl>,
7660    ) -> Result<TriggerExpansion> {
7661        commit_prepare_checkpoint(control, 0)?;
7662        let triggers: Vec<StoredTrigger> = self
7663            .catalog
7664            .read()
7665            .triggers
7666            .iter()
7667            .filter(|entry| {
7668                entry.trigger.enabled
7669                    && matches!(
7670                        entry.trigger.timing,
7671                        TriggerTiming::Before | TriggerTiming::After
7672                    )
7673                    && matches!(entry.trigger.target, TriggerTarget::Table(_))
7674            })
7675            .map(|entry| entry.trigger.clone())
7676            .collect();
7677        if triggers.is_empty() || staging.is_empty() {
7678            return Ok(TriggerExpansion::default());
7679        }
7680
7681        let before_triggers = triggers
7682            .iter()
7683            .filter(|trigger| trigger.timing == TriggerTiming::Before)
7684            .cloned()
7685            .collect::<Vec<_>>();
7686        let after_triggers = triggers
7687            .iter()
7688            .filter(|trigger| trigger.timing == TriggerTiming::After)
7689            .cloned()
7690            .collect::<Vec<_>>();
7691
7692        let mut before_added = Vec::new();
7693        let mut before_stacks = Vec::new();
7694        let mut before_external = Vec::new();
7695        let mut ignored_indices = std::collections::BTreeSet::new();
7696        if !before_triggers.is_empty() {
7697            let before_events =
7698                self.trigger_events_for_staging(staging, read_epoch, trigger_stacks, control)?;
7699            let mut out = TriggerProgramOutput {
7700                added: &mut before_added,
7701                added_stacks: &mut before_stacks,
7702                added_external: &mut before_external,
7703                ignored_indices: &mut ignored_indices,
7704            };
7705            self.execute_triggers_for_events(
7706                &before_triggers,
7707                &before_events,
7708                Some(staging),
7709                &mut out,
7710                config,
7711                read_epoch,
7712                control,
7713            )?;
7714        }
7715
7716        let after_events = if after_triggers.is_empty() {
7717            Vec::new()
7718        } else {
7719            self.trigger_events_for_staging(staging, read_epoch, trigger_stacks, control)?
7720                .into_iter()
7721                .filter(|event| {
7722                    !event
7723                        .op_indices
7724                        .iter()
7725                        .any(|idx| ignored_indices.contains(idx))
7726                })
7727                .collect()
7728        };
7729
7730        let mut after_added = Vec::new();
7731        let mut after_stacks = Vec::new();
7732        let mut after_external = Vec::new();
7733        let mut out = TriggerProgramOutput {
7734            added: &mut after_added,
7735            added_stacks: &mut after_stacks,
7736            added_external: &mut after_external,
7737            ignored_indices: &mut ignored_indices,
7738        };
7739        self.execute_triggers_for_events(
7740            &after_triggers,
7741            &after_events,
7742            None,
7743            &mut out,
7744            config,
7745            read_epoch,
7746            control,
7747        )?;
7748        Ok(TriggerExpansion {
7749            before: before_added,
7750            before_stacks,
7751            before_external,
7752            after: after_added,
7753            after_stacks,
7754            after_external,
7755            ignored_indices,
7756        })
7757    }
7758
7759    #[allow(clippy::too_many_arguments)]
7760    fn execute_triggers_for_events(
7761        &self,
7762        triggers: &[StoredTrigger],
7763        events: &[WriteEvent],
7764        mut staging: Option<&mut Vec<(u64, crate::txn::Staged)>>,
7765        out: &mut TriggerProgramOutput<'_>,
7766        config: &TriggerConfig,
7767        read_epoch: Epoch,
7768        control: Option<&crate::ExecutionControl>,
7769    ) -> Result<()> {
7770        let mut checkpoint_index = 0_usize;
7771        for event in events {
7772            for trigger in triggers {
7773                commit_prepare_checkpoint(control, checkpoint_index)?;
7774                checkpoint_index += 1;
7775                if event
7776                    .op_indices
7777                    .iter()
7778                    .any(|idx| out.ignored_indices.contains(idx))
7779                {
7780                    break;
7781                }
7782                let matches = {
7783                    let cat = self.catalog.read();
7784                    trigger_matches_event(trigger, event, &cat)?
7785                };
7786                if !matches {
7787                    continue;
7788                }
7789                if let Some(when) = &trigger.when {
7790                    if !eval_trigger_expr(when, event)? {
7791                        continue;
7792                    }
7793                }
7794                let trigger_stack = Self::trigger_stack_with(&event.trigger_stack, &trigger.name);
7795                if event.trigger_stack.iter().any(|name| name == &trigger.name) {
7796                    return Err(MongrelError::TriggerValidation(format!(
7797                        "trigger recursion cycle detected; trigger stack: {}",
7798                        Self::format_trigger_stack(&trigger_stack)
7799                    )));
7800                }
7801                let outcome = match staging.as_mut() {
7802                    Some(staging) => self.execute_trigger_program(
7803                        trigger,
7804                        event,
7805                        Some(&mut **staging),
7806                        out,
7807                        &trigger_stack,
7808                        config,
7809                        read_epoch,
7810                        control,
7811                    )?,
7812                    None => self.execute_trigger_program(
7813                        trigger,
7814                        event,
7815                        None,
7816                        out,
7817                        &trigger_stack,
7818                        config,
7819                        read_epoch,
7820                        control,
7821                    )?,
7822                };
7823                if outcome == TriggerProgramOutcome::Ignore {
7824                    out.ignored_indices.extend(event.op_indices.iter().copied());
7825                    break;
7826                }
7827            }
7828        }
7829        Ok(())
7830    }
7831
7832    fn trigger_events_for_staging(
7833        &self,
7834        staging: &[(u64, crate::txn::Staged)],
7835        read_epoch: Epoch,
7836        trigger_stacks: Option<&[Vec<String>]>,
7837        control: Option<&crate::ExecutionControl>,
7838    ) -> Result<Vec<WriteEvent>> {
7839        use crate::txn::Staged;
7840        use std::collections::{HashMap, VecDeque};
7841
7842        let snapshot = Snapshot::at(read_epoch);
7843        let cat = self.catalog.read();
7844        let mut table_names = HashMap::new();
7845        let mut table_schemas = HashMap::new();
7846        for entry in cat
7847            .tables
7848            .iter()
7849            .filter(|entry| matches!(entry.state, TableState::Live))
7850        {
7851            table_names.insert(entry.table_id, entry.name.clone());
7852            table_schemas.insert(entry.table_id, entry.schema.clone());
7853        }
7854        drop(cat);
7855
7856        let mut old_rows: HashMap<usize, TriggerRowImage> = HashMap::new();
7857        let mut delete_by_key: HashMap<(u64, Vec<u8>), VecDeque<usize>> = HashMap::new();
7858        let mut put_by_key: HashMap<(u64, Vec<u8>), VecDeque<usize>> = HashMap::new();
7859
7860        for (idx, (table_id, staged)) in staging.iter().enumerate() {
7861            commit_prepare_checkpoint(control, idx)?;
7862            let Some(schema) = table_schemas.get(table_id) else {
7863                continue;
7864            };
7865            let Some(pk) = schema.primary_key() else {
7866                continue;
7867            };
7868            match staged {
7869                Staged::Delete(row_id) => {
7870                    let handle = self.table_by_id(*table_id)?;
7871                    let Some(row) = handle.lock().get(*row_id, snapshot) else {
7872                        continue;
7873                    };
7874                    let Some(pk_value) = row.columns.get(&pk.id) else {
7875                        continue;
7876                    };
7877                    old_rows.insert(idx, TriggerRowImage::from_row(row.clone()));
7878                    delete_by_key
7879                        .entry((*table_id, pk_value.encode_key()))
7880                        .or_default()
7881                        .push_back(idx);
7882                }
7883                Staged::Put(cells) => {
7884                    if let Some((_, value)) = cells.iter().find(|(id, _)| *id == pk.id) {
7885                        put_by_key
7886                            .entry((*table_id, value.encode_key()))
7887                            .or_default()
7888                            .push_back(idx);
7889                    }
7890                }
7891                Staged::Update { row_id, .. } => {
7892                    let handle = self.table_by_id(*table_id)?;
7893                    let row = handle.lock().get(*row_id, snapshot);
7894                    if let Some(row) = row {
7895                        old_rows.insert(idx, TriggerRowImage::from_row(row));
7896                    }
7897                }
7898                Staged::Truncate => {}
7899            }
7900        }
7901
7902        let mut paired_delete = std::collections::HashSet::new();
7903        let mut paired_put = std::collections::HashSet::new();
7904        let mut events = Vec::new();
7905
7906        for (pair_index, (key, deletes)) in delete_by_key.iter_mut().enumerate() {
7907            commit_prepare_checkpoint(control, pair_index)?;
7908            let Some(puts) = put_by_key.get_mut(key) else {
7909                continue;
7910            };
7911            while let (Some(delete_idx), Some(put_idx)) = (deletes.pop_front(), puts.pop_front()) {
7912                paired_delete.insert(delete_idx);
7913                paired_put.insert(put_idx);
7914                let (table_id, _) = &staging[put_idx];
7915                let Some(table_name) = table_names.get(table_id).cloned() else {
7916                    continue;
7917                };
7918                let old = old_rows.get(&delete_idx).cloned();
7919                let new = match &staging[put_idx].1 {
7920                    Staged::Put(cells) => Some(TriggerRowImage::from_cells(cells)),
7921                    _ => None,
7922                };
7923                let changed_columns = changed_columns(old.as_ref(), new.as_ref());
7924                events.push(WriteEvent {
7925                    table: table_name,
7926                    kind: TriggerEvent::Update,
7927                    old,
7928                    new,
7929                    changed_columns,
7930                    op_indices: vec![delete_idx, put_idx],
7931                    put_idx: Some(put_idx),
7932                    trigger_stack: Self::trigger_stack_for_indices(
7933                        trigger_stacks,
7934                        &[delete_idx, put_idx],
7935                    ),
7936                });
7937            }
7938        }
7939
7940        for (idx, (table_id, staged)) in staging.iter().enumerate() {
7941            commit_prepare_checkpoint(control, idx)?;
7942            let Some(table_name) = table_names.get(table_id).cloned() else {
7943                continue;
7944            };
7945            match staged {
7946                Staged::Put(cells) if !paired_put.contains(&idx) => {
7947                    let new = Some(TriggerRowImage::from_cells(cells));
7948                    let changed_columns = cells.iter().map(|(id, _)| *id).collect();
7949                    events.push(WriteEvent {
7950                        table: table_name,
7951                        kind: TriggerEvent::Insert,
7952                        old: None,
7953                        new,
7954                        changed_columns,
7955                        op_indices: vec![idx],
7956                        put_idx: Some(idx),
7957                        trigger_stack: Self::trigger_stack_for_indices(trigger_stacks, &[idx]),
7958                    });
7959                }
7960                Staged::Delete(row_id) if !paired_delete.contains(&idx) => {
7961                    let old = match old_rows.get(&idx).cloned() {
7962                        Some(old) => Some(old),
7963                        None => {
7964                            let handle = self.table_by_id(*table_id)?;
7965                            let row = handle.lock().get(*row_id, snapshot);
7966                            row.map(TriggerRowImage::from_row)
7967                        }
7968                    };
7969                    let Some(old) = old else {
7970                        continue;
7971                    };
7972                    let changed_columns = old.columns.keys().copied().collect();
7973                    events.push(WriteEvent {
7974                        table: table_name,
7975                        kind: TriggerEvent::Delete,
7976                        old: Some(old),
7977                        new: None,
7978                        changed_columns,
7979                        op_indices: vec![idx],
7980                        put_idx: None,
7981                        trigger_stack: Self::trigger_stack_for_indices(trigger_stacks, &[idx]),
7982                    });
7983                }
7984                Staged::Update { new_row: cells, .. } => {
7985                    let old = old_rows.get(&idx).cloned();
7986                    let new = Some(TriggerRowImage::from_cells(cells));
7987                    let changed_columns = changed_columns(old.as_ref(), new.as_ref());
7988                    events.push(WriteEvent {
7989                        table: table_name,
7990                        kind: TriggerEvent::Update,
7991                        old,
7992                        new,
7993                        changed_columns,
7994                        op_indices: vec![idx],
7995                        put_idx: Some(idx),
7996                        trigger_stack: Self::trigger_stack_for_indices(trigger_stacks, &[idx]),
7997                    });
7998                }
7999                Staged::Truncate => {}
8000                _ => {}
8001            }
8002        }
8003
8004        Ok(events)
8005    }
8006
8007    #[allow(clippy::too_many_arguments)]
8008    fn execute_trigger_program(
8009        &self,
8010        trigger: &StoredTrigger,
8011        event: &WriteEvent,
8012        staging: Option<&mut Vec<(u64, crate::txn::Staged)>>,
8013        out: &mut TriggerProgramOutput<'_>,
8014        trigger_stack: &[String],
8015        config: &TriggerConfig,
8016        read_epoch: Epoch,
8017        control: Option<&crate::ExecutionControl>,
8018    ) -> Result<TriggerProgramOutcome> {
8019        let mut event = event.clone();
8020        let mut select_results: HashMap<String, Vec<TriggerRowImage>> = HashMap::new();
8021        self.execute_trigger_steps(
8022            trigger,
8023            &trigger.program.steps,
8024            &mut event,
8025            staging,
8026            out,
8027            trigger_stack,
8028            config,
8029            &mut select_results,
8030            0,
8031            None,
8032            read_epoch,
8033            control,
8034        )
8035    }
8036
8037    #[allow(clippy::too_many_arguments)]
8038    fn execute_trigger_steps(
8039        &self,
8040        trigger: &StoredTrigger,
8041        steps: &[TriggerStep],
8042        event: &mut WriteEvent,
8043        mut staging: Option<&mut Vec<(u64, crate::txn::Staged)>>,
8044        out: &mut TriggerProgramOutput<'_>,
8045        trigger_stack: &[String],
8046        config: &TriggerConfig,
8047        select_results: &mut HashMap<String, Vec<TriggerRowImage>>,
8048        depth: u32,
8049        selected: Option<&TriggerRowImage>,
8050        read_epoch: Epoch,
8051        control: Option<&crate::ExecutionControl>,
8052    ) -> Result<TriggerProgramOutcome> {
8053        let _ = depth;
8054        for (step_index, step) in steps.iter().enumerate() {
8055            commit_prepare_checkpoint(control, step_index)?;
8056            match step {
8057                TriggerStep::SetNew { cells } => {
8058                    if trigger.timing != TriggerTiming::Before {
8059                        return Err(MongrelError::InvalidArgument(
8060                            "SetNew trigger step is only valid in BEFORE triggers".into(),
8061                        ));
8062                    }
8063                    let put_idx = event.put_idx.ok_or_else(|| {
8064                        MongrelError::InvalidArgument(
8065                            "SetNew trigger step requires INSERT or UPDATE NEW row".into(),
8066                        )
8067                    })?;
8068                    let staging = staging.as_deref_mut().ok_or_else(|| {
8069                        MongrelError::InvalidArgument(
8070                            "SetNew trigger step requires mutable trigger staging".into(),
8071                        )
8072                    })?;
8073                    let mut update_changed_columns = None;
8074                    let row_cells = match staging.get_mut(put_idx).map(|(_, op)| op) {
8075                        Some(crate::txn::Staged::Put(cells)) => cells,
8076                        Some(crate::txn::Staged::Update {
8077                            new_row,
8078                            changed_columns,
8079                            ..
8080                        }) => {
8081                            update_changed_columns = Some(changed_columns);
8082                            new_row
8083                        }
8084                        _ => {
8085                            return Err(MongrelError::InvalidArgument(
8086                                "SetNew trigger step target row is not mutable".into(),
8087                            ))
8088                        }
8089                    };
8090                    for (column_id, value) in eval_trigger_cells(cells, event, selected)? {
8091                        row_cells.retain(|(id, _)| *id != column_id);
8092                        row_cells.push((column_id, value.clone()));
8093                        if let Some(changed_columns) = &mut update_changed_columns {
8094                            changed_columns.push(column_id);
8095                        }
8096                        if let Some(new) = &mut event.new {
8097                            new.columns.insert(column_id, value);
8098                        }
8099                    }
8100                    row_cells.sort_by_key(|(id, _)| *id);
8101                    if let Some(changed_columns) = update_changed_columns {
8102                        changed_columns.sort_unstable();
8103                        changed_columns.dedup();
8104                    }
8105                }
8106                TriggerStep::Insert { table, cells } => {
8107                    let cells = eval_trigger_cells(cells, event, selected)?;
8108                    if let Ok(table_id) = self.table_id(table) {
8109                        out.added.push((table_id, crate::txn::Staged::Put(cells)));
8110                        out.added_stacks.push(trigger_stack.to_vec());
8111                    } else if self.external_table(table).is_some() {
8112                        out.added_external.push(ExternalTriggerWrite::Insert {
8113                            table: table.clone(),
8114                            cells,
8115                        });
8116                    } else {
8117                        return Err(MongrelError::NotFound(format!(
8118                            "trigger {:?} insert target {table:?} not found",
8119                            trigger.name
8120                        )));
8121                    }
8122                }
8123                TriggerStep::UpdateByPk { table, pk, cells } => {
8124                    let pk = eval_trigger_value(pk, event, selected)?;
8125                    let cells = eval_trigger_cells(cells, event, selected)?;
8126                    if self.external_table(table).is_some() {
8127                        out.added_external.push(ExternalTriggerWrite::UpdateByPk {
8128                            table: table.clone(),
8129                            pk,
8130                            cells,
8131                        });
8132                    } else {
8133                        let row_id = self
8134                            .table(table)?
8135                            .lock()
8136                            .lookup_pk(&pk.encode_key())
8137                            .ok_or_else(|| {
8138                                MongrelError::NotFound(format!(
8139                                    "trigger {:?} update target not found",
8140                                    trigger.name
8141                                ))
8142                            })?;
8143                        let handle = self.table(table)?;
8144                        let snapshot = Snapshot::at(self.epoch.visible());
8145                        let old = handle.lock().get(row_id, snapshot).ok_or_else(|| {
8146                            MongrelError::NotFound(format!(
8147                                "trigger {:?} update target not visible",
8148                                trigger.name
8149                            ))
8150                        })?;
8151                        let mut changed_columns = cells
8152                            .iter()
8153                            .map(|(column_id, _)| *column_id)
8154                            .collect::<Vec<_>>();
8155                        changed_columns.sort_unstable();
8156                        changed_columns.dedup();
8157                        let mut merged = old.columns;
8158                        for (column_id, value) in cells {
8159                            merged.insert(column_id, value);
8160                        }
8161                        out.added.push((
8162                            self.table_id(table)?,
8163                            crate::txn::Staged::Update {
8164                                row_id,
8165                                new_row: merged.into_iter().collect(),
8166                                changed_columns,
8167                            },
8168                        ));
8169                        out.added_stacks.push(trigger_stack.to_vec());
8170                    }
8171                }
8172                TriggerStep::DeleteByPk { table, pk } => {
8173                    let pk = eval_trigger_value(pk, event, selected)?;
8174                    if self.external_table(table).is_some() {
8175                        out.added_external.push(ExternalTriggerWrite::DeleteByPk {
8176                            table: table.clone(),
8177                            pk,
8178                        });
8179                    } else {
8180                        let row_id = self
8181                            .table(table)?
8182                            .lock()
8183                            .lookup_pk(&pk.encode_key())
8184                            .ok_or_else(|| {
8185                                MongrelError::NotFound(format!(
8186                                    "trigger {:?} delete target not found",
8187                                    trigger.name
8188                                ))
8189                            })?;
8190                        out.added
8191                            .push((self.table_id(table)?, crate::txn::Staged::Delete(row_id)));
8192                        out.added_stacks.push(trigger_stack.to_vec());
8193                    }
8194                }
8195                TriggerStep::Select {
8196                    id,
8197                    table,
8198                    conditions,
8199                } => {
8200                    let schema = self.table(table)?.lock().schema().clone();
8201                    let snapshot = Snapshot::at(read_epoch);
8202                    let handle = self.table(table)?;
8203                    let rows = match control {
8204                        Some(control) => {
8205                            handle.lock().visible_rows_controlled(snapshot, control)?
8206                        }
8207                        None => handle.lock().visible_rows(snapshot)?,
8208                    };
8209                    let mut matched = Vec::new();
8210                    for (row_index, row) in rows.into_iter().enumerate() {
8211                        commit_prepare_checkpoint(control, row_index)?;
8212                        let image = TriggerRowImage::from_row(row);
8213                        let passes = conditions
8214                            .iter()
8215                            .map(|cond| eval_trigger_condition(cond, event, &image, &schema))
8216                            .collect::<Result<Vec<_>>>()?
8217                            .into_iter()
8218                            .all(|b| b);
8219                        if passes {
8220                            matched.push(image);
8221                        }
8222                    }
8223                    if let Some(pk) = schema.primary_key() {
8224                        matched.sort_by(|a, b| {
8225                            let av = a.columns.get(&pk.id).unwrap_or(&Value::Null);
8226                            let bv = b.columns.get(&pk.id).unwrap_or(&Value::Null);
8227                            value_order(av, bv).unwrap_or(std::cmp::Ordering::Equal)
8228                        });
8229                    }
8230                    select_results.insert(id.clone(), matched);
8231                }
8232                TriggerStep::Foreach { id, steps } => {
8233                    let rows = select_results.get(id).ok_or_else(|| {
8234                        MongrelError::InvalidArgument(format!(
8235                            "trigger {:?} foreach references unknown select id {id:?}",
8236                            trigger.name
8237                        ))
8238                    })?;
8239                    if rows.len() > config.max_loop_iterations as usize {
8240                        return Err(MongrelError::InvalidArgument(format!(
8241                            "trigger {:?} foreach exceeded max_loop_iterations ({})",
8242                            trigger.name, config.max_loop_iterations
8243                        )));
8244                    }
8245                    for (row_index, row) in rows.clone().into_iter().enumerate() {
8246                        commit_prepare_checkpoint(control, row_index)?;
8247                        let result = self.execute_trigger_steps(
8248                            trigger,
8249                            steps,
8250                            event,
8251                            staging.as_deref_mut(),
8252                            out,
8253                            trigger_stack,
8254                            config,
8255                            select_results,
8256                            depth + 1,
8257                            Some(&row),
8258                            read_epoch,
8259                            control,
8260                        )?;
8261                        if result == TriggerProgramOutcome::Ignore {
8262                            return Ok(TriggerProgramOutcome::Ignore);
8263                        }
8264                    }
8265                }
8266                TriggerStep::DeleteWhere { table, conditions } => {
8267                    let schema = self.table(table)?.lock().schema().clone();
8268                    let snapshot = Snapshot::at(read_epoch);
8269                    let handle = self.table(table)?;
8270                    let rows = match control {
8271                        Some(control) => {
8272                            handle.lock().visible_rows_controlled(snapshot, control)?
8273                        }
8274                        None => handle.lock().visible_rows(snapshot)?,
8275                    };
8276                    let table_id = self.table_id(table)?;
8277                    let mut to_delete = Vec::new();
8278                    for (row_index, row) in rows.into_iter().enumerate() {
8279                        commit_prepare_checkpoint(control, row_index)?;
8280                        let image = TriggerRowImage::from_row(row.clone());
8281                        let passes = conditions
8282                            .iter()
8283                            .map(|cond| eval_trigger_condition(cond, event, &image, &schema))
8284                            .collect::<Result<Vec<_>>>()?
8285                            .into_iter()
8286                            .all(|b| b);
8287                        if passes {
8288                            to_delete.push((table_id, row.row_id));
8289                        }
8290                    }
8291                    for (row_index, (table_id, row_id)) in to_delete.into_iter().enumerate() {
8292                        commit_prepare_checkpoint(control, row_index)?;
8293                        out.added
8294                            .push((table_id, crate::txn::Staged::Delete(row_id)));
8295                        out.added_stacks.push(trigger_stack.to_vec());
8296                    }
8297                }
8298                TriggerStep::UpdateWhere {
8299                    table,
8300                    conditions,
8301                    cells,
8302                } => {
8303                    let schema = self.table(table)?.lock().schema().clone();
8304                    let snapshot = Snapshot::at(read_epoch);
8305                    let handle = self.table(table)?;
8306                    let rows = match control {
8307                        Some(control) => {
8308                            handle.lock().visible_rows_controlled(snapshot, control)?
8309                        }
8310                        None => handle.lock().visible_rows(snapshot)?,
8311                    };
8312                    let table_id = self.table_id(table)?;
8313                    let mut changed_columns =
8314                        cells.iter().map(|cell| cell.column_id).collect::<Vec<_>>();
8315                    changed_columns.sort_unstable();
8316                    changed_columns.dedup();
8317                    let mut to_update = Vec::new();
8318                    for (row_index, row) in rows.into_iter().enumerate() {
8319                        commit_prepare_checkpoint(control, row_index)?;
8320                        let image = TriggerRowImage::from_row(row.clone());
8321                        let passes = conditions
8322                            .iter()
8323                            .map(|cond| eval_trigger_condition(cond, event, &image, &schema))
8324                            .collect::<Result<Vec<_>>>()?
8325                            .into_iter()
8326                            .all(|b| b);
8327                        if passes {
8328                            let new_cells = cells
8329                                .iter()
8330                                .map(|cell| {
8331                                    Ok((
8332                                        cell.column_id,
8333                                        eval_trigger_value(&cell.value, event, Some(&image))?,
8334                                    ))
8335                                })
8336                                .collect::<Result<Vec<_>>>()?;
8337                            let mut merged = row.columns.clone();
8338                            for (column_id, value) in new_cells {
8339                                merged.insert(column_id, value);
8340                            }
8341                            to_update.push((table_id, row.row_id, merged));
8342                        }
8343                    }
8344                    for (row_index, (table_id, row_id, merged)) in to_update.into_iter().enumerate()
8345                    {
8346                        commit_prepare_checkpoint(control, row_index)?;
8347                        out.added.push((
8348                            table_id,
8349                            crate::txn::Staged::Update {
8350                                row_id,
8351                                new_row: merged.into_iter().collect(),
8352                                changed_columns: changed_columns.clone(),
8353                            },
8354                        ));
8355                        out.added_stacks.push(trigger_stack.to_vec());
8356                    }
8357                }
8358                TriggerStep::Raise { action, message } => match action {
8359                    TriggerRaiseAction::Ignore => return Ok(TriggerProgramOutcome::Ignore),
8360                    TriggerRaiseAction::Abort
8361                    | TriggerRaiseAction::Fail
8362                    | TriggerRaiseAction::Rollback => {
8363                        let message = eval_trigger_value(message, event, selected)?;
8364                        return Err(MongrelError::TriggerValidation(format!(
8365                            "trigger {:?} raised: {}; trigger stack: {}",
8366                            trigger.name,
8367                            trigger_message(message),
8368                            Self::format_trigger_stack(trigger_stack)
8369                        )));
8370                    }
8371                },
8372            }
8373        }
8374        Ok(TriggerProgramOutcome::Continue)
8375    }
8376
8377    fn trigger_stack_for_indices(stacks: Option<&[Vec<String>]>, indices: &[usize]) -> Vec<String> {
8378        let Some(stacks) = stacks else {
8379            return Vec::new();
8380        };
8381        let mut out = Vec::new();
8382        for idx in indices {
8383            let Some(stack) = stacks.get(*idx) else {
8384                continue;
8385            };
8386            for name in stack {
8387                if !out.iter().any(|existing| existing == name) {
8388                    out.push(name.clone());
8389                }
8390            }
8391        }
8392        out
8393    }
8394
8395    fn trigger_stack_with(stack: &[String], trigger_name: &str) -> Vec<String> {
8396        let mut out = stack.to_vec();
8397        out.push(trigger_name.to_string());
8398        out
8399    }
8400
8401    fn format_trigger_stack(stack: &[String]) -> String {
8402        if stack.is_empty() {
8403            "<root>".into()
8404        } else {
8405            stack.join(" -> ")
8406        }
8407    }
8408
8409    /// Authoritatively validate every declared constraint on the staged write
8410    /// set under the transaction's read snapshot, AND expand ON DELETE CASCADE /
8411    /// SET NULL actions into explicit child ops. Called from
8412    /// [`Self::commit_transaction`] outside the WAL mutex. Returns the first
8413    /// violation as an `Err`, aborting the commit atomically. This is the
8414    /// server-side authority point: concurrent remote writers that each pass
8415    /// their own client-side checks still cannot both commit a violating batch.
8416    ///
8417    /// Scope: CHECK (full, three-valued), UNIQUE beyond the PK (existence scan +
8418    /// intra-transaction dedup; concurrent-txn races are additionally caught by
8419    /// `WriteKey::Unique`), and FK insert-side parent existence + ON DELETE
8420    /// {RESTRICT, CASCADE, SET NULL}. CASCADE appends child deletes (transitive
8421    /// fixpoint); SET NULL appends child updates (FK columns nulled). Truncate is
8422    /// RESTRICT-only (cascade-truncate is unsupported).
8423    fn validate_constraints(
8424        &self,
8425        staging: &mut Vec<(u64, crate::txn::Staged)>,
8426        read_epoch: Epoch,
8427        control: Option<&crate::ExecutionControl>,
8428    ) -> Result<()> {
8429        use crate::constraint::{encode_composite_key, validate_checks, FkAction};
8430        use crate::memtable::Row;
8431        use crate::txn::Staged;
8432        use std::collections::HashSet;
8433
8434        commit_prepare_checkpoint(control, 0)?;
8435        let snapshot = Snapshot::at(read_epoch);
8436        let cat = self.catalog.read();
8437
8438        // Collect live (id, name, constraints-bearing?) for staged tables.
8439        let live: Vec<(u64, &str, &crate::schema::Schema)> = cat
8440            .tables
8441            .iter()
8442            .filter(|entry| matches!(entry.state, TableState::Live | TableState::Building { .. }))
8443            .map(|e| (e.table_id, e.name.as_str(), &e.schema))
8444            .collect();
8445
8446        // Fast path: bail if no live table declares any constraints at all.
8447        let any_constraints = live.iter().any(|(_, _, s)| !s.constraints.is_empty());
8448        if !any_constraints {
8449            return Ok(());
8450        }
8451
8452        // Lazily-loaded visible rows per table, shared across checks.
8453        let mut rows_cache: HashMap<u64, Vec<Row>> = HashMap::new();
8454        let mut load_rows = |table_id: u64| -> Result<Vec<Row>> {
8455            if let Some(r) = rows_cache.get(&table_id) {
8456                return Ok(r.clone());
8457            }
8458            let handle = self.table_by_id(table_id)?;
8459            let rows = match control {
8460                Some(control) => handle.lock().visible_rows_controlled(snapshot, control)?,
8461                None => handle.lock().visible_rows(snapshot)?,
8462            };
8463            rows_cache.insert(table_id, rows.clone());
8464            Ok(rows)
8465        };
8466
8467        // ── Phase A1: expand ON UPDATE CASCADE / SET NULL while updates still
8468        // carry an explicit old RowId + full new image. This makes action choice
8469        // reliable even when the referenced key itself changes; a delete+put
8470        // heuristic cannot distinguish that from unrelated operations.
8471        let mut processed_updates = HashSet::new();
8472        type PendingUpdate = (usize, u64, crate::rowid::RowId, Vec<(u16, Value)>);
8473        let mut update_pass = 0_usize;
8474        loop {
8475            commit_prepare_checkpoint(control, update_pass)?;
8476            update_pass += 1;
8477            let updates: Vec<PendingUpdate> = staging
8478                .iter()
8479                .enumerate()
8480                .filter_map(|(index, (table_id, op))| match op {
8481                    Staged::Update {
8482                        row_id,
8483                        new_row: cells,
8484                        ..
8485                    } if !processed_updates.contains(&index) => {
8486                        Some((index, *table_id, *row_id, cells.clone()))
8487                    }
8488                    _ => None,
8489                })
8490                .collect();
8491            if updates.is_empty() {
8492                break;
8493            }
8494            let mut new_ops = Vec::new();
8495            for (update_index, (index, table_id, row_id, new_cells)) in
8496                updates.into_iter().enumerate()
8497            {
8498                commit_prepare_checkpoint(control, update_index)?;
8499                processed_updates.insert(index);
8500                let Some(tname) = live
8501                    .iter()
8502                    .find(|(id, _, _)| *id == table_id)
8503                    .map(|(_, name, _)| *name)
8504                else {
8505                    continue;
8506                };
8507                let Some(old_row) = self.table_by_id(table_id)?.lock().get(row_id, snapshot) else {
8508                    continue;
8509                };
8510                let new_map: HashMap<u16, Value> = new_cells.iter().cloned().collect();
8511                for (child_id, _child_name, child_schema) in &live {
8512                    for fk in &child_schema.constraints.foreign_keys {
8513                        if fk.ref_table != tname {
8514                            continue;
8515                        }
8516                        let Some(old_key) = encode_composite_key(&fk.ref_columns, &old_row.columns)
8517                        else {
8518                            continue;
8519                        };
8520                        if encode_composite_key(&fk.ref_columns, &new_map).as_deref()
8521                            == Some(old_key.as_slice())
8522                        {
8523                            continue;
8524                        }
8525                        if fk.on_update == FkAction::Restrict {
8526                            continue;
8527                        }
8528                        let child_rows = load_rows(*child_id)?;
8529                        for (child_index, child) in child_rows.into_iter().enumerate() {
8530                            commit_prepare_checkpoint(control, child_index)?;
8531                            if encode_composite_key(&fk.columns, &child.columns).as_deref()
8532                                != Some(old_key.as_slice())
8533                            {
8534                                continue;
8535                            }
8536                            if staging.iter().any(|(id, op)| {
8537                                *id == *child_id
8538                                    && matches!(op, Staged::Delete(id) if *id == child.row_id)
8539                            }) {
8540                                continue;
8541                            }
8542                            let mut cells: Vec<(u16, Value)> = child
8543                                .columns
8544                                .iter()
8545                                .map(|(column_id, value)| (*column_id, value.clone()))
8546                                .collect();
8547                            for (child_column, parent_column) in
8548                                fk.columns.iter().zip(&fk.ref_columns)
8549                            {
8550                                cells.retain(|(column_id, _)| column_id != child_column);
8551                                let value = match fk.on_update {
8552                                    FkAction::Cascade => {
8553                                        new_map.get(parent_column).cloned().unwrap_or(Value::Null)
8554                                    }
8555                                    FkAction::SetNull => Value::Null,
8556                                    FkAction::Restrict => {
8557                                        return Err(MongrelError::Other(
8558                                            "restricted foreign-key update reached cascade preparation"
8559                                                .into(),
8560                                        ));
8561                                    }
8562                                };
8563                                cells.push((*child_column, value));
8564                            }
8565                            cells.sort_by_key(|(column_id, _)| *column_id);
8566                            if let Some(existing_index) = staging.iter().position(|(id, op)| {
8567                                *id == *child_id
8568                                    && matches!(op, Staged::Update { row_id, .. } if *row_id == child.row_id)
8569                            }) {
8570                                if let Staged::Update {
8571                                    new_row: existing,
8572                                    changed_columns,
8573                                    ..
8574                                } = &mut staging[existing_index].1 {
8575                                    changed_columns.extend(fk.columns.iter().copied());
8576                                    changed_columns.sort_unstable();
8577                                    changed_columns.dedup();
8578                                    if *existing != cells {
8579                                        *existing = cells;
8580                                        processed_updates.remove(&existing_index);
8581                                    }
8582                                }
8583                            } else {
8584                                new_ops.push((
8585                                    *child_id,
8586                                    Staged::Update {
8587                                        row_id: child.row_id,
8588                                        new_row: cells,
8589                                        changed_columns: fk.columns.clone(),
8590                                    },
8591                                ));
8592                            }
8593                        }
8594                    }
8595                }
8596            }
8597            staging.extend(new_ops);
8598        }
8599
8600        // ── Phase A2: expand ON DELETE CASCADE / SET NULL into explicit child
8601        // ops (transitive fixpoint). RESTRICT is not expanded here — it is
8602        // enforced as a violation in Phase B. `cascaded` records every delete
8603        // we have already expanded so a self-referential CASCADE FK cannot loop.
8604        let mut cascaded: HashSet<(u64, u64)> = HashSet::new();
8605        let mut cascade_pass = 0_usize;
8606        loop {
8607            commit_prepare_checkpoint(control, cascade_pass)?;
8608            cascade_pass += 1;
8609            let mut new_ops: Vec<(u64, Staged)> = Vec::new();
8610            let deletes: Vec<(u64, crate::rowid::RowId)> = staging
8611                .iter()
8612                .filter_map(|(t, op)| match op {
8613                    Staged::Delete(rid) => Some((*t, *rid)),
8614                    _ => None,
8615                })
8616                .collect();
8617            for (delete_index, (table_id, rid)) in deletes.into_iter().enumerate() {
8618                commit_prepare_checkpoint(control, delete_index)?;
8619                if !cascaded.insert((table_id, rid.0)) {
8620                    continue;
8621                }
8622                let Some(tname) = live
8623                    .iter()
8624                    .find(|(t, _, _)| *t == table_id)
8625                    .map(|(_, n, _)| *n)
8626                else {
8627                    continue;
8628                };
8629                let parent_handle = self.table_by_id(table_id)?;
8630                let Some(parent_row) = parent_handle.lock().get(rid, snapshot) else {
8631                    continue;
8632                };
8633                for (child_id, _child_name, child_schema) in &live {
8634                    for fk in &child_schema.constraints.foreign_keys {
8635                        if fk.ref_table != tname {
8636                            continue;
8637                        }
8638                        let Some(parent_key) =
8639                            encode_composite_key(&fk.ref_columns, &parent_row.columns)
8640                        else {
8641                            continue;
8642                        };
8643                        // Suppress ON DELETE cascade/set-null when this "delete"
8644                        // is actually half of an UPDATE encoded as Delete(old)+
8645                        // Put(new): if a staged Put in the SAME table still
8646                        // provides the referenced parent key, the parent still
8647                        // exists (its non-key columns changed) and the children
8648                        // must be left alone. A genuine delete, or an update
8649                        // that CHANGES the referenced key, has no preserving Put
8650                        // → cascade fires as before.
8651                        let key_preserved = staging.iter().any(|(t, op)| {
8652                            if *t != table_id {
8653                                return false;
8654                            }
8655                            let Staged::Put(cells) = op else {
8656                                return false;
8657                            };
8658                            let map: HashMap<u16, crate::memtable::Value> =
8659                                cells.iter().cloned().collect();
8660                            encode_composite_key(&fk.ref_columns, &map).as_deref()
8661                                == Some(parent_key.as_slice())
8662                        });
8663                        if key_preserved {
8664                            continue;
8665                        }
8666                        match fk.on_delete {
8667                            FkAction::Restrict => continue,
8668                            FkAction::Cascade => {
8669                                let child_rows = load_rows(*child_id)?;
8670                                for (child_index, cr) in child_rows.iter().enumerate() {
8671                                    commit_prepare_checkpoint(control, child_index)?;
8672                                    if !cascaded.contains(&(*child_id, cr.row_id.0))
8673                                        && encode_composite_key(&fk.columns, &cr.columns).as_deref()
8674                                            == Some(parent_key.as_slice())
8675                                    {
8676                                        new_ops.push((*child_id, Staged::Delete(cr.row_id)));
8677                                    }
8678                                }
8679                            }
8680                            FkAction::SetNull => {
8681                                let child_rows = load_rows(*child_id)?;
8682                                for (child_index, cr) in child_rows.iter().enumerate() {
8683                                    commit_prepare_checkpoint(control, child_index)?;
8684                                    if !cascaded.contains(&(*child_id, cr.row_id.0))
8685                                        && encode_composite_key(&fk.columns, &cr.columns).as_deref()
8686                                            == Some(parent_key.as_slice())
8687                                    {
8688                                        // Re-emit the child row with the FK
8689                                        // columns set to NULL (delete + put).
8690                                        let mut cells: Vec<(u16, crate::memtable::Value)> = cr
8691                                            .columns
8692                                            .iter()
8693                                            .map(|(k, v)| (*k, v.clone()))
8694                                            .collect();
8695                                        for cid in &fk.columns {
8696                                            cells.retain(|(k, _)| k != cid);
8697                                            cells.push((*cid, crate::memtable::Value::Null));
8698                                        }
8699                                        new_ops.push((
8700                                            *child_id,
8701                                            Staged::Update {
8702                                                row_id: cr.row_id,
8703                                                new_row: cells,
8704                                                changed_columns: fk.columns.clone(),
8705                                            },
8706                                        ));
8707                                    }
8708                                }
8709                            }
8710                        }
8711                    }
8712                }
8713            }
8714            if new_ops.is_empty() {
8715                break;
8716            }
8717            staging.extend(new_ops);
8718        }
8719
8720        // Rows staged for deletion in THIS transaction (now including cascaded
8721        // deletes). Used to exclude the old version of an updated row from
8722        // unique-existence scans.
8723        let staged_deletes: HashSet<(u64, u64)> = staging
8724            .iter()
8725            .filter_map(|(t, op)| match op {
8726                Staged::Delete(rid) | Staged::Update { row_id: rid, .. } => Some((*t, rid.0)),
8727                _ => None,
8728            })
8729            .collect();
8730
8731        // Intra-transaction unique-key dedup: (table_id, uc_id, key).
8732        let mut seen_unique: HashSet<(u64, u16, Vec<u8>)> = HashSet::new();
8733
8734        // ── Phase B: validate the fully-expanded staging set.
8735        for (operation_index, (table_id, op)) in staging.iter().enumerate() {
8736            commit_prepare_checkpoint(control, operation_index)?;
8737            let Some((_, tname, schema)) = live.iter().find(|(t, _, _)| t == table_id).copied()
8738            else {
8739                continue;
8740            };
8741            let cells_map: HashMap<u16, crate::memtable::Value>;
8742            match op {
8743                Staged::Put(cells) | Staged::Update { new_row: cells, .. } => {
8744                    cells_map = cells.iter().cloned().collect();
8745
8746                    // CHECK constraints.
8747                    if !schema.constraints.checks.is_empty() {
8748                        validate_checks(&schema.constraints.checks, &cells_map)?;
8749                    }
8750
8751                    // UNIQUE (non-PK) constraints.
8752                    for uc in &schema.constraints.uniques {
8753                        let Some(key) = encode_composite_key(&uc.columns, &cells_map) else {
8754                            continue; // NULL in a constrained column → skip (SQL).
8755                        };
8756                        let marker = (*table_id, uc.id, key.clone());
8757                        if !seen_unique.insert(marker) {
8758                            return Err(MongrelError::Conflict(format!(
8759                                "UNIQUE constraint '{}' on table '{tname}' violated within batch",
8760                                uc.name
8761                            )));
8762                        }
8763                        let rows = load_rows(*table_id)?;
8764                        for (row_index, r) in rows.iter().enumerate() {
8765                            commit_prepare_checkpoint(control, row_index)?;
8766                            // Skip rows this same transaction is deleting (the
8767                            // old version of an updated/cascade-deleted row).
8768                            if staged_deletes.contains(&(*table_id, r.row_id.0)) {
8769                                continue;
8770                            }
8771                            if let Some(theirs) = encode_composite_key(&uc.columns, &r.columns) {
8772                                if theirs == key {
8773                                    return Err(MongrelError::Conflict(format!(
8774                                        "UNIQUE constraint '{}' on table '{tname}' violated",
8775                                        uc.name
8776                                    )));
8777                                }
8778                            }
8779                        }
8780                    }
8781
8782                    // FK insert-side: parent must exist.
8783                    for fk in &schema.constraints.foreign_keys {
8784                        let Some(child_key) = encode_composite_key(&fk.columns, &cells_map) else {
8785                            continue; // NULL FK component → not checked (SQL).
8786                        };
8787                        let Some(parent_id) = cat
8788                            .tables
8789                            .iter()
8790                            .find(|t| t.name == fk.ref_table)
8791                            .map(|t| t.table_id)
8792                        else {
8793                            return Err(MongrelError::InvalidArgument(format!(
8794                                "FOREIGN KEY '{}' references unknown table '{}'",
8795                                fk.name, fk.ref_table
8796                            )));
8797                        };
8798                        let parent_rows = load_rows(parent_id)?;
8799                        let mut found = false;
8800                        for (row_index, r) in parent_rows.iter().enumerate() {
8801                            commit_prepare_checkpoint(control, row_index)?;
8802                            if staged_deletes.contains(&(parent_id, r.row_id.0)) {
8803                                continue;
8804                            }
8805                            if let Some(pkey) = encode_composite_key(&fk.ref_columns, &r.columns) {
8806                                if pkey == child_key {
8807                                    found = true;
8808                                    break;
8809                                }
8810                            }
8811                        }
8812                        // Final-write-set FK validation: a parent inserted in
8813                        // THIS transaction also satisfies the FK. This enables
8814                        // atomic parent+child batches and cyclical/mutual FK
8815                        // inserts within a single transaction — the child sees
8816                        // the staged parent put even though it is not committed
8817                        // yet.
8818                        if !found {
8819                            for (staged_index, (st_table, st_op)) in staging.iter().enumerate() {
8820                                commit_prepare_checkpoint(control, staged_index)?;
8821                                if *st_table != parent_id {
8822                                    continue;
8823                                }
8824                                if let Staged::Put(pcells)
8825                                | Staged::Update {
8826                                    new_row: pcells, ..
8827                                } = st_op
8828                                {
8829                                    let pmap: HashMap<u16, crate::memtable::Value> =
8830                                        pcells.iter().cloned().collect();
8831                                    if let Some(pkey) = encode_composite_key(&fk.ref_columns, &pmap)
8832                                    {
8833                                        if pkey == child_key {
8834                                            found = true;
8835                                            break;
8836                                        }
8837                                    }
8838                                }
8839                            }
8840                        }
8841                        if !found {
8842                            return Err(MongrelError::Conflict(format!(
8843                                "FOREIGN KEY '{}' on table '{tname}' has no matching parent in '{}'",
8844                                fk.name, fk.ref_table
8845                            )));
8846                        }
8847                    }
8848
8849                    // Parent-side ON UPDATE RESTRICT. CASCADE/SET NULL were
8850                    // expanded in Phase A; here the final child write set is
8851                    // known, so a child explicitly moved/deleted by this same
8852                    // transaction does not cause a false violation.
8853                    if let Staged::Update { row_id, .. } = op {
8854                        let parent_handle = self.table_by_id(*table_id)?;
8855                        let Some(old_parent) = parent_handle.lock().get(*row_id, snapshot) else {
8856                            continue;
8857                        };
8858                        for (child_id, child_name, child_schema) in &live {
8859                            for fk in &child_schema.constraints.foreign_keys {
8860                                if fk.ref_table != tname || fk.on_update != FkAction::Restrict {
8861                                    continue;
8862                                }
8863                                let Some(old_key) =
8864                                    encode_composite_key(&fk.ref_columns, &old_parent.columns)
8865                                else {
8866                                    continue;
8867                                };
8868                                if encode_composite_key(&fk.ref_columns, &cells_map).as_deref()
8869                                    == Some(old_key.as_slice())
8870                                {
8871                                    continue;
8872                                }
8873                                for (child_index, child) in
8874                                    load_rows(*child_id)?.into_iter().enumerate()
8875                                {
8876                                    commit_prepare_checkpoint(control, child_index)?;
8877                                    if encode_composite_key(&fk.columns, &child.columns).as_deref()
8878                                        != Some(old_key.as_slice())
8879                                    {
8880                                        continue;
8881                                    }
8882                                    let replacement = staging.iter().find_map(|(id, op)| {
8883                                        if *id != *child_id {
8884                                            return None;
8885                                        }
8886                                        match op {
8887                                            Staged::Delete(id) if *id == child.row_id => Some(None),
8888                                            Staged::Update {
8889                                                row_id,
8890                                                new_row: cells,
8891                                                ..
8892                                            } if *row_id == child.row_id => {
8893                                                let map: HashMap<u16, Value> =
8894                                                    cells.iter().cloned().collect();
8895                                                Some(encode_composite_key(&fk.columns, &map))
8896                                            }
8897                                            _ => None,
8898                                        }
8899                                    });
8900                                    if replacement.is_some_and(|key| {
8901                                        key.as_deref() != Some(old_key.as_slice())
8902                                    }) {
8903                                        continue;
8904                                    }
8905                                    return Err(MongrelError::Conflict(format!(
8906                                        "FOREIGN KEY '{}' on table '{child_name}' restricts update (parent key referenced)",
8907                                        fk.name
8908                                    )));
8909                                }
8910                            }
8911                        }
8912                    }
8913                }
8914                Staged::Delete(rid) => {
8915                    // FK ON DELETE RESTRICT: a child row (whose FK action is
8916                    // RESTRICT) referencing this parent blocks the delete.
8917                    // CASCADE/SET NULL children were expanded in Phase A.
8918                    let parent_handle = self.table_by_id(*table_id)?;
8919                    let Some(parent_row) = parent_handle.lock().get(*rid, snapshot) else {
8920                        continue;
8921                    };
8922                    for (child_id, child_name, child_schema) in &live {
8923                        for fk in &child_schema.constraints.foreign_keys {
8924                            if fk.ref_table != tname || fk.on_delete != FkAction::Restrict {
8925                                continue;
8926                            }
8927                            let Some(parent_key) =
8928                                encode_composite_key(&fk.ref_columns, &parent_row.columns)
8929                            else {
8930                                continue;
8931                            };
8932                            let child_rows = load_rows(*child_id)?;
8933                            for (row_index, r) in child_rows.iter().enumerate() {
8934                                commit_prepare_checkpoint(control, row_index)?;
8935                                // A child already being deleted by this txn
8936                                // (cascade/inline) is not a restrict violation.
8937                                if staged_deletes.contains(&(*child_id, r.row_id.0)) {
8938                                    continue;
8939                                }
8940                                if let Some(ck) = encode_composite_key(&fk.columns, &r.columns) {
8941                                    if ck == parent_key {
8942                                        return Err(MongrelError::Conflict(format!(
8943                                            "FOREIGN KEY '{}' on table '{child_name}' restricts delete (parent referenced)",
8944                                            fk.name
8945                                        )));
8946                                    }
8947                                }
8948                            }
8949                        }
8950                    }
8951                }
8952                Staged::Truncate => {
8953                    // Truncate is RESTRICT-only: reject if any child references
8954                    // this table (any FK action), since cascade-truncate is
8955                    // unsupported.
8956                    for (child_id, child_name, child_schema) in &live {
8957                        for fk in &child_schema.constraints.foreign_keys {
8958                            if fk.ref_table != tname {
8959                                continue;
8960                            }
8961                            let child_rows = load_rows(*child_id)?;
8962                            if child_rows
8963                                .iter()
8964                                .any(|r| encode_composite_key(&fk.columns, &r.columns).is_some())
8965                            {
8966                                return Err(MongrelError::Conflict(format!(
8967                                    "FOREIGN KEY '{}' on table '{child_name}' restricts truncate of '{tname}'",
8968                                    fk.name
8969                                )));
8970                            }
8971                        }
8972                    }
8973                }
8974            }
8975        }
8976        Ok(())
8977    }
8978
8979    fn validate_write_permissions(
8980        &self,
8981        staging: &[(u64, crate::txn::Staged)],
8982        principal: Option<&crate::auth::Principal>,
8983        control: Option<&crate::ExecutionControl>,
8984    ) -> Result<()> {
8985        commit_prepare_checkpoint(control, 0)?;
8986        if principal.is_none() && !self.auth_state.require_auth() {
8987            return Ok(());
8988        }
8989        let principal = principal.ok_or(MongrelError::AuthRequired)?;
8990        let needs = summarize_write_permissions(staging);
8991        let catalog = self.catalog.read();
8992
8993        if needs.values().any(|need| need.truncate) {
8994            self.require_for(Some(principal), &crate::auth::Permission::Admin)?;
8995        }
8996        for (need_index, (table_id, need)) in needs.into_iter().enumerate() {
8997            commit_prepare_checkpoint(control, need_index)?;
8998            let entry = catalog
8999                .tables
9000                .iter()
9001                .find(|entry| {
9002                    entry.table_id == table_id
9003                        && matches!(entry.state, TableState::Live | TableState::Building { .. })
9004                })
9005                .ok_or_else(|| {
9006                    MongrelError::NotFound(format!(
9007                        "live table {table_id} not found during write validation"
9008                    ))
9009                })?;
9010            if matches!(entry.state, TableState::Building { .. }) {
9011                self.require_for(Some(principal), &crate::auth::Permission::Ddl)?;
9012                continue;
9013            }
9014            if need.insert {
9015                Self::require_columns_for_principal(
9016                    &entry.name,
9017                    &entry.schema,
9018                    crate::auth::ColumnOperation::Insert,
9019                    &need.insert_columns,
9020                    principal,
9021                )?;
9022            }
9023            if need.update {
9024                Self::require_columns_for_principal(
9025                    &entry.name,
9026                    &entry.schema,
9027                    crate::auth::ColumnOperation::Update,
9028                    &need.update_columns,
9029                    principal,
9030                )?;
9031            }
9032            if need.delete {
9033                self.require_for(
9034                    Some(principal),
9035                    &crate::auth::Permission::Delete {
9036                        table: entry.name.clone(),
9037                    },
9038                )?;
9039            }
9040        }
9041        Ok(())
9042    }
9043
9044    fn validate_security_writes(
9045        &self,
9046        staging: &[(u64, crate::txn::Staged)],
9047        read_epoch: Epoch,
9048        explicit_principal: Option<&crate::auth::Principal>,
9049        control: Option<&crate::ExecutionControl>,
9050    ) -> Result<()> {
9051        commit_prepare_checkpoint(control, 0)?;
9052        use crate::security::PolicyCommand;
9053        use crate::txn::Staged;
9054
9055        let catalog = self.catalog.read();
9056        if catalog.security.rls_tables.is_empty() {
9057            return Ok(());
9058        }
9059        let security = catalog.security.clone();
9060        let table_names = catalog
9061            .tables
9062            .iter()
9063            .filter(|entry| matches!(entry.state, TableState::Live))
9064            .map(|entry| (entry.table_id, entry.name.clone()))
9065            .collect::<HashMap<_, _>>();
9066        drop(catalog);
9067        if !staging.iter().any(|(table_id, _)| {
9068            table_names
9069                .get(table_id)
9070                .is_some_and(|table| security.rls_enabled(table))
9071        }) {
9072            return Ok(());
9073        }
9074        let principal = explicit_principal.ok_or(MongrelError::AuthRequired)?;
9075
9076        for (operation_index, (table_id, operation)) in staging.iter().enumerate() {
9077            commit_prepare_checkpoint(control, operation_index)?;
9078            let Some(table) = table_names.get(table_id) else {
9079                continue;
9080            };
9081            if !security.rls_enabled(table) || principal.is_admin {
9082                continue;
9083            }
9084            let denied = |command| MongrelError::PermissionDenied {
9085                required: match command {
9086                    PolicyCommand::Insert => crate::auth::Permission::Insert {
9087                        table: table.clone(),
9088                    },
9089                    PolicyCommand::Update => crate::auth::Permission::Update {
9090                        table: table.clone(),
9091                    },
9092                    PolicyCommand::Delete | PolicyCommand::All | PolicyCommand::Select => {
9093                        crate::auth::Permission::Delete {
9094                            table: table.clone(),
9095                        }
9096                    }
9097                },
9098                principal: principal.username.clone(),
9099            };
9100            match operation {
9101                Staged::Put(cells) => {
9102                    let mut row = crate::memtable::Row::new(RowId(0), Epoch(read_epoch.0));
9103                    row.columns.extend(cells.iter().cloned());
9104                    if !security.row_allowed(table, PolicyCommand::Insert, &row, principal, true) {
9105                        return Err(denied(PolicyCommand::Insert));
9106                    }
9107                }
9108                Staged::Update {
9109                    row_id,
9110                    new_row: cells,
9111                    ..
9112                } => {
9113                    let old = self
9114                        .table_by_id(*table_id)?
9115                        .lock()
9116                        .get(*row_id, Snapshot::at(read_epoch))
9117                        .ok_or_else(|| {
9118                            MongrelError::NotFound(format!("row {} not found", row_id.0))
9119                        })?;
9120                    if !security.row_allowed(table, PolicyCommand::Update, &old, principal, false) {
9121                        return Err(denied(PolicyCommand::Update));
9122                    }
9123                    let mut new = crate::memtable::Row::new(*row_id, Epoch(read_epoch.0));
9124                    new.columns.extend(cells.iter().cloned());
9125                    if !security.row_allowed(table, PolicyCommand::Update, &new, principal, true) {
9126                        return Err(denied(PolicyCommand::Update));
9127                    }
9128                }
9129                Staged::Delete(row_id) => {
9130                    let old = self
9131                        .table_by_id(*table_id)?
9132                        .lock()
9133                        .get(*row_id, Snapshot::at(read_epoch))
9134                        .ok_or_else(|| {
9135                            MongrelError::NotFound(format!("row {} not found", row_id.0))
9136                        })?;
9137                    if !security.row_allowed(table, PolicyCommand::Delete, &old, principal, false) {
9138                        return Err(denied(PolicyCommand::Delete));
9139                    }
9140                }
9141                Staged::Truncate => return Err(denied(PolicyCommand::Delete)),
9142            }
9143        }
9144        Ok(())
9145    }
9146
9147    /// Seal a transaction (spec §9.3):
9148    /// 1. Prepare — derive write keys, allocate row ids (brief table locks).
9149    /// 2. Sequencer — validate-first under the WAL mutex; abort on conflict
9150    ///    with no epoch consumed; assign epoch, append data records + TxnCommit,
9151    ///    group-sync, record conflict keys.
9152    /// 3. Publish — apply to tables, advance visible in-order.
9153    #[allow(clippy::too_many_arguments)]
9154    pub(crate) fn commit_transaction_with_external_states(
9155        &self,
9156        txn_id: u64,
9157        read_epoch: Epoch,
9158        staging: Vec<(u64, crate::txn::Staged)>,
9159        external_states: Vec<(String, Vec<u8>)>,
9160        materialized_view_updates: Vec<crate::catalog::MaterializedViewEntry>,
9161        security_principal: Option<crate::auth::Principal>,
9162        principal_catalog_bound: bool,
9163        external_trigger_bridge: Option<&dyn ExternalTriggerBridge>,
9164    ) -> Result<(Epoch, Vec<RowId>)> {
9165        self.commit_transaction_with_external_states_inner(
9166            txn_id,
9167            read_epoch,
9168            staging,
9169            external_states,
9170            materialized_view_updates,
9171            security_principal,
9172            principal_catalog_bound,
9173            external_trigger_bridge,
9174            None,
9175            None,
9176        )
9177    }
9178
9179    #[allow(clippy::too_many_arguments)]
9180    pub(crate) fn commit_transaction_with_external_states_controlled(
9181        &self,
9182        txn_id: u64,
9183        read_epoch: Epoch,
9184        staging: Vec<(u64, crate::txn::Staged)>,
9185        external_states: Vec<(String, Vec<u8>)>,
9186        materialized_view_updates: Vec<crate::catalog::MaterializedViewEntry>,
9187        security_principal: Option<crate::auth::Principal>,
9188        principal_catalog_bound: bool,
9189        external_trigger_bridge: Option<&dyn ExternalTriggerBridge>,
9190        control: &crate::ExecutionControl,
9191        before_commit: &mut dyn FnMut() -> Result<()>,
9192    ) -> Result<(Epoch, Vec<RowId>)> {
9193        self.commit_transaction_with_external_states_inner(
9194            txn_id,
9195            read_epoch,
9196            staging,
9197            external_states,
9198            materialized_view_updates,
9199            security_principal,
9200            principal_catalog_bound,
9201            external_trigger_bridge,
9202            Some(control),
9203            Some(before_commit),
9204        )
9205    }
9206
9207    #[allow(clippy::too_many_arguments)]
9208    fn commit_transaction_with_external_states_inner(
9209        &self,
9210        txn_id: u64,
9211        read_epoch: Epoch,
9212        mut staging: Vec<(u64, crate::txn::Staged)>,
9213        external_states: Vec<(String, Vec<u8>)>,
9214        materialized_view_updates: Vec<crate::catalog::MaterializedViewEntry>,
9215        mut security_principal: Option<crate::auth::Principal>,
9216        principal_catalog_bound: bool,
9217        external_trigger_bridge: Option<&dyn ExternalTriggerBridge>,
9218        control: Option<&crate::ExecutionControl>,
9219        mut before_commit: Option<&mut dyn FnMut() -> Result<()>>,
9220    ) -> Result<(Epoch, Vec<RowId>)> {
9221        use crate::memtable::Row;
9222        use crate::txn::{Staged, StagedOp, WriteKey};
9223        use crate::wal::Op;
9224        use std::collections::hash_map::DefaultHasher;
9225        use std::hash::{Hash, Hasher};
9226        use std::sync::atomic::Ordering;
9227
9228        if txn_id == crate::wal::SYSTEM_TXN_ID {
9229            return Err(MongrelError::Full(
9230                "per-open transaction id namespace exhausted; reopen the database".into(),
9231            ));
9232        }
9233        if self.read_only {
9234            return Err(MongrelError::ReadOnlyReplica);
9235        }
9236        commit_prepare_checkpoint(control, 0)?;
9237        let observed_security_version = self.security_coordinator.version.load(Ordering::Acquire);
9238        self.refresh_security_catalog_if_stale(observed_security_version)?;
9239        let trigger_binding = trigger_catalog_binding(&self.catalog.read());
9240        if self.auth_state.require_auth() && security_principal.is_none() {
9241            return Err(MongrelError::AuthRequired);
9242        }
9243        {
9244            let catalog = self.catalog.read();
9245            if catalog.require_auth
9246                || principal_catalog_bound
9247                || security_principal
9248                    .as_ref()
9249                    .is_some_and(|principal| principal.user_id != 0)
9250            {
9251                let principal = security_principal
9252                    .as_ref()
9253                    .ok_or(MongrelError::AuthRequired)?;
9254                security_principal =
9255                    Self::resolve_bound_principal_from_catalog(&catalog, principal);
9256                if security_principal.is_none() {
9257                    return Err(MongrelError::AuthRequired);
9258                }
9259            }
9260        }
9261        let _replication_guard = self.replication_barrier.read();
9262        if self.poisoned.load(Ordering::Relaxed) {
9263            return Err(MongrelError::Other(
9264                "database poisoned by fsync error".into(),
9265            ));
9266        }
9267        let mut external_states = dedup_external_states(external_states);
9268        if !external_states.is_empty() {
9269            let cat = self.catalog.read();
9270            for (name, _) in &external_states {
9271                if !cat.external_tables.iter().any(|entry| entry.name == *name) {
9272                    return Err(MongrelError::NotFound(format!(
9273                        "external table {name:?} not found"
9274                    )));
9275                }
9276            }
9277        }
9278        let prepared_materialized_views = {
9279            let mut deduplicated = HashMap::new();
9280            for (definition_index, definition) in materialized_view_updates.into_iter().enumerate()
9281            {
9282                commit_prepare_checkpoint(control, definition_index)?;
9283                if definition.name.is_empty() || definition.query.trim().is_empty() {
9284                    return Err(MongrelError::InvalidArgument(
9285                        "materialized view name and query must not be empty".into(),
9286                    ));
9287                }
9288                deduplicated.insert(definition.name.clone(), definition);
9289            }
9290            let catalog = self.catalog.read();
9291            let mut prepared = Vec::with_capacity(deduplicated.len());
9292            for (definition_index, definition) in deduplicated.into_values().enumerate() {
9293                commit_prepare_checkpoint(control, definition_index)?;
9294                let table_id = catalog
9295                    .live(&definition.name)
9296                    .ok_or_else(|| {
9297                        MongrelError::NotFound(format!(
9298                            "materialized view table {:?} not found",
9299                            definition.name
9300                        ))
9301                    })?
9302                    .table_id;
9303                prepared.push((table_id, definition));
9304            }
9305            prepared.sort_by(|left, right| left.1.name.cmp(&right.1.name));
9306            prepared
9307        };
9308
9309        // ── 1. Prepare: fill generated values, expand triggers, validate, then
9310        // derive write keys from the final atomic write set.
9311        self.fill_auto_increment_for_staging(&mut staging, control)?;
9312        self.expand_table_triggers(
9313            &mut staging,
9314            read_epoch,
9315            external_trigger_bridge,
9316            &mut external_states,
9317            control,
9318        )?;
9319        self.fill_auto_increment_for_staging(&mut staging, control)?;
9320        external_states = dedup_external_states(external_states);
9321        let expected_external_generations = {
9322            let catalog = self.catalog.read();
9323            let mut generations = HashMap::with_capacity(external_states.len());
9324            for (name, _) in &external_states {
9325                let entry = catalog
9326                    .external_tables
9327                    .iter()
9328                    .find(|entry| entry.name == *name)
9329                    .ok_or_else(|| {
9330                        MongrelError::NotFound(format!("external table {name:?} not found"))
9331                    })?;
9332                generations.insert(name.clone(), entry.created_epoch);
9333            }
9334            generations
9335        };
9336
9337        // Validate declarative constraints (unique / FK / check) under the read
9338        // snapshot, outside the WAL mutex. Trigger-produced writes are included
9339        // here, so the batch either satisfies every declared constraint or is
9340        // rejected atomically.
9341        self.validate_constraints(&mut staging, read_epoch, control)?;
9342        self.validate_write_permissions(&staging, security_principal.as_ref(), control)?;
9343        self.validate_security_writes(&staging, read_epoch, security_principal.as_ref(), control)?;
9344        let mut normalized = Vec::with_capacity(staging.len() * 2);
9345        for (staged_index, (table_id, op)) in staging.into_iter().enumerate() {
9346            commit_prepare_checkpoint(control, staged_index)?;
9347            match op {
9348                crate::txn::Staged::Update {
9349                    row_id,
9350                    new_row: cells,
9351                    ..
9352                } => {
9353                    normalized.push((table_id, crate::txn::Staged::Delete(row_id)));
9354                    normalized.push((table_id, crate::txn::Staged::Put(cells)));
9355                }
9356                op => normalized.push((table_id, op)),
9357            }
9358        }
9359        staging = normalized;
9360        let has_changes = !staging.is_empty()
9361            || !external_states.is_empty()
9362            || !prepared_materialized_views.is_empty();
9363        let truncated_tables: HashSet<u64> = staging
9364            .iter()
9365            .filter_map(|(table_id, op)| matches!(op, Staged::Truncate).then_some(*table_id))
9366            .collect();
9367
9368        let write_keys = {
9369            let cat = self.catalog.read();
9370            let mut keys: Vec<WriteKey> = Vec::new();
9371            for (staged_index, (table_id, staged)) in staging.iter().enumerate() {
9372                commit_prepare_checkpoint(control, staged_index)?;
9373                match staged {
9374                    Staged::Put(cells) => {
9375                        if let Some(entry) = cat.tables.iter().find(|t| t.table_id == *table_id) {
9376                            for col in &entry.schema.columns {
9377                                if col.flags.contains(crate::schema::ColumnFlags::PRIMARY_KEY) {
9378                                    if let Some((_, val)) =
9379                                        cells.iter().find(|(id, _)| *id == col.id)
9380                                    {
9381                                        let mut h = DefaultHasher::new();
9382                                        val.encode_key().hash(&mut h);
9383                                        keys.push(WriteKey::Unique {
9384                                            table_id: *table_id,
9385                                            index_id: 0,
9386                                            key_hash: h.finish(),
9387                                        });
9388                                    }
9389                                }
9390                            }
9391                            // Declared non-PK unique constraints register a
9392                            // `WriteKey::Unique` (namespace-separated from the
9393                            // PK's index_id==0 by setting the high bit) so two
9394                            // concurrent transactions inserting the same key
9395                            // cannot both commit. Rows with any NULL constrained
9396                            // column are skipped (SQL semantics).
9397                            for uc in &entry.schema.constraints.uniques {
9398                                if let Some(key_bytes) = crate::constraint::encode_composite_key(
9399                                    &uc.columns,
9400                                    &cells.iter().cloned().collect(),
9401                                ) {
9402                                    let mut h = DefaultHasher::new();
9403                                    key_bytes.hash(&mut h);
9404                                    keys.push(WriteKey::Unique {
9405                                        table_id: *table_id,
9406                                        index_id: uc.id | 0x8000,
9407                                        key_hash: h.finish(),
9408                                    });
9409                                }
9410                            }
9411                        }
9412                    }
9413                    Staged::Delete(rid) => keys.push(WriteKey::Row {
9414                        table_id: *table_id,
9415                        row_id: rid.0,
9416                    }),
9417                    Staged::Truncate => keys.push(WriteKey::Table {
9418                        table_id: *table_id,
9419                    }),
9420                    Staged::Update { .. } => {
9421                        return Err(MongrelError::Other(
9422                            "transaction contains an unnormalized update during preparation".into(),
9423                        ));
9424                    }
9425                }
9426            }
9427            for (external_index, (name, _)) in external_states.iter().enumerate() {
9428                commit_prepare_checkpoint(control, external_index)?;
9429                let mut h = DefaultHasher::new();
9430                name.hash(&mut h);
9431                keys.push(WriteKey::Unique {
9432                    table_id: EXTERNAL_TABLE_ID,
9433                    index_id: 0,
9434                    key_hash: h.finish(),
9435                });
9436            }
9437            keys
9438        };
9439
9440        // Opportunistic pruning.
9441        let min_active = self.active_txns.min_read_epoch();
9442        if min_active < u64::MAX {
9443            self.conflicts.prune_below(Epoch(min_active));
9444        }
9445
9446        // ── 1a. Pre-validate the full write-set OUTSIDE the sequencer (spec
9447        // §8.5, review fix #17). Snapshot the conflict-index version so the
9448        // sequencer only re-checks if new commits arrived in the interim.
9449        if self.conflicts.conflicts(&write_keys, read_epoch) {
9450            return Err(MongrelError::Conflict(
9451                "write-write conflict (pre-validate, first-committer-wins)".into(),
9452            ));
9453        }
9454        let pre_validate_version = self.conflicts.version();
9455
9456        // ── 1b. Spill: if a table's staged puts exceed the threshold, write a
9457        // uniform-epoch pending run (spec §8.5). Rows in the run are NOT
9458        // streamed as Put records; they are linked at publish time.
9459        let mut spilled: Vec<SpilledRun> = Vec::new();
9460        let mut spilled_tables: std::collections::HashSet<u64> = std::collections::HashSet::new();
9461        // Protect this txn's `_txn/<id>/` dir from a concurrent `gc()` for as long
9462        // as the spill runs are live (registered on first spill, dropped at the
9463        // end of this function on commit/abort/error).
9464        let mut spill_guard: Option<crate::retention::SpillGuard> = None;
9465        {
9466            let mut table_bytes: HashMap<u64, u64> = HashMap::new();
9467            let mut put_indexes: HashMap<u64, Vec<usize>> = HashMap::new();
9468            for (staged_index, (table_id, staged)) in staging.iter().enumerate() {
9469                commit_prepare_checkpoint(control, staged_index)?;
9470                if let Staged::Put(cells) = staged {
9471                    let bytes = cells.iter().fold(32_u64, |bytes, (_, value)| {
9472                        bytes.saturating_add(value.estimated_bytes())
9473                    });
9474                    let table_bytes = table_bytes.entry(*table_id).or_default();
9475                    *table_bytes = table_bytes.saturating_add(bytes);
9476                    put_indexes.entry(*table_id).or_default().push(staged_index);
9477                }
9478            }
9479            let tables = self.tables.read();
9480            for (table_index, (&table_id, &bytes)) in table_bytes.iter().enumerate() {
9481                commit_prepare_checkpoint(control, table_index)?;
9482                if bytes
9483                    <= self
9484                        .spill_threshold
9485                        .load(std::sync::atomic::Ordering::Relaxed)
9486                {
9487                    continue;
9488                }
9489                let Some(handle) = tables.get(&table_id) else {
9490                    continue;
9491                };
9492                spill_guard.get_or_insert_with(|| self.active_spills.register(txn_id));
9493                let mut t = handle.lock();
9494                let tdir = t.table_dir().to_path_buf();
9495                let txn_dir = tdir.join("_txn").join(txn_id.to_string());
9496                std::fs::create_dir_all(&txn_dir)?;
9497                let run_id = t.alloc_run_id()? as u128;
9498                let pending_path = txn_dir.join(format!("r-{run_id}.sr"));
9499                let final_path = t.run_path(run_id as u64);
9500
9501                let mut rows: Vec<Row> = Vec::new();
9502                for (put_index, staged_index) in put_indexes[&table_id].iter().enumerate() {
9503                    commit_prepare_checkpoint(control, put_index)?;
9504                    let Staged::Put(cells) = &mut staging[*staged_index].1 else {
9505                        return Err(MongrelError::Other(
9506                            "transaction put index no longer references a put".into(),
9507                        ));
9508                    };
9509                    t.validate_cells_not_null(cells)?;
9510                    let row_id = t.alloc_row_id()?;
9511                    let mut row = Row::new(row_id, Epoch(0));
9512                    row.columns.extend(std::mem::take(cells));
9513                    rows.push(row);
9514                }
9515                let schema = t.schema_ref().clone();
9516                let kek = t.kek_ref().cloned();
9517                let specs = t.indexable_column_specs();
9518                drop(t);
9519
9520                let mut writer = crate::sorted_run::RunWriter::new(&schema, run_id, Epoch(0), 0)
9521                    .uniform_epoch(true);
9522                if let Some(ref kek) = kek {
9523                    writer = writer.with_encryption(kek.as_ref(), specs);
9524                }
9525                commit_prepare_checkpoint(control, 0)?;
9526                let header = writer.write(&pending_path, &rows)?;
9527                commit_prepare_checkpoint(control, 0)?;
9528                let row_count = header.row_count;
9529                let min_rid = rows.first().map(|r| r.row_id.0).unwrap_or(0);
9530                let max_rid = rows.last().map(|r| r.row_id.0).unwrap_or(0);
9531
9532                spilled.push(SpilledRun {
9533                    table_id,
9534                    run_id,
9535                    pending_path,
9536                    final_path,
9537                    rows,
9538                    row_count,
9539                    min_rid,
9540                    max_rid,
9541                    content_hash: header.content_hash,
9542                });
9543                spilled_tables.insert(table_id);
9544            }
9545        }
9546
9547        // Test seam: let a test race `gc()` against this in-flight spill.
9548        if spill_guard.is_some() {
9549            if let Some(hook) = self.spill_hook.lock().as_ref() {
9550                hook();
9551            }
9552        }
9553
9554        // ── 1c. Pre-build non-spilled put rows OUTSIDE the WAL critical section.
9555        // Allocating row ids + building the rows here (lock order: table handle →
9556        // nothing) means the sequencer never locks a table handle while holding
9557        // the shared-WAL mutex. That matters because `Table::commit`/`flush` lock
9558        // the table handle THEN the shared WAL; if the sequencer did the reverse
9559        // (WAL then handle) the two paths would deadlock (review fix: B1).
9560        // Aligned 1:1 with `staging`; `None` for deletes and spilled puts.
9561        // Row ids are allocated here, before the sequencer's delta conflict
9562        // re-check, so a losing txn leaks the ids it reserved — harmless, the
9563        // u64 row-id space is monotonic and gaps are expected (spills do the same).
9564        let mut prebuilt: Vec<Option<Row>> = std::iter::repeat_with(|| None)
9565            .take(staging.len())
9566            .collect();
9567        let mut delete_images: Vec<Option<Row>> = std::iter::repeat_with(|| None)
9568            .take(staging.len())
9569            .collect();
9570        {
9571            let mut indexes_by_table: HashMap<u64, Vec<usize>> = HashMap::new();
9572            for (index, (table_id, staged)) in staging.iter().enumerate() {
9573                commit_prepare_checkpoint(control, index)?;
9574                if matches!(staged, Staged::Delete(_))
9575                    || matches!(staged, Staged::Put(_) if !spilled_tables.contains(table_id))
9576                {
9577                    indexes_by_table.entry(*table_id).or_default().push(index);
9578                }
9579            }
9580            let tables = self.tables.read();
9581            for (table_index, (table_id, indexes)) in indexes_by_table.into_iter().enumerate() {
9582                commit_prepare_checkpoint(control, table_index)?;
9583                let handle = tables.get(&table_id).ok_or_else(|| {
9584                    MongrelError::NotFound(format!("table {table_id} not mounted"))
9585                })?;
9586                #[cfg(test)]
9587                PREBUILD_TABLE_LOCKS.with(|count| count.set(count.get() + 1));
9588                let mut t = handle.lock();
9589                for (prepare_index, index) in indexes.into_iter().enumerate() {
9590                    commit_prepare_checkpoint(control, prepare_index)?;
9591                    match &staging[index].1 {
9592                        Staged::Put(cells) if !spilled_tables.contains(&table_id) => {
9593                            t.validate_cells_not_null(cells)?;
9594                            let mut row = Row::new(t.alloc_row_id()?, Epoch(0));
9595                            for (column, value) in cells {
9596                                row.columns.insert(*column, value.clone());
9597                            }
9598                            prebuilt[index] = Some(row);
9599                        }
9600                        Staged::Delete(row_id) => {
9601                            delete_images[index] = t.get(*row_id, Snapshot::at(read_epoch));
9602                        }
9603                        Staged::Put(_) | Staged::Truncate => {}
9604                        Staged::Update { .. } => {
9605                            return Err(MongrelError::Other(
9606                                "transaction contains an unnormalized update during row preparation"
9607                                    .into(),
9608                            ));
9609                        }
9610                    }
9611                }
9612            }
9613        }
9614
9615        // Finish every fallible index read before the commit marker can become
9616        // durable. Post-durable row/run metadata application is then entirely
9617        // in-memory and cannot stop halfway through a multi-table publish.
9618        let prepared_table_handles = {
9619            let table_ids: HashSet<u64> = staging.iter().map(|(table_id, _)| *table_id).collect();
9620            let put_table_ids: HashSet<u64> = staging
9621                .iter()
9622                .filter_map(|(table_id, staged)| {
9623                    matches!(staged, Staged::Put(_)).then_some(*table_id)
9624                })
9625                .collect();
9626            let tables = self.tables.read();
9627            let mut handles = HashMap::with_capacity(table_ids.len());
9628            for (table_index, table_id) in table_ids.into_iter().enumerate() {
9629                commit_prepare_checkpoint(control, table_index)?;
9630                let handle = tables.get(&table_id).ok_or_else(|| {
9631                    MongrelError::NotFound(format!("table {table_id} not mounted"))
9632                })?;
9633                if put_table_ids.contains(&table_id) {
9634                    match control {
9635                        Some(control) => {
9636                            handle.lock().prepare_durable_publish_controlled(control)?
9637                        }
9638                        None => handle.lock().prepare_durable_publish()?,
9639                    }
9640                }
9641                handles.insert(table_id, handle.clone());
9642            }
9643            handles
9644        };
9645
9646        // Link large-transaction spill files before WAL durability. The guard
9647        // restores their pending names on every error before WAL append begins;
9648        // publication only attaches already-present files in memory.
9649        let mut prepared_run_links = PreparedRunLinks::prepare(&spilled)?;
9650
9651        let mut spilled_row_ids: HashMap<u64, VecDeque<RowId>> = spilled
9652            .iter()
9653            .map(|run| {
9654                (
9655                    run.table_id,
9656                    run.rows.iter().map(|row| row.row_id).collect(),
9657                )
9658            })
9659            .collect();
9660        let committed_row_ids = staging
9661            .iter()
9662            .enumerate()
9663            .filter_map(|(index, (table_id, staged))| {
9664                if !matches!(staged, Staged::Put(_)) {
9665                    return None;
9666                }
9667                prebuilt[index].as_ref().map(|row| row.row_id).or_else(|| {
9668                    spilled_row_ids
9669                        .get_mut(table_id)
9670                        .and_then(VecDeque::pop_front)
9671                })
9672            })
9673            .collect();
9674
9675        let mut prepared_external = Vec::with_capacity(external_states.len());
9676        for (external_index, (name, state)) in external_states.iter().enumerate() {
9677            commit_prepare_checkpoint(control, external_index)?;
9678            let pending = prepare_external_state_file(&self.root, name, state, txn_id)?;
9679            prepared_external.push((name.clone(), state.clone(), pending));
9680        }
9681
9682        // ── 2. Sequencer: validate-first → assign → append → sync → record ──
9683        let added_runs: Vec<crate::wal::AddedRun> = spilled
9684            .iter()
9685            .map(|s| crate::wal::AddedRun {
9686                table_id: s.table_id,
9687                run_id: s.run_id,
9688                row_count: s.row_count,
9689                level: 0,
9690                min_row_id: s.min_rid,
9691                max_row_id: s.max_rid,
9692                content_hash: s.content_hash,
9693            })
9694            .collect();
9695        if let Some(hook) = self.catalog_commit_hook.lock().as_ref() {
9696            hook();
9697        }
9698        // Lock order: security gate -> commit lock -> shared WAL -> table locks.
9699        // Security mutations cannot overtake an authorized commit before its
9700        // commit marker is durable.
9701        let security_guard = self.security_coordinator.gate.read();
9702        if self.security_coordinator.version.load(Ordering::Acquire) != observed_security_version {
9703            return Err(MongrelError::Conflict(
9704                "security policy changed during write".into(),
9705            ));
9706        }
9707        if spill_guard.is_some() {
9708            if let Some(hook) = self.security_commit_hook.lock().as_ref() {
9709                hook();
9710            }
9711        }
9712        let commit_guard = self.commit_lock.lock();
9713        let catalog_generation_result = (|| {
9714            {
9715                let catalog = self.catalog.read();
9716                for table_id in prepared_table_handles.keys() {
9717                    let is_current = catalog.tables.iter().any(|entry| {
9718                        entry.table_id == *table_id
9719                            && matches!(entry.state, TableState::Live | TableState::Building { .. })
9720                    });
9721                    if !is_current {
9722                        return Err(MongrelError::Conflict(format!(
9723                            "table {table_id} changed during transaction preparation"
9724                        )));
9725                    }
9726                }
9727                for (name, created_epoch) in &expected_external_generations {
9728                    let current = catalog
9729                        .external_tables
9730                        .iter()
9731                        .find(|entry| entry.name == *name)
9732                        .map(|entry| entry.created_epoch);
9733                    if current != Some(*created_epoch) {
9734                        return Err(MongrelError::Conflict(format!(
9735                            "external table {name:?} changed during transaction preparation"
9736                        )));
9737                    }
9738                }
9739                for (table_id, definition) in &prepared_materialized_views {
9740                    let current = catalog.live(&definition.name).map(|entry| entry.table_id);
9741                    if current != Some(*table_id) {
9742                        return Err(MongrelError::Conflict(format!(
9743                            "materialized view {:?} changed during transaction preparation",
9744                            definition.name
9745                        )));
9746                    }
9747                }
9748                if trigger_catalog_binding(&catalog) != trigger_binding {
9749                    return Err(MongrelError::Conflict(
9750                        "trigger or referenced table generation changed during transaction preparation"
9751                            .into(),
9752                    ));
9753                }
9754            }
9755            let tables = self.tables.read();
9756            for (table_id, prepared) in &prepared_table_handles {
9757                if !tables
9758                    .get(table_id)
9759                    .is_some_and(|current| current.ptr_eq(prepared))
9760                {
9761                    return Err(MongrelError::Conflict(format!(
9762                        "table {table_id} mount changed during transaction preparation"
9763                    )));
9764                }
9765            }
9766            Ok(())
9767        })();
9768        if let Err(error) = catalog_generation_result {
9769            drop(commit_guard);
9770            for (_, _, pending) in &prepared_external {
9771                let _ = std::fs::remove_file(pending);
9772            }
9773            return Err(error);
9774        }
9775        // The commit lock keeps the next epoch stable while logical spill
9776        // records are serialized. Build them before taking the shared WAL
9777        // lock, and cap their aggregate memory/WAL footprint.
9778        let new_epoch = self.epoch.assigned().next();
9779        let mut spilled_wal_bytes = 0;
9780        let mut spilled_wal_records = Vec::<(u64, Op)>::new();
9781        let spill_prepare = (|| {
9782            for run in &mut spilled {
9783                for row in &mut run.rows {
9784                    row.committed_epoch = new_epoch;
9785                }
9786                for rows in encode_spilled_row_chunks(
9787                    &run.rows,
9788                    &mut spilled_wal_bytes,
9789                    SPILLED_WAL_TOTAL_MAX_BYTES,
9790                    control,
9791                )? {
9792                    spilled_wal_records.push((
9793                        run.table_id,
9794                        Op::SpilledRows {
9795                            table_id: run.table_id,
9796                            rows,
9797                        },
9798                    ));
9799                }
9800            }
9801            Result::<()>::Ok(())
9802        })();
9803        if let Err(error) = spill_prepare {
9804            for (_, _, pending) in &prepared_external {
9805                let _ = std::fs::remove_file(pending);
9806            }
9807            return Err(error);
9808        }
9809        let (new_epoch, mut _epoch_guard, applies, committed_materialized_views, commit_seq) = {
9810            let mut wal = self.shared_wal.lock();
9811
9812            // Re-check only if the conflict index advanced since pre-validation
9813            // (bounded delta — spec §8.5, review fix #17). If the version is
9814            // unchanged, the pre-check result is still valid and the sequencer
9815            // does O(1) work regardless of write-set size.
9816            if self.conflicts.version() != pre_validate_version
9817                && self.conflicts.conflicts(&write_keys, read_epoch)
9818            {
9819                // Abort: this txn assigned no epoch yet. The prepared-run guard
9820                // restores final run names to their pending paths on return.
9821                drop(wal);
9822                for (_, _, pending) in &prepared_external {
9823                    let _ = std::fs::remove_file(pending);
9824                }
9825                return Err(MongrelError::Conflict(
9826                    "write-write conflict (sequencer delta re-check)".into(),
9827                ));
9828            }
9829
9830            if let Some(control) = control {
9831                if let Err(error) = control.checkpoint() {
9832                    drop(wal);
9833                    for (_, _, pending) in &prepared_external {
9834                        let _ = std::fs::remove_file(pending);
9835                    }
9836                    return Err(error);
9837                }
9838            }
9839            let mut applies = Vec::<TableApplyBatch>::new();
9840            let mut apply_indexes = HashMap::<u64, usize>::new();
9841            let mut committed_materialized_views = Vec::new();
9842            let mut wal_records = spilled_wal_records;
9843
9844            let mut index = 0;
9845            while index < staging.len() {
9846                let table_id = staging[index].0;
9847                let handle = prepared_table_handles
9848                    .get(&table_id)
9849                    .cloned()
9850                    .ok_or_else(|| {
9851                        MongrelError::NotFound(format!("table {table_id} not prepared"))
9852                    })?;
9853                let batch_index = *apply_indexes.entry(table_id).or_insert_with(|| {
9854                    let index = applies.len();
9855                    applies.push(TableApplyBatch {
9856                        table_id,
9857                        handle,
9858                        ops: Vec::new(),
9859                    });
9860                    index
9861                });
9862
9863                // Skip puts for tables that were spilled — their data is in a
9864                // pending run, not in streamed Put records.
9865                if spilled_tables.contains(&table_id) && matches!(&staging[index].1, Staged::Put(_))
9866                {
9867                    index += 1;
9868                    continue;
9869                }
9870
9871                match &staging[index].1 {
9872                    Staged::Put(_) => {
9873                        let mut rows = Vec::new();
9874                        while index < staging.len()
9875                            && staging[index].0 == table_id
9876                            && matches!(&staging[index].1, Staged::Put(_))
9877                        {
9878                            let mut row = prebuilt[index].take().ok_or_else(|| {
9879                                MongrelError::Other(
9880                                    "transaction prepare lost a prebuilt put row".into(),
9881                                )
9882                            })?;
9883                            row.committed_epoch = new_epoch;
9884                            rows.push(row);
9885                            index += 1;
9886                        }
9887                        let payload = bincode::serialize(&rows)
9888                            .map_err(|e| MongrelError::Other(format!("row serialize: {e}")))?;
9889                        wal_records.push((
9890                            table_id,
9891                            Op::Put {
9892                                table_id,
9893                                rows: payload,
9894                            },
9895                        ));
9896                        applies[batch_index].ops.push(StagedOp::Put(rows));
9897                    }
9898                    Staged::Delete(_) => {
9899                        let mut row_ids = Vec::new();
9900                        while index < staging.len()
9901                            && staging[index].0 == table_id
9902                            && matches!(&staging[index].1, Staged::Delete(_))
9903                        {
9904                            let Staged::Delete(row_id) = &staging[index].1 else {
9905                                return Err(MongrelError::Other(
9906                                    "transaction delete batch changed during WAL preparation"
9907                                        .into(),
9908                                ));
9909                            };
9910                            if let Some(before) = &delete_images[index] {
9911                                wal_records.push((
9912                                    table_id,
9913                                    Op::BeforeImage {
9914                                        table_id,
9915                                        row_id: *row_id,
9916                                        row: bincode::serialize(before).map_err(|error| {
9917                                            MongrelError::Other(format!(
9918                                                "before-image serialize: {error}"
9919                                            ))
9920                                        })?,
9921                                    },
9922                                ));
9923                            }
9924                            row_ids.push(*row_id);
9925                            index += 1;
9926                        }
9927                        wal_records.push((
9928                            table_id,
9929                            Op::Delete {
9930                                table_id,
9931                                row_ids: row_ids.clone(),
9932                            },
9933                        ));
9934                        applies[batch_index].ops.push(StagedOp::Delete(row_ids));
9935                    }
9936                    Staged::Truncate => {
9937                        wal_records.push((table_id, Op::TruncateTable { table_id }));
9938                        applies[batch_index].ops.push(StagedOp::Truncate);
9939                        index += 1;
9940                    }
9941                    Staged::Update { .. } => {
9942                        return Err(MongrelError::Other(
9943                            "transaction contains an unnormalized update at the sequencer".into(),
9944                        ));
9945                    }
9946                }
9947            }
9948
9949            for (name, state, _) in &prepared_external {
9950                wal_records.push((
9951                    EXTERNAL_TABLE_ID,
9952                    Op::ExternalTableState {
9953                        name: name.clone(),
9954                        state: state.clone(),
9955                    },
9956                ));
9957            }
9958
9959            for (table_id, definition) in &prepared_materialized_views {
9960                let mut definition = definition.clone();
9961                definition.last_refresh_epoch = new_epoch.0;
9962                wal_records.push((
9963                    *table_id,
9964                    Op::Ddl(crate::wal::DdlOp::SetMaterializedView {
9965                        name: definition.name.clone(),
9966                        definition_json: crate::wal::DdlOp::encode_materialized_view(&definition)?,
9967                    }),
9968                ));
9969                committed_materialized_views.push(definition);
9970            }
9971            if !committed_materialized_views.is_empty() {
9972                let mut next_catalog = self.catalog.read().clone();
9973                for definition in &committed_materialized_views {
9974                    if let Some(existing) = next_catalog
9975                        .materialized_views
9976                        .iter_mut()
9977                        .find(|existing| existing.name == definition.name)
9978                    {
9979                        *existing = definition.clone();
9980                    } else {
9981                        next_catalog.materialized_views.push(definition.clone());
9982                    }
9983                }
9984                next_catalog.db_epoch = next_catalog.db_epoch.max(new_epoch.0);
9985                wal_records.push((
9986                    WAL_TABLE_ID,
9987                    Op::Ddl(crate::wal::DdlOp::CatalogSnapshot {
9988                        catalog_json: crate::wal::DdlOp::encode_catalog(&next_catalog)?,
9989                    }),
9990                ));
9991            }
9992
9993            if let Some(control) = control {
9994                if let Err(error) = control.checkpoint() {
9995                    drop(wal);
9996                    for (_, _, pending) in &prepared_external {
9997                        let _ = std::fs::remove_file(pending);
9998                    }
9999                    return Err(error);
10000                }
10001            }
10002            if let Some(before_commit) = before_commit.as_mut() {
10003                if let Err(error) = before_commit() {
10004                    drop(wal);
10005                    for (_, _, pending) in &prepared_external {
10006                        let _ = std::fs::remove_file(pending);
10007                    }
10008                    return Err(error);
10009                }
10010            }
10011
10012            let assigned_epoch = self.epoch.bump_assigned();
10013            let _epoch_guard = EpochGuard::new(self.epoch.as_ref(), assigned_epoch);
10014            if assigned_epoch != new_epoch {
10015                for (_, _, pending) in &prepared_external {
10016                    let _ = std::fs::remove_file(pending);
10017                }
10018                return Err(MongrelError::Conflict(
10019                    "commit epoch changed while sequencer lock was held".into(),
10020                ));
10021            }
10022
10023            // From this point the outcome can become ambiguous. Keep prepared
10024            // spill files at the final names referenced by a possibly durable
10025            // commit marker; orphan cleanup is safe when the append did fail.
10026            prepared_run_links.disarm();
10027
10028            let append: Result<u64> = (|| {
10029                for (table_id, op) in wal_records {
10030                    wal.append(txn_id, table_id, op)?;
10031                }
10032                wal.append_commit(txn_id, new_epoch, &added_runs)
10033            })();
10034            let commit_seq =
10035                append.map_err(|error| self.commit_outcome_unknown(new_epoch, error))?;
10036
10037            // Record the conflict + assign the epoch under the WAL lock so commit
10038            // order == WAL append order, but DO NOT fsync here (P3.2): the fsync
10039            // moves out of this critical section to the group-commit coordinator
10040            // so concurrent committers share a single leader fsync.
10041            self.conflicts.record(&write_keys, new_epoch);
10042            (
10043                new_epoch,
10044                _epoch_guard,
10045                applies,
10046                committed_materialized_views,
10047                commit_seq,
10048            )
10049        };
10050        drop(commit_guard);
10051
10052        // ── 2b. Durability: one leader fsync serves this whole batch (P3.2). ──
10053        self.await_durable_commit(commit_seq, new_epoch)?;
10054        drop(security_guard);
10055
10056        // ── 3. Publish: apply non-spilled ops + link spilled runs ──
10057        let publish_result: Result<()> = {
10058            let mut first_error = None;
10059            let mut spilled_by_table: HashMap<u64, Vec<&SpilledRun>> = HashMap::new();
10060            for run in &spilled {
10061                spilled_by_table.entry(run.table_id).or_default().push(run);
10062            }
10063            let mut modified_tables = Vec::with_capacity(applies.len());
10064            // Apply every table completely before any fallible manifest write.
10065            // The visible epoch remains unchanged until all tables are coherent.
10066            for batch in applies {
10067                #[cfg(test)]
10068                PUBLISH_TABLE_LOCKS.with(|count| count.set(count.get() + 1));
10069                let mut t = batch.handle.lock();
10070                for op in batch.ops {
10071                    match op {
10072                        StagedOp::Put(rows) => t.apply_put_rows_prepared(rows),
10073                        StagedOp::Delete(row_ids) => {
10074                            for row_id in row_ids {
10075                                t.apply_delete(row_id, new_epoch);
10076                            }
10077                        }
10078                        StagedOp::Truncate => t.apply_truncate(new_epoch),
10079                    }
10080                }
10081                if let Some(runs) = spilled_by_table.remove(&batch.table_id) {
10082                    for run in runs {
10083                        t.link_run(crate::manifest::RunRef {
10084                            run_id: run.run_id,
10085                            level: 0,
10086                            epoch_created: new_epoch.0,
10087                            row_count: run.row_count,
10088                        });
10089                        t.apply_run_metadata_prepared(&run.rows)?;
10090                        if truncated_tables.contains(&batch.table_id) {
10091                            // TRUNCATE + spilled puts fully describe this table at
10092                            // the commit epoch. Endorse the epoch so clean-reopen
10093                            // recovery does not replay the truncate over the
10094                            // already-linked replacement run.
10095                            t.set_flushed_epoch(new_epoch);
10096                        }
10097                    }
10098                }
10099                t.invalidate_pending_cache();
10100                drop(t);
10101                modified_tables.push(batch.handle);
10102            }
10103
10104            // Checkpoint only after every live table carries the durable state.
10105            // Continue after one checkpoint failure so runtime publication stays
10106            // all-or-nothing; WAL recovery repairs failed files on reopen.
10107            for handle in modified_tables {
10108                #[cfg(test)]
10109                COMMIT_MANIFEST_WRITES.with(|count| count.set(count.get() + 1));
10110                if let Err(error) = handle.lock().persist_manifest(new_epoch) {
10111                    first_error.get_or_insert(error);
10112                }
10113            }
10114            for (name, _, pending) in &prepared_external {
10115                if let Err(error) = publish_external_state_file(&self.root, name, pending) {
10116                    first_error.get_or_insert(error);
10117                }
10118            }
10119            if !committed_materialized_views.is_empty() {
10120                let mut next_catalog = self.catalog.read().clone();
10121                for definition in committed_materialized_views {
10122                    if let Some(existing) = next_catalog
10123                        .materialized_views
10124                        .iter_mut()
10125                        .find(|existing| existing.name == definition.name)
10126                    {
10127                        *existing = definition;
10128                    } else {
10129                        next_catalog.materialized_views.push(definition);
10130                    }
10131                }
10132                next_catalog.db_epoch = next_catalog.db_epoch.max(new_epoch.0);
10133                if let Err(error) = self.checkpoint_catalog_after_durable(next_catalog) {
10134                    first_error.get_or_insert(error);
10135                }
10136            }
10137            match first_error {
10138                Some(error) => Err(error),
10139                None => Ok(()),
10140            }
10141        };
10142
10143        if has_changes {
10144            let _ = self.change_wake.send(());
10145        }
10146        self.finish_durable_publish(new_epoch, &mut _epoch_guard, publish_result)?;
10147        Ok((new_epoch, committed_row_ids))
10148    }
10149
10150    /// Register a read snapshot at the current visible epoch and return it with
10151    /// a guard that retains it for GC until dropped.
10152    pub fn snapshot(&self) -> (Snapshot, SnapshotGuard<'_>) {
10153        let e = self.epoch.visible();
10154        let g = self.snapshots.register(e);
10155        (Snapshot::at(e), g)
10156    }
10157
10158    /// Owned (clonable-handle) variant of [`Self::snapshot`] for cross-thread
10159    /// retention.
10160    pub fn snapshot_owned(&self) -> (Snapshot, OwnedSnapshotGuard) {
10161        let e = self.epoch.visible();
10162        let g = self.snapshots.register_owned(e);
10163        (Snapshot::at(e), g)
10164    }
10165
10166    /// Configure a rolling history window measured in prior commit epochs.
10167    /// The first enable starts at the current epoch because earlier versions
10168    /// may already have been compacted. Increasing the window likewise cannot
10169    /// recreate history that fell outside the previous guarantee.
10170    pub fn set_history_retention_epochs(&self, epochs: u64) -> Result<()> {
10171        let _guard = self.ddl_lock.lock();
10172        let current = self.epoch.visible();
10173        let (old_epochs, old_start) = self.snapshots.history_config();
10174        let earliest_already_guaranteed = if old_epochs == 0 {
10175            current
10176        } else {
10177            Epoch(old_start.0.max(current.0.saturating_sub(old_epochs)))
10178        };
10179        let start = if epochs == 0 {
10180            current
10181        } else {
10182            earliest_already_guaranteed
10183        };
10184        let published = std::cell::Cell::new(false);
10185        let result = write_history_retention(&self.root, epochs, start, || {
10186            self.snapshots.configure_history(epochs, start);
10187            published.set(true);
10188        });
10189        match result {
10190            Err(error) if published.get() => Err(MongrelError::CommitOutcomeUnknown {
10191                epoch: current.0,
10192                message: format!("history-retention publication was not durable: {error}"),
10193            }),
10194            result => result,
10195        }
10196    }
10197
10198    pub fn history_retention_epochs(&self) -> u64 {
10199        self.snapshots.history_config().0
10200    }
10201
10202    pub fn earliest_retained_epoch(&self) -> Epoch {
10203        let current = self.epoch.visible();
10204        self.snapshots.history_floor(current).unwrap_or(current)
10205    }
10206
10207    /// Pin a guaranteed historical epoch for the lifetime of the returned
10208    /// guard. Rejects future epochs and epochs outside the configured window.
10209    pub fn snapshot_at_owned(&self, epoch: Epoch) -> Result<(Snapshot, OwnedSnapshotGuard)> {
10210        let current = self.epoch.visible();
10211        if epoch > current {
10212            return Err(MongrelError::InvalidArgument(format!(
10213                "epoch {} is in the future; current epoch is {}",
10214                epoch.0, current.0
10215            )));
10216        }
10217        let earliest = self.earliest_retained_epoch();
10218        if epoch < earliest {
10219            return Err(MongrelError::InvalidArgument(format!(
10220                "epoch {} is no longer retained; earliest available epoch is {}",
10221                epoch.0, earliest.0
10222            )));
10223        }
10224        let guard = self.snapshots.register_owned(epoch);
10225        Ok((Snapshot::at(epoch), guard))
10226    }
10227
10228    /// Names of all live tables.
10229    pub fn table_names(&self) -> Vec<String> {
10230        self.catalog
10231            .read()
10232            .tables
10233            .iter()
10234            .filter(|t| matches!(t.state, TableState::Live))
10235            .map(|t| t.name.clone())
10236            .collect()
10237    }
10238
10239    /// Best-effort flush-on-close (§4.4): force-flush every mounted table
10240    /// that has pending writes to a `.sr` sorted run, so WAL segments can be
10241    /// reaped on the next open. Call this as the last action before a
10242    /// short-lived process (CLI, one-shot script) exits. The daemon does not
10243    /// need this — its background auto-compactor handles run management.
10244    pub fn close(&self) -> Result<()> {
10245        for name in self.table_names() {
10246            if let Ok(handle) = self.table(&name) {
10247                if let Err(e) = handle.lock().close() {
10248                    eprintln!("[close] flush failed for {name}: {e}");
10249                }
10250            }
10251        }
10252        Ok(())
10253    }
10254
10255    /// Compact every mounted table: merge all sorted runs into one clean run
10256    /// so query cost stays flat (single-run fast path) instead of growing
10257    /// with run count. Tables with < 2 runs are skipped unless TTL has expired
10258    /// rows to reclaim. Each table
10259    /// is locked individually for its own compaction; snapshot retention is
10260    /// honored by `Table::compact`. Returns `(tables_compacted, tables_skipped)`.
10261    pub fn compact(&self) -> Result<(usize, usize)> {
10262        self.require(&crate::auth::Permission::Ddl)?;
10263        let mut compacted = 0;
10264        let mut skipped = 0;
10265        for name in self.table_names() {
10266            let Ok(handle) = self.table(&name) else {
10267                continue;
10268            };
10269            {
10270                let mut t = handle.lock();
10271                let before = t.run_count();
10272                if before < 2 && !t.should_compact() {
10273                    skipped += 1;
10274                    continue;
10275                }
10276                match t.compact() {
10277                    Ok(()) => {
10278                        let after = t.run_count();
10279                        compacted += 1;
10280                        eprintln!("[compact] {name}: {before} -> {after} runs");
10281                    }
10282                    Err(e) => {
10283                        eprintln!("[compact] {name}: compaction failed: {e}");
10284                        skipped += 1;
10285                    }
10286                }
10287            }
10288        }
10289        Ok((compacted, skipped))
10290    }
10291
10292    /// Compact a single table by name. Returns `Ok(true)` if it was
10293    /// compacted, `Ok(false)` if skipped (< 2 runs).
10294    pub fn compact_table(&self, name: &str) -> Result<bool> {
10295        self.require(&crate::auth::Permission::Ddl)?;
10296        let handle = self.table(name)?;
10297        let mut t = handle.lock();
10298        let before = t.run_count();
10299        if before < 2 {
10300            return Ok(false);
10301        }
10302        t.compact()?;
10303        Ok(t.run_count() < before)
10304    }
10305
10306    /// Look up a live table by name.
10307    pub fn table(&self, name: &str) -> Result<TableHandle> {
10308        let cat = self.catalog.read();
10309        let entry = cat
10310            .live(name)
10311            .ok_or_else(|| MongrelError::NotFound(format!("table {name:?} not found")))?;
10312        let id = entry.table_id;
10313        drop(cat);
10314        self.tables
10315            .read()
10316            .get(&id)
10317            .cloned()
10318            .ok_or_else(|| MongrelError::NotFound(format!("table {name:?} not mounted")))
10319    }
10320
10321    /// Whether any mounted table has wall-clock TTL retention. SQL sessions
10322    /// use this to avoid epoch-keyed result caches that can outlive a cutoff.
10323    pub fn has_ttl_tables(&self) -> bool {
10324        self.tables
10325            .read()
10326            .values()
10327            .any(|table| table.lock().ttl().is_some())
10328    }
10329
10330    /// Resolve a live table id → mounted handle (used by the constraint
10331    /// validation pass and other id-qualified internal paths).
10332    pub(crate) fn table_by_id(&self, id: u64) -> Result<TableHandle> {
10333        self.tables
10334            .read()
10335            .get(&id)
10336            .cloned()
10337            .ok_or_else(|| MongrelError::NotFound(format!("table id {id} not mounted")))
10338    }
10339
10340    /// Create a new table. The DDL is first logged to the shared WAL
10341    /// (`Op::Ddl(CreateTable)` + `TxnCommit`) and group-synced so it is durable
10342    /// BEFORE the in-memory catalog and table map are mutated; the catalog
10343    /// checkpoint is rewritten afterwards (spec §15, review fix #16). A reopen
10344    /// that sees a stale catalog still recovers the table by replaying the Ddl.
10345    pub fn create_table(&self, name: &str, schema: Schema) -> Result<u64> {
10346        if name.starts_with(CTAS_BUILD_TABLE_PREFIX) {
10347            return Err(MongrelError::InvalidArgument(format!(
10348                "table names beginning with {CTAS_BUILD_TABLE_PREFIX:?} are reserved"
10349            )));
10350        }
10351        self.create_table_with_state(name, schema, TableState::Live)
10352    }
10353
10354    /// Create a durable but non-queryable CTAS build table.
10355    #[doc(hidden)]
10356    pub fn create_building_table(
10357        &self,
10358        build_name: &str,
10359        intended_name: &str,
10360        query_id: &str,
10361        schema: Schema,
10362    ) -> Result<u64> {
10363        if !build_name.starts_with(CTAS_BUILD_TABLE_PREFIX)
10364            || intended_name.is_empty()
10365            || intended_name.starts_with(CTAS_BUILD_TABLE_PREFIX)
10366            || query_id.is_empty()
10367        {
10368            return Err(MongrelError::InvalidArgument(
10369                "invalid CTAS building-table identity".into(),
10370            ));
10371        }
10372        self.create_table_with_state(
10373            build_name,
10374            schema,
10375            TableState::Building {
10376                intended_name: intended_name.to_string(),
10377                query_id: query_id.to_string(),
10378                created_at_unix_nanos: current_unix_nanos(),
10379                replaces_table_id: None,
10380            },
10381        )
10382    }
10383
10384    /// Create a hidden schema-rebuild table while the intended target remains
10385    /// live. Publication later validates that the same target is still live.
10386    #[doc(hidden)]
10387    pub fn create_rebuilding_table(
10388        &self,
10389        build_name: &str,
10390        intended_name: &str,
10391        query_id: &str,
10392        schema: Schema,
10393    ) -> Result<u64> {
10394        if !build_name.starts_with(CTAS_BUILD_TABLE_PREFIX)
10395            || intended_name.is_empty()
10396            || intended_name.starts_with(CTAS_BUILD_TABLE_PREFIX)
10397            || query_id.is_empty()
10398        {
10399            return Err(MongrelError::InvalidArgument(
10400                "invalid rebuilding-table identity".into(),
10401            ));
10402        }
10403        let replaces_table_id = self
10404            .catalog
10405            .read()
10406            .live(intended_name)
10407            .ok_or_else(|| MongrelError::NotFound(format!("table {intended_name:?} not found")))?
10408            .table_id;
10409        self.create_table_with_state(
10410            build_name,
10411            schema,
10412            TableState::Building {
10413                intended_name: intended_name.to_string(),
10414                query_id: query_id.to_string(),
10415                created_at_unix_nanos: current_unix_nanos(),
10416                replaces_table_id: Some(replaces_table_id),
10417            },
10418        )
10419    }
10420
10421    fn create_table_with_state(
10422        &self,
10423        name: &str,
10424        schema: Schema,
10425        state: TableState,
10426    ) -> Result<u64> {
10427        use crate::wal::DdlOp;
10428        use std::sync::atomic::Ordering;
10429
10430        self.require(&crate::auth::Permission::Ddl)?;
10431        if self.poisoned.load(Ordering::Relaxed) {
10432            return Err(MongrelError::Other(
10433                "database poisoned by fsync error".into(),
10434            ));
10435        }
10436
10437        let _g = self.ddl_lock.lock();
10438        let _security_write = self.security_write()?;
10439        self.require(&crate::auth::Permission::Ddl)?;
10440        {
10441            let cat = self.catalog.read();
10442            match &state {
10443                TableState::Live => {
10444                    if cat.live(name).is_some() || cat.building_for(name).is_some() {
10445                        return Err(MongrelError::InvalidArgument(format!(
10446                            "table {name:?} already exists or is being built"
10447                        )));
10448                    }
10449                }
10450                TableState::Building {
10451                    intended_name,
10452                    replaces_table_id,
10453                    ..
10454                } => {
10455                    let target_matches = match replaces_table_id {
10456                        Some(table_id) => cat
10457                            .live(intended_name)
10458                            .is_some_and(|entry| entry.table_id == *table_id),
10459                        None => cat.live(intended_name).is_none(),
10460                    };
10461                    if !target_matches || cat.building_for(intended_name).is_some() {
10462                        return Err(MongrelError::InvalidArgument(format!(
10463                            "table {intended_name:?} changed or is already being built"
10464                        )));
10465                    }
10466                    if cat.building(name).is_some() {
10467                        return Err(MongrelError::InvalidArgument(format!(
10468                            "building table {name:?} already exists"
10469                        )));
10470                    }
10471                }
10472                TableState::Dropped { .. } => {
10473                    return Err(MongrelError::InvalidArgument(
10474                        "cannot create a dropped table".into(),
10475                    ));
10476                }
10477            }
10478        }
10479
10480        // Allocate id + epoch + txn id under the commit lock so the DDL commit
10481        // is serialized with data commits (in-order publish).
10482        let commit_lock = Arc::clone(&self.commit_lock);
10483        let _c = commit_lock.lock();
10484        let table_id = {
10485            let mut cat = self.catalog.write();
10486            let id = cat.next_table_id;
10487            cat.next_table_id = id
10488                .checked_add(1)
10489                .ok_or_else(|| MongrelError::InvalidArgument("table id space exhausted".into()))?;
10490            Result::<u64>::Ok(id)
10491        }?;
10492        let epoch = self.epoch.bump_assigned();
10493        let mut _epoch_guard = EpochGuard::new(self.epoch.as_ref(), epoch);
10494        let txn_id = self.alloc_txn_id()?;
10495
10496        // Stamp the schema_id with the unique table_id so every table in the
10497        // database has a distinct schema_id (caller-provided values are
10498        // ignored to prevent collisions).
10499        let mut schema = schema;
10500        schema.schema_id = table_id;
10501        // Defense in depth: reject an invalid schema BEFORE any durable
10502        // side-effect. `Table::create_in` re-validates, but by then the DDL has
10503        // already been appended to the shared WAL; a failing create_in would
10504        // leave a dangling entry that `recover_ddl_from_wal` replays without
10505        // re-validating, corrupting the catalog on reopen. Validating here
10506        // keeps the WAL free of schemas that can never be opened.
10507        schema.validate_auto_increment()?;
10508        schema.validate_defaults()?;
10509        schema.validate_ai()?;
10510        for index in &schema.indexes {
10511            index.validate_options()?;
10512        }
10513        for constraint in &schema.constraints.checks {
10514            constraint.expr.validate()?;
10515        }
10516
10517        // Build the complete mounted table before its DDL can become durable.
10518        // Any failure removes the unpublished directory and abandons the epoch.
10519        let table_relative = Path::new(TABLES_DIR).join(table_id.to_string());
10520        let canonical_tdir = self.root.join(&table_relative);
10521        let table_root = Arc::new(
10522            self.durable_root
10523                .create_directory_all_pinned(&table_relative)?,
10524        );
10525        let tdir = table_root.io_path()?;
10526        let mut pending_table_dir = PendingTableDir::new(canonical_tdir);
10527        let ctx = SharedCtx {
10528            root_guard: Some(table_root),
10529            epoch: Arc::clone(&self.epoch),
10530            page_cache: Arc::clone(&self.page_cache),
10531            decoded_cache: Arc::clone(&self.decoded_cache),
10532            snapshots: Arc::clone(&self.snapshots),
10533            kek: self.kek.clone(),
10534            commit_lock: Arc::clone(&self.commit_lock),
10535            shared: Some(crate::engine::SharedWalCtx {
10536                wal: Arc::clone(&self.shared_wal),
10537                group: Arc::clone(&self.group),
10538                poisoned: Arc::clone(&self.poisoned),
10539                txn_ids: Arc::clone(&self.next_txn_id),
10540                change_wake: self.change_wake.clone(),
10541            }),
10542            table_name: Some(name.to_string()),
10543            auth: self.table_auth_checker(),
10544            read_only: self.read_only,
10545        };
10546        let table = Table::create_in(&tdir, schema.clone(), table_id, ctx)?;
10547
10548        // 1. Log the DDL + commit marker to the shared WAL, then make it durable
10549        //    via the group-commit coordinator (no fsync under the WAL lock — P3.2).
10550        let schema_json = DdlOp::encode_schema(&schema)?;
10551        let ddl = match &state {
10552            TableState::Live => DdlOp::CreateTable {
10553                table_id,
10554                name: name.to_string(),
10555                schema_json,
10556            },
10557            TableState::Building {
10558                intended_name,
10559                query_id,
10560                created_at_unix_nanos,
10561                replaces_table_id,
10562            } => match replaces_table_id {
10563                Some(replaces_table_id) => DdlOp::CreateRebuildingTable {
10564                    table_id,
10565                    build_name: name.to_string(),
10566                    intended_name: intended_name.clone(),
10567                    query_id: query_id.clone(),
10568                    created_at_unix_nanos: *created_at_unix_nanos,
10569                    replaces_table_id: *replaces_table_id,
10570                    schema_json,
10571                },
10572                None => DdlOp::CreateBuildingTable {
10573                    table_id,
10574                    build_name: name.to_string(),
10575                    intended_name: intended_name.clone(),
10576                    query_id: query_id.clone(),
10577                    created_at_unix_nanos: *created_at_unix_nanos,
10578                    schema_json,
10579                },
10580            },
10581            TableState::Dropped { .. } => {
10582                return Err(MongrelError::InvalidArgument(
10583                    "cannot create a table in dropped state".into(),
10584                ));
10585            }
10586        };
10587        let mut next_catalog = self.catalog.read().clone();
10588        next_catalog.tables.push(CatalogEntry {
10589            table_id,
10590            name: name.to_string(),
10591            schema: schema.clone(),
10592            state: state.clone(),
10593            created_epoch: epoch.0,
10594        });
10595        next_catalog.db_epoch = next_catalog.db_epoch.max(epoch.0);
10596        let commit_seq = {
10597            let mut wal = self.shared_wal.lock();
10598            let append: Result<u64> = (|| {
10599                wal.append(txn_id, table_id, crate::wal::Op::Ddl(ddl))?;
10600                append_catalog_snapshot(&mut wal, txn_id, &next_catalog)?;
10601                wal.append_commit(txn_id, epoch, &[])
10602            })();
10603            append.map_err(|error| self.commit_outcome_unknown(epoch, error))?
10604        };
10605        self.await_durable_commit(commit_seq, epoch)?;
10606        pending_table_dir.disarm();
10607
10608        // Publish the mounted table and catalog in memory even if the catalog
10609        // checkpoint fails after the WAL commit.
10610        self.tables
10611            .write()
10612            .insert(table_id, TableHandle::new(table));
10613        let checkpoint = self.checkpoint_catalog_after_durable(next_catalog);
10614        self.finish_durable_publish(epoch, &mut _epoch_guard, checkpoint)?;
10615        Ok(table_id)
10616    }
10617
10618    /// Logically drop a table, logging the DDL through the shared WAL first.
10619    pub fn drop_table(&self, name: &str) -> Result<()> {
10620        self.drop_table_with_epoch(name).map(|_| ())
10621    }
10622
10623    /// Logically drop a table and return the exact publication epoch.
10624    pub fn drop_table_with_epoch(&self, name: &str) -> Result<Epoch> {
10625        self.drop_table_with_state(name, false, None)
10626    }
10627
10628    pub fn drop_table_with_epoch_controlled<F>(
10629        &self,
10630        name: &str,
10631        mut before_commit: F,
10632    ) -> Result<Epoch>
10633    where
10634        F: FnMut() -> Result<()>,
10635    {
10636        self.drop_table_with_state(name, false, Some(&mut before_commit))
10637    }
10638
10639    /// Discard an unpublished CTAS build.
10640    #[doc(hidden)]
10641    pub fn discard_building_table(&self, name: &str) -> Result<()> {
10642        if !name.starts_with(CTAS_BUILD_TABLE_PREFIX) {
10643            return Err(MongrelError::InvalidArgument(
10644                "not a CTAS building table".into(),
10645            ));
10646        }
10647        self.drop_table_with_state(name, true, None).map(|_| ())
10648    }
10649
10650    fn drop_table_with_state(
10651        &self,
10652        name: &str,
10653        building: bool,
10654        before_commit: Option<&mut dyn FnMut() -> Result<()>>,
10655    ) -> Result<Epoch> {
10656        use crate::wal::DdlOp;
10657        use std::sync::atomic::Ordering;
10658
10659        self.require(&crate::auth::Permission::Ddl)?;
10660        if self.poisoned.load(Ordering::Relaxed) {
10661            return Err(MongrelError::Other(
10662                "database poisoned by fsync error".into(),
10663            ));
10664        }
10665
10666        let _g = self.ddl_lock.lock();
10667        let _security_write = self.security_write()?;
10668        self.require(&crate::auth::Permission::Ddl)?;
10669        let table_id = {
10670            let cat = self.catalog.read();
10671            if building {
10672                cat.building(name)
10673            } else {
10674                cat.live(name)
10675            }
10676            .ok_or_else(|| MongrelError::NotFound(format!("table {name:?} not found")))?
10677            .table_id
10678        };
10679
10680        let commit_lock = Arc::clone(&self.commit_lock);
10681        let _c = commit_lock.lock();
10682        let epoch = self.epoch.bump_assigned();
10683        let mut _epoch_guard = EpochGuard::new(self.epoch.as_ref(), epoch);
10684        let txn_id = self.alloc_txn_id()?;
10685        let mut next_catalog = self.catalog.read().clone();
10686        let entry = next_catalog
10687            .tables
10688            .iter_mut()
10689            .find(|t| t.table_id == table_id)
10690            .ok_or_else(|| MongrelError::NotFound(format!("table {name:?} not found")))?;
10691        entry.state = TableState::Dropped { at_epoch: epoch.0 };
10692        next_catalog.triggers.retain(|trigger| {
10693            !matches!(
10694                &trigger.trigger.target,
10695                TriggerTarget::Table(target) if target == name
10696            )
10697        });
10698        next_catalog
10699            .materialized_views
10700            .retain(|definition| definition.name != name);
10701        next_catalog
10702            .security
10703            .rls_tables
10704            .retain(|table| table != name);
10705        next_catalog
10706            .security
10707            .policies
10708            .retain(|policy| policy.table != name);
10709        next_catalog
10710            .security
10711            .masks
10712            .retain(|mask| mask.table != name);
10713        for role in &mut next_catalog.roles {
10714            role.permissions
10715                .retain(|permission| permission_table(permission) != Some(name));
10716        }
10717        advance_security_version(&mut next_catalog)?;
10718        next_catalog.db_epoch = next_catalog.db_epoch.max(epoch.0);
10719        let commit_seq = {
10720            let mut wal = self.shared_wal.lock();
10721            if let Some(before_commit) = before_commit {
10722                before_commit()?;
10723            }
10724            let append: Result<u64> = (|| {
10725                wal.append(
10726                    txn_id,
10727                    table_id,
10728                    crate::wal::Op::Ddl(DdlOp::DropTable { table_id }),
10729                )?;
10730                append_catalog_snapshot(&mut wal, txn_id, &next_catalog)?;
10731                wal.append_commit(txn_id, epoch, &[])
10732            })();
10733            append.map_err(|error| self.commit_outcome_unknown(epoch, error))?
10734        };
10735        self.await_durable_commit(commit_seq, epoch)?;
10736
10737        let checkpoint = self.checkpoint_catalog_after_durable(next_catalog);
10738        self.tables.write().remove(&table_id);
10739        self.finish_durable_publish(epoch, &mut _epoch_guard, checkpoint)?;
10740        Ok(epoch)
10741    }
10742
10743    /// Rename a live table. `name` must exist and `new_name` must not collide
10744    /// with any live table; both checks run under `ddl_lock` so they are atomic
10745    /// with the rename and with concurrent `create_table` existence checks (no
10746    /// TOCTOU window). A no-op rename (`name == new_name`) succeeds without
10747    /// side-effects. The rename is logged to the shared WAL as
10748    /// `DdlOp::RenameTable` and recovered on reopen; the `table_id`, schema,
10749    /// and on-disk layout are unchanged (the table is keyed by `table_id`, so
10750    /// the in-memory object does not move — only the catalog name changes).
10751    pub fn rename_table(&self, name: &str, new_name: &str) -> Result<()> {
10752        self.rename_table_with_epoch(name, new_name).map(|_| ())
10753    }
10754
10755    /// Rename a table and return its exact publication epoch.
10756    pub fn rename_table_with_epoch(&self, name: &str, new_name: &str) -> Result<Epoch> {
10757        self.rename_table_with_epoch_inner(name, new_name, None)
10758    }
10759
10760    pub fn rename_table_with_epoch_controlled<F>(
10761        &self,
10762        name: &str,
10763        new_name: &str,
10764        mut before_commit: F,
10765    ) -> Result<Epoch>
10766    where
10767        F: FnMut() -> Result<()>,
10768    {
10769        self.rename_table_with_epoch_inner(name, new_name, Some(&mut before_commit))
10770    }
10771
10772    fn rename_table_with_epoch_inner(
10773        &self,
10774        name: &str,
10775        new_name: &str,
10776        before_commit: Option<&mut dyn FnMut() -> Result<()>>,
10777    ) -> Result<Epoch> {
10778        if name.starts_with(CTAS_BUILD_TABLE_PREFIX)
10779            || new_name.starts_with(CTAS_BUILD_TABLE_PREFIX)
10780        {
10781            return Err(MongrelError::InvalidArgument(
10782                "the CTAS building-table namespace is reserved".into(),
10783            ));
10784        }
10785        self.rename_table_with_state(name, new_name, false, None, before_commit)
10786    }
10787
10788    /// Atomically publish a hidden CTAS build under its intended live name.
10789    #[doc(hidden)]
10790    pub fn publish_building_table(&self, build_name: &str, new_name: &str) -> Result<Epoch> {
10791        self.publish_building_table_inner(build_name, new_name, None)
10792    }
10793
10794    #[doc(hidden)]
10795    pub fn publish_building_table_controlled<F>(
10796        &self,
10797        build_name: &str,
10798        new_name: &str,
10799        mut before_commit: F,
10800    ) -> Result<Epoch>
10801    where
10802        F: FnMut() -> Result<()>,
10803    {
10804        self.publish_building_table_inner(build_name, new_name, Some(&mut before_commit))
10805    }
10806
10807    fn publish_building_table_inner(
10808        &self,
10809        build_name: &str,
10810        new_name: &str,
10811        before_commit: Option<&mut dyn FnMut() -> Result<()>>,
10812    ) -> Result<Epoch> {
10813        if !build_name.starts_with(CTAS_BUILD_TABLE_PREFIX)
10814            || new_name.starts_with(CTAS_BUILD_TABLE_PREFIX)
10815        {
10816            return Err(MongrelError::InvalidArgument(
10817                "invalid CTAS publish identity".into(),
10818            ));
10819        }
10820        self.rename_table_with_state(build_name, new_name, true, None, before_commit)
10821    }
10822
10823    /// Atomically publish a hidden build and its materialized-view definition.
10824    #[doc(hidden)]
10825    pub fn publish_materialized_building_table(
10826        &self,
10827        build_name: &str,
10828        new_name: &str,
10829        definition: crate::catalog::MaterializedViewEntry,
10830    ) -> Result<Epoch> {
10831        self.publish_materialized_building_table_inner(build_name, new_name, definition, None)
10832    }
10833
10834    #[doc(hidden)]
10835    pub fn publish_materialized_building_table_controlled<F>(
10836        &self,
10837        build_name: &str,
10838        new_name: &str,
10839        definition: crate::catalog::MaterializedViewEntry,
10840        mut before_commit: F,
10841    ) -> Result<Epoch>
10842    where
10843        F: FnMut() -> Result<()>,
10844    {
10845        self.publish_materialized_building_table_inner(
10846            build_name,
10847            new_name,
10848            definition,
10849            Some(&mut before_commit),
10850        )
10851    }
10852
10853    fn publish_materialized_building_table_inner(
10854        &self,
10855        build_name: &str,
10856        new_name: &str,
10857        definition: crate::catalog::MaterializedViewEntry,
10858        before_commit: Option<&mut dyn FnMut() -> Result<()>>,
10859    ) -> Result<Epoch> {
10860        if definition.name != new_name || definition.query.trim().is_empty() {
10861            return Err(MongrelError::InvalidArgument(
10862                "invalid materialized-view publication".into(),
10863            ));
10864        }
10865        self.rename_table_with_state(build_name, new_name, true, Some(definition), before_commit)
10866    }
10867
10868    /// Atomically replace a still-live table with its completed hidden rebuild.
10869    #[doc(hidden)]
10870    pub fn publish_rebuilding_table(&self, build_name: &str, new_name: &str) -> Result<Epoch> {
10871        self.publish_rebuilding_table_inner(build_name, new_name, None, None)
10872    }
10873
10874    #[doc(hidden)]
10875    pub fn publish_rebuilding_table_controlled<F>(
10876        &self,
10877        build_name: &str,
10878        new_name: &str,
10879        mut before_commit: F,
10880    ) -> Result<Epoch>
10881    where
10882        F: FnMut() -> Result<()>,
10883    {
10884        self.publish_rebuilding_table_inner(build_name, new_name, None, Some(&mut before_commit))
10885    }
10886
10887    /// Atomically replace a live materialized-view table and its definition.
10888    #[doc(hidden)]
10889    pub fn publish_materialized_rebuilding_table_controlled<F>(
10890        &self,
10891        build_name: &str,
10892        new_name: &str,
10893        definition: crate::catalog::MaterializedViewEntry,
10894        mut before_commit: F,
10895    ) -> Result<Epoch>
10896    where
10897        F: FnMut() -> Result<()>,
10898    {
10899        self.publish_rebuilding_table_inner(
10900            build_name,
10901            new_name,
10902            Some(definition),
10903            Some(&mut before_commit),
10904        )
10905    }
10906
10907    fn publish_rebuilding_table_inner(
10908        &self,
10909        build_name: &str,
10910        new_name: &str,
10911        mut materialized_view: Option<crate::catalog::MaterializedViewEntry>,
10912        before_commit: Option<&mut dyn FnMut() -> Result<()>>,
10913    ) -> Result<Epoch> {
10914        use crate::wal::DdlOp;
10915
10916        if !build_name.starts_with(CTAS_BUILD_TABLE_PREFIX)
10917            || new_name.is_empty()
10918            || new_name.starts_with(CTAS_BUILD_TABLE_PREFIX)
10919        {
10920            return Err(MongrelError::InvalidArgument(
10921                "invalid rebuilding-table publish identity".into(),
10922            ));
10923        }
10924        if materialized_view.as_ref().is_some_and(|definition| {
10925            definition.name != new_name || definition.query.trim().is_empty()
10926        }) {
10927            return Err(MongrelError::InvalidArgument(
10928                "invalid materialized-view replacement".into(),
10929            ));
10930        }
10931        self.require(&crate::auth::Permission::Ddl)?;
10932        if self.poisoned.load(Ordering::Relaxed) {
10933            return Err(MongrelError::Other(
10934                "database poisoned by fsync error".into(),
10935            ));
10936        }
10937
10938        let _ddl = self.ddl_lock.lock();
10939        let _security_write = self.security_write()?;
10940        let (table_id, replaced_table_id) = {
10941            let catalog = self.catalog.read();
10942            let build = catalog.building(build_name).ok_or_else(|| {
10943                MongrelError::NotFound(format!("building table {build_name:?} not found"))
10944            })?;
10945            let replaced_table_id = match &build.state {
10946                TableState::Building {
10947                    intended_name,
10948                    replaces_table_id: Some(replaced_table_id),
10949                    ..
10950                } if intended_name == new_name => *replaced_table_id,
10951                _ => {
10952                    return Err(MongrelError::InvalidArgument(format!(
10953                        "building table {build_name:?} is not a replacement for {new_name:?}"
10954                    )))
10955                }
10956            };
10957            if catalog
10958                .live(new_name)
10959                .is_none_or(|entry| entry.table_id != replaced_table_id)
10960            {
10961                return Err(MongrelError::Conflict(format!(
10962                    "table {new_name:?} changed while its replacement was built"
10963                )));
10964            }
10965            (build.table_id, replaced_table_id)
10966        };
10967
10968        let _commit = self.commit_lock.lock();
10969        let epoch = self.epoch.assigned().next();
10970        let txn_id = self.alloc_txn_id()?;
10971        let mut next_catalog = self.catalog.read().clone();
10972        apply_rebuilding_publish(
10973            &mut next_catalog,
10974            table_id,
10975            replaced_table_id,
10976            new_name,
10977            epoch.0,
10978        )?;
10979        if let Some(definition) = materialized_view.as_mut() {
10980            definition.last_refresh_epoch = epoch.0;
10981        }
10982        let materialized_view_json = materialized_view
10983            .as_ref()
10984            .map(DdlOp::encode_materialized_view)
10985            .transpose()?;
10986        if let Some(definition) = materialized_view {
10987            if let Some(existing) = next_catalog
10988                .materialized_views
10989                .iter_mut()
10990                .find(|existing| existing.name == definition.name)
10991            {
10992                *existing = definition;
10993            } else {
10994                next_catalog.materialized_views.push(definition);
10995            }
10996        }
10997        next_catalog.db_epoch = next_catalog.db_epoch.max(epoch.0);
10998        if let Some(before_commit) = before_commit {
10999            before_commit()?;
11000        }
11001        let assigned_epoch = self.epoch.bump_assigned();
11002        let mut epoch_guard = EpochGuard::new(self.epoch.as_ref(), assigned_epoch);
11003        if assigned_epoch != epoch {
11004            return Err(MongrelError::Conflict(
11005                "commit epoch changed while sequencer lock was held".into(),
11006            ));
11007        }
11008        let commit_seq = {
11009            let mut wal = self.shared_wal.lock();
11010            let append: Result<u64> = (|| {
11011                wal.append(
11012                    txn_id,
11013                    table_id,
11014                    crate::wal::Op::Ddl(DdlOp::ReplaceBuildingTable {
11015                        table_id,
11016                        replaced_table_id,
11017                        new_name: new_name.to_string(),
11018                    }),
11019                )?;
11020                if let Some(definition_json) = materialized_view_json {
11021                    wal.append(
11022                        txn_id,
11023                        table_id,
11024                        crate::wal::Op::Ddl(DdlOp::SetMaterializedView {
11025                            name: new_name.to_string(),
11026                            definition_json,
11027                        }),
11028                    )?;
11029                }
11030                append_catalog_snapshot(&mut wal, txn_id, &next_catalog)?;
11031                wal.append_commit(txn_id, epoch, &[])
11032            })();
11033            append.map_err(|error| self.commit_outcome_unknown(epoch, error))?
11034        };
11035        self.await_durable_commit(commit_seq, epoch)?;
11036
11037        let checkpoint = self.checkpoint_catalog_after_durable(next_catalog);
11038        self.tables.write().remove(&replaced_table_id);
11039        if let Some(table) = self.tables.read().get(&table_id) {
11040            table.lock().set_catalog_name(new_name.to_string());
11041        }
11042        self.finish_durable_publish(epoch, &mut epoch_guard, checkpoint)?;
11043        Ok(epoch)
11044    }
11045
11046    fn rename_table_with_state(
11047        &self,
11048        name: &str,
11049        new_name: &str,
11050        building: bool,
11051        mut materialized_view: Option<crate::catalog::MaterializedViewEntry>,
11052        before_commit: Option<&mut dyn FnMut() -> Result<()>>,
11053    ) -> Result<Epoch> {
11054        use crate::wal::DdlOp;
11055        use std::sync::atomic::Ordering;
11056
11057        self.require(&crate::auth::Permission::Ddl)?;
11058        if self.poisoned.load(Ordering::Relaxed) {
11059            return Err(MongrelError::Other(
11060                "database poisoned by fsync error".into(),
11061            ));
11062        }
11063
11064        // A no-op rename short-circuits before any locking, so it can never
11065        // trip the "target already exists" check (the source *is* that name).
11066        if name == new_name {
11067            return Ok(self.visible_epoch());
11068        }
11069        if new_name.is_empty() {
11070            return Err(MongrelError::InvalidArgument(
11071                "rename_table: new name must not be empty".into(),
11072            ));
11073        }
11074
11075        let _g = self.ddl_lock.lock();
11076        let _security_write = self.security_write()?;
11077        self.require(&crate::auth::Permission::Ddl)?;
11078        let table_id = {
11079            let cat = self.catalog.read();
11080            let src = if building {
11081                cat.building(name)
11082            } else {
11083                cat.live(name)
11084            }
11085            .ok_or_else(|| MongrelError::NotFound(format!("table {name:?} not found")))?;
11086            if building
11087                && !matches!(
11088                    &src.state,
11089                    TableState::Building { intended_name, .. } if intended_name == new_name
11090                )
11091            {
11092                return Err(MongrelError::InvalidArgument(format!(
11093                    "building table {name:?} is not reserved for {new_name:?}"
11094                )));
11095            }
11096            // Target must be free. Checked under ddl_lock, which every other
11097            // DDL (create/rename/drop) also holds, so a concurrent operation
11098            // cannot claim `new_name` between this check and the catalog write.
11099            if cat.live(new_name).is_some() || (!building && cat.building_for(new_name).is_some()) {
11100                return Err(MongrelError::InvalidArgument(format!(
11101                    "rename_table: a table named {new_name:?} already exists"
11102                )));
11103            }
11104            src.table_id
11105        };
11106
11107        let commit_lock = Arc::clone(&self.commit_lock);
11108        let _c = commit_lock.lock();
11109        let epoch = self.epoch.bump_assigned();
11110        let mut _epoch_guard = EpochGuard::new(self.epoch.as_ref(), epoch);
11111        let txn_id = self.alloc_txn_id()?;
11112        if let Some(definition) = materialized_view.as_mut() {
11113            definition.last_refresh_epoch = epoch.0;
11114        }
11115        let materialized_view_json = materialized_view
11116            .as_ref()
11117            .map(DdlOp::encode_materialized_view)
11118            .transpose()?;
11119        let mut next_catalog = self.catalog.read().clone();
11120        let entry = next_catalog
11121            .tables
11122            .iter_mut()
11123            .find(|t| t.table_id == table_id)
11124            .ok_or_else(|| MongrelError::NotFound(format!("table {name:?} not found")))?;
11125        entry.name = new_name.to_string();
11126        if building {
11127            entry.state = TableState::Live;
11128        }
11129        for trigger in &mut next_catalog.triggers {
11130            if matches!(
11131                &trigger.trigger.target,
11132                TriggerTarget::Table(target) if target == name
11133            ) {
11134                trigger.trigger = trigger.trigger.retarget_table(new_name, epoch.0)?;
11135            }
11136        }
11137        if let Some(definition) = next_catalog
11138            .materialized_views
11139            .iter_mut()
11140            .find(|definition| definition.name == name)
11141        {
11142            definition.name = new_name.to_string();
11143        }
11144        if let Some(definition) = materialized_view.take() {
11145            next_catalog.materialized_views.push(definition);
11146        }
11147        next_catalog.db_epoch = next_catalog.db_epoch.max(epoch.0);
11148        for table in &mut next_catalog.security.rls_tables {
11149            if table == name {
11150                *table = new_name.to_string();
11151            }
11152        }
11153        for policy in &mut next_catalog.security.policies {
11154            if policy.table == name {
11155                policy.table = new_name.to_string();
11156            }
11157        }
11158        for mask in &mut next_catalog.security.masks {
11159            if mask.table == name {
11160                mask.table = new_name.to_string();
11161            }
11162        }
11163        for role in &mut next_catalog.roles {
11164            for permission in &mut role.permissions {
11165                rename_permission_table(permission, name, new_name);
11166            }
11167        }
11168        advance_security_version(&mut next_catalog)?;
11169        let ddl = if building {
11170            DdlOp::PublishBuildingTable {
11171                table_id,
11172                new_name: new_name.to_string(),
11173            }
11174        } else {
11175            DdlOp::RenameTable {
11176                table_id,
11177                new_name: new_name.to_string(),
11178            }
11179        };
11180        let commit_seq = {
11181            let mut wal = self.shared_wal.lock();
11182            if let Some(before_commit) = before_commit {
11183                before_commit()?;
11184            }
11185            let append: Result<u64> = (|| {
11186                wal.append(txn_id, table_id, crate::wal::Op::Ddl(ddl))?;
11187                if let Some(definition_json) = materialized_view_json {
11188                    wal.append(
11189                        txn_id,
11190                        table_id,
11191                        crate::wal::Op::Ddl(DdlOp::SetMaterializedView {
11192                            name: new_name.to_string(),
11193                            definition_json,
11194                        }),
11195                    )?;
11196                }
11197                append_catalog_snapshot(&mut wal, txn_id, &next_catalog)?;
11198                wal.append_commit(txn_id, epoch, &[])
11199            })();
11200            append.map_err(|error| self.commit_outcome_unknown(epoch, error))?
11201        };
11202        self.await_durable_commit(commit_seq, epoch)?;
11203
11204        let checkpoint = self.checkpoint_catalog_after_durable(next_catalog);
11205        // The in-memory table object is keyed by table_id, not name, so it does
11206        // not move and live TableHandles remain valid.
11207        if let Some(table) = self.tables.read().get(&table_id) {
11208            table.lock().set_catalog_name(new_name.to_string());
11209        }
11210        self.finish_durable_publish(epoch, &mut _epoch_guard, checkpoint)?;
11211        Ok(epoch)
11212    }
11213
11214    pub fn alter_column(
11215        &self,
11216        table_name: &str,
11217        column_name: &str,
11218        change: AlterColumn,
11219    ) -> Result<ColumnDef> {
11220        self.alter_column_with_epoch(table_name, column_name, change)
11221            .map(|(column, _)| column)
11222    }
11223
11224    pub fn alter_column_with_epoch(
11225        &self,
11226        table_name: &str,
11227        column_name: &str,
11228        change: AlterColumn,
11229    ) -> Result<(ColumnDef, Option<Epoch>)> {
11230        self.alter_column_with_epoch_inner(table_name, column_name, change, None, None, None)
11231    }
11232
11233    /// Cooperatively prepare an ALTER and fence each durable commit separately.
11234    /// `after_commit(Some(epoch))` follows an exact durable outcome;
11235    /// `after_commit(None)` follows an uncertain WAL attempt. It is called once
11236    /// for every successful `before_commit` callback.
11237    pub fn alter_column_with_epoch_controlled<B, A>(
11238        &self,
11239        table_name: &str,
11240        column_name: &str,
11241        change: AlterColumn,
11242        control: &crate::ExecutionControl,
11243        mut before_commit: B,
11244        mut after_commit: A,
11245    ) -> Result<(ColumnDef, Option<Epoch>)>
11246    where
11247        B: FnMut() -> Result<()>,
11248        A: FnMut(Option<Epoch>) -> Result<()>,
11249    {
11250        self.alter_column_with_epoch_inner(
11251            table_name,
11252            column_name,
11253            change,
11254            Some(control),
11255            Some(&mut before_commit),
11256            Some(&mut after_commit),
11257        )
11258    }
11259
11260    #[allow(clippy::too_many_arguments)]
11261    fn alter_column_with_epoch_inner(
11262        &self,
11263        table_name: &str,
11264        column_name: &str,
11265        change: AlterColumn,
11266        control: Option<&crate::ExecutionControl>,
11267        mut before_commit: Option<&mut dyn FnMut() -> Result<()>>,
11268        mut after_commit: Option<&mut dyn FnMut(Option<Epoch>) -> Result<()>>,
11269    ) -> Result<(ColumnDef, Option<Epoch>)> {
11270        use crate::wal::DdlOp;
11271        use std::sync::atomic::Ordering;
11272
11273        self.require(&crate::auth::Permission::Ddl)?;
11274        commit_prepare_checkpoint(control, 0)?;
11275        if self.poisoned.load(Ordering::Relaxed) {
11276            return Err(MongrelError::Other(
11277                "database poisoned by fsync error".into(),
11278            ));
11279        }
11280
11281        let _g = self.ddl_lock.lock();
11282        let table_id = {
11283            let cat = self.catalog.read();
11284            cat.live(table_name)
11285                .ok_or_else(|| MongrelError::NotFound(format!("table {table_name:?} not found")))?
11286                .table_id
11287        };
11288        let handle =
11289            self.tables.read().get(&table_id).cloned().ok_or_else(|| {
11290                MongrelError::NotFound(format!("table {table_name:?} not mounted"))
11291            })?;
11292
11293        // Legitimate online-ALTER slice: when nullable -> NOT NULL has a
11294        // declared default, backfill existing NULL/absent cells as one durable
11295        // transaction before logging the metadata change. A crash between the
11296        // two commits leaves a harmless nullable-but-filled column; retry is
11297        // idempotent because only remaining NULLs are touched.
11298        let backfill = {
11299            let table = handle.lock();
11300            let old = table
11301                .schema()
11302                .column(column_name)
11303                .cloned()
11304                .ok_or_else(|| MongrelError::Schema(format!("unknown column {column_name}")))?;
11305            let next_flags = change.flags.unwrap_or(old.flags);
11306            if old.flags.contains(crate::schema::ColumnFlags::NULLABLE)
11307                && !next_flags.contains(crate::schema::ColumnFlags::NULLABLE)
11308                && old.default_value.is_some()
11309            {
11310                let snapshot = Snapshot::at(self.epoch.visible());
11311                let mut updates = Vec::new();
11312                let rows = match control {
11313                    Some(control) => table.visible_rows_controlled(snapshot, control)?,
11314                    None => table.visible_rows(snapshot)?,
11315                };
11316                for (row_index, row) in rows.into_iter().enumerate() {
11317                    commit_prepare_checkpoint(control, row_index)?;
11318                    if row
11319                        .columns
11320                        .get(&old.id)
11321                        .is_some_and(|value| !matches!(value, Value::Null))
11322                    {
11323                        continue;
11324                    }
11325                    let mut cells: Vec<(u16, Value)> = row.columns.into_iter().collect();
11326                    table.apply_defaults(&mut cells)?;
11327                    updates.push((
11328                        table_id,
11329                        crate::txn::Staged::Update {
11330                            row_id: row.row_id,
11331                            new_row: cells,
11332                            changed_columns: vec![old.id],
11333                        },
11334                    ));
11335                }
11336                updates
11337            } else {
11338                Vec::new()
11339            }
11340        };
11341        let durable_epoch = std::cell::Cell::new(None);
11342        let backfill_epoch = if backfill.is_empty() {
11343            None
11344        } else {
11345            let (principal, catalog_bound) = self.transaction_principal_snapshot();
11346            let txn_id = self.alloc_txn_id()?;
11347            let mut entered_fence = false;
11348            let commit_result = match (control, before_commit.as_deref_mut()) {
11349                (Some(control), Some(before_commit)) => self
11350                    .commit_transaction_with_external_states_controlled(
11351                        txn_id,
11352                        self.epoch.visible(),
11353                        backfill,
11354                        Vec::new(),
11355                        Vec::new(),
11356                        principal.clone(),
11357                        catalog_bound,
11358                        None,
11359                        control,
11360                        &mut || {
11361                            before_commit()?;
11362                            entered_fence = true;
11363                            Ok(())
11364                        },
11365                    )
11366                    .map(|(epoch, _)| epoch),
11367                _ => self
11368                    .commit_transaction_with_external_states(
11369                        txn_id,
11370                        self.epoch.visible(),
11371                        backfill,
11372                        Vec::new(),
11373                        Vec::new(),
11374                        principal,
11375                        catalog_bound,
11376                        None,
11377                    )
11378                    .map(|(epoch, _)| epoch),
11379            };
11380            let commit_result = if entered_fence {
11381                finish_controlled_commit_attempt(commit_result, &mut after_commit)
11382            } else {
11383                commit_result
11384            };
11385            match &commit_result {
11386                Ok(epoch) => durable_epoch.set(Some(*epoch)),
11387                Err(MongrelError::DurableCommit { epoch, .. }) => {
11388                    durable_epoch.set(Some(Epoch(*epoch)));
11389                }
11390                Err(_) => {}
11391            }
11392            Some(commit_result?)
11393        };
11394        let result: Result<(ColumnDef, Option<Epoch>)> = (|| {
11395            let _security_write = self.security_write()?;
11396            self.require(&crate::auth::Permission::Ddl)?;
11397            if self
11398                .catalog
11399                .read()
11400                .live(table_name)
11401                .is_none_or(|entry| entry.table_id != table_id)
11402            {
11403                return Err(MongrelError::Conflict(format!(
11404                    "table {table_name:?} changed during ALTER"
11405                )));
11406            }
11407            let mut table = handle.lock();
11408            let (column, prepared_schema) = table.prepare_alter_column(column_name, &change)?;
11409            let renamed_column = (column.name != column_name).then(|| column.name.clone());
11410            let Some(prepared_schema) = prepared_schema else {
11411                return Ok((column, backfill_epoch));
11412            };
11413
11414            let commit_lock = Arc::clone(&self.commit_lock);
11415            let _c = commit_lock.lock();
11416            let epoch = self.epoch.bump_assigned();
11417            let mut _epoch_guard = EpochGuard::new(self.epoch.as_ref(), epoch);
11418            let txn_id = self.alloc_txn_id()?;
11419            let column_json = DdlOp::encode_column(&column)?;
11420            let mut next_catalog = self.catalog.read().clone();
11421            let catalog_entry_index = next_catalog
11422                .tables
11423                .iter()
11424                .position(|entry| entry.table_id == table_id)
11425                .ok_or_else(|| MongrelError::NotFound(format!("table {table_name:?} not found")))?;
11426            if let Some(new_column_name) = &renamed_column {
11427                for (trigger_index, trigger) in next_catalog.triggers.iter_mut().enumerate() {
11428                    commit_prepare_checkpoint(control, trigger_index)?;
11429                    if matches!(
11430                        &trigger.trigger.target,
11431                        TriggerTarget::Table(target) if target == table_name
11432                    ) {
11433                        trigger.trigger = trigger.trigger.renamed_update_column(
11434                            column_name,
11435                            new_column_name.clone(),
11436                            epoch.0,
11437                        )?;
11438                    }
11439                }
11440                for (role_index, role) in next_catalog.roles.iter_mut().enumerate() {
11441                    commit_prepare_checkpoint(control, role_index)?;
11442                    for (permission_index, permission) in role.permissions.iter_mut().enumerate() {
11443                        commit_prepare_checkpoint(control, permission_index)?;
11444                        rename_permission_column(
11445                            permission,
11446                            table_name,
11447                            column_name,
11448                            new_column_name,
11449                        );
11450                    }
11451                }
11452                advance_security_version(&mut next_catalog)?;
11453            }
11454            next_catalog.tables[catalog_entry_index].schema = prepared_schema.clone();
11455            next_catalog.db_epoch = next_catalog.db_epoch.max(epoch.0);
11456            commit_prepare_checkpoint(control, 0)?;
11457            let mut entered_fence = false;
11458            if let Some(before_commit) = before_commit.as_deref_mut() {
11459                before_commit()?;
11460                entered_fence = true;
11461            }
11462            let commit_result: Result<Epoch> = (|| {
11463                let commit_seq = {
11464                    let mut wal = self.shared_wal.lock();
11465                    let append: Result<u64> = (|| {
11466                        wal.append(
11467                            txn_id,
11468                            table_id,
11469                            crate::wal::Op::Ddl(DdlOp::AlterTable {
11470                                table_id,
11471                                column_json,
11472                            }),
11473                        )?;
11474                        append_catalog_snapshot(&mut wal, txn_id, &next_catalog)?;
11475                        wal.append_commit(txn_id, epoch, &[])
11476                    })();
11477                    append.map_err(|error| self.commit_outcome_unknown(epoch, error))?
11478                };
11479                self.await_durable_commit(commit_seq, epoch)?;
11480                durable_epoch.set(Some(epoch));
11481
11482                table.apply_altered_schema_prepared(prepared_schema);
11483                let schema = table.schema().clone();
11484                let table_checkpoint = table.checkpoint_altered_schema();
11485                drop(table);
11486                next_catalog.tables[catalog_entry_index].schema = schema;
11487                next_catalog.db_epoch = next_catalog.db_epoch.max(epoch.0);
11488                let catalog_result =
11489                    catalog::write_atomic(&self.root, &next_catalog, self.meta_dek.as_ref());
11490                let security_version = next_catalog.security_version;
11491                *self.catalog.write() = next_catalog;
11492                if renamed_column.is_some() {
11493                    self.security_coordinator
11494                        .version
11495                        .store(security_version, Ordering::Release);
11496                }
11497                self.epoch.publish_in_order(epoch);
11498                _epoch_guard.disarm();
11499                if let Err(error) = table_checkpoint.and(catalog_result) {
11500                    self.poisoned.store(true, Ordering::Relaxed);
11501                    return Err(MongrelError::DurableCommit {
11502                        epoch: epoch.0,
11503                        message: error.to_string(),
11504                    });
11505                }
11506                Ok(epoch)
11507            })();
11508            let commit_result = if entered_fence {
11509                finish_controlled_commit_attempt(commit_result, &mut after_commit)
11510            } else {
11511                commit_result
11512            };
11513            let epoch = commit_result?;
11514            Ok((column, Some(epoch)))
11515        })();
11516        result.map_err(|error| match (durable_epoch.get(), error) {
11517            (_, error @ MongrelError::DurableCommit { .. }) => error,
11518            (Some(epoch), error) => MongrelError::DurableCommit {
11519                epoch: epoch.0,
11520                message: error.to_string(),
11521            },
11522            (None, error) => error,
11523        })
11524    }
11525
11526    /// Set a timestamp-column TTL policy and WAL-log it for crash recovery and
11527    /// replication. Duration is in nanoseconds.
11528    pub fn set_table_ttl(
11529        &self,
11530        table_name: &str,
11531        column_name: &str,
11532        duration_nanos: u64,
11533    ) -> Result<crate::manifest::TtlPolicy> {
11534        let policy = self.replace_table_ttl(table_name, Some((column_name, duration_nanos)))?;
11535        policy.ok_or_else(|| MongrelError::Other("set TTL produced no policy".into()))
11536    }
11537
11538    /// Set TTL metadata on a hidden build before it is published.
11539    #[doc(hidden)]
11540    pub fn set_building_table_ttl(
11541        &self,
11542        table_name: &str,
11543        column_name: &str,
11544        duration_nanos: u64,
11545    ) -> Result<crate::manifest::TtlPolicy> {
11546        let policy = self.replace_table_ttl_with_state(
11547            table_name,
11548            Some((column_name, duration_nanos)),
11549            true,
11550        )?;
11551        policy
11552            .ok_or_else(|| MongrelError::Other("set building-table TTL produced no policy".into()))
11553    }
11554
11555    pub fn clear_table_ttl(&self, table_name: &str) -> Result<()> {
11556        self.replace_table_ttl(table_name, None)?;
11557        Ok(())
11558    }
11559
11560    fn replace_table_ttl(
11561        &self,
11562        table_name: &str,
11563        requested: Option<(&str, u64)>,
11564    ) -> Result<Option<crate::manifest::TtlPolicy>> {
11565        self.replace_table_ttl_with_state(table_name, requested, false)
11566    }
11567
11568    fn replace_table_ttl_with_state(
11569        &self,
11570        table_name: &str,
11571        requested: Option<(&str, u64)>,
11572        building: bool,
11573    ) -> Result<Option<crate::manifest::TtlPolicy>> {
11574        use crate::wal::DdlOp;
11575        use std::sync::atomic::Ordering;
11576
11577        self.require(&crate::auth::Permission::Ddl)?;
11578        if self.poisoned.load(Ordering::Relaxed) {
11579            return Err(MongrelError::Other(
11580                "database poisoned by fsync error".into(),
11581            ));
11582        }
11583
11584        let _g = self.ddl_lock.lock();
11585        let _security_write = self.security_write()?;
11586        self.require(&crate::auth::Permission::Ddl)?;
11587        let table_id = {
11588            let cat = self.catalog.read();
11589            if building {
11590                cat.building(table_name)
11591            } else {
11592                cat.live(table_name)
11593            }
11594            .ok_or_else(|| MongrelError::NotFound(format!("table {table_name:?} not found")))?
11595            .table_id
11596        };
11597        let handle =
11598            self.tables.read().get(&table_id).cloned().ok_or_else(|| {
11599                MongrelError::NotFound(format!("table {table_name:?} not mounted"))
11600            })?;
11601        let mut table = handle.lock();
11602        let policy = match requested {
11603            Some((column, duration)) => Some(table.prepare_ttl_policy(column, duration)?),
11604            None => None,
11605        };
11606        if table.ttl() == policy {
11607            return Ok(policy);
11608        }
11609
11610        let commit_lock = Arc::clone(&self.commit_lock);
11611        let _c = commit_lock.lock();
11612        let epoch = self.epoch.bump_assigned();
11613        let mut _epoch_guard = EpochGuard::new(self.epoch.as_ref(), epoch);
11614        let txn_id = self.alloc_txn_id()?;
11615        let policy_json = DdlOp::encode_ttl(policy)?;
11616        let mut next_catalog = self.catalog.read().clone();
11617        next_catalog.db_epoch = next_catalog.db_epoch.max(epoch.0);
11618        let commit_seq = {
11619            let mut wal = self.shared_wal.lock();
11620            let append: Result<u64> = (|| {
11621                wal.append(
11622                    txn_id,
11623                    table_id,
11624                    crate::wal::Op::Ddl(DdlOp::SetTtl {
11625                        table_id,
11626                        policy_json,
11627                    }),
11628                )?;
11629                append_catalog_snapshot(&mut wal, txn_id, &next_catalog)?;
11630                wal.append_commit(txn_id, epoch, &[])
11631            })();
11632            append.map_err(|error| self.commit_outcome_unknown(epoch, error))?
11633        };
11634        self.await_durable_commit(commit_seq, epoch)?;
11635
11636        let mut publish_error = table.apply_ttl_policy_at(policy, epoch).err();
11637        drop(table);
11638        if let Err(error) = self.checkpoint_catalog_after_durable(next_catalog) {
11639            publish_error.get_or_insert(error);
11640        }
11641        self.finish_durable_publish(epoch, &mut _epoch_guard, publish_error.map_or(Ok(()), Err))?;
11642        Ok(policy)
11643    }
11644
11645    /// Retention-gated garbage collection (spec §6.4, §7.4, §16). Deletes:
11646    /// - Dropped-table subdirs whose `at_epoch < min_active_snapshot`.
11647    /// - Stale `_txn/` dirs (aborted/crashed large-txn pending runs).
11648    ///
11649    /// Returns the number of items reclaimed.
11650    pub fn gc(&self) -> Result<usize> {
11651        let control = crate::ExecutionControl::new(None);
11652        self.gc_controlled(&control, || true)
11653    }
11654
11655    /// Discover reclaimable state cooperatively, then cross one publication
11656    /// boundary immediately before the first irreversible deletion.
11657    #[doc(hidden)]
11658    pub fn gc_controlled<F>(
11659        &self,
11660        control: &crate::ExecutionControl,
11661        before_publish: F,
11662    ) -> Result<usize>
11663    where
11664        F: FnOnce() -> bool,
11665    {
11666        self.gc_controlled_with_receipt(control, before_publish)
11667            .map(|(reclaimed, _)| reclaimed)
11668    }
11669
11670    /// Discover reclaimable state from one exact catalog/epoch snapshot, then
11671    /// return that snapshot if an irreversible deletion was attempted.
11672    #[doc(hidden)]
11673    pub fn gc_controlled_with_receipt<F>(
11674        &self,
11675        control: &crate::ExecutionControl,
11676        before_publish: F,
11677    ) -> Result<(usize, Option<MaintenanceReceipt>)>
11678    where
11679        F: FnOnce() -> bool,
11680    {
11681        enum Candidate {
11682            Directory(PathBuf),
11683            File(PathBuf),
11684        }
11685
11686        self.require(&crate::auth::Permission::Ddl)?;
11687        let _ddl = self.ddl_lock.lock();
11688        self.require(&crate::auth::Permission::Ddl)?;
11689        control.checkpoint()?;
11690        let maintenance_epoch = self.epoch.visible();
11691        let min_active = self.snapshots.min_active(maintenance_epoch).0;
11692        let mut candidates = Vec::new();
11693
11694        // Reclaim dropped-table dirs where no pinned snapshot still needs them.
11695        let cat = self.catalog.read();
11696        for (entry_index, entry) in cat.tables.iter().enumerate() {
11697            if entry_index % 256 == 0 {
11698                control.checkpoint()?;
11699            }
11700            if let TableState::Dropped { at_epoch } = entry.state {
11701                if at_epoch <= min_active {
11702                    let tdir = self.root.join(TABLES_DIR).join(entry.table_id.to_string());
11703                    if tdir.exists() {
11704                        candidates.push(Candidate::Directory(tdir));
11705                    }
11706                }
11707            }
11708        }
11709        drop(cat);
11710
11711        // Sweep stale _txn/<id>/ dirs on remaining live tables — but NEVER an
11712        // in-flight spill's dir (deleting it would lose the pending run and fail
11713        // the commit, review fix #14). Each `_txn/` subdir is named by its txn id;
11714        // skip any id still registered in `active_spills`.
11715        let cat = self.catalog.read();
11716        for (entry_index, entry) in cat.tables.iter().enumerate() {
11717            if entry_index % 256 == 0 {
11718                control.checkpoint()?;
11719            }
11720            if !matches!(entry.state, TableState::Live) {
11721                continue;
11722            }
11723            let txn_dir = self
11724                .root
11725                .join(TABLES_DIR)
11726                .join(entry.table_id.to_string())
11727                .join("_txn");
11728            if !txn_dir.exists() {
11729                continue;
11730            }
11731            for (sub_index, sub) in std::fs::read_dir(&txn_dir)?.enumerate() {
11732                if sub_index % 256 == 0 {
11733                    control.checkpoint()?;
11734                }
11735                let sub = sub?;
11736                let name = sub.file_name();
11737                let Some(name) = name.to_str() else { continue };
11738                // A non-numeric entry can't belong to a live txn — sweep it.
11739                let is_active = name
11740                    .parse::<u64>()
11741                    .map(|id| self.active_spills.is_active(id))
11742                    .unwrap_or(false);
11743                if is_active {
11744                    continue;
11745                }
11746                candidates.push(Candidate::Directory(sub.path()));
11747            }
11748        }
11749        drop(cat);
11750
11751        let external_names = {
11752            let cat = self.catalog.read();
11753            cat.external_tables
11754                .iter()
11755                .map(|entry| entry.name.clone())
11756                .collect::<std::collections::HashSet<_>>()
11757        };
11758        let vtab_dir = self.root.join(VTAB_DIR);
11759        if vtab_dir.exists() {
11760            for (entry_index, entry) in std::fs::read_dir(&vtab_dir)?.enumerate() {
11761                if entry_index % 256 == 0 {
11762                    control.checkpoint()?;
11763                }
11764                let entry = entry?;
11765                let name = entry.file_name();
11766                let Some(name) = name.to_str() else { continue };
11767                if external_names.contains(name) {
11768                    continue;
11769                }
11770                let path = entry.path();
11771                if path.is_dir() {
11772                    candidates.push(Candidate::Directory(path));
11773                } else {
11774                    candidates.push(Candidate::File(path));
11775                }
11776            }
11777        }
11778
11779        // Reap compaction-superseded runs whose retire epoch no pinned snapshot
11780        // can still need (spec §6.4). Each table deletes its own retired files
11781        // gated on `min_active` and persists its manifest.
11782        let tables = self
11783            .tables
11784            .read()
11785            .iter()
11786            .map(|(table_id, handle)| (*table_id, handle.clone()))
11787            .collect::<Vec<_>>();
11788        let mut retiring = Vec::new();
11789        for (table_index, (table_id, handle)) in tables.iter().enumerate() {
11790            if table_index % 256 == 0 {
11791                control.checkpoint()?;
11792            }
11793            let backup_pinned: HashSet<u128> = self
11794                .backup_pins
11795                .lock()
11796                .keys()
11797                .filter_map(|(pinned_table, run_id)| {
11798                    (*pinned_table == *table_id).then_some(*run_id)
11799                })
11800                .collect();
11801            if handle
11802                .lock()
11803                .has_reapable_retiring(Epoch(min_active), &backup_pinned)
11804            {
11805                retiring.push((handle.clone(), backup_pinned));
11806            }
11807        }
11808
11809        // WAL-segment GC (spec §6.4/§16). `SharedWal::open` mints a fresh active
11810        // segment on every reopen without truncating the prior ones, so rotated
11811        // segments accumulate. Once every live table's committed data is durable
11812        // in runs (no in-memory rows) and no in-flight spill is open, all rotated
11813        // (non-active) segments are redundant for recovery and safe to delete —
11814        // an in-flight txn only ever appends to the active segment, which is
11815        // never deleted.
11816        let all_durable = self.active_spills.is_idle()
11817            && tables.iter().all(|(_, handle)| {
11818                let g = handle.lock();
11819                g.memtable_len() == 0 && g.mutable_run_len() == 0
11820            });
11821        let retain = self
11822            .replication_wal_retention_segments
11823            .load(std::sync::atomic::Ordering::Relaxed);
11824        let reap_wal = all_durable
11825            && self
11826                .shared_wal
11827                .lock()
11828                .has_gc_segments_retain_recent(retain)?;
11829
11830        if candidates.is_empty() && retiring.is_empty() && !reap_wal {
11831            return Ok((0, None));
11832        }
11833        control.checkpoint()?;
11834        if !before_publish() {
11835            return Err(MongrelError::Cancelled);
11836        }
11837
11838        let mut reclaimed = 0;
11839        for candidate in candidates {
11840            match candidate {
11841                Candidate::Directory(path) => std::fs::remove_dir_all(path)?,
11842                Candidate::File(path) => std::fs::remove_file(path)?,
11843            }
11844            reclaimed += 1;
11845        }
11846        for (handle, backup_pinned) in retiring {
11847            reclaimed += handle
11848                .lock()
11849                .reap_retiring(Epoch(min_active), &backup_pinned)?;
11850        }
11851        if reap_wal {
11852            reclaimed += self
11853                .shared_wal
11854                .lock()
11855                .gc_segments_retain_recent(u64::MAX, retain)?;
11856        }
11857
11858        Ok((
11859            reclaimed,
11860            Some(MaintenanceReceipt {
11861                epoch: maintenance_epoch,
11862            }),
11863        ))
11864    }
11865
11866    /// Produce a deterministic-stable byte image of the database directory.
11867    ///
11868    /// After `checkpoint()`:
11869    ///   - All pending writes are flushed to sorted runs (no memtable data).
11870    ///   - Each table is compacted to a single sorted run (no run fragmentation).
11871    ///   - All non-active WAL segments are deleted (data is durable in runs).
11872    ///   - The active WAL segment is rotated to a fresh empty segment.
11873    ///   - Dropped-table directories are removed.
11874    ///   - All manifests + catalog are persisted.
11875    ///
11876    /// The resulting directory is byte-stable: `git add` captures a snapshot
11877    /// that `git checkout` restores deterministically. No stale WAL tail bytes,
11878    /// no unbounded segment growth, no mutable-run spill files.
11879    ///
11880    /// This is the engine primitive behind `mongreldb snapshot <dir>` (CLI).
11881    /// It does NOT clear the exclusive lock — the caller still owns the
11882    /// database handle.
11883    pub fn checkpoint(&self) -> Result<()> {
11884        self.checkpoint_controlled(|| Ok(()))
11885    }
11886
11887    /// Strict checkpoint with a deterministic test hook after every table is
11888    /// flushed/compacted but before WAL replacement.
11889    #[doc(hidden)]
11890    pub fn checkpoint_controlled<F>(&self, before_wal_reset: F) -> Result<()>
11891    where
11892        F: FnOnce() -> Result<()>,
11893    {
11894        self.require(&crate::auth::Permission::Ddl)?;
11895        // Block cross-table commits and DDL for the full operation. Locking all
11896        // mounted handles also excludes direct `Table` commits, which do not
11897        // enter the database replication barrier.
11898        let _replication = self.replication_barrier.write();
11899        let _ddl = self.ddl_lock.lock();
11900        let _security = self.security_coordinator.gate.read();
11901        self.require(&crate::auth::Permission::Ddl)?;
11902
11903        let mut handles = self
11904            .tables
11905            .read()
11906            .iter()
11907            .map(|(table_id, handle)| (*table_id, handle.clone()))
11908            .collect::<Vec<_>>();
11909        handles.sort_by_key(|(table_id, _)| *table_id);
11910        let mut tables = handles
11911            .iter()
11912            .map(|(table_id, handle)| (*table_id, handle.lock()))
11913            .collect::<Vec<_>>();
11914
11915        // Strict flush. Any error leaves the old WAL recovery source intact.
11916        for (_, table) in &mut tables {
11917            if table.has_pending_writes() || table.memtable_len() > 0 || table.mutable_run_len() > 0
11918            {
11919                table.force_flush()?;
11920            }
11921        }
11922
11923        // Strict compaction. Checkpoint never reports a stable image after a
11924        // skipped failure.
11925        for (_, table) in &mut tables {
11926            if table.run_count() >= 2 || table.should_compact() {
11927                table.compact()?;
11928            }
11929        }
11930
11931        before_wal_reset()?;
11932
11933        // Reap table-local retired runs while every table remains quiesced.
11934        let maintenance_epoch = self.epoch.visible();
11935        let min_active = self.snapshots.min_active(maintenance_epoch);
11936        for (table_id, table) in &mut tables {
11937            let backup_pinned: HashSet<u128> = self
11938                .backup_pins
11939                .lock()
11940                .keys()
11941                .filter_map(|(pinned_table, run_id)| {
11942                    (*pinned_table == *table_id).then_some(*run_id)
11943                })
11944                .collect();
11945            table.reap_retiring(min_active, &backup_pinned)?;
11946        }
11947
11948        // Publish a fresh synced active WAL, then durably reap every older
11949        // segment. This point is reached only after every strict flush succeeds.
11950        self.shared_wal.lock().reset_after_checkpoint()?;
11951
11952        // Remove catalog-unreachable directories and stale transaction state.
11953        let catalog_snapshot = self.catalog.read().clone();
11954        for entry in &catalog_snapshot.tables {
11955            if matches!(entry.state, TableState::Dropped { at_epoch } if at_epoch <= min_active.0) {
11956                crate::durable_file::remove_directory_all(
11957                    &self.root.join(TABLES_DIR).join(entry.table_id.to_string()),
11958                )?;
11959            }
11960            if !matches!(entry.state, TableState::Live) {
11961                continue;
11962            }
11963            let transaction_dir = self
11964                .root
11965                .join(TABLES_DIR)
11966                .join(entry.table_id.to_string())
11967                .join("_txn");
11968            if transaction_dir.is_dir() {
11969                for child in std::fs::read_dir(&transaction_dir)? {
11970                    let child = child?;
11971                    let active = child
11972                        .file_name()
11973                        .to_str()
11974                        .and_then(|name| name.parse::<u64>().ok())
11975                        .is_some_and(|txn_id| self.active_spills.is_active(txn_id));
11976                    if !active {
11977                        crate::durable_file::remove_directory_all(&child.path())?;
11978                    }
11979                }
11980            }
11981        }
11982        let external_names = catalog_snapshot
11983            .external_tables
11984            .iter()
11985            .map(|entry| entry.name.as_str())
11986            .collect::<HashSet<_>>();
11987        let external_root = self.root.join(VTAB_DIR);
11988        if external_root.is_dir() {
11989            for entry in std::fs::read_dir(&external_root)? {
11990                let entry = entry?;
11991                let name = entry.file_name();
11992                if name
11993                    .to_str()
11994                    .is_some_and(|name| external_names.contains(name))
11995                {
11996                    continue;
11997                }
11998                if entry.file_type()?.is_dir() {
11999                    crate::durable_file::remove_directory_all(&entry.path())?;
12000                } else {
12001                    std::fs::remove_file(entry.path())?;
12002                    crate::durable_file::sync_directory(&external_root)?;
12003                }
12004            }
12005        }
12006
12007        // Final authoritative metadata checkpoint while all writers remain
12008        // excluded.
12009        catalog::write_atomic(&self.root, &catalog_snapshot, self.meta_dek.as_ref())?;
12010        let visible = self.epoch.visible();
12011        for (_, table) in &tables {
12012            table.persist_manifest(visible)?;
12013        }
12014
12015        Ok(())
12016    }
12017    fn alloc_txn_id(&self) -> Result<u64> {
12018        crate::txn::allocate_txn_id(&self.next_txn_id)
12019    }
12020
12021    /// Set the per-table spill threshold (bytes). When a transaction's staged
12022    /// bytes for a single table exceed this, the rows are written as a
12023    /// uniform-epoch pending run instead of streamed Put records (spec §8.5).
12024    pub fn set_spill_threshold(&self, bytes: u64) {
12025        self.spill_threshold
12026            .store(bytes, std::sync::atomic::Ordering::Relaxed);
12027    }
12028
12029    /// Test-only: install a hook invoked after a transaction writes its spill
12030    /// runs but before the sequencer, so a test can race `gc()` against an
12031    /// in-flight spill. Not part of the stable API.
12032    #[doc(hidden)]
12033    pub fn __set_spill_hook(&self, f: impl Fn() + Send + Sync + 'static) {
12034        *self.spill_hook.lock() = Some(Box::new(f));
12035    }
12036
12037    /// Test-only: install a hook invoked while a spilled commit holds the
12038    /// security read gate and before it appends to the WAL.
12039    #[doc(hidden)]
12040    pub fn __set_security_commit_hook(&self, f: impl Fn() + Send + Sync + 'static) {
12041        *self.security_commit_hook.lock() = Some(Box::new(f));
12042    }
12043
12044    /// Test-only: install a hook after transaction preparation and before the
12045    /// commit sequencer validates catalog generations.
12046    #[doc(hidden)]
12047    pub fn __set_catalog_commit_hook(&self, f: impl Fn() + Send + Sync + 'static) {
12048        *self.catalog_commit_hook.lock() = Some(Box::new(f));
12049    }
12050
12051    /// Test-only: pause an online backup after its consistent boundary is
12052    /// captured but before the pinned immutable runs are copied.
12053    #[doc(hidden)]
12054    pub fn __set_backup_hook(&self, f: impl Fn() + Send + Sync + 'static) {
12055        *self.backup_hook.lock() = Some(Box::new(f));
12056    }
12057
12058    /// Test-only: pause WAL extraction before its final principal recheck.
12059    #[doc(hidden)]
12060    pub fn __set_replication_hook(&self, f: impl Fn() + Send + Sync + 'static) {
12061        *self.replication_hook.lock() = Some(Box::new(f));
12062    }
12063
12064    /// Number of WAL fsyncs issued so far (test/diagnostic). With group commit
12065    /// this stays well below the number of committed transactions when commits
12066    /// are concurrent (one leader fsync covers a whole batch — spec §9.3).
12067    #[doc(hidden)]
12068    pub fn __wal_group_sync_count(&self) -> u64 {
12069        self.shared_wal.lock().group_sync_count()
12070    }
12071
12072    /// Force the poisoned state (test-only) to verify the §9.3e fail-fast
12073    /// contract that an fsync error would trigger in production.
12074    #[doc(hidden)]
12075    pub fn __poison(&self) {
12076        self.poisoned
12077            .store(true, std::sync::atomic::Ordering::Relaxed);
12078    }
12079
12080    /// Verify multi-table integrity (spec §16). For every live table this:
12081    /// authenticates the manifest; opens each `RunRef`'s file through
12082    /// [`RunReader`](crate::sorted_run::RunReader), which verifies the run footer
12083    /// checksum and — for encrypted DBs — the keyed run-metadata MAC; checks each
12084    /// run's physical row count against its `RunRef`; flags `RunRef`s whose file
12085    /// is missing (dangling) and `.sr` files on disk that no `RunRef` references
12086    /// (orphan); and verifies `flushed_epoch <= current_epoch`. Returns the list
12087    /// of issues found (empty = healthy). Orphans are `warning`-severity; all
12088    /// other findings are `error`-severity (so [`Self::doctor`] quarantines them).
12089    ///
12090    /// Cost: O(total run bytes) — the footer checksum is verified over each run's
12091    /// full body, so this is an integrity tool, not a hot path.
12092    pub fn check(&self) -> Vec<CheckIssue> {
12093        match self.check_inner(None) {
12094            Ok(issues) => issues,
12095            Err(error) => vec![CheckIssue {
12096                table_id: WAL_TABLE_ID,
12097                table_name: "shared WAL".into(),
12098                severity: "error".into(),
12099                description: error.to_string(),
12100            }],
12101        }
12102    }
12103
12104    /// Integrity check with cooperative cancellation between tables and runs.
12105    #[doc(hidden)]
12106    pub fn check_controlled(&self, control: &crate::ExecutionControl) -> Result<Vec<CheckIssue>> {
12107        self.check_inner(Some(control))
12108    }
12109
12110    fn check_inner(&self, control: Option<&crate::ExecutionControl>) -> Result<Vec<CheckIssue>> {
12111        let mut issues = Vec::new();
12112        let cat = self.catalog.read();
12113        let manifest_meta_dek = crate::encryption::meta_dek_for(self.kek.as_deref());
12114        for (table_index, entry) in cat.tables.iter().enumerate() {
12115            if table_index % 256 == 0 {
12116                if let Some(control) = control {
12117                    control.checkpoint()?;
12118                }
12119            }
12120            if !matches!(entry.state, TableState::Live) {
12121                continue;
12122            }
12123            let tdir = self.root.join(TABLES_DIR).join(entry.table_id.to_string());
12124            let mut err = |sev: &str, desc: String| {
12125                issues.push(CheckIssue {
12126                    table_id: entry.table_id,
12127                    table_name: entry.name.clone(),
12128                    severity: sev.into(),
12129                    description: desc,
12130                });
12131            };
12132            let m = match crate::manifest::read(&tdir, manifest_meta_dek.as_ref()) {
12133                Ok(m) => m,
12134                Err(e) => {
12135                    err("error", format!("manifest read failed: {e}"));
12136                    continue;
12137                }
12138            };
12139            if m.flushed_epoch > m.current_epoch {
12140                err(
12141                    "error",
12142                    format!(
12143                        "flushed_epoch {} exceeds current_epoch {} (impossible)",
12144                        m.flushed_epoch, m.current_epoch
12145                    ),
12146                );
12147            }
12148
12149            let runs_dir = tdir.join(crate::engine::RUNS_DIR);
12150            let mut referenced: std::collections::HashSet<u128> = std::collections::HashSet::new();
12151            for (run_index, rr) in m.runs.iter().enumerate() {
12152                if run_index % 256 == 0 {
12153                    if let Some(control) = control {
12154                        control.checkpoint()?;
12155                    }
12156                }
12157                referenced.insert(rr.run_id);
12158                let run_path = runs_dir.join(format!("r-{}.sr", rr.run_id));
12159                if !run_path.exists() {
12160                    err("error", format!("missing run file: r-{}.sr", rr.run_id));
12161                    continue;
12162                }
12163                match crate::sorted_run::RunReader::open(
12164                    &run_path,
12165                    entry.schema.clone(),
12166                    self.kek.clone(),
12167                ) {
12168                    Ok(reader) => {
12169                        if reader.row_count() as u64 != rr.row_count {
12170                            err(
12171                                "error",
12172                                format!(
12173                                    "run r-{} row count mismatch: manifest {} vs run {}",
12174                                    rr.run_id,
12175                                    rr.row_count,
12176                                    reader.row_count()
12177                                ),
12178                            );
12179                        }
12180                    }
12181                    Err(e) => {
12182                        err(
12183                            "error",
12184                            format!("run r-{} integrity check failed: {e}", rr.run_id),
12185                        );
12186                    }
12187                }
12188            }
12189
12190            // Compaction-superseded runs awaiting retention-gated deletion are
12191            // tracked in `retiring`; their files are expected on disk, so they
12192            // are not orphans.
12193            for r in &m.retiring {
12194                referenced.insert(r.run_id);
12195            }
12196
12197            // Orphan `.sr` files present on disk but absent from the manifest.
12198            if let Ok(rd) = std::fs::read_dir(&runs_dir) {
12199                for (entry_index, ent) in rd.flatten().enumerate() {
12200                    if entry_index % 256 == 0 {
12201                        if let Some(control) = control {
12202                            control.checkpoint()?;
12203                        }
12204                    }
12205                    let p = ent.path();
12206                    if p.extension().and_then(|s| s.to_str()) != Some("sr") {
12207                        continue;
12208                    }
12209                    let run_id = p
12210                        .file_stem()
12211                        .and_then(|s| s.to_str())
12212                        .and_then(|s| s.strip_prefix("r-"))
12213                        .and_then(|s| s.parse::<u128>().ok());
12214                    if let Some(id) = run_id {
12215                        if !referenced.contains(&id) {
12216                            err(
12217                                "warning",
12218                                format!("orphan run file r-{id}.sr not referenced by the manifest"),
12219                            );
12220                        }
12221                    }
12222                }
12223            }
12224        }
12225
12226        let external_names = cat
12227            .external_tables
12228            .iter()
12229            .map(|entry| entry.name.clone())
12230            .collect::<std::collections::HashSet<_>>();
12231        let vtab_dir = self.root.join(VTAB_DIR);
12232        if let Ok(entries) = std::fs::read_dir(&vtab_dir) {
12233            for (entry_index, entry) in entries.flatten().enumerate() {
12234                if entry_index % 256 == 0 {
12235                    if let Some(control) = control {
12236                        control.checkpoint()?;
12237                    }
12238                }
12239                let name = entry.file_name();
12240                let Some(name) = name.to_str() else { continue };
12241                if !external_names.contains(name) {
12242                    issues.push(CheckIssue {
12243                        table_id: EXTERNAL_TABLE_ID,
12244                        table_name: name.to_string(),
12245                        severity: "warning".into(),
12246                        description: format!(
12247                            "orphan external table state entry {:?} not referenced by the catalog",
12248                            entry.path()
12249                        ),
12250                    });
12251                }
12252            }
12253        }
12254
12255        // WAL retention / integrity invariant (spec §16): every on-disk WAL
12256        // segment must open (header magic + version, and the frame cipher must
12257        // be derivable for an encrypted WAL). A segment that won't open is
12258        // corrupt or truncated and would break crash recovery. `table_id` is
12259        // the reserved `WAL_TABLE_ID` sentinel (u64::MAX) so [`Self::doctor`]
12260        // never confuses a WAL issue with a real table.
12261        if let Some(control) = control {
12262            control.checkpoint()?;
12263        }
12264        for (seg, msg) in self.shared_wal.lock().verify_segments() {
12265            issues.push(CheckIssue {
12266                table_id: WAL_TABLE_ID,
12267                table_name: "<wal>".into(),
12268                severity: "error".into(),
12269                description: format!("WAL segment seg-{seg:06}.wal failed integrity check: {msg}"),
12270            });
12271        }
12272        Ok(issues)
12273    }
12274
12275    /// Quarantine unreadable tables (spec §16). Moves corrupt table dirs to
12276    /// `_quarantine/<table_id>/`, marks them dropped in the catalog, and
12277    /// unmounts them from the live table map so the DB still opens.
12278    pub fn doctor(&self) -> Result<Vec<u64>> {
12279        let control = crate::ExecutionControl::new(None);
12280        self.doctor_controlled(&control, || true)
12281    }
12282
12283    /// Check cancellably, then fence immediately before the first quarantine
12284    /// mutation. Returning `false` from `before_publish` leaves the database
12285    /// untouched.
12286    #[doc(hidden)]
12287    pub fn doctor_controlled<F>(
12288        &self,
12289        control: &crate::ExecutionControl,
12290        before_publish: F,
12291    ) -> Result<Vec<u64>>
12292    where
12293        F: FnOnce() -> bool,
12294    {
12295        self.doctor_controlled_with_receipt(control, before_publish)
12296            .map(|(quarantined, _)| quarantined)
12297    }
12298
12299    /// Check cancellably and return the exact catalog epoch used for a
12300    /// quarantine publication. No receipt is returned when nothing changes.
12301    #[doc(hidden)]
12302    pub fn doctor_controlled_with_receipt<F>(
12303        &self,
12304        control: &crate::ExecutionControl,
12305        before_publish: F,
12306    ) -> Result<(Vec<u64>, Option<MaintenanceReceipt>)>
12307    where
12308        F: FnOnce() -> bool,
12309    {
12310        // Hold the DDL lock for the whole operation to prevent concurrent
12311        // create_table/drop_table from racing the catalog/dir mutation.
12312        let _ddl = self.ddl_lock.lock();
12313        let _security_write = self.security_write()?;
12314        let issues = self.check_inner(Some(control))?;
12315        // A corrupt WAL segment is reported as an error but is NOT a table
12316        // problem — quarantining an innocent table cannot fix it (and the first
12317        // real table is id 0, so the WAL sentinel WAL_TABLE_ID = u64::MAX keeps
12318        // them disjoint). The admin must address WAL corruption manually.
12319        let bad_tables: std::collections::HashSet<u64> = issues
12320            .iter()
12321            .filter(|i| {
12322                i.severity == "error"
12323                    && i.table_id != WAL_TABLE_ID
12324                    && i.table_id != EXTERNAL_TABLE_ID
12325            })
12326            .map(|i| i.table_id)
12327            .collect();
12328        if bad_tables.is_empty() {
12329            return Ok((Vec::new(), None));
12330        }
12331        let _commit = self.commit_lock.lock();
12332        control.checkpoint()?;
12333        if !before_publish() {
12334            return Err(MongrelError::Cancelled);
12335        }
12336        let maintenance_epoch = self.epoch.bump_assigned();
12337        let mut epoch_guard = EpochGuard::new(self.epoch.as_ref(), maintenance_epoch);
12338
12339        let qdir = self.root.join("_quarantine");
12340        crate::durable_file::create_directory(&qdir)?;
12341        let mut bad_tables = bad_tables.into_iter().collect::<Vec<_>>();
12342        bad_tables.sort_unstable();
12343
12344        // Quiesce every mounted target before catalog publication. Existing
12345        // handle clones are marked unavailable in the publication callback so
12346        // they cannot append to the shared WAL after their catalog entry drops.
12347        let mut handles = self
12348            .tables
12349            .read()
12350            .iter()
12351            .filter(|(table_id, _)| bad_tables.binary_search(table_id).is_ok())
12352            .map(|(table_id, handle)| (*table_id, handle.clone()))
12353            .collect::<Vec<_>>();
12354        handles.sort_by_key(|(table_id, _)| *table_id);
12355        let mut table_guards = handles
12356            .iter()
12357            .map(|(table_id, handle)| (*table_id, handle.lock()))
12358            .collect::<Vec<_>>();
12359
12360        let mut next_catalog = self.catalog.read().clone();
12361        for table_id in &bad_tables {
12362            if let Some(entry) = next_catalog
12363                .tables
12364                .iter_mut()
12365                .find(|entry| entry.table_id == *table_id)
12366            {
12367                entry.state = TableState::Dropped {
12368                    at_epoch: maintenance_epoch.0,
12369                };
12370            }
12371        }
12372        next_catalog.db_epoch = next_catalog.db_epoch.max(maintenance_epoch.0);
12373
12374        let txn_id = self.alloc_txn_id()?;
12375        let commit_seq = {
12376            let mut wal = self.shared_wal.lock();
12377            let append: Result<u64> = (|| {
12378                for table_id in &bad_tables {
12379                    wal.append(
12380                        txn_id,
12381                        *table_id,
12382                        crate::wal::Op::Ddl(crate::wal::DdlOp::DropTable {
12383                            table_id: *table_id,
12384                        }),
12385                    )?;
12386                }
12387                append_catalog_snapshot(&mut wal, txn_id, &next_catalog)?;
12388                wal.append_commit(txn_id, maintenance_epoch, &[])
12389            })();
12390            append.map_err(|error| self.commit_outcome_unknown(maintenance_epoch, error))?
12391        };
12392        self.await_durable_commit(commit_seq, maintenance_epoch)?;
12393        for (_, table) in &mut table_guards {
12394            table.mark_unavailable_after_quarantine();
12395        }
12396        {
12397            let mut live_tables = self.tables.write();
12398            for table_id in &bad_tables {
12399                live_tables.remove(table_id);
12400            }
12401        }
12402        let checkpoint = self.checkpoint_catalog_after_durable(next_catalog);
12403        self.finish_durable_publish(maintenance_epoch, &mut epoch_guard, checkpoint)?;
12404
12405        // The catalog drop is durable. Directory placement is secondary but
12406        // still uses a write-through rename. A failure reports the known
12407        // catalog outcome and leaves a harmless orphan under `tables/`.
12408        for table_id in &bad_tables {
12409            let source = self.root.join(TABLES_DIR).join(table_id.to_string());
12410            if source.exists() {
12411                let destination = qdir.join(table_id.to_string());
12412                if let Err(error) = crate::durable_file::rename(&source, &destination) {
12413                    return Err(MongrelError::DurableCommit {
12414                        epoch: maintenance_epoch.0,
12415                        message: format!(
12416                            "DOCTOR dropped table {table_id} but quarantine move failed: {error}"
12417                        ),
12418                    });
12419                }
12420            }
12421        }
12422        Ok((
12423            bad_tables,
12424            Some(MaintenanceReceipt {
12425                epoch: maintenance_epoch,
12426            }),
12427        ))
12428    }
12429
12430    /// The DB-wide KEK (if encrypted).
12431    #[allow(dead_code)]
12432    pub(crate) fn kek(&self) -> Option<&Arc<crate::encryption::Kek>> {
12433        self.kek.as_ref()
12434    }
12435
12436    /// Shared epoch authority (used by the transaction layer in P2).
12437    #[allow(dead_code)]
12438    pub(crate) fn epoch_authority(&self) -> &Arc<EpochAuthority> {
12439        &self.epoch
12440    }
12441
12442    /// Shared snapshot registry (used by GC in P3.6).
12443    #[allow(dead_code)]
12444    pub(crate) fn snapshots(&self) -> &Arc<SnapshotRegistry> {
12445        &self.snapshots
12446    }
12447}
12448
12449fn external_state_dir(root: &Path, name: &str) -> PathBuf {
12450    root.join(VTAB_DIR).join(name)
12451}
12452
12453fn append_catalog_snapshot(
12454    wal: &mut crate::wal::SharedWal,
12455    txn_id: u64,
12456    catalog: &Catalog,
12457) -> Result<()> {
12458    let catalog_json = crate::wal::DdlOp::encode_catalog(catalog)?;
12459    wal.append(
12460        txn_id,
12461        WAL_TABLE_ID,
12462        crate::wal::Op::Ddl(crate::wal::DdlOp::CatalogSnapshot { catalog_json }),
12463    )?;
12464    Ok(())
12465}
12466
12467fn filter_ignored_staging(
12468    staging: Vec<(u64, crate::txn::Staged)>,
12469    ignored_indices: &std::collections::BTreeSet<usize>,
12470) -> Vec<(u64, crate::txn::Staged)> {
12471    if ignored_indices.is_empty() {
12472        return staging;
12473    }
12474    staging
12475        .into_iter()
12476        .enumerate()
12477        .filter_map(|(idx, staged)| (!ignored_indices.contains(&idx)).then_some(staged))
12478        .collect()
12479}
12480
12481fn external_state_file(root: &Path, name: &str) -> PathBuf {
12482    external_state_dir(root, name).join("state.json")
12483}
12484
12485fn read_external_state_file(root: &Path, name: &str) -> Result<Vec<u8>> {
12486    let path = external_state_file(root, name);
12487    match std::fs::read(path) {
12488        Ok(bytes) => Ok(bytes),
12489        Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(Vec::new()),
12490        Err(e) => Err(e.into()),
12491    }
12492}
12493
12494fn current_external_state_bytes(
12495    root: &Path,
12496    external_states: &[(String, Vec<u8>)],
12497    name: &str,
12498) -> Result<Vec<u8>> {
12499    for (table, state) in external_states.iter().rev() {
12500        if table == name {
12501            return Ok(state.clone());
12502        }
12503    }
12504    read_external_state_file(root, name)
12505}
12506
12507fn dedup_external_states(external_states: Vec<(String, Vec<u8>)>) -> Vec<(String, Vec<u8>)> {
12508    let mut out = external_states;
12509    dedup_external_states_in_place(&mut out);
12510    out
12511}
12512
12513fn dedup_external_states_in_place(external_states: &mut Vec<(String, Vec<u8>)>) {
12514    let mut seen = std::collections::HashSet::new();
12515    let mut out = Vec::with_capacity(external_states.len());
12516    for (name, state) in std::mem::take(external_states).into_iter().rev() {
12517        if seen.insert(name.clone()) {
12518            out.push((name, state));
12519        }
12520    }
12521    out.reverse();
12522    *external_states = out;
12523}
12524
12525fn prepare_external_state_file(
12526    root: &Path,
12527    name: &str,
12528    state: &[u8],
12529    txn_id: u64,
12530) -> Result<PathBuf> {
12531    crate::durable_file::create_directory(&root.join(VTAB_DIR))?;
12532    let dir = external_state_dir(root, name);
12533    crate::durable_file::create_directory(&dir)?;
12534    let pending = dir.join(format!("state.json.{txn_id}.tmp"));
12535    {
12536        let mut file = std::fs::OpenOptions::new()
12537            .create_new(true)
12538            .write(true)
12539            .open(&pending)?;
12540        file.write_all(state)?;
12541        file.sync_all()?;
12542    }
12543    Ok(pending)
12544}
12545
12546fn publish_external_state_file(root: &Path, name: &str, pending: &Path) -> Result<()> {
12547    let path = external_state_file(root, name);
12548    crate::durable_file::replace(pending, &path)?;
12549    Ok(())
12550}
12551
12552fn write_external_state_file(
12553    durable: &crate::durable_file::DurableRoot,
12554    name: &str,
12555    state: &[u8],
12556) -> Result<()> {
12557    let directory = Path::new(VTAB_DIR).join(name);
12558    durable.create_directory_all(&directory)?;
12559    durable.write_atomic(directory.join("state.json"), state)?;
12560    Ok(())
12561}
12562
12563fn validate_recovered_data_table(
12564    catalog: &Catalog,
12565    tables: &HashMap<u64, TableHandle>,
12566    table_id: u64,
12567    commit_epoch: u64,
12568    offset: u64,
12569) -> Result<bool> {
12570    let entry = catalog
12571        .tables
12572        .iter()
12573        .find(|entry| entry.table_id == table_id)
12574        .ok_or_else(|| MongrelError::CorruptWal {
12575            offset,
12576            reason: format!("committed record references unknown table {table_id}"),
12577        })?;
12578    if commit_epoch < entry.created_epoch {
12579        return Err(MongrelError::CorruptWal {
12580            offset,
12581            reason: format!(
12582                "table {table_id} record epoch {commit_epoch} precedes creation epoch {}",
12583                entry.created_epoch
12584            ),
12585        });
12586    }
12587    match entry.state {
12588        TableState::Dropped { at_epoch } => {
12589            // Abandoned hidden builds are marked dropped at the last durable
12590            // boundary during open, so their final build commit may equal the
12591            // cleanup epoch. Ordinary table drops consume a new epoch and must
12592            // remain strictly later than every data commit.
12593            let abandoned_build_boundary =
12594                entry.name.starts_with(CTAS_BUILD_TABLE_PREFIX) && commit_epoch == at_epoch;
12595            if commit_epoch >= at_epoch && !abandoned_build_boundary {
12596                Err(MongrelError::CorruptWal {
12597                    offset,
12598                    reason: format!(
12599                        "table {table_id} record epoch {commit_epoch} is not before drop epoch {at_epoch}"
12600                    ),
12601                })
12602            } else {
12603                Ok(false)
12604            }
12605        }
12606        TableState::Live | TableState::Building { .. } => {
12607            if tables.contains_key(&table_id) {
12608                Ok(true)
12609            } else {
12610                Err(MongrelError::CorruptWal {
12611                    offset,
12612                    reason: format!("live table {table_id} has no mounted recovery handle"),
12613                })
12614            }
12615        }
12616    }
12617}
12618
12619type RecoveryTableStage = (
12620    Vec<crate::memtable::Row>,
12621    Vec<(crate::rowid::RowId, Epoch)>,
12622    Option<Epoch>,
12623    Epoch,
12624);
12625
12626#[derive(Clone)]
12627struct RecoveryValidationTable {
12628    schema: Schema,
12629    flushed_epoch: u64,
12630}
12631
12632fn validate_shared_wal_recovery_plan(
12633    durable_root: &crate::durable_file::DurableRoot,
12634    catalog: &Catalog,
12635    recovered_table_ids: &HashSet<u64>,
12636    reconciled_table_ids: &HashSet<u64>,
12637    meta_dek: Option<&[u8; META_DEK_LEN]>,
12638    kek: Option<Arc<crate::encryption::Kek>>,
12639    records: &[crate::wal::Record],
12640) -> Result<()> {
12641    use crate::wal::{DdlOp, Op};
12642
12643    let mut tables = HashMap::<u64, RecoveryValidationTable>::new();
12644    for entry in &catalog.tables {
12645        if !matches!(entry.state, TableState::Live) {
12646            continue;
12647        }
12648        let relative_dir = Path::new(TABLES_DIR).join(entry.table_id.to_string());
12649        let manifest = match crate::manifest::read_durable(durable_root, &relative_dir, meta_dek) {
12650            Ok(manifest) => Some(manifest),
12651            Err(MongrelError::Io(error)) if error.kind() == std::io::ErrorKind::NotFound => None,
12652            Err(error) => return Err(error),
12653        };
12654        let flushed_epoch = if let Some(manifest) = manifest {
12655            if manifest.table_id != entry.table_id {
12656                return Err(MongrelError::Conflict(format!(
12657                    "catalog table {} storage identity mismatch",
12658                    entry.table_id
12659                )));
12660            }
12661            if (manifest.schema_id != entry.schema.schema_id
12662                && !reconciled_table_ids.contains(&entry.table_id))
12663                || manifest.flushed_epoch > manifest.current_epoch
12664                || manifest.global_idx_epoch > manifest.current_epoch
12665                || manifest.next_row_id == u64::MAX
12666                || manifest.auto_inc_next < 0
12667                || manifest.auto_inc_next == i64::MAX
12668                || (entry.schema.auto_increment_column().is_none() && manifest.auto_inc_next != 0)
12669            {
12670                return Err(MongrelError::InvalidArgument(format!(
12671                    "table {} manifest counters or schema identity are invalid",
12672                    entry.table_id
12673                )));
12674            }
12675            #[cfg(feature = "encryption")]
12676            let idx_dek = kek.as_ref().map(|key| key.derive_idx_key());
12677            #[cfg(not(feature = "encryption"))]
12678            let idx_dek: Option<zeroize::Zeroizing<[u8; 32]>> = None;
12679            crate::global_idx::read_durable_for(
12680                durable_root,
12681                &relative_dir,
12682                entry.table_id,
12683                &entry.schema,
12684                idx_dek.as_deref(),
12685            )?;
12686            let mut run_ids = HashSet::new();
12687            let mut maximum_row_id = None::<u64>;
12688            for run in &manifest.runs {
12689                if run.run_id >= u64::MAX as u128
12690                    || run.epoch_created > manifest.current_epoch
12691                    || !run_ids.insert(run.run_id)
12692                {
12693                    return Err(MongrelError::InvalidArgument(format!(
12694                        "table {} manifest contains an invalid or duplicate run id",
12695                        entry.table_id
12696                    )));
12697                }
12698                let relative = relative_dir
12699                    .join(crate::engine::RUNS_DIR)
12700                    .join(format!("r-{}.sr", run.run_id as u64));
12701                let file = durable_root.open_regular(&relative)?;
12702                let mut reader = crate::sorted_run::RunReader::open_file(
12703                    file,
12704                    entry.schema.clone(),
12705                    kek.clone(),
12706                )?;
12707                let header = reader.header();
12708                if header.run_id != run.run_id
12709                    || header.level != run.level
12710                    || header.row_count != run.row_count
12711                    || !header.is_uniform_epoch() && header.epoch_created != run.epoch_created
12712                    || header.is_uniform_epoch() && header.epoch_created != 0
12713                    || header.schema_id > entry.schema.schema_id
12714                {
12715                    return Err(MongrelError::InvalidArgument(format!(
12716                        "table {} run {} differs from its manifest: header=(id {}, level {}, rows {}, epoch {}, schema {}), manifest=(id {}, level {}, rows {}, epoch {}, schema <= {})",
12717                        entry.table_id,
12718                        run.run_id,
12719                        header.run_id,
12720                        header.level,
12721                        header.row_count,
12722                        header.epoch_created,
12723                        header.schema_id,
12724                        run.run_id,
12725                        run.level,
12726                        run.row_count,
12727                        run.epoch_created,
12728                        entry.schema.schema_id,
12729                    )));
12730                }
12731                if header.row_count != 0 {
12732                    maximum_row_id = Some(
12733                        maximum_row_id
12734                            .map_or(header.max_row_id, |value| value.max(header.max_row_id)),
12735                    );
12736                }
12737                reader.validate_all_pages()?;
12738            }
12739            if maximum_row_id.is_some_and(|maximum| manifest.next_row_id <= maximum) {
12740                return Err(MongrelError::InvalidArgument(format!(
12741                    "table {} next_row_id does not advance beyond persisted rows",
12742                    entry.table_id
12743                )));
12744            }
12745            for run in &manifest.retiring {
12746                if run.run_id >= u64::MAX as u128
12747                    || run.retire_epoch > manifest.current_epoch
12748                    || !run_ids.insert(run.run_id)
12749                {
12750                    return Err(MongrelError::InvalidArgument(format!(
12751                        "table {} manifest contains an invalid or aliased retired run",
12752                        entry.table_id
12753                    )));
12754                }
12755            }
12756            manifest.flushed_epoch
12757        } else {
12758            if !recovered_table_ids.contains(&entry.table_id) {
12759                return Err(MongrelError::NotFound(format!(
12760                    "live table {} manifest is missing",
12761                    entry.table_id
12762                )));
12763            }
12764            0
12765        };
12766        tables.insert(
12767            entry.table_id,
12768            RecoveryValidationTable {
12769                schema: entry.schema.clone(),
12770                flushed_epoch,
12771            },
12772        );
12773    }
12774
12775    let committed = records
12776        .iter()
12777        .filter_map(|record| match record.op {
12778            Op::TxnCommit { epoch, .. } => Some((record.txn_id, epoch)),
12779            _ => None,
12780        })
12781        .collect::<HashMap<_, _>>();
12782    let mut run_ids = HashSet::new();
12783    let mut recovered_row_ids = HashMap::<u64, HashSet<u64>>::new();
12784    for record in records {
12785        let Some(&commit_epoch) = committed.get(&record.txn_id) else {
12786            continue;
12787        };
12788        match &record.op {
12789            Op::Put { table_id, rows } => {
12790                let table = validate_recovery_data_table_plan(
12791                    catalog,
12792                    &tables,
12793                    *table_id,
12794                    commit_epoch,
12795                    record.seq.0,
12796                )?;
12797                let decoded: Vec<crate::memtable::Row> =
12798                    bincode::deserialize(rows).map_err(|error| MongrelError::CorruptWal {
12799                        offset: record.seq.0,
12800                        reason: format!(
12801                            "committed Put payload for transaction {} could not be decoded: {error}",
12802                            record.txn_id
12803                        ),
12804                    })?;
12805                if let Some(table) = table {
12806                    for row in &decoded {
12807                        if !recovered_row_ids
12808                            .entry(*table_id)
12809                            .or_default()
12810                            .insert(row.row_id.0)
12811                        {
12812                            return Err(MongrelError::CorruptWal {
12813                                offset: record.seq.0,
12814                                reason: format!(
12815                                    "committed WAL repeats recovered row id {} for table {table_id}",
12816                                    row.row_id.0
12817                                ),
12818                            });
12819                        }
12820                        validate_recovered_row(&table.schema, row)?;
12821                    }
12822                }
12823            }
12824            Op::Delete { table_id, .. } | Op::TruncateTable { table_id } => {
12825                validate_recovery_data_table_plan(
12826                    catalog,
12827                    &tables,
12828                    *table_id,
12829                    commit_epoch,
12830                    record.seq.0,
12831                )?;
12832            }
12833            Op::ExternalTableState { name, .. } => validate_recovered_external_name(name)?,
12834            Op::Ddl(DdlOp::ResetExternalTableState {
12835                name,
12836                generation_epoch,
12837            }) => {
12838                if *generation_epoch != commit_epoch {
12839                    return Err(MongrelError::CorruptWal {
12840                        offset: record.seq.0,
12841                        reason: format!(
12842                            "external state reset epoch {generation_epoch} does not match WAL commit epoch {commit_epoch}"
12843                        ),
12844                    });
12845                }
12846                validate_recovered_external_name(name)?;
12847            }
12848            Op::TxnCommit { added_runs, .. } => {
12849                for added in added_runs {
12850                    let Some(table) = validate_recovery_data_table_plan(
12851                        catalog,
12852                        &tables,
12853                        added.table_id,
12854                        commit_epoch,
12855                        record.seq.0,
12856                    )?
12857                    else {
12858                        continue;
12859                    };
12860                    if added.run_id >= u64::MAX as u128
12861                        || !run_ids.insert((added.table_id, added.run_id))
12862                    {
12863                        return Err(MongrelError::CorruptWal {
12864                            offset: record.seq.0,
12865                            reason: format!(
12866                                "duplicate or invalid recovered run {} for table {}",
12867                                added.run_id, added.table_id
12868                            ),
12869                        });
12870                    }
12871                    if commit_epoch <= table.flushed_epoch {
12872                        continue;
12873                    }
12874                    validate_planned_spilled_run(
12875                        durable_root,
12876                        record.txn_id,
12877                        commit_epoch,
12878                        added,
12879                        &table.schema,
12880                        kek.clone(),
12881                    )?;
12882                }
12883            }
12884            _ => {}
12885        }
12886    }
12887    Ok(())
12888}
12889
12890fn validate_recovery_data_table_plan<'a>(
12891    catalog: &Catalog,
12892    tables: &'a HashMap<u64, RecoveryValidationTable>,
12893    table_id: u64,
12894    commit_epoch: u64,
12895    offset: u64,
12896) -> Result<Option<&'a RecoveryValidationTable>> {
12897    let entry = catalog
12898        .tables
12899        .iter()
12900        .find(|entry| entry.table_id == table_id)
12901        .ok_or_else(|| MongrelError::CorruptWal {
12902            offset,
12903            reason: format!("committed record references unknown table {table_id}"),
12904        })?;
12905    if commit_epoch < entry.created_epoch {
12906        return Err(MongrelError::CorruptWal {
12907            offset,
12908            reason: format!(
12909                "table {table_id} record epoch {commit_epoch} precedes creation epoch {}",
12910                entry.created_epoch
12911            ),
12912        });
12913    }
12914    match entry.state {
12915        TableState::Dropped { at_epoch } => {
12916            let abandoned =
12917                entry.name.starts_with(CTAS_BUILD_TABLE_PREFIX) && commit_epoch == at_epoch;
12918            if commit_epoch >= at_epoch && !abandoned {
12919                return Err(MongrelError::CorruptWal {
12920                    offset,
12921                    reason: format!(
12922                        "table {table_id} record epoch {commit_epoch} is not before drop epoch {at_epoch}"
12923                    ),
12924                });
12925            }
12926            Ok(None)
12927        }
12928        TableState::Live => {
12929            tables
12930                .get(&table_id)
12931                .map(Some)
12932                .ok_or_else(|| MongrelError::CorruptWal {
12933                    offset,
12934                    reason: format!("live table {table_id} has no recovery plan"),
12935                })
12936        }
12937        TableState::Building { .. } => Err(MongrelError::CorruptWal {
12938            offset,
12939            reason: format!("building table {table_id} was not normalized before recovery"),
12940        }),
12941    }
12942}
12943
12944fn validate_planned_spilled_run(
12945    root: &crate::durable_file::DurableRoot,
12946    txn_id: u64,
12947    commit_epoch: u64,
12948    added: &crate::wal::AddedRun,
12949    schema: &Schema,
12950    kek: Option<Arc<crate::encryption::Kek>>,
12951) -> Result<()> {
12952    let table = Path::new(TABLES_DIR).join(added.table_id.to_string());
12953    let destination = table
12954        .join(crate::engine::RUNS_DIR)
12955        .join(format!("r-{}.sr", added.run_id as u64));
12956    let pending = table
12957        .join("_txn")
12958        .join(txn_id.to_string())
12959        .join(format!("r-{}.sr", added.run_id as u64));
12960    let file = match root.open_regular(&destination) {
12961        Ok(file) => file,
12962        Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
12963            root.open_regular(&pending).map_err(|pending_error| {
12964                if pending_error.kind() == std::io::ErrorKind::NotFound {
12965                    MongrelError::CorruptWal {
12966                        offset: commit_epoch,
12967                        reason: format!(
12968                            "committed spilled run {} for transaction {txn_id} is missing",
12969                            added.run_id
12970                        ),
12971                    }
12972                } else {
12973                    pending_error.into()
12974                }
12975            })?
12976        }
12977        Err(error) => return Err(error.into()),
12978    };
12979    let mut reader = crate::sorted_run::RunReader::open_file(file, schema.clone(), kek)?;
12980    let header = reader.header();
12981    if header.run_id != added.run_id
12982        || header.content_hash != added.content_hash
12983        || header.row_count != added.row_count
12984        || header.level != added.level
12985        || header.min_row_id != added.min_row_id
12986        || header.max_row_id != added.max_row_id
12987        || header.schema_id != schema.schema_id
12988        || !header.is_uniform_epoch()
12989        || header.epoch_created != 0
12990    {
12991        return Err(MongrelError::CorruptWal {
12992            offset: commit_epoch,
12993            reason: format!(
12994                "committed spilled run {} metadata differs from WAL",
12995                added.run_id
12996            ),
12997        });
12998    }
12999    reader.validate_all_pages()?;
13000    Ok(())
13001}
13002
13003/// Two-pass, `flushed_epoch`-gated recovery of the shared WAL (spec §15).
13004///
13005/// Pass 1 scans every `TxnCommit` marker and records `txn_id → commit_epoch`
13006/// (the per-txn outcome; aborted / in-flight / torn-tail txns are absent). Pass
13007/// 2 applies each committed data record (Put/Delete) to its table at the commit
13008/// epoch, skipping records whose `commit_epoch <= table.flushed_epoch` (already
13009/// durable in a sorted run). Finally the shared epoch authority is raised to the
13010/// max committed epoch so the next commit continues monotonically.
13011fn recover_shared_wal(
13012    durable_root: &crate::durable_file::DurableRoot,
13013    tables: &HashMap<u64, TableHandle>,
13014    catalog: &Catalog,
13015    epoch: &EpochAuthority,
13016    records: &[crate::wal::Record],
13017) -> Result<()> {
13018    use crate::memtable::Row;
13019    use crate::wal::{DdlOp, Op};
13020
13021    // Pass 1: committed-txn outcomes + collect spilled-run info.
13022    let mut committed: HashMap<u64, u64> = HashMap::new();
13023    let mut spilled_to_link: Vec<(
13024        u64, /*txn_id*/
13025        u64, /*epoch*/
13026        Vec<crate::wal::AddedRun>,
13027    )> = Vec::new();
13028    for r in records {
13029        if let Op::TxnCommit {
13030            epoch: ce,
13031            ref added_runs,
13032        } = r.op
13033        {
13034            committed.insert(r.txn_id, ce);
13035            if !added_runs.is_empty() {
13036                spilled_to_link.push((r.txn_id, ce, added_runs.clone()));
13037            }
13038        }
13039    }
13040    for record in records {
13041        let Some(&commit_epoch) = committed.get(&record.txn_id) else {
13042            continue;
13043        };
13044        match &record.op {
13045            Op::Put { table_id, .. }
13046            | Op::Delete { table_id, .. }
13047            | Op::TruncateTable { table_id } => {
13048                validate_recovered_data_table(
13049                    catalog,
13050                    tables,
13051                    *table_id,
13052                    commit_epoch,
13053                    record.seq.0,
13054                )?;
13055            }
13056            Op::TxnCommit { added_runs, .. } => {
13057                for run in added_runs {
13058                    validate_recovered_data_table(
13059                        catalog,
13060                        tables,
13061                        run.table_id,
13062                        commit_epoch,
13063                        record.seq.0,
13064                    )?;
13065                }
13066            }
13067            _ => {}
13068        }
13069    }
13070    let truncated_transactions: HashSet<(u64, u64)> = records
13071        .iter()
13072        .filter_map(|record| {
13073            committed.get(&record.txn_id)?;
13074            match record.op {
13075                Op::TruncateTable { table_id } => Some((record.txn_id, table_id)),
13076                _ => None,
13077            }
13078        })
13079        .collect();
13080
13081    // Pass 2: stage data per table, gated by flushed_epoch.
13082    enum ExternalRecoveryAction {
13083        Write { name: String, state: Vec<u8> },
13084        Reset { name: String },
13085    }
13086    let mut stage: HashMap<u64, RecoveryTableStage> = HashMap::new();
13087    let mut external_actions = Vec::new();
13088    let mut max_epoch = epoch.visible().0;
13089    for r in records.iter().cloned() {
13090        let Some(&ce) = committed.get(&r.txn_id) else {
13091            continue; // aborted / in-flight — discard
13092        };
13093        let commit_epoch = Epoch(ce);
13094        max_epoch = max_epoch.max(ce);
13095        match r.op {
13096            Op::Put { table_id, rows } => {
13097                // Skip if this table already flushed past the commit epoch.
13098                let skip = tables
13099                    .get(&table_id)
13100                    .map(|h| h.lock().flushed_epoch() >= ce)
13101                    .unwrap_or(true);
13102                if skip {
13103                    continue;
13104                }
13105                let rows: Vec<Row> = bincode::deserialize(&rows).map_err(|error| {
13106                    MongrelError::CorruptWal {
13107                        offset: r.seq.0,
13108                        reason: format!(
13109                            "committed Put payload for transaction {} could not be decoded: {error}",
13110                            r.txn_id
13111                        ),
13112                    }
13113                })?;
13114                // Re-stamp each row at the txn commit epoch (rows are pre-stamped
13115                // at pending_epoch which equals the commit epoch, but be robust).
13116                let rows: Vec<Row> = rows
13117                    .into_iter()
13118                    .map(|mut row| {
13119                        row.committed_epoch = commit_epoch;
13120                        row
13121                    })
13122                    .collect();
13123                let entry = stage
13124                    .entry(table_id)
13125                    .or_insert_with(|| (Vec::new(), Vec::new(), None, commit_epoch));
13126                entry.0.extend(rows);
13127                entry.3 = commit_epoch;
13128            }
13129            Op::Delete { table_id, row_ids } => {
13130                let skip = tables
13131                    .get(&table_id)
13132                    .map(|h| h.lock().flushed_epoch() >= ce)
13133                    .unwrap_or(true);
13134                if skip {
13135                    continue;
13136                }
13137                let dels = row_ids.into_iter().map(|rid| (rid, commit_epoch));
13138                let entry = stage
13139                    .entry(table_id)
13140                    .or_insert_with(|| (Vec::new(), Vec::new(), None, commit_epoch));
13141                entry.1.extend(dels);
13142                entry.3 = commit_epoch;
13143            }
13144            Op::TruncateTable { table_id } => {
13145                let skip = tables
13146                    .get(&table_id)
13147                    .map(|h| h.lock().flushed_epoch() >= ce)
13148                    .unwrap_or(true);
13149                if skip {
13150                    continue;
13151                }
13152                stage.insert(
13153                    table_id,
13154                    (Vec::new(), Vec::new(), Some(commit_epoch), commit_epoch),
13155                );
13156            }
13157            Op::ExternalTableState { name, state } => {
13158                let current_generation = catalog
13159                    .external_tables
13160                    .iter()
13161                    .find(|entry| entry.name == name)
13162                    .map(|entry| entry.created_epoch);
13163                if current_generation.is_some_and(|created_epoch| ce >= created_epoch) {
13164                    validate_recovered_external_name(&name)?;
13165                    external_actions.push(ExternalRecoveryAction::Write { name, state });
13166                }
13167            }
13168            Op::Ddl(DdlOp::ResetExternalTableState {
13169                name,
13170                generation_epoch,
13171            }) => {
13172                if generation_epoch != ce {
13173                    return Err(MongrelError::CorruptWal {
13174                        offset: r.seq.0,
13175                        reason: format!(
13176                        "external state reset epoch {generation_epoch} does not match WAL commit epoch {ce}"
13177                    ),
13178                    });
13179                }
13180                validate_recovered_external_name(&name)?;
13181                external_actions.push(ExternalRecoveryAction::Reset { name });
13182            }
13183            Op::Flush { .. }
13184            | Op::TxnCommit { .. }
13185            | Op::TxnAbort
13186            | Op::Ddl(_)
13187            | Op::BeforeImage { .. }
13188            | Op::CommitTimestamp { .. }
13189            | Op::SpilledRows { .. } => {}
13190        }
13191    }
13192    for (_, commit_epoch, added_runs) in &mut spilled_to_link {
13193        added_runs.retain(|added| {
13194            tables
13195                .get(&added.table_id)
13196                .is_some_and(|table| table.lock().flushed_epoch() < *commit_epoch)
13197        });
13198    }
13199    spilled_to_link.retain(|(_, _, added_runs)| !added_runs.is_empty());
13200    validate_recovery_table_stages(tables, &stage)?;
13201    validate_recovery_spilled_runs(durable_root, tables, &spilled_to_link)?;
13202
13203    // All WAL payloads, catalog generations, table stages, and immutable run
13204    // identities have now been validated. Only this application phase mutates
13205    // the database tree.
13206    for action in external_actions {
13207        match action {
13208            ExternalRecoveryAction::Write { name, state } => {
13209                write_external_state_file(durable_root, &name, &state)?;
13210            }
13211            ExternalRecoveryAction::Reset { name } => {
13212                durable_root.create_directory_all(VTAB_DIR)?;
13213                durable_root.remove_directory_all(Path::new(VTAB_DIR).join(name))?;
13214            }
13215        }
13216    }
13217    for (table_id, (rows, deletes, truncate_epoch, table_epoch)) in stage {
13218        let Some(handle) = tables.get(&table_id) else {
13219            continue;
13220        };
13221        let mut t = handle.lock();
13222        if let Some(epoch) = truncate_epoch {
13223            t.apply_truncate(epoch);
13224        }
13225        t.recover_apply(rows, deletes)?;
13226        // The WAL can be newer than the copied/persisted manifest after a
13227        // crash or replication apply. Rebuild O(1) count metadata from the
13228        // recovered state before endorsing the commit epoch in the manifest.
13229        let rows = t.visible_rows(Snapshot::at(Epoch(u64::MAX)))?;
13230        t.live_count = rows.len() as u64;
13231        // Recovery can replay older row commits while a newer spilled run is
13232        // already linked by the copied manifest. Never move that manifest's
13233        // epoch behind its existing run references.
13234        t.persist_manifest(table_epoch.max(epoch.visible()))?;
13235    }
13236
13237    // Pass 3: link spilled runs from committed txns (spec §8.5). A crash
13238    // between TxnCommit sync and the publish phase leaves the run in
13239    // `_txn/<txn_id>/`. Move it to `_runs/` and add the RunRef.
13240    for (txn_id, ce, added_runs) in &spilled_to_link {
13241        for ar in added_runs {
13242            let Some(handle) = tables.get(&ar.table_id) else {
13243                continue;
13244            };
13245            let mut t = handle.lock();
13246            let table_dir = Path::new(TABLES_DIR).join(ar.table_id.to_string());
13247            let destination = table_dir
13248                .join(crate::engine::RUNS_DIR)
13249                .join(format!("r-{}.sr", ar.run_id));
13250            match durable_root.open_regular(&destination) {
13251                Ok(_) => {}
13252                Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
13253                    let pending = table_dir
13254                        .join("_txn")
13255                        .join(txn_id.to_string())
13256                        .join(format!("r-{}.sr", ar.run_id));
13257                    durable_root.rename_file_new(&pending, &destination)?;
13258                }
13259                Err(error) => return Err(error.into()),
13260            }
13261            // Only link a run whose file is actually present, and never re-link
13262            // one the publish phase already persisted into the manifest (which is
13263            // the common clean-reopen case, since the `TxnCommit` lives in the WAL
13264            // until segment GC). `recover_spilled_run` is idempotent + reconciles
13265            // `live_count`/indexes only when the run is genuinely new.
13266            let linked = t.recover_spilled_run(crate::manifest::RunRef {
13267                run_id: ar.run_id,
13268                level: ar.level,
13269                epoch_created: *ce,
13270                row_count: ar.row_count,
13271            });
13272            let replaced = truncated_transactions.contains(&(*txn_id, ar.table_id));
13273            if replaced {
13274                t.set_flushed_epoch(Epoch(*ce));
13275            }
13276            if linked || replaced {
13277                t.persist_manifest(Epoch(*ce).max(epoch.visible()))?;
13278            }
13279        }
13280    }
13281
13282    epoch.advance_recovered(Epoch(max_epoch));
13283    Ok(())
13284}
13285
13286fn reconcile_recovered_table_metadata(
13287    tables: &HashMap<u64, TableHandle>,
13288    epoch: Epoch,
13289) -> Result<()> {
13290    let mut table_ids = tables.keys().copied().collect::<Vec<_>>();
13291    table_ids.sort_unstable();
13292    let mut plans = Vec::with_capacity(table_ids.len());
13293    for table_id in &table_ids {
13294        let handle = tables.get(table_id).ok_or_else(|| {
13295            MongrelError::Other(format!("mounted table {table_id} vanished during recovery"))
13296        })?;
13297        plans.push((*table_id, handle.lock().plan_recovered_metadata()?));
13298    }
13299    // Every table's data and metadata have been decoded successfully. Publish
13300    // repairs only after the complete database-wide plan is known valid.
13301    for (table_id, plan) in plans {
13302        let handle = tables.get(&table_id).ok_or_else(|| {
13303            MongrelError::Other(format!("mounted table {table_id} vanished during recovery"))
13304        })?;
13305        handle.lock().apply_recovered_metadata(plan, epoch)?;
13306    }
13307    Ok(())
13308}
13309
13310fn validate_recovered_external_name(name: &str) -> Result<()> {
13311    if name.is_empty()
13312        || !name.chars().all(|character| {
13313            character.is_ascii_alphanumeric() || character == '_' || character == '-'
13314        })
13315    {
13316        return Err(MongrelError::CorruptWal {
13317            offset: 0,
13318            reason: format!("unsafe recovered external-table name {name:?}"),
13319        });
13320    }
13321    Ok(())
13322}
13323
13324fn validate_recovery_table_stages(
13325    tables: &HashMap<u64, TableHandle>,
13326    stages: &HashMap<u64, RecoveryTableStage>,
13327) -> Result<()> {
13328    for (table_id, (rows, _, _, _)) in stages {
13329        let handle = tables
13330            .get(table_id)
13331            .ok_or_else(|| MongrelError::CorruptWal {
13332                offset: *table_id,
13333                reason: format!("recovery stage references unmounted table {table_id}"),
13334            })?;
13335        let table = handle.lock();
13336        // Force all existing immutable runs through their integrity/decode path
13337        // before any other table manifest can be changed.
13338        table.visible_rows(Snapshot::at(Epoch(u64::MAX)))?;
13339        for row in rows {
13340            validate_recovered_row(table.schema(), row)?;
13341        }
13342    }
13343    Ok(())
13344}
13345
13346fn validate_recovered_row(schema: &Schema, row: &crate::memtable::Row) -> Result<()> {
13347    if row.deleted || row.row_id.0 == u64::MAX {
13348        return Err(MongrelError::CorruptWal {
13349            offset: row.row_id.0,
13350            reason: "committed Put payload contains a tombstone or exhausted row id".into(),
13351        });
13352    }
13353    let cells = row
13354        .columns
13355        .iter()
13356        .map(|(column, value)| (*column, value.clone()))
13357        .collect::<Vec<_>>();
13358    schema
13359        .validate_persisted_values(&cells)
13360        .map_err(|error| MongrelError::CorruptWal {
13361            offset: row.row_id.0,
13362            reason: format!("recovered row violates table schema: {error}"),
13363        })?;
13364    if schema.auto_increment_column().is_some_and(|column| {
13365        matches!(row.columns.get(&column.id), Some(Value::Int64(value)) if *value == i64::MAX)
13366    }) {
13367        return Err(MongrelError::CorruptWal {
13368            offset: row.row_id.0,
13369            reason: "recovered AUTO_INCREMENT value exhausts i64".into(),
13370        });
13371    }
13372    Ok(())
13373}
13374
13375fn validate_recovery_spilled_runs(
13376    root: &crate::durable_file::DurableRoot,
13377    tables: &HashMap<u64, TableHandle>,
13378    spilled: &[(u64, u64, Vec<crate::wal::AddedRun>)],
13379) -> Result<()> {
13380    let mut identities = HashSet::new();
13381    for (txn_id, commit_epoch, added_runs) in spilled {
13382        for added in added_runs {
13383            if added.run_id >= u64::MAX as u128 {
13384                return Err(MongrelError::CorruptWal {
13385                    offset: *commit_epoch,
13386                    reason: format!(
13387                        "recovered run id {} exceeds the on-disk namespace",
13388                        added.run_id
13389                    ),
13390                });
13391            }
13392            let Some(handle) = tables.get(&added.table_id) else {
13393                continue;
13394            };
13395            if !identities.insert((added.table_id, added.run_id)) {
13396                return Err(MongrelError::CorruptWal {
13397                    offset: *commit_epoch,
13398                    reason: format!(
13399                        "duplicate recovered run {} for table {}",
13400                        added.run_id, added.table_id
13401                    ),
13402                });
13403            }
13404            let table = handle.lock();
13405            validate_planned_spilled_run(
13406                root,
13407                *txn_id,
13408                *commit_epoch,
13409                added,
13410                table.schema(),
13411                table.kek(),
13412            )?;
13413        }
13414    }
13415    Ok(())
13416}
13417
13418fn validate_condition_columns(condition: &ProcedureCondition, schema: &Schema) -> Result<()> {
13419    match condition {
13420        ProcedureCondition::Pk { .. } => {
13421            if schema.primary_key().is_none() {
13422                return Err(MongrelError::InvalidArgument(
13423                    "procedure condition Pk references a table without a primary key".into(),
13424                ));
13425            }
13426        }
13427        ProcedureCondition::BitmapEq { column_id, .. }
13428        | ProcedureCondition::BitmapIn { column_id, .. }
13429        | ProcedureCondition::Range { column_id, .. }
13430        | ProcedureCondition::RangeF64 { column_id, .. }
13431        | ProcedureCondition::IsNull { column_id }
13432        | ProcedureCondition::IsNotNull { column_id }
13433        | ProcedureCondition::FmContains { column_id, .. } => {
13434            validate_column_id(*column_id, schema)?;
13435        }
13436    }
13437    Ok(())
13438}
13439
13440fn bind_procedure_args(
13441    procedure: &StoredProcedure,
13442    mut args: HashMap<String, crate::Value>,
13443) -> Result<HashMap<String, crate::Value>> {
13444    let mut out = HashMap::new();
13445    for param in &procedure.params {
13446        let value = match args.remove(&param.name) {
13447            Some(value) => value,
13448            None => param.default.clone().ok_or_else(|| {
13449                MongrelError::InvalidArgument(format!(
13450                    "missing required procedure parameter {:?}",
13451                    param.name
13452                ))
13453            })?,
13454        };
13455        if !param.nullable && matches!(value, crate::Value::Null) {
13456            return Err(MongrelError::InvalidArgument(format!(
13457                "procedure parameter {:?} must not be NULL",
13458                param.name
13459            )));
13460        }
13461        if !matches!(value, crate::Value::Null) && !value_matches_type(&value, param.ty.clone()) {
13462            return Err(MongrelError::InvalidArgument(format!(
13463                "procedure parameter {:?} has wrong type",
13464                param.name
13465            )));
13466        }
13467        out.insert(param.name.clone(), value);
13468    }
13469    if let Some(extra) = args.keys().next() {
13470        return Err(MongrelError::InvalidArgument(format!(
13471            "unknown procedure parameter {extra:?}"
13472        )));
13473    }
13474    Ok(out)
13475}
13476
13477fn value_matches_type(value: &crate::Value, ty: crate::TypeId) -> bool {
13478    matches!(
13479        (value, ty),
13480        (crate::Value::Bool(_), crate::TypeId::Bool)
13481            | (crate::Value::Int64(_), crate::TypeId::Int8)
13482            | (crate::Value::Int64(_), crate::TypeId::Int16)
13483            | (crate::Value::Int64(_), crate::TypeId::Int32)
13484            | (crate::Value::Int64(_), crate::TypeId::Int64)
13485            | (crate::Value::Int64(_), crate::TypeId::UInt8)
13486            | (crate::Value::Int64(_), crate::TypeId::UInt16)
13487            | (crate::Value::Int64(_), crate::TypeId::UInt32)
13488            | (crate::Value::Int64(_), crate::TypeId::UInt64)
13489            | (crate::Value::Int64(_), crate::TypeId::TimestampNanos)
13490            | (crate::Value::Int64(_), crate::TypeId::Date32)
13491            | (crate::Value::Float64(_), crate::TypeId::Float32)
13492            | (crate::Value::Float64(_), crate::TypeId::Float64)
13493            | (crate::Value::Bytes(_), crate::TypeId::Bytes)
13494            | (crate::Value::Embedding(_), crate::TypeId::Embedding { .. })
13495    )
13496}
13497
13498fn eval_cells(
13499    cells: &[crate::procedure::ProcedureCell],
13500    args: &HashMap<String, crate::Value>,
13501    outputs: &HashMap<String, ProcedureCallOutput>,
13502) -> Result<Vec<(u16, crate::Value)>> {
13503    cells
13504        .iter()
13505        .map(|cell| Ok((cell.column_id, eval_value(&cell.value, args, outputs)?)))
13506        .collect()
13507}
13508
13509fn eval_condition(
13510    condition: &ProcedureCondition,
13511    args: &HashMap<String, crate::Value>,
13512    outputs: &HashMap<String, ProcedureCallOutput>,
13513) -> Result<crate::Condition> {
13514    Ok(match condition {
13515        ProcedureCondition::Pk { value } => {
13516            crate::Condition::Pk(eval_value(value, args, outputs)?.encode_key())
13517        }
13518        ProcedureCondition::BitmapEq { column_id, value } => crate::Condition::BitmapEq {
13519            column_id: *column_id,
13520            value: eval_value(value, args, outputs)?.encode_key(),
13521        },
13522        ProcedureCondition::BitmapIn { column_id, values } => crate::Condition::BitmapIn {
13523            column_id: *column_id,
13524            values: values
13525                .iter()
13526                .map(|value| Ok(eval_value(value, args, outputs)?.encode_key()))
13527                .collect::<Result<Vec<_>>>()?,
13528        },
13529        ProcedureCondition::Range { column_id, lo, hi } => crate::Condition::Range {
13530            column_id: *column_id,
13531            lo: expect_i64(eval_value(lo, args, outputs)?)?,
13532            hi: expect_i64(eval_value(hi, args, outputs)?)?,
13533        },
13534        ProcedureCondition::RangeF64 {
13535            column_id,
13536            lo,
13537            lo_inclusive,
13538            hi,
13539            hi_inclusive,
13540        } => crate::Condition::RangeF64 {
13541            column_id: *column_id,
13542            lo: expect_f64(eval_value(lo, args, outputs)?)?,
13543            lo_inclusive: *lo_inclusive,
13544            hi: expect_f64(eval_value(hi, args, outputs)?)?,
13545            hi_inclusive: *hi_inclusive,
13546        },
13547        ProcedureCondition::IsNull { column_id } => crate::Condition::IsNull {
13548            column_id: *column_id,
13549        },
13550        ProcedureCondition::IsNotNull { column_id } => crate::Condition::IsNotNull {
13551            column_id: *column_id,
13552        },
13553        ProcedureCondition::FmContains { column_id, pattern } => crate::Condition::FmContains {
13554            column_id: *column_id,
13555            pattern: expect_bytes(eval_value(pattern, args, outputs)?)?,
13556        },
13557    })
13558}
13559
13560fn eval_value(
13561    value: &ProcedureValue,
13562    args: &HashMap<String, crate::Value>,
13563    outputs: &HashMap<String, ProcedureCallOutput>,
13564) -> Result<crate::Value> {
13565    match value {
13566        ProcedureValue::Literal(value) => Ok(value.clone()),
13567        ProcedureValue::Param(name) => args.get(name).cloned().ok_or_else(|| {
13568            MongrelError::InvalidArgument(format!("unknown procedure parameter {name:?}"))
13569        }),
13570        ProcedureValue::StepScalar(id) => match outputs.get(id) {
13571            Some(ProcedureCallOutput::Scalar(value)) => Ok(value.clone()),
13572            _ => Err(MongrelError::InvalidArgument(format!(
13573                "procedure step {id:?} did not return a scalar"
13574            ))),
13575        },
13576        ProcedureValue::StepRows(_) | ProcedureValue::StepRow(_) => {
13577            Err(MongrelError::InvalidArgument(
13578                "row-valued procedure reference cannot be used as a scalar".into(),
13579            ))
13580        }
13581        ProcedureValue::Object(_) | ProcedureValue::Array(_) => Err(MongrelError::InvalidArgument(
13582            "structured procedure value cannot be used as a scalar cell".into(),
13583        )),
13584    }
13585}
13586
13587fn eval_return_output(
13588    value: &ProcedureValue,
13589    args: &HashMap<String, crate::Value>,
13590    outputs: &HashMap<String, ProcedureCallOutput>,
13591) -> Result<ProcedureCallOutput> {
13592    match value {
13593        ProcedureValue::Literal(value) => Ok(ProcedureCallOutput::Scalar(value.clone())),
13594        ProcedureValue::Param(name) => Ok(ProcedureCallOutput::Scalar(
13595            args.get(name).cloned().ok_or_else(|| {
13596                MongrelError::InvalidArgument(format!("unknown procedure parameter {name:?}"))
13597            })?,
13598        )),
13599        ProcedureValue::StepRows(id)
13600        | ProcedureValue::StepRow(id)
13601        | ProcedureValue::StepScalar(id) => outputs.get(id).cloned().ok_or_else(|| {
13602            MongrelError::InvalidArgument(format!("unknown procedure step output {id:?}"))
13603        }),
13604        ProcedureValue::Object(fields) => {
13605            let mut out = Vec::with_capacity(fields.len());
13606            for (name, value) in fields {
13607                out.push((name.clone(), eval_return_output(value, args, outputs)?));
13608            }
13609            Ok(ProcedureCallOutput::Object(out))
13610        }
13611        ProcedureValue::Array(values) => {
13612            let mut out = Vec::with_capacity(values.len());
13613            for value in values {
13614                out.push(eval_return_output(value, args, outputs)?);
13615            }
13616            Ok(ProcedureCallOutput::Array(out))
13617        }
13618    }
13619}
13620
13621fn expect_i64(value: crate::Value) -> Result<i64> {
13622    match value {
13623        crate::Value::Int64(value) => Ok(value),
13624        _ => Err(MongrelError::InvalidArgument(
13625            "procedure value must be Int64".into(),
13626        )),
13627    }
13628}
13629
13630fn expect_f64(value: crate::Value) -> Result<f64> {
13631    match value {
13632        crate::Value::Float64(value) => Ok(value),
13633        _ => Err(MongrelError::InvalidArgument(
13634            "procedure value must be Float64".into(),
13635        )),
13636    }
13637}
13638
13639fn expect_bytes(value: crate::Value) -> Result<Vec<u8>> {
13640    match value {
13641        crate::Value::Bytes(value) => Ok(value),
13642        _ => Err(MongrelError::InvalidArgument(
13643            "procedure value must be Bytes".into(),
13644        )),
13645    }
13646}
13647
13648fn validate_column_id(column_id: u16, schema: &Schema) -> Result<()> {
13649    if schema.columns.iter().any(|c| c.id == column_id) {
13650        Ok(())
13651    } else {
13652        Err(MongrelError::InvalidArgument(format!(
13653            "unknown column id {column_id}"
13654        )))
13655    }
13656}
13657
13658fn trigger_matches_event(
13659    trigger: &StoredTrigger,
13660    event: &WriteEvent,
13661    cat: &Catalog,
13662) -> Result<bool> {
13663    if trigger.event != event.kind {
13664        return Ok(false);
13665    }
13666    let TriggerTarget::Table(target) = &trigger.target else {
13667        return Ok(false);
13668    };
13669    if target != &event.table {
13670        return Ok(false);
13671    }
13672    if trigger.event == TriggerEvent::Update && !trigger.update_of.is_empty() {
13673        let schema = &cat
13674            .live(target)
13675            .ok_or_else(|| {
13676                MongrelError::InvalidArgument(format!(
13677                    "trigger {:?} references unknown table {target:?}",
13678                    trigger.name
13679                ))
13680            })?
13681            .schema;
13682        let mut watched = Vec::with_capacity(trigger.update_of.len());
13683        for name in &trigger.update_of {
13684            let col = schema.column(name).ok_or_else(|| {
13685                MongrelError::InvalidArgument(format!(
13686                    "trigger {:?} references unknown UPDATE OF column {name:?}",
13687                    trigger.name
13688                ))
13689            })?;
13690            watched.push(col.id);
13691        }
13692        if !event
13693            .changed_columns
13694            .iter()
13695            .any(|column_id| watched.contains(column_id))
13696        {
13697            return Ok(false);
13698        }
13699    }
13700    Ok(true)
13701}
13702
13703fn changed_columns(old: Option<&TriggerRowImage>, new: Option<&TriggerRowImage>) -> Vec<u16> {
13704    let mut ids = std::collections::BTreeSet::new();
13705    if let Some(old) = old {
13706        ids.extend(old.columns.keys().copied());
13707    }
13708    if let Some(new) = new {
13709        ids.extend(new.columns.keys().copied());
13710    }
13711    ids.into_iter()
13712        .filter(|id| {
13713            old.and_then(|row| row.columns.get(id)) != new.and_then(|row| row.columns.get(id))
13714        })
13715        .collect()
13716}
13717
13718fn eval_trigger_cells(
13719    cells: &[crate::trigger::TriggerCell],
13720    event: &WriteEvent,
13721    selected: Option<&TriggerRowImage>,
13722) -> Result<Vec<(u16, Value)>> {
13723    cells
13724        .iter()
13725        .map(|cell| {
13726            Ok((
13727                cell.column_id,
13728                eval_trigger_value(&cell.value, event, selected)?,
13729            ))
13730        })
13731        .collect()
13732}
13733
13734fn eval_trigger_expr(expr: &TriggerExpr, event: &WriteEvent) -> Result<bool> {
13735    match expr {
13736        TriggerExpr::Value(value) => match eval_trigger_value(value, event, None)? {
13737            Value::Bool(value) => Ok(value),
13738            Value::Null => Ok(false),
13739            other => Err(MongrelError::InvalidArgument(format!(
13740                "trigger WHEN value must be boolean, got {other:?}"
13741            ))),
13742        },
13743        TriggerExpr::Eq { left, right } => Ok(values_equal(
13744            &eval_trigger_value(left, event, None)?,
13745            &eval_trigger_value(right, event, None)?,
13746        )),
13747        TriggerExpr::NotEq { left, right } => Ok(!values_equal(
13748            &eval_trigger_value(left, event, None)?,
13749            &eval_trigger_value(right, event, None)?,
13750        )),
13751        TriggerExpr::Lt { left, right } => match value_order(
13752            &eval_trigger_value(left, event, None)?,
13753            &eval_trigger_value(right, event, None)?,
13754        ) {
13755            Some(ordering) => Ok(ordering == std::cmp::Ordering::Less),
13756            None => Ok(false),
13757        },
13758        TriggerExpr::Lte { 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::Greater),
13763            None => Ok(false),
13764        },
13765        TriggerExpr::Gt { 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::Gte { 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::Less),
13777            None => Ok(false),
13778        },
13779        TriggerExpr::IsNull(value) => Ok(matches!(
13780            eval_trigger_value(value, event, None)?,
13781            Value::Null
13782        )),
13783        TriggerExpr::IsNotNull(value) => Ok(!matches!(
13784            eval_trigger_value(value, event, None)?,
13785            Value::Null
13786        )),
13787        TriggerExpr::And { left, right } => {
13788            if !eval_trigger_expr(left, event)? {
13789                Ok(false)
13790            } else {
13791                Ok(eval_trigger_expr(right, event)?)
13792            }
13793        }
13794        TriggerExpr::Or { left, right } => {
13795            if eval_trigger_expr(left, event)? {
13796                Ok(true)
13797            } else {
13798                Ok(eval_trigger_expr(right, event)?)
13799            }
13800        }
13801        TriggerExpr::Not(expr) => Ok(!eval_trigger_expr(expr, event)?),
13802    }
13803}
13804
13805fn eval_trigger_condition(
13806    condition: &TriggerCondition,
13807    event: &WriteEvent,
13808    selected: &TriggerRowImage,
13809    schema: &Schema,
13810) -> Result<bool> {
13811    match condition {
13812        TriggerCondition::Pk { value } => {
13813            let pk = schema.primary_key().ok_or_else(|| {
13814                MongrelError::InvalidArgument(
13815                    "trigger condition Pk references a table without a primary key".into(),
13816                )
13817            })?;
13818            let lhs = eval_trigger_value(value, event, Some(selected))?;
13819            Ok(values_equal(
13820                &lhs,
13821                selected.columns.get(&pk.id).unwrap_or(&Value::Null),
13822            ))
13823        }
13824        TriggerCondition::Eq { column_id, value } => Ok(values_equal(
13825            selected.columns.get(column_id).unwrap_or(&Value::Null),
13826            &eval_trigger_value(value, event, Some(selected))?,
13827        )),
13828        TriggerCondition::NotEq { column_id, value } => Ok(!values_equal(
13829            selected.columns.get(column_id).unwrap_or(&Value::Null),
13830            &eval_trigger_value(value, event, Some(selected))?,
13831        )),
13832        TriggerCondition::Lt { column_id, value } => match value_order(
13833            selected.columns.get(column_id).unwrap_or(&Value::Null),
13834            &eval_trigger_value(value, event, Some(selected))?,
13835        ) {
13836            Some(ordering) => Ok(ordering == std::cmp::Ordering::Less),
13837            None => Ok(false),
13838        },
13839        TriggerCondition::Lte { 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::Greater),
13844            None => Ok(false),
13845        },
13846        TriggerCondition::Gt { 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::Gte { 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::Less),
13858            None => Ok(false),
13859        },
13860        TriggerCondition::IsNull { column_id } => Ok(matches!(
13861            selected.columns.get(column_id),
13862            None | Some(Value::Null)
13863        )),
13864        TriggerCondition::IsNotNull { column_id } => Ok(!matches!(
13865            selected.columns.get(column_id),
13866            None | Some(Value::Null)
13867        )),
13868        TriggerCondition::And { left, right } => {
13869            if !eval_trigger_condition(left, event, selected, schema)? {
13870                Ok(false)
13871            } else {
13872                Ok(eval_trigger_condition(right, event, selected, schema)?)
13873            }
13874        }
13875        TriggerCondition::Or { left, right } => {
13876            if eval_trigger_condition(left, event, selected, schema)? {
13877                Ok(true)
13878            } else {
13879                Ok(eval_trigger_condition(right, event, selected, schema)?)
13880            }
13881        }
13882        TriggerCondition::Not(condition) => {
13883            Ok(!eval_trigger_condition(condition, event, selected, schema)?)
13884        }
13885    }
13886}
13887
13888fn eval_trigger_value(
13889    value: &TriggerValue,
13890    event: &WriteEvent,
13891    selected: Option<&TriggerRowImage>,
13892) -> Result<Value> {
13893    match value {
13894        TriggerValue::Literal(value) => Ok(value.clone()),
13895        TriggerValue::NewColumn(column_id) => event
13896            .new
13897            .as_ref()
13898            .and_then(|row| row.columns.get(column_id))
13899            .cloned()
13900            .ok_or_else(|| MongrelError::InvalidArgument("NEW column is not available".into())),
13901        TriggerValue::OldColumn(column_id) => event
13902            .old
13903            .as_ref()
13904            .and_then(|row| row.columns.get(column_id))
13905            .cloned()
13906            .ok_or_else(|| MongrelError::InvalidArgument("OLD column is not available".into())),
13907        TriggerValue::SelectedColumn(column_id) => selected
13908            .and_then(|row| row.columns.get(column_id))
13909            .cloned()
13910            .ok_or_else(|| {
13911                MongrelError::InvalidArgument("SELECTED column is not available".into())
13912            }),
13913    }
13914}
13915
13916fn values_equal(left: &Value, right: &Value) -> bool {
13917    match (left, right) {
13918        (Value::Null, Value::Null) => true,
13919        (Value::Bool(a), Value::Bool(b)) => a == b,
13920        (Value::Int64(a), Value::Int64(b)) => a == b,
13921        (Value::Float64(a), Value::Float64(b)) => a.to_bits() == b.to_bits(),
13922        (Value::Bytes(a), Value::Bytes(b)) => a == b,
13923        (Value::Embedding(a), Value::Embedding(b)) => {
13924            a.len() == b.len()
13925                && a.iter()
13926                    .zip(b.iter())
13927                    .all(|(a, b)| a.to_bits() == b.to_bits())
13928        }
13929        _ => false,
13930    }
13931}
13932
13933fn value_order(left: &Value, right: &Value) -> Option<std::cmp::Ordering> {
13934    match (left, right) {
13935        (Value::Null, _) | (_, Value::Null) => None,
13936        (Value::Bool(a), Value::Bool(b)) => Some(a.cmp(b)),
13937        (Value::Int64(a), Value::Int64(b)) => Some(a.cmp(b)),
13938        // Cross-type Int64/Float64 comparison coerces the integer to f64.
13939        // This matches the spec but can lose precision for i64 values above 2^53.
13940        (Value::Int64(a), Value::Float64(b)) => {
13941            let af = *a as f64;
13942            Some(af.total_cmp(b))
13943        }
13944        // Cross-type Int64/Float64 comparison coerces the integer to f64.
13945        // This matches the spec but can lose precision for i64 values above 2^53.
13946        (Value::Float64(a), Value::Int64(b)) => {
13947            let bf = *b as f64;
13948            Some(a.total_cmp(&bf))
13949        }
13950        (Value::Float64(a), Value::Float64(b)) => Some(a.total_cmp(b)),
13951        (Value::Bytes(a), Value::Bytes(b)) => Some(a.cmp(b)),
13952        (Value::Embedding(_), Value::Embedding(_)) => None,
13953        _ => None,
13954    }
13955}
13956
13957fn trigger_message(value: Value) -> String {
13958    match value {
13959        Value::Null => "NULL".into(),
13960        Value::Bool(value) => value.to_string(),
13961        Value::Int64(value) => value.to_string(),
13962        Value::Float64(value) => value.to_string(),
13963        Value::Bytes(value) => String::from_utf8_lossy(&value).into_owned(),
13964        Value::Embedding(value) => format!("{value:?}"),
13965        Value::Decimal(value) => value.to_string(),
13966        Value::Interval {
13967            months,
13968            days,
13969            nanos,
13970        } => format!("{months}m {days}d {nanos}ns"),
13971        Value::Uuid(b) => b.iter().map(|x| format!("{x:02x}")).collect(),
13972        Value::Json(b) => String::from_utf8_lossy(&b).into_owned(),
13973    }
13974}
13975
13976fn validate_trigger_step<'a>(
13977    step: &TriggerStep,
13978    cat: &'a Catalog,
13979    target_schema: &Schema,
13980    event: TriggerEvent,
13981    select_schemas: &mut HashMap<String, &'a Schema>,
13982) -> Result<()> {
13983    match step {
13984        TriggerStep::SetNew { cells } => {
13985            if event == TriggerEvent::Delete {
13986                return Err(MongrelError::InvalidArgument(
13987                    "SetNew trigger step is not valid for DELETE triggers".into(),
13988                ));
13989            }
13990            for cell in cells {
13991                validate_column_id(cell.column_id, target_schema)?;
13992                validate_trigger_value(&cell.value, target_schema, event)?;
13993            }
13994        }
13995        TriggerStep::Insert { table, cells } => {
13996            let schema = trigger_write_schema(cat, table, "insert")?;
13997            for cell in cells {
13998                validate_column_id(cell.column_id, schema)?;
13999                validate_trigger_value(&cell.value, target_schema, event)?;
14000            }
14001        }
14002        TriggerStep::UpdateByPk { table, pk, cells } => {
14003            let schema = trigger_write_schema(cat, table, "update")?;
14004            if schema.primary_key().is_none() {
14005                return Err(MongrelError::InvalidArgument(format!(
14006                    "trigger update_by_pk references table {table:?} without a primary key"
14007                )));
14008            }
14009            validate_trigger_value(pk, target_schema, event)?;
14010            for cell in cells {
14011                validate_column_id(cell.column_id, schema)?;
14012                validate_trigger_value(&cell.value, target_schema, event)?;
14013            }
14014        }
14015        TriggerStep::DeleteByPk { table, pk } => {
14016            let schema = trigger_write_schema(cat, table, "delete")?;
14017            if schema.primary_key().is_none() {
14018                return Err(MongrelError::InvalidArgument(format!(
14019                    "trigger delete_by_pk references table {table:?} without a primary key"
14020                )));
14021            }
14022            validate_trigger_value(pk, target_schema, event)?;
14023        }
14024        TriggerStep::Select {
14025            id,
14026            table,
14027            conditions,
14028        } => {
14029            let schema = trigger_read_schema(cat, table)?;
14030            for condition in conditions {
14031                validate_trigger_condition(condition, schema, target_schema, event)?;
14032            }
14033            if select_schemas.contains_key(id) {
14034                return Err(MongrelError::InvalidArgument(format!(
14035                    "duplicate select id {id:?} in trigger program"
14036                )));
14037            }
14038            select_schemas.insert(id.clone(), schema);
14039        }
14040        TriggerStep::Foreach { id, steps } => {
14041            if !select_schemas.contains_key(id) {
14042                return Err(MongrelError::InvalidArgument(format!(
14043                    "foreach references unknown select id {id:?}"
14044                )));
14045            }
14046            let mut inner_select_schemas = select_schemas.clone();
14047            for step in steps {
14048                validate_trigger_step(step, cat, target_schema, event, &mut inner_select_schemas)?;
14049            }
14050        }
14051        TriggerStep::DeleteWhere { table, conditions } => {
14052            let schema = trigger_write_schema(cat, table, "delete")?;
14053            for condition in conditions {
14054                validate_trigger_condition(condition, schema, target_schema, event)?;
14055            }
14056        }
14057        TriggerStep::UpdateWhere {
14058            table,
14059            conditions,
14060            cells,
14061        } => {
14062            let schema = trigger_write_schema(cat, table, "update")?;
14063            for condition in conditions {
14064                validate_trigger_condition(condition, schema, target_schema, event)?;
14065            }
14066            for cell in cells {
14067                validate_column_id(cell.column_id, schema)?;
14068                validate_trigger_value(&cell.value, target_schema, event)?;
14069            }
14070        }
14071        TriggerStep::Raise { message, .. } => {
14072            validate_trigger_value(message, target_schema, event)?
14073        }
14074    }
14075    Ok(())
14076}
14077
14078fn trigger_validation_error(error: MongrelError) -> MongrelError {
14079    match error {
14080        MongrelError::TriggerValidation(_) => error,
14081        MongrelError::InvalidArgument(message)
14082        | MongrelError::Conflict(message)
14083        | MongrelError::NotFound(message) => MongrelError::TriggerValidation(message),
14084        error => error,
14085    }
14086}
14087
14088fn trigger_write_schema<'a>(cat: &'a Catalog, table: &str, op: &str) -> Result<&'a Schema> {
14089    if let Some(entry) = cat.live(table) {
14090        return Ok(&entry.schema);
14091    }
14092    if let Some(entry) = cat.external_tables.iter().find(|entry| entry.name == table) {
14093        let allowed = match op {
14094            "insert" => entry.capabilities.writable || entry.capabilities.insert_only,
14095            "update" | "delete" => entry.capabilities.writable,
14096            _ => false,
14097        };
14098        if !allowed {
14099            return Err(MongrelError::InvalidArgument(format!(
14100                "trigger {op} references external table {table:?}, but module {:?} is not writable for that operation",
14101                entry.module
14102            )));
14103        }
14104        if !entry.capabilities.transaction_safe {
14105            return Err(MongrelError::InvalidArgument(format!(
14106                "trigger {op} references external table {table:?}, but module {:?} is not transaction-safe",
14107                entry.module
14108            )));
14109        }
14110        return Ok(&entry.declared_schema);
14111    }
14112    Err(MongrelError::InvalidArgument(format!(
14113        "trigger references unknown table {table:?}"
14114    )))
14115}
14116
14117fn trigger_read_schema<'a>(cat: &'a Catalog, table: &str) -> Result<&'a Schema> {
14118    if let Some(entry) = cat.live(table) {
14119        return Ok(&entry.schema);
14120    }
14121    if let Some(entry) = cat.external_tables.iter().find(|entry| entry.name == table) {
14122        if entry.capabilities.trigger_safe {
14123            return Ok(&entry.declared_schema);
14124        }
14125        return Err(MongrelError::InvalidArgument(format!(
14126            "trigger reads external table {table:?}, but module {:?} is not trigger-safe",
14127            entry.module
14128        )));
14129    }
14130    Err(MongrelError::InvalidArgument(format!(
14131        "trigger references unknown table {table:?}"
14132    )))
14133}
14134
14135fn validate_trigger_condition(
14136    condition: &TriggerCondition,
14137    schema: &Schema,
14138    target_schema: &Schema,
14139    event: TriggerEvent,
14140) -> Result<()> {
14141    match condition {
14142        TriggerCondition::Pk { value } => {
14143            if schema.primary_key().is_none() {
14144                return Err(MongrelError::InvalidArgument(
14145                    "trigger condition Pk references a table without a primary key".into(),
14146                ));
14147            }
14148            validate_trigger_value(value, target_schema, event)
14149        }
14150        TriggerCondition::Eq { column_id, value }
14151        | TriggerCondition::NotEq { column_id, value }
14152        | TriggerCondition::Lt { column_id, value }
14153        | TriggerCondition::Lte { column_id, value }
14154        | TriggerCondition::Gt { column_id, value }
14155        | TriggerCondition::Gte { column_id, value } => {
14156            validate_column_id(*column_id, schema)?;
14157            validate_trigger_value(value, target_schema, event)
14158        }
14159        TriggerCondition::IsNull { column_id } | TriggerCondition::IsNotNull { column_id } => {
14160            validate_column_id(*column_id, schema)
14161        }
14162        TriggerCondition::And { left, right } | TriggerCondition::Or { left, right } => {
14163            validate_trigger_condition(left, schema, target_schema, event)?;
14164            validate_trigger_condition(right, schema, target_schema, event)
14165        }
14166        TriggerCondition::Not(condition) => {
14167            validate_trigger_condition(condition, schema, target_schema, event)
14168        }
14169    }
14170}
14171
14172fn validate_trigger_expr(expr: &TriggerExpr, schema: &Schema, event: TriggerEvent) -> Result<()> {
14173    match expr {
14174        TriggerExpr::Value(value) | TriggerExpr::IsNull(value) | TriggerExpr::IsNotNull(value) => {
14175            validate_trigger_value(value, schema, event)
14176        }
14177        TriggerExpr::Eq { left, right }
14178        | TriggerExpr::NotEq { left, right }
14179        | TriggerExpr::Lt { left, right }
14180        | TriggerExpr::Lte { left, right }
14181        | TriggerExpr::Gt { left, right }
14182        | TriggerExpr::Gte { left, right } => {
14183            validate_trigger_value(left, schema, event)?;
14184            validate_trigger_value(right, schema, event)
14185        }
14186        TriggerExpr::And { left, right } | TriggerExpr::Or { left, right } => {
14187            validate_trigger_expr(left, schema, event)?;
14188            validate_trigger_expr(right, schema, event)
14189        }
14190        TriggerExpr::Not(expr) => validate_trigger_expr(expr, schema, event),
14191    }
14192}
14193
14194fn validate_trigger_value(
14195    value: &TriggerValue,
14196    schema: &Schema,
14197    event: TriggerEvent,
14198) -> Result<()> {
14199    match value {
14200        TriggerValue::Literal(_) => Ok(()),
14201        TriggerValue::NewColumn(id) => {
14202            if event == TriggerEvent::Delete {
14203                return Err(MongrelError::InvalidArgument(
14204                    "DELETE triggers cannot reference NEW".into(),
14205                ));
14206            }
14207            validate_column_id(*id, schema)
14208        }
14209        TriggerValue::OldColumn(id) => {
14210            if event == TriggerEvent::Insert {
14211                return Err(MongrelError::InvalidArgument(
14212                    "INSERT triggers cannot reference OLD".into(),
14213                ));
14214            }
14215            validate_column_id(*id, schema)
14216        }
14217        // SELECTED column references are only meaningful inside a foreach loop.
14218        // Strict loop-scope validation is deferred to runtime; the executor raises
14219        // an error if a selected row is not available.
14220        TriggerValue::SelectedColumn(_) => Ok(()),
14221    }
14222}
14223
14224/// Replay committed `Op::Ddl` records from the shared WAL into the catalog
14225/// (spec §15, review fix #16). A crash between WAL group-sync and the catalog
14226/// checkpoint leaves DDL durable in the WAL but absent from the on-disk
14227/// catalog. This pass closes that window by reconstructing missing entries
14228/// (and marking committed drops) before tables are mounted.
14229fn recover_ddl_from_wal(
14230    root: &Path,
14231    durable_root: Option<&crate::durable_file::DurableRoot>,
14232    target_catalog: &mut Catalog,
14233    meta_dek: Option<&[u8; META_DEK_LEN]>,
14234    wal_dek: Option<&zeroize::Zeroizing<[u8; 32]>>,
14235    apply: bool,
14236    table_roots: Option<&HashMap<u64, Arc<crate::durable_file::DurableRoot>>>,
14237) -> Result<()> {
14238    use crate::wal::SharedWal;
14239    let records = match durable_root {
14240        Some(root) => SharedWal::replay_durable_with_dek(root, wal_dek)?,
14241        None => SharedWal::replay_with_dek(root, wal_dek)?,
14242    };
14243    recover_ddl_from_records(
14244        root,
14245        durable_root,
14246        target_catalog,
14247        meta_dek,
14248        apply,
14249        table_roots,
14250        &records,
14251    )
14252}
14253
14254fn recover_ddl_from_records(
14255    root: &Path,
14256    durable_root: Option<&crate::durable_file::DurableRoot>,
14257    target_catalog: &mut Catalog,
14258    meta_dek: Option<&[u8; META_DEK_LEN]>,
14259    apply: bool,
14260    table_roots: Option<&HashMap<u64, Arc<crate::durable_file::DurableRoot>>>,
14261    records: &[crate::wal::Record],
14262) -> Result<()> {
14263    use crate::wal::{DdlOp, Op};
14264
14265    let original_catalog = target_catalog.clone();
14266    let mut recovered_catalog = original_catalog.clone();
14267    let cat = &mut recovered_catalog;
14268    let mut created_table_ids = HashSet::<u64>::new();
14269    let mut ttl_updates = HashMap::<u64, (Option<crate::manifest::TtlPolicy>, u64)>::new();
14270
14271    let mut committed: HashMap<u64, u64> = HashMap::new();
14272    for r in records {
14273        if let Op::TxnCommit { epoch: ce, .. } = r.op {
14274            committed.insert(r.txn_id, ce);
14275        }
14276    }
14277    let catalog_snapshot_txns = records
14278        .iter()
14279        .filter_map(|record| {
14280            (committed.contains_key(&record.txn_id)
14281                && matches!(&record.op, Op::Ddl(DdlOp::CatalogSnapshot { .. })))
14282            .then_some(record.txn_id)
14283        })
14284        .collect::<HashSet<_>>();
14285
14286    let mut changed = false;
14287    let mut applied_catalog_epoch = cat.db_epoch;
14288    let max_committed_epoch = committed.values().copied().max().unwrap_or(cat.db_epoch);
14289    for r in records.iter().cloned() {
14290        let Some(&ce) = committed.get(&r.txn_id) else {
14291            continue;
14292        };
14293        let txn_id = r.txn_id;
14294        match r.op {
14295            Op::Ddl(DdlOp::CreateTable {
14296                table_id,
14297                ref name,
14298                ref schema_json,
14299            }) => {
14300                if cat.tables.iter().any(|t| t.table_id == table_id) {
14301                    continue;
14302                }
14303                let schema = DdlOp::decode_schema(schema_json)?;
14304                validate_recovered_schema(&schema)?;
14305                created_table_ids.insert(table_id);
14306                cat.tables.push(CatalogEntry {
14307                    table_id,
14308                    name: name.clone(),
14309                    schema,
14310                    state: TableState::Live,
14311                    created_epoch: ce,
14312                });
14313                cat.next_table_id =
14314                    cat.next_table_id
14315                        .max(table_id.checked_add(1).ok_or_else(|| {
14316                            MongrelError::Full("table id namespace exhausted".into())
14317                        })?);
14318                changed = true;
14319            }
14320            Op::Ddl(DdlOp::CreateBuildingTable {
14321                table_id,
14322                ref build_name,
14323                ref intended_name,
14324                ref query_id,
14325                created_at_unix_nanos,
14326                ref schema_json,
14327            }) => {
14328                if cat.tables.iter().any(|table| table.table_id == table_id) {
14329                    continue;
14330                }
14331                let schema = DdlOp::decode_schema(schema_json)?;
14332                validate_recovered_schema(&schema)?;
14333                created_table_ids.insert(table_id);
14334                cat.tables.push(CatalogEntry {
14335                    table_id,
14336                    name: build_name.clone(),
14337                    schema,
14338                    state: TableState::Building {
14339                        intended_name: intended_name.clone(),
14340                        query_id: query_id.clone(),
14341                        created_at_unix_nanos,
14342                        replaces_table_id: None,
14343                    },
14344                    created_epoch: ce,
14345                });
14346                cat.next_table_id =
14347                    cat.next_table_id
14348                        .max(table_id.checked_add(1).ok_or_else(|| {
14349                            MongrelError::Full("table id namespace exhausted".into())
14350                        })?);
14351                changed = true;
14352            }
14353            Op::Ddl(DdlOp::CreateRebuildingTable {
14354                table_id,
14355                ref build_name,
14356                ref intended_name,
14357                ref query_id,
14358                created_at_unix_nanos,
14359                replaces_table_id,
14360                ref schema_json,
14361            }) => {
14362                if cat.tables.iter().any(|table| table.table_id == table_id) {
14363                    continue;
14364                }
14365                let schema = DdlOp::decode_schema(schema_json)?;
14366                validate_recovered_schema(&schema)?;
14367                created_table_ids.insert(table_id);
14368                cat.tables.push(CatalogEntry {
14369                    table_id,
14370                    name: build_name.clone(),
14371                    schema,
14372                    state: TableState::Building {
14373                        intended_name: intended_name.clone(),
14374                        query_id: query_id.clone(),
14375                        created_at_unix_nanos,
14376                        replaces_table_id: Some(replaces_table_id),
14377                    },
14378                    created_epoch: ce,
14379                });
14380                cat.next_table_id =
14381                    cat.next_table_id
14382                        .max(table_id.checked_add(1).ok_or_else(|| {
14383                            MongrelError::Full("table id namespace exhausted".into())
14384                        })?);
14385                changed = true;
14386            }
14387            Op::Ddl(DdlOp::DropTable { table_id }) => {
14388                let mut dropped_name = None;
14389                if let Some(entry) = cat.tables.iter_mut().find(|t| t.table_id == table_id) {
14390                    if matches!(entry.state, TableState::Live | TableState::Building { .. }) {
14391                        dropped_name = Some(entry.name.clone());
14392                        entry.state = TableState::Dropped { at_epoch: ce };
14393                        changed = true;
14394                    }
14395                }
14396                if let Some(name) = dropped_name {
14397                    let before = cat.materialized_views.len();
14398                    cat.materialized_views
14399                        .retain(|definition| definition.name != name);
14400                    changed |= before != cat.materialized_views.len();
14401                    cat.security.rls_tables.retain(|table| table != &name);
14402                    cat.security.policies.retain(|policy| policy.table != name);
14403                    cat.security.masks.retain(|mask| mask.table != name);
14404                    for role in &mut cat.roles {
14405                        role.permissions
14406                            .retain(|permission| permission_table(permission) != Some(&name));
14407                    }
14408                    if !catalog_snapshot_txns.contains(&txn_id) {
14409                        advance_security_version(cat)?;
14410                    }
14411                }
14412            }
14413            Op::Ddl(DdlOp::PublishBuildingTable {
14414                table_id,
14415                ref new_name,
14416            }) => {
14417                if let Some(entry) = cat
14418                    .tables
14419                    .iter_mut()
14420                    .find(|table| table.table_id == table_id)
14421                {
14422                    if entry.name != *new_name || !matches!(entry.state, TableState::Live) {
14423                        entry.name = new_name.clone();
14424                        entry.state = TableState::Live;
14425                        changed = true;
14426                    }
14427                }
14428            }
14429            Op::Ddl(DdlOp::ReplaceBuildingTable {
14430                table_id,
14431                replaced_table_id,
14432                ref new_name,
14433            }) => {
14434                changed |=
14435                    apply_rebuilding_publish(cat, table_id, replaced_table_id, new_name, ce)?;
14436            }
14437            Op::Ddl(DdlOp::RenameTable {
14438                table_id,
14439                ref new_name,
14440            }) => {
14441                let mut old_name = None;
14442                if let Some(entry) = cat.tables.iter_mut().find(|t| t.table_id == table_id) {
14443                    if entry.name != *new_name {
14444                        old_name = Some(entry.name.clone());
14445                        entry.name = new_name.clone();
14446                        changed = true;
14447                    }
14448                }
14449                if let Some(old_name) = old_name {
14450                    if let Some(definition) = cat
14451                        .materialized_views
14452                        .iter_mut()
14453                        .find(|definition| definition.name == old_name)
14454                    {
14455                        definition.name = new_name.clone();
14456                    }
14457                    for table in &mut cat.security.rls_tables {
14458                        if *table == old_name {
14459                            *table = new_name.clone();
14460                        }
14461                    }
14462                    for policy in &mut cat.security.policies {
14463                        if policy.table == old_name {
14464                            policy.table = new_name.clone();
14465                        }
14466                    }
14467                    for mask in &mut cat.security.masks {
14468                        if mask.table == old_name {
14469                            mask.table = new_name.clone();
14470                        }
14471                    }
14472                    for role in &mut cat.roles {
14473                        for permission in &mut role.permissions {
14474                            rename_permission_table(permission, &old_name, new_name);
14475                        }
14476                    }
14477                    if !catalog_snapshot_txns.contains(&txn_id) {
14478                        advance_security_version(cat)?;
14479                    }
14480                }
14481                // If the entry is absent, its CreateTable was already
14482                // checkpointed carrying the post-rename name, so there is
14483                // nothing to apply — a no-op, not an error.
14484            }
14485            Op::Ddl(DdlOp::AlterTable {
14486                table_id,
14487                ref column_json,
14488            }) => {
14489                let column = DdlOp::decode_column(column_json)?;
14490                let mut renamed = None;
14491                if let Some(entry) = cat.tables.iter_mut().find(|t| t.table_id == table_id) {
14492                    renamed = entry
14493                        .schema
14494                        .columns
14495                        .iter()
14496                        .find(|existing| existing.id == column.id && existing.name != column.name)
14497                        .map(|existing| {
14498                            (
14499                                entry.name.clone(),
14500                                existing.name.clone(),
14501                                column.name.clone(),
14502                            )
14503                        });
14504                    if apply_recovered_column_def(&mut entry.schema, column)? {
14505                        validate_recovered_schema(&entry.schema)?;
14506                        changed = true;
14507                    }
14508                }
14509                if let Some((table, old_name, new_name)) = renamed {
14510                    for role in &mut cat.roles {
14511                        for permission in &mut role.permissions {
14512                            rename_permission_column(permission, &table, &old_name, &new_name);
14513                        }
14514                    }
14515                    if !catalog_snapshot_txns.contains(&txn_id) {
14516                        advance_security_version(cat)?;
14517                    }
14518                }
14519            }
14520            Op::Ddl(DdlOp::SetTtl {
14521                table_id,
14522                ref policy_json,
14523            }) => {
14524                let policy = DdlOp::decode_ttl(policy_json)?;
14525                let entry = cat
14526                    .tables
14527                    .iter()
14528                    .find(|entry| entry.table_id == table_id)
14529                    .ok_or_else(|| {
14530                        MongrelError::Schema(format!(
14531                            "recovered TTL references unknown table id {table_id}"
14532                        ))
14533                    })?;
14534                if let Some(policy) = policy {
14535                    let valid = entry
14536                        .schema
14537                        .columns
14538                        .iter()
14539                        .find(|column| column.id == policy.column_id)
14540                        .is_some_and(|column| {
14541                            column.ty == TypeId::TimestampNanos
14542                                && policy.duration_nanos > 0
14543                                && policy.duration_nanos <= i64::MAX as u64
14544                        });
14545                    if !valid {
14546                        return Err(MongrelError::Schema(format!(
14547                            "invalid recovered TTL policy for table id {table_id}"
14548                        )));
14549                    }
14550                }
14551                ttl_updates.insert(table_id, (policy, ce));
14552            }
14553            Op::Ddl(DdlOp::SetMaterializedView {
14554                ref name,
14555                ref definition_json,
14556            }) => {
14557                let definition = DdlOp::decode_materialized_view(definition_json)?;
14558                if definition.name != *name {
14559                    return Err(MongrelError::Schema(format!(
14560                        "materialized view WAL name mismatch: {name:?}"
14561                    )));
14562                }
14563                if cat.live(name).is_some() {
14564                    if let Some(existing) = cat
14565                        .materialized_views
14566                        .iter_mut()
14567                        .find(|existing| existing.name == *name)
14568                    {
14569                        if *existing != definition {
14570                            *existing = definition;
14571                            changed = true;
14572                        }
14573                    } else {
14574                        cat.materialized_views.push(definition);
14575                        changed = true;
14576                    }
14577                }
14578            }
14579            Op::Ddl(DdlOp::SetSecurityCatalog { ref security_json }) => {
14580                let security = DdlOp::decode_security(security_json)?;
14581                validate_security_catalog(cat, &security)?;
14582                if cat.security != security {
14583                    cat.security = security;
14584                    if !catalog_snapshot_txns.contains(&txn_id) {
14585                        advance_security_version(cat)?;
14586                    }
14587                    changed = true;
14588                }
14589            }
14590            Op::Ddl(DdlOp::SetSqlPragma { ref key, value }) => {
14591                let target = match key.as_str() {
14592                    "user_version" => &mut cat.user_version,
14593                    "application_id" => &mut cat.application_id,
14594                    _ => {
14595                        return Err(MongrelError::InvalidArgument(format!(
14596                            "unsupported recovered SQL pragma {key:?}"
14597                        )))
14598                    }
14599                };
14600                if *target != Some(value) {
14601                    *target = Some(value);
14602                    cat.db_epoch = cat.db_epoch.max(ce);
14603                    changed = true;
14604                }
14605            }
14606            Op::Ddl(DdlOp::CatalogSnapshot { ref catalog_json }) => {
14607                if ce <= applied_catalog_epoch {
14608                    continue;
14609                }
14610                let snapshot = DdlOp::decode_catalog(catalog_json)?;
14611                if snapshot.db_epoch != ce {
14612                    return Err(MongrelError::Schema(format!(
14613                        "catalog snapshot epoch {} does not match WAL commit epoch {ce}",
14614                        snapshot.db_epoch
14615                    )));
14616                }
14617                validate_recovered_catalog(&snapshot)?;
14618                validate_catalog_transition(cat, &snapshot)?;
14619                *cat = snapshot;
14620                applied_catalog_epoch = ce;
14621                changed = true;
14622            }
14623            _ => {}
14624        }
14625    }
14626
14627    if cat.db_epoch < max_committed_epoch {
14628        cat.db_epoch = max_committed_epoch;
14629        changed = true;
14630    }
14631    changed |= repair_catalog_allocator_counters(cat)?;
14632
14633    validate_recovered_catalog(cat)?;
14634    let storage_reconciliation = validate_recovered_storage_plan(
14635        root,
14636        durable_root,
14637        cat,
14638        &created_table_ids,
14639        &ttl_updates,
14640        meta_dek,
14641    )?;
14642
14643    let needs_storage_apply = !storage_reconciliation.is_empty() || !ttl_updates.is_empty();
14644    if apply && (changed || needs_storage_apply) {
14645        for table_id in storage_reconciliation {
14646            let entry = cat
14647                .tables
14648                .iter()
14649                .find(|entry| entry.table_id == table_id)
14650                .ok_or_else(|| MongrelError::CorruptWal {
14651                    offset: table_id,
14652                    reason: "recovery storage plan lost its catalog table".into(),
14653                })?;
14654            ensure_recovered_table_storage(
14655                table_roots
14656                    .and_then(|roots| roots.get(&table_id))
14657                    .map(Arc::as_ref),
14658                durable_root,
14659                &root.join(TABLES_DIR).join(table_id.to_string()),
14660                table_id,
14661                &entry.schema,
14662                meta_dek,
14663            )?;
14664        }
14665        for (table_id, (policy, ttl_epoch)) in ttl_updates {
14666            let Some(entry) = cat.tables.iter().find(|entry| {
14667                entry.table_id == table_id
14668                    && matches!(entry.state, TableState::Live | TableState::Building { .. })
14669            }) else {
14670                continue;
14671            };
14672            let table_root = if let Some(root) = table_roots.and_then(|roots| roots.get(&table_id))
14673            {
14674                root.try_clone()?
14675            } else if let Some(root) = durable_root {
14676                root.open_directory(Path::new(TABLES_DIR).join(table_id.to_string()))?
14677            } else {
14678                crate::durable_file::DurableRoot::open(
14679                    root.join(TABLES_DIR).join(table_id.to_string()),
14680                )?
14681            };
14682            let table_dir = table_root.io_path()?;
14683            let mut manifest = crate::manifest::read_durable(&table_root, "", meta_dek)?;
14684            if manifest.ttl != policy || manifest.current_epoch < ttl_epoch {
14685                manifest.ttl = policy;
14686                manifest.current_epoch = manifest.current_epoch.max(ttl_epoch);
14687                manifest.schema_id = entry.schema.schema_id;
14688                crate::manifest::write_atomic(&table_dir, &mut manifest, meta_dek)?;
14689            }
14690        }
14691        if changed {
14692            match durable_root {
14693                Some(root) => catalog::write_durable(root, cat, meta_dek)?,
14694                None => catalog::write_atomic(root, cat, meta_dek)?,
14695            }
14696        }
14697    }
14698    *target_catalog = recovered_catalog;
14699    Ok(())
14700}
14701
14702fn ensure_recovered_table_storage(
14703    pinned_table: Option<&crate::durable_file::DurableRoot>,
14704    durable_root: Option<&crate::durable_file::DurableRoot>,
14705    fallback_table_dir: &Path,
14706    table_id: u64,
14707    schema: &Schema,
14708    meta_dek: Option<&[u8; META_DEK_LEN]>,
14709) -> Result<()> {
14710    let table_root = if let Some(root) = pinned_table {
14711        root.try_clone()?
14712    } else if let Some(root) = durable_root {
14713        let relative = Path::new(TABLES_DIR).join(table_id.to_string());
14714        match root.open_directory(&relative) {
14715            Ok(table) => table,
14716            Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
14717                root.create_directory_all_pinned(relative)?
14718            }
14719            Err(error) => return Err(error.into()),
14720        }
14721    } else {
14722        crate::durable_file::create_directory_all(fallback_table_dir)?;
14723        crate::durable_file::DurableRoot::open(fallback_table_dir)?
14724    };
14725    let table_dir = table_root.io_path()?;
14726    let mut existing_manifest = match crate::manifest::read_durable(&table_root, "", meta_dek) {
14727        Ok(manifest) => {
14728            if manifest.table_id != table_id {
14729                return Err(MongrelError::Conflict(format!(
14730                    "recovered table directory id mismatch: expected {table_id}, found {}",
14731                    manifest.table_id
14732                )));
14733            }
14734            Some(manifest)
14735        }
14736        Err(MongrelError::Io(error)) if error.kind() == std::io::ErrorKind::NotFound => None,
14737        Err(error) => return Err(error),
14738    };
14739
14740    table_root.create_directory_all(crate::engine::WAL_DIR)?;
14741    table_root.create_directory_all(crate::engine::RUNS_DIR)?;
14742    crate::engine::write_schema(&table_dir, schema)?;
14743
14744    if let Some(mut manifest) = existing_manifest.take() {
14745        if manifest.schema_id != schema.schema_id {
14746            manifest.schema_id = schema.schema_id;
14747            crate::manifest::write_atomic(&table_dir, &mut manifest, meta_dek)?;
14748        }
14749    } else {
14750        // The DB-wide meta DEK is also the per-table manifest meta DEK.
14751        let mut manifest = crate::manifest::Manifest::new(table_id, schema.schema_id);
14752        crate::manifest::write_atomic(&table_dir, &mut manifest, meta_dek)?;
14753    }
14754    Ok(())
14755}
14756
14757fn validate_recovered_schema(schema: &Schema) -> Result<()> {
14758    schema.validate_auto_increment()?;
14759    schema.validate_defaults()?;
14760    schema.validate_ai()?;
14761    let mut column_ids = HashSet::new();
14762    let mut column_names = HashSet::new();
14763    for column in &schema.columns {
14764        if !column_ids.insert(column.id) || !column_names.insert(column.name.as_str()) {
14765            return Err(MongrelError::Schema(
14766                "recovered schema contains duplicate columns".into(),
14767            ));
14768        }
14769        match &column.ty {
14770            TypeId::Decimal128 { precision, scale }
14771                if *precision == 0 || *precision > 38 || scale.unsigned_abs() > *precision =>
14772            {
14773                return Err(MongrelError::Schema(format!(
14774                    "column {:?} has invalid decimal precision or scale",
14775                    column.name
14776                )));
14777            }
14778            TypeId::Enum { variants }
14779                if variants.is_empty()
14780                    || variants.iter().any(String::is_empty)
14781                    || variants.iter().collect::<HashSet<_>>().len() != variants.len() =>
14782            {
14783                return Err(MongrelError::Schema(format!(
14784                    "column {:?} has invalid enum variants",
14785                    column.name
14786                )));
14787            }
14788            _ => {}
14789        }
14790    }
14791    let mut index_names = HashSet::new();
14792    for index in &schema.indexes {
14793        index.validate_options()?;
14794        if index.name.is_empty()
14795            || !index_names.insert(index.name.as_str())
14796            || schema
14797                .columns
14798                .iter()
14799                .all(|column| column.id != index.column_id)
14800        {
14801            return Err(MongrelError::Schema(format!(
14802                "recovered index {:?} references missing column {}",
14803                index.name, index.column_id
14804            )));
14805        }
14806    }
14807    let mut colocated = HashSet::new();
14808    for group in &schema.colocation {
14809        if group.is_empty()
14810            || group.iter().any(|id| !column_ids.contains(id))
14811            || group.iter().any(|id| !colocated.insert(*id))
14812        {
14813            return Err(MongrelError::Schema(
14814                "recovered schema contains invalid column co-location groups".into(),
14815            ));
14816        }
14817    }
14818
14819    let mut constraint_ids = HashSet::new();
14820    let mut constraint_names = HashSet::<String>::new();
14821    let mut validate_constraint_identity = |id: u16, name: &str| -> Result<()> {
14822        if name.is_empty()
14823            || !constraint_ids.insert(id)
14824            || !constraint_names.insert(name.to_owned())
14825        {
14826            return Err(MongrelError::Schema(
14827                "recovered schema contains duplicate or empty constraint identities".into(),
14828            ));
14829        }
14830        Ok(())
14831    };
14832    for unique in &schema.constraints.uniques {
14833        validate_constraint_identity(unique.id, &unique.name)?;
14834        if unique.columns.is_empty()
14835            || unique.columns.iter().any(|id| !column_ids.contains(id))
14836            || unique.columns.iter().collect::<HashSet<_>>().len() != unique.columns.len()
14837        {
14838            return Err(MongrelError::Schema(format!(
14839                "unique constraint {:?} has invalid columns",
14840                unique.name
14841            )));
14842        }
14843    }
14844    for foreign_key in &schema.constraints.foreign_keys {
14845        validate_constraint_identity(foreign_key.id, &foreign_key.name)?;
14846        if foreign_key.ref_table.is_empty()
14847            || foreign_key.columns.is_empty()
14848            || foreign_key.columns.len() != foreign_key.ref_columns.len()
14849            || foreign_key
14850                .columns
14851                .iter()
14852                .any(|id| !column_ids.contains(id))
14853            || foreign_key.columns.iter().collect::<HashSet<_>>().len() != foreign_key.columns.len()
14854            || foreign_key.ref_columns.iter().collect::<HashSet<_>>().len()
14855                != foreign_key.ref_columns.len()
14856        {
14857            return Err(MongrelError::Schema(format!(
14858                "foreign key {:?} has invalid columns",
14859                foreign_key.name
14860            )));
14861        }
14862        if (matches!(foreign_key.on_delete, crate::constraint::FkAction::SetNull)
14863            || matches!(foreign_key.on_update, crate::constraint::FkAction::SetNull))
14864            && foreign_key.columns.iter().any(|id| {
14865                schema
14866                    .columns
14867                    .iter()
14868                    .find(|column| column.id == *id)
14869                    .is_none_or(|column| {
14870                        !column.flags.contains(crate::schema::ColumnFlags::NULLABLE)
14871                    })
14872            })
14873        {
14874            return Err(MongrelError::Schema(format!(
14875                "foreign key {:?} uses SET NULL on a non-nullable column",
14876                foreign_key.name
14877            )));
14878        }
14879    }
14880    for check in &schema.constraints.checks {
14881        validate_constraint_identity(check.id, &check.name)?;
14882        check.expr.validate()?;
14883        validate_check_columns(&check.expr, &column_ids)?;
14884    }
14885    Ok(())
14886}
14887
14888fn validate_check_columns(
14889    expression: &crate::constraint::CheckExpr,
14890    column_ids: &HashSet<u16>,
14891) -> Result<()> {
14892    use crate::constraint::CheckExpr;
14893    match expression {
14894        CheckExpr::Col(id) | CheckExpr::IsNull(id) | CheckExpr::IsNotNull(id) => {
14895            if column_ids.contains(id) {
14896                Ok(())
14897            } else {
14898                Err(MongrelError::Schema(format!(
14899                    "check constraint references unknown column {id}"
14900                )))
14901            }
14902        }
14903        CheckExpr::Regex { col, .. } => {
14904            if column_ids.contains(col) {
14905                Ok(())
14906            } else {
14907                Err(MongrelError::Schema(format!(
14908                    "check constraint references unknown column {col}"
14909                )))
14910            }
14911        }
14912        CheckExpr::Add(left, right)
14913        | CheckExpr::Sub(left, right)
14914        | CheckExpr::Mul(left, right)
14915        | CheckExpr::Div(left, right)
14916        | CheckExpr::Mod(left, right)
14917        | CheckExpr::Eq(left, right)
14918        | CheckExpr::Ne(left, right)
14919        | CheckExpr::Lt(left, right)
14920        | CheckExpr::Le(left, right)
14921        | CheckExpr::Gt(left, right)
14922        | CheckExpr::Ge(left, right)
14923        | CheckExpr::And(left, right)
14924        | CheckExpr::Or(left, right) => {
14925            validate_check_columns(left, column_ids)?;
14926            validate_check_columns(right, column_ids)
14927        }
14928        CheckExpr::Not(inner) => validate_check_columns(inner, column_ids),
14929        CheckExpr::True | CheckExpr::Lit(_) => Ok(()),
14930    }
14931}
14932
14933fn validate_catalog_transition(current: &Catalog, next: &Catalog) -> Result<()> {
14934    for (name, prior, candidate) in [
14935        ("db_epoch", current.db_epoch, next.db_epoch),
14936        ("next_table_id", current.next_table_id, next.next_table_id),
14937        (
14938            "next_segment_no",
14939            current.next_segment_no,
14940            next.next_segment_no,
14941        ),
14942        ("next_user_id", current.next_user_id, next.next_user_id),
14943        (
14944            "security_version",
14945            current.security_version,
14946            next.security_version,
14947        ),
14948    ] {
14949        if candidate < prior {
14950            return Err(MongrelError::Schema(format!(
14951                "catalog snapshot rolls back {name} from {prior} to {candidate}"
14952            )));
14953        }
14954    }
14955    for prior in &current.tables {
14956        let Some(candidate) = next
14957            .tables
14958            .iter()
14959            .find(|entry| entry.table_id == prior.table_id)
14960        else {
14961            return Err(MongrelError::Schema(format!(
14962                "catalog snapshot removes table identity {}",
14963                prior.table_id
14964            )));
14965        };
14966        if candidate.created_epoch != prior.created_epoch
14967            || candidate.schema.schema_id < prior.schema.schema_id
14968            || matches!(prior.state, TableState::Dropped { .. })
14969                && !matches!(candidate.state, TableState::Dropped { .. })
14970        {
14971            return Err(MongrelError::Schema(format!(
14972                "catalog snapshot rolls back table identity {}",
14973                prior.table_id
14974            )));
14975        }
14976    }
14977    for prior in &current.users {
14978        if let Some(candidate) = next.users.iter().find(|user| user.id == prior.id) {
14979            if candidate.username != prior.username
14980                || candidate.created_epoch != prior.created_epoch
14981            {
14982                return Err(MongrelError::Schema(format!(
14983                    "catalog snapshot reuses user identity {}",
14984                    prior.id
14985                )));
14986            }
14987        }
14988    }
14989    Ok(())
14990}
14991
14992fn validate_recovered_catalog(catalog: &Catalog) -> Result<()> {
14993    let mut table_ids = HashSet::new();
14994    let mut active_names = HashSet::new();
14995    let mut max_table_id = None::<u64>;
14996    for entry in &catalog.tables {
14997        if !table_ids.insert(entry.table_id) {
14998            return Err(MongrelError::Schema(format!(
14999                "catalog contains duplicate table id {}",
15000                entry.table_id
15001            )));
15002        }
15003        max_table_id = Some(max_table_id.map_or(entry.table_id, |value| value.max(entry.table_id)));
15004        if entry.name.is_empty() || entry.created_epoch > catalog.db_epoch {
15005            return Err(MongrelError::Schema(format!(
15006                "catalog table {} has invalid name or creation epoch",
15007                entry.table_id
15008            )));
15009        }
15010        validate_recovered_schema(&entry.schema)?;
15011        match &entry.state {
15012            TableState::Live => {
15013                if !active_names.insert(entry.name.as_str()) {
15014                    return Err(MongrelError::Schema(format!(
15015                        "catalog contains duplicate active table name {:?}",
15016                        entry.name
15017                    )));
15018                }
15019            }
15020            TableState::Dropped { at_epoch } => {
15021                if *at_epoch < entry.created_epoch || *at_epoch > catalog.db_epoch {
15022                    return Err(MongrelError::Schema(format!(
15023                        "catalog table {} has invalid drop epoch {at_epoch}",
15024                        entry.table_id
15025                    )));
15026                }
15027            }
15028            TableState::Building {
15029                intended_name,
15030                query_id,
15031                replaces_table_id,
15032                ..
15033            } => {
15034                if intended_name.is_empty() || query_id.is_empty() {
15035                    return Err(MongrelError::Schema(format!(
15036                        "building table {} has empty identity fields",
15037                        entry.table_id
15038                    )));
15039                }
15040                if !active_names.insert(entry.name.as_str()) {
15041                    return Err(MongrelError::Schema(format!(
15042                        "catalog contains duplicate active/building table name {:?}",
15043                        entry.name
15044                    )));
15045                }
15046                if replaces_table_id.is_some_and(|id| id == entry.table_id) {
15047                    return Err(MongrelError::Schema(
15048                        "building table cannot replace itself".into(),
15049                    ));
15050                }
15051            }
15052        }
15053    }
15054    if let Some(maximum) = max_table_id {
15055        let required = maximum
15056            .checked_add(1)
15057            .ok_or_else(|| MongrelError::Full("table id namespace exhausted".into()))?;
15058        if catalog.next_table_id < required {
15059            return Err(MongrelError::Schema(format!(
15060                "catalog next_table_id {} precedes required {required}",
15061                catalog.next_table_id
15062            )));
15063        }
15064    }
15065    for entry in &catalog.tables {
15066        if let TableState::Building {
15067            replaces_table_id: Some(replaced),
15068            ..
15069        } = entry.state
15070        {
15071            if !table_ids.contains(&replaced) {
15072                return Err(MongrelError::Schema(format!(
15073                    "building table {} replaces unknown table {replaced}",
15074                    entry.table_id
15075                )));
15076            }
15077        }
15078    }
15079    for entry in &catalog.tables {
15080        if matches!(entry.state, TableState::Live | TableState::Building { .. }) {
15081            validate_foreign_key_targets(catalog, &entry.schema)?;
15082        }
15083    }
15084
15085    let mut external_names = HashSet::new();
15086    for entry in &catalog.external_tables {
15087        entry.validate()?;
15088        validate_recovered_schema(&entry.declared_schema)?;
15089        if !entry.declared_schema.constraints.is_empty() {
15090            return Err(MongrelError::Schema(format!(
15091                "external table {:?} cannot carry engine-enforced constraints",
15092                entry.name
15093            )));
15094        }
15095        if entry.created_epoch > catalog.db_epoch
15096            || !external_names.insert(entry.name.as_str())
15097            || active_names.contains(entry.name.as_str())
15098        {
15099            return Err(MongrelError::Schema(format!(
15100                "invalid or duplicate external table {:?}",
15101                entry.name
15102            )));
15103        }
15104    }
15105
15106    let mut procedure_names = HashSet::new();
15107    for entry in &catalog.procedures {
15108        entry.procedure.validate()?;
15109        if entry.procedure.created_epoch > entry.procedure.updated_epoch
15110            || entry.procedure.updated_epoch > catalog.db_epoch
15111            || !procedure_names.insert(entry.procedure.name.as_str())
15112        {
15113            return Err(MongrelError::Schema(format!(
15114                "invalid or duplicate procedure {:?}",
15115                entry.procedure.name
15116            )));
15117        }
15118        validate_recovered_procedure_references(catalog, &entry.procedure)?;
15119    }
15120
15121    let mut trigger_names = HashSet::new();
15122    for entry in &catalog.triggers {
15123        entry.trigger.validate()?;
15124        if entry.trigger.created_epoch > entry.trigger.updated_epoch
15125            || entry.trigger.updated_epoch > catalog.db_epoch
15126            || !trigger_names.insert(entry.trigger.name.as_str())
15127        {
15128            return Err(MongrelError::Schema(format!(
15129                "invalid or duplicate trigger {:?}",
15130                entry.trigger.name
15131            )));
15132        }
15133        validate_recovered_trigger_references(catalog, &entry.trigger)?;
15134    }
15135
15136    let mut views = HashSet::new();
15137    for view in &catalog.materialized_views {
15138        let target = catalog.live(&view.name).ok_or_else(|| {
15139            MongrelError::Schema(format!(
15140                "materialized view {:?} has no live table",
15141                view.name
15142            ))
15143        })?;
15144        if view.name.is_empty()
15145            || view.query.trim().is_empty()
15146            || view.last_refresh_epoch > catalog.db_epoch
15147            || !views.insert(view.name.as_str())
15148        {
15149            return Err(MongrelError::Schema(format!(
15150                "materialized view {:?} has no unique live table",
15151                view.name
15152            )));
15153        }
15154        if let Some(incremental) = &view.incremental {
15155            let source = catalog.live(&incremental.source_table).ok_or_else(|| {
15156                MongrelError::Schema(format!(
15157                    "materialized view {:?} references missing source {:?}",
15158                    view.name, incremental.source_table
15159                ))
15160            })?;
15161            if source.table_id != incremental.source_table_id
15162                || source
15163                    .schema
15164                    .columns
15165                    .iter()
15166                    .all(|column| column.id != incremental.group_column)
15167            {
15168                return Err(MongrelError::Schema(format!(
15169                    "materialized view {:?} has invalid incremental source",
15170                    view.name
15171                )));
15172            }
15173            let target_ids = target
15174                .schema
15175                .columns
15176                .iter()
15177                .map(|column| column.id)
15178                .collect::<HashSet<_>>();
15179            let mut output_ids = HashSet::new();
15180            let count_outputs = incremental
15181                .outputs
15182                .iter()
15183                .filter(|output| {
15184                    matches!(output.kind, crate::catalog::IncrementalAggregateKind::Count)
15185                })
15186                .count();
15187            if incremental.checkpoint_event_id.is_empty()
15188                || !target_ids.contains(&incremental.group_output_column)
15189                || !target_ids.contains(&incremental.count_output_column)
15190                || incremental.outputs.is_empty()
15191                || count_outputs != 1
15192                || incremental.outputs.iter().any(|output| {
15193                    !target_ids.contains(&output.output_column)
15194                        || output.output_column == incremental.group_output_column
15195                        || !output_ids.insert(output.output_column)
15196                        || matches!(output.kind, crate::catalog::IncrementalAggregateKind::Count)
15197                            && output.output_column != incremental.count_output_column
15198                        || match output.kind {
15199                            crate::catalog::IncrementalAggregateKind::Sum { source_column } => {
15200                                source
15201                                    .schema
15202                                    .columns
15203                                    .iter()
15204                                    .all(|column| column.id != source_column)
15205                            }
15206                            crate::catalog::IncrementalAggregateKind::Count => false,
15207                        }
15208                })
15209            {
15210                return Err(MongrelError::Schema(format!(
15211                    "materialized view {:?} has invalid incremental outputs",
15212                    view.name
15213                )));
15214            }
15215        }
15216    }
15217
15218    validate_security_catalog(catalog, &catalog.security)?;
15219    validate_recovered_auth_catalog(catalog)?;
15220    Ok(())
15221}
15222
15223fn repair_catalog_allocator_counters(catalog: &mut Catalog) -> Result<bool> {
15224    let mut changed = false;
15225    if let Some(maximum) = catalog.tables.iter().map(|entry| entry.table_id).max() {
15226        let required = maximum
15227            .checked_add(1)
15228            .ok_or_else(|| MongrelError::Full("table id namespace exhausted".into()))?;
15229        if catalog.next_table_id < required {
15230            catalog.next_table_id = required;
15231            changed = true;
15232        }
15233    }
15234    if let Some(maximum) = catalog.users.iter().map(|user| user.id).max() {
15235        let required = maximum
15236            .checked_add(1)
15237            .ok_or_else(|| MongrelError::Full("user id namespace exhausted".into()))?;
15238        if catalog.next_user_id < required {
15239            catalog.next_user_id = required;
15240            changed = true;
15241        }
15242    }
15243    Ok(changed)
15244}
15245
15246fn validate_foreign_key_targets(catalog: &Catalog, schema: &Schema) -> Result<()> {
15247    for foreign_key in &schema.constraints.foreign_keys {
15248        let parent = catalog.live(&foreign_key.ref_table).ok_or_else(|| {
15249            MongrelError::Schema(format!(
15250                "foreign key {:?} references unknown live table {:?}",
15251                foreign_key.name, foreign_key.ref_table
15252            ))
15253        })?;
15254        let referenced_unique = parent
15255            .schema
15256            .constraints
15257            .uniques
15258            .iter()
15259            .any(|unique| unique.columns == foreign_key.ref_columns)
15260            || foreign_key.ref_columns.len() == 1
15261                && parent
15262                    .schema
15263                    .primary_key()
15264                    .is_some_and(|column| column.id == foreign_key.ref_columns[0]);
15265        if !referenced_unique {
15266            return Err(MongrelError::Schema(format!(
15267                "foreign key {:?} does not reference a unique key",
15268                foreign_key.name
15269            )));
15270        }
15271        for (local_id, parent_id) in foreign_key.columns.iter().zip(&foreign_key.ref_columns) {
15272            let local = schema.columns.iter().find(|column| column.id == *local_id);
15273            let referenced = parent
15274                .schema
15275                .columns
15276                .iter()
15277                .find(|column| column.id == *parent_id);
15278            if local
15279                .zip(referenced)
15280                .is_none_or(|(local, referenced)| local.ty != referenced.ty)
15281            {
15282                return Err(MongrelError::Schema(format!(
15283                    "foreign key {:?} has missing or incompatible columns",
15284                    foreign_key.name
15285                )));
15286            }
15287        }
15288    }
15289    Ok(())
15290}
15291
15292fn validate_recovered_procedure_references(
15293    catalog: &Catalog,
15294    procedure: &StoredProcedure,
15295) -> Result<()> {
15296    for step in &procedure.body.steps {
15297        let Some(table_name) = step.table() else {
15298            continue;
15299        };
15300        let schema = &catalog
15301            .live(table_name)
15302            .ok_or_else(|| {
15303                MongrelError::Schema(format!(
15304                    "procedure {:?} references unknown table {table_name:?}",
15305                    procedure.name
15306                ))
15307            })?
15308            .schema;
15309        match step {
15310            ProcedureStep::NativeQuery {
15311                conditions,
15312                projection,
15313                ..
15314            } => {
15315                for condition in conditions {
15316                    validate_condition_columns(condition, schema)?;
15317                }
15318                for id in projection.iter().flatten() {
15319                    validate_column_id(*id, schema)?;
15320                }
15321            }
15322            ProcedureStep::Put { cells, .. } => {
15323                for cell in cells {
15324                    validate_column_id(cell.column_id, schema)?;
15325                }
15326            }
15327            ProcedureStep::Upsert {
15328                cells,
15329                update_cells,
15330                ..
15331            } => {
15332                for cell in cells.iter().chain(update_cells.iter().flatten()) {
15333                    validate_column_id(cell.column_id, schema)?;
15334                }
15335            }
15336            ProcedureStep::DeleteByPk { .. } if schema.primary_key().is_none() => {
15337                return Err(MongrelError::Schema(format!(
15338                    "procedure {:?} deletes by primary key on table without one",
15339                    procedure.name
15340                )));
15341            }
15342            ProcedureStep::DeleteByPk { .. }
15343            | ProcedureStep::DeleteRows { .. }
15344            | ProcedureStep::SqlQuery { .. } => {}
15345        }
15346    }
15347    Ok(())
15348}
15349
15350fn validate_recovered_trigger_references(catalog: &Catalog, trigger: &StoredTrigger) -> Result<()> {
15351    let target_schema = match &trigger.target {
15352        TriggerTarget::Table(name) => catalog
15353            .live(name)
15354            .ok_or_else(|| {
15355                MongrelError::Schema(format!(
15356                    "trigger {:?} references unknown table {name:?}",
15357                    trigger.name
15358                ))
15359            })?
15360            .schema
15361            .clone(),
15362        TriggerTarget::View(_) => Schema {
15363            columns: trigger.target_columns.clone(),
15364            ..Schema::default()
15365        },
15366    };
15367    for column in &trigger.update_of {
15368        if target_schema.column(column).is_none() {
15369            return Err(MongrelError::Schema(format!(
15370                "trigger {:?} references unknown UPDATE OF column {column:?}",
15371                trigger.name
15372            )));
15373        }
15374    }
15375    if let Some(expr) = &trigger.when {
15376        validate_trigger_expr(expr, &target_schema, trigger.event)?;
15377    }
15378    let mut selects = HashMap::new();
15379    for step in &trigger.program.steps {
15380        if matches!(step, TriggerStep::SetNew { .. }) && trigger.timing != TriggerTiming::Before {
15381            return Err(MongrelError::Schema(
15382                "SetNew is only valid in BEFORE triggers".into(),
15383            ));
15384        }
15385        validate_trigger_step(step, catalog, &target_schema, trigger.event, &mut selects)?;
15386    }
15387    Ok(())
15388}
15389
15390fn validate_recovered_auth_catalog(catalog: &Catalog) -> Result<()> {
15391    let mut role_names = HashSet::new();
15392    for role in &catalog.roles {
15393        if role.name.is_empty()
15394            || role.created_epoch > catalog.db_epoch
15395            || !role_names.insert(role.name.as_str())
15396        {
15397            return Err(MongrelError::Schema(format!(
15398                "invalid or duplicate role {:?}",
15399                role.name
15400            )));
15401        }
15402        for permission in &role.permissions {
15403            if let Some(table) = permission_table(permission) {
15404                let schema = catalog
15405                    .live(table)
15406                    .map(|entry| &entry.schema)
15407                    .or_else(|| {
15408                        catalog
15409                            .external_tables
15410                            .iter()
15411                            .find(|entry| entry.name == table)
15412                            .map(|entry| &entry.declared_schema)
15413                    })
15414                    .ok_or_else(|| {
15415                        MongrelError::Schema(format!(
15416                            "role {:?} references unknown table {table:?}",
15417                            role.name
15418                        ))
15419                    })?;
15420                let columns = match permission {
15421                    crate::auth::Permission::SelectColumns { columns, .. }
15422                    | crate::auth::Permission::InsertColumns { columns, .. }
15423                    | crate::auth::Permission::UpdateColumns { columns, .. } => Some(columns),
15424                    _ => None,
15425                };
15426                if columns.is_some_and(|columns| {
15427                    columns.is_empty()
15428                        || columns.iter().any(|column| schema.column(column).is_none())
15429                }) {
15430                    return Err(MongrelError::Schema(format!(
15431                        "role {:?} contains invalid column permissions",
15432                        role.name
15433                    )));
15434                }
15435            }
15436        }
15437    }
15438    let mut user_ids = HashSet::new();
15439    let mut usernames = HashSet::new();
15440    let mut maximum_user_id = 0;
15441    for user in &catalog.users {
15442        maximum_user_id = maximum_user_id.max(user.id);
15443        if user.id == 0
15444            || user.username.is_empty()
15445            || user.password_hash.is_empty()
15446            || user.created_epoch > catalog.db_epoch
15447            || !user_ids.insert(user.id)
15448            || !usernames.insert(user.username.as_str())
15449            || user
15450                .roles
15451                .iter()
15452                .any(|role| !role_names.contains(role.as_str()))
15453        {
15454            return Err(MongrelError::Schema(format!(
15455                "invalid or duplicate user {:?}",
15456                user.username
15457            )));
15458        }
15459    }
15460    if !catalog.users.is_empty() && catalog.next_user_id <= maximum_user_id {
15461        return Err(MongrelError::Schema(
15462            "catalog next_user_id does not advance beyond existing user ids".into(),
15463        ));
15464    }
15465    if catalog.require_auth && !catalog.users.iter().any(|user| user.is_admin) {
15466        return Err(MongrelError::Schema(
15467            "authenticated catalog has no administrator".into(),
15468        ));
15469    }
15470    Ok(())
15471}
15472
15473fn validate_recovered_storage_plan(
15474    root: &Path,
15475    durable_root: Option<&crate::durable_file::DurableRoot>,
15476    catalog: &Catalog,
15477    created_table_ids: &HashSet<u64>,
15478    ttl_updates: &HashMap<u64, (Option<crate::manifest::TtlPolicy>, u64)>,
15479    meta_dek: Option<&[u8; META_DEK_LEN]>,
15480) -> Result<Vec<u64>> {
15481    const MAX_SCHEMA_BYTES: u64 = 16 * 1024 * 1024;
15482    let mut reconcile = Vec::new();
15483    for entry in &catalog.tables {
15484        if !matches!(entry.state, TableState::Live | TableState::Building { .. }) {
15485            continue;
15486        }
15487        let relative_dir = Path::new(TABLES_DIR).join(entry.table_id.to_string());
15488        let table_dir = root.join(TABLES_DIR).join(entry.table_id.to_string());
15489        let table_exists = match durable_root {
15490            Some(root) => match root.open_directory(&relative_dir) {
15491                Ok(_) => true,
15492                Err(error) if error.kind() == std::io::ErrorKind::NotFound => false,
15493                Err(error) => return Err(error.into()),
15494            },
15495            None => table_dir.is_dir(),
15496        };
15497        if !table_exists {
15498            if created_table_ids.contains(&entry.table_id) {
15499                reconcile.push(entry.table_id);
15500                continue;
15501            }
15502            return Err(MongrelError::NotFound(format!(
15503                "catalog table {} storage is missing",
15504                entry.table_id
15505            )));
15506        }
15507        let manifest_result = match durable_root {
15508            Some(root) => crate::manifest::read_durable(root, &relative_dir, meta_dek),
15509            None => crate::manifest::read(&table_dir, meta_dek),
15510        };
15511        let manifest = match manifest_result {
15512            Ok(manifest) => manifest,
15513            Err(MongrelError::Io(error))
15514                if created_table_ids.contains(&entry.table_id)
15515                    && error.kind() == std::io::ErrorKind::NotFound =>
15516            {
15517                reconcile.push(entry.table_id);
15518                continue;
15519            }
15520            Err(error) => return Err(error),
15521        };
15522        if manifest.table_id != entry.table_id {
15523            return Err(MongrelError::Conflict(format!(
15524                "catalog table {} storage identity mismatch",
15525                entry.table_id
15526            )));
15527        }
15528        let schema_result = match durable_root {
15529            Some(root) => root
15530                .open_regular(relative_dir.join(crate::engine::SCHEMA_FILENAME))
15531                .map_err(MongrelError::from),
15532            None => crate::durable_file::open_regular_nofollow(
15533                &table_dir.join(crate::engine::SCHEMA_FILENAME),
15534            ),
15535        };
15536        let file = match schema_result {
15537            Ok(file) => file,
15538            Err(MongrelError::Io(error))
15539                if created_table_ids.contains(&entry.table_id)
15540                    && error.kind() == std::io::ErrorKind::NotFound =>
15541            {
15542                reconcile.push(entry.table_id);
15543                continue;
15544            }
15545            Err(error) => return Err(error),
15546        };
15547        let length = file.metadata()?.len();
15548        if length > MAX_SCHEMA_BYTES {
15549            return Err(MongrelError::ResourceLimitExceeded {
15550                resource: "recovered schema bytes",
15551                requested: usize::try_from(length).unwrap_or(usize::MAX),
15552                limit: MAX_SCHEMA_BYTES as usize,
15553            });
15554        }
15555        let disk_schema: Schema = serde_json::from_reader(file.take(MAX_SCHEMA_BYTES + 1))
15556            .map_err(|error| MongrelError::Schema(format!("decode recovered schema: {error}")))?;
15557        if manifest.schema_id != entry.schema.schema_id
15558            || crate::wal::DdlOp::encode_schema(&disk_schema)?
15559                != crate::wal::DdlOp::encode_schema(&entry.schema)?
15560        {
15561            reconcile.push(entry.table_id);
15562        }
15563    }
15564    for table_id in ttl_updates.keys() {
15565        if !catalog.tables.iter().any(|entry| {
15566            entry.table_id == *table_id
15567                && matches!(entry.state, TableState::Live | TableState::Building { .. })
15568        }) {
15569            continue;
15570        }
15571        let relative_dir = Path::new(TABLES_DIR).join(table_id.to_string());
15572        let table_exists = match durable_root {
15573            Some(root) => match root.open_directory(&relative_dir) {
15574                Ok(_) => true,
15575                Err(error) if error.kind() == std::io::ErrorKind::NotFound => false,
15576                Err(error) => return Err(error.into()),
15577            },
15578            None => root.join(&relative_dir).is_dir(),
15579        };
15580        if !table_exists && !created_table_ids.contains(table_id) {
15581            return Err(MongrelError::NotFound(format!(
15582                "TTL recovery table {table_id} storage is missing"
15583            )));
15584        }
15585    }
15586    reconcile.sort_unstable();
15587    reconcile.dedup();
15588    Ok(reconcile)
15589}
15590
15591fn validate_catalog_table_storage(
15592    root: &crate::durable_file::DurableRoot,
15593    catalog: &Catalog,
15594    meta_dek: Option<&[u8; META_DEK_LEN]>,
15595) -> Result<()> {
15596    for entry in &catalog.tables {
15597        if !matches!(entry.state, TableState::Live | TableState::Building { .. }) {
15598            continue;
15599        }
15600        let table_dir = Path::new(TABLES_DIR).join(entry.table_id.to_string());
15601        let manifest = crate::manifest::read_durable(root, &table_dir, meta_dek)?;
15602        if manifest.table_id != entry.table_id || manifest.schema_id != entry.schema.schema_id {
15603            return Err(MongrelError::Conflict(format!(
15604                "catalog table {} storage identity mismatch",
15605                entry.table_id
15606            )));
15607        }
15608        root.open_regular(table_dir.join(crate::engine::SCHEMA_FILENAME))?;
15609    }
15610    Ok(())
15611}
15612
15613fn apply_recovered_column_def(schema: &mut Schema, column: ColumnDef) -> Result<bool> {
15614    match schema.columns.iter_mut().find(|c| c.id == column.id) {
15615        Some(existing) if *existing == column => Ok(false),
15616        Some(existing) => {
15617            *existing = column;
15618            schema.schema_id = schema
15619                .schema_id
15620                .checked_add(1)
15621                .ok_or_else(|| MongrelError::Schema("schema id space exhausted".into()))?;
15622            Ok(true)
15623        }
15624        None => {
15625            schema.columns.push(column);
15626            schema.schema_id = schema
15627                .schema_id
15628                .checked_add(1)
15629                .ok_or_else(|| MongrelError::Schema("schema id space exhausted".into()))?;
15630            Ok(true)
15631        }
15632    }
15633}
15634
15635fn permission_table(permission: &crate::auth::Permission) -> Option<&str> {
15636    use crate::auth::Permission;
15637    match permission {
15638        Permission::Select { table }
15639        | Permission::Insert { table }
15640        | Permission::Update { table }
15641        | Permission::Delete { table }
15642        | Permission::SelectColumns { table, .. }
15643        | Permission::InsertColumns { table, .. }
15644        | Permission::UpdateColumns { table, .. } => Some(table),
15645        Permission::All | Permission::Ddl | Permission::Admin => None,
15646    }
15647}
15648
15649fn apply_rebuilding_publish(
15650    catalog: &mut Catalog,
15651    table_id: u64,
15652    replaced_table_id: u64,
15653    new_name: &str,
15654    epoch: u64,
15655) -> Result<bool> {
15656    let already_published = catalog.tables.iter().any(|entry| {
15657        entry.table_id == table_id
15658            && entry.name == new_name
15659            && matches!(entry.state, TableState::Live)
15660    }) && catalog.tables.iter().any(|entry| {
15661        entry.table_id == replaced_table_id && matches!(entry.state, TableState::Dropped { .. })
15662    });
15663    if already_published {
15664        return Ok(false);
15665    }
15666    let schema = catalog
15667        .tables
15668        .iter()
15669        .find(|entry| entry.table_id == table_id)
15670        .ok_or_else(|| MongrelError::NotFound(format!("table id {table_id} not found")))?
15671        .schema
15672        .clone();
15673    let replaced = catalog
15674        .tables
15675        .iter_mut()
15676        .find(|entry| entry.table_id == replaced_table_id)
15677        .ok_or_else(|| MongrelError::NotFound(format!("table id {replaced_table_id} not found")))?;
15678    replaced.state = TableState::Dropped { at_epoch: epoch };
15679    let replacement = catalog
15680        .tables
15681        .iter_mut()
15682        .find(|entry| entry.table_id == table_id)
15683        .ok_or_else(|| MongrelError::NotFound(format!("table id {table_id} not found")))?;
15684    replacement.name = new_name.to_string();
15685    replacement.state = TableState::Live;
15686
15687    for role in &mut catalog.roles {
15688        role.permissions.retain_mut(|permission| {
15689            retain_rebuilt_permission_columns(permission, new_name, &schema)
15690        });
15691    }
15692    for definition in &mut catalog.materialized_views {
15693        if let Some(incremental) = definition.incremental.as_mut() {
15694            if incremental.source_table == new_name
15695                && incremental.source_table_id == replaced_table_id
15696            {
15697                incremental.source_table_id = table_id;
15698            }
15699        }
15700    }
15701    advance_security_version(catalog)?;
15702    Ok(true)
15703}
15704
15705fn retain_rebuilt_permission_columns(
15706    permission: &mut crate::auth::Permission,
15707    target_table: &str,
15708    schema: &Schema,
15709) -> bool {
15710    use crate::auth::Permission;
15711    let columns = match permission {
15712        Permission::SelectColumns { table, columns }
15713        | Permission::InsertColumns { table, columns }
15714        | Permission::UpdateColumns { table, columns }
15715            if table == target_table =>
15716        {
15717            Some(columns)
15718        }
15719        _ => None,
15720    };
15721    if let Some(columns) = columns {
15722        columns.retain(|column| schema.column(column).is_some());
15723        !columns.is_empty()
15724    } else {
15725        true
15726    }
15727}
15728
15729fn rename_permission_table(permission: &mut crate::auth::Permission, old: &str, new: &str) {
15730    use crate::auth::Permission;
15731    let table = match permission {
15732        Permission::Select { table }
15733        | Permission::Insert { table }
15734        | Permission::Update { table }
15735        | Permission::Delete { table }
15736        | Permission::SelectColumns { table, .. }
15737        | Permission::InsertColumns { table, .. }
15738        | Permission::UpdateColumns { table, .. } => Some(table),
15739        Permission::All | Permission::Ddl | Permission::Admin => None,
15740    };
15741    if let Some(table) = table.filter(|table| table.as_str() == old) {
15742        *table = new.to_string();
15743    }
15744}
15745
15746fn rename_permission_column(
15747    permission: &mut crate::auth::Permission,
15748    target_table: &str,
15749    old: &str,
15750    new: &str,
15751) {
15752    use crate::auth::Permission;
15753    let columns = match permission {
15754        Permission::SelectColumns { table, columns }
15755        | Permission::InsertColumns { table, columns }
15756        | Permission::UpdateColumns { table, columns }
15757            if table == target_table =>
15758        {
15759            Some(columns)
15760        }
15761        _ => None,
15762    };
15763    if let Some(column) = columns
15764        .into_iter()
15765        .flatten()
15766        .find(|column| column.as_str() == old)
15767    {
15768        *column = new.to_string();
15769    }
15770}
15771
15772fn merge_permission(
15773    permissions: &mut Vec<crate::auth::Permission>,
15774    permission: crate::auth::Permission,
15775) {
15776    use crate::auth::Permission;
15777    let (kind, table, mut columns) = match permission {
15778        Permission::SelectColumns { table, columns } => (0, table, columns),
15779        Permission::InsertColumns { table, columns } => (1, table, columns),
15780        Permission::UpdateColumns { table, columns } => (2, table, columns),
15781        permission if !permissions.contains(&permission) => {
15782            permissions.push(permission);
15783            return;
15784        }
15785        _ => return,
15786    };
15787    for permission in permissions.iter_mut() {
15788        let existing = match permission {
15789            Permission::SelectColumns {
15790                table: existing_table,
15791                columns,
15792            } if kind == 0 && existing_table == &table => Some(columns),
15793            Permission::InsertColumns {
15794                table: existing_table,
15795                columns,
15796            } if kind == 1 && existing_table == &table => Some(columns),
15797            Permission::UpdateColumns {
15798                table: existing_table,
15799                columns,
15800            } if kind == 2 && existing_table == &table => Some(columns),
15801            _ => None,
15802        };
15803        if let Some(existing) = existing {
15804            existing.append(&mut columns);
15805            existing.sort();
15806            existing.dedup();
15807            return;
15808        }
15809    }
15810    columns.sort();
15811    columns.dedup();
15812    let permission = if kind == 0 {
15813        Permission::SelectColumns { table, columns }
15814    } else if kind == 1 {
15815        Permission::InsertColumns { table, columns }
15816    } else {
15817        Permission::UpdateColumns { table, columns }
15818    };
15819    permissions.push(permission);
15820}
15821
15822fn revoke_permission_from(
15823    permissions: &mut Vec<crate::auth::Permission>,
15824    revoked: &crate::auth::Permission,
15825) {
15826    use crate::auth::Permission;
15827    let revoked_columns = match revoked {
15828        Permission::SelectColumns { table, columns } => Some((0, table, columns)),
15829        Permission::InsertColumns { table, columns } => Some((1, table, columns)),
15830        Permission::UpdateColumns { table, columns } => Some((2, table, columns)),
15831        _ => None,
15832    };
15833    let Some((kind, table, columns)) = revoked_columns else {
15834        permissions.retain(|permission| permission != revoked);
15835        return;
15836    };
15837    for permission in permissions.iter_mut() {
15838        let current = match permission {
15839            Permission::SelectColumns {
15840                table: current_table,
15841                columns,
15842            } if kind == 0 && current_table == table => Some(columns),
15843            Permission::InsertColumns {
15844                table: current_table,
15845                columns,
15846            } if kind == 1 && current_table == table => Some(columns),
15847            Permission::UpdateColumns {
15848                table: current_table,
15849                columns,
15850            } if kind == 2 && current_table == table => Some(columns),
15851            _ => None,
15852        };
15853        if let Some(current) = current {
15854            current.retain(|column| !columns.contains(column));
15855        }
15856    }
15857    permissions.retain(|permission| match permission {
15858        Permission::SelectColumns { columns, .. }
15859        | Permission::InsertColumns { columns, .. }
15860        | Permission::UpdateColumns { columns, .. } => !columns.is_empty(),
15861        _ => true,
15862    });
15863}
15864
15865fn validate_security_catalog(
15866    catalog: &Catalog,
15867    security: &crate::security::SecurityCatalog,
15868) -> Result<()> {
15869    let mut policy_names = HashSet::new();
15870    for table in &security.rls_tables {
15871        if catalog.live(table).is_none() {
15872            return Err(MongrelError::NotFound(format!(
15873                "RLS table {table:?} not found"
15874            )));
15875        }
15876    }
15877    for policy in &security.policies {
15878        if !policy_names.insert((policy.table.clone(), policy.name.clone())) {
15879            return Err(MongrelError::InvalidArgument(format!(
15880                "duplicate policy {:?} on {:?}",
15881                policy.name, policy.table
15882            )));
15883        }
15884        let schema = &catalog
15885            .live(&policy.table)
15886            .ok_or_else(|| {
15887                MongrelError::NotFound(format!("policy table {:?} not found", policy.table))
15888            })?
15889            .schema;
15890        if let Some(expression) = &policy.using {
15891            validate_security_expression(expression, schema)?;
15892        }
15893        if let Some(expression) = &policy.with_check {
15894            validate_security_expression(expression, schema)?;
15895        }
15896    }
15897    let mut mask_names = HashSet::new();
15898    for mask in &security.masks {
15899        if !mask_names.insert((mask.table.clone(), mask.name.clone())) {
15900            return Err(MongrelError::InvalidArgument(format!(
15901                "duplicate mask {:?} on {:?}",
15902                mask.name, mask.table
15903            )));
15904        }
15905        let column = catalog
15906            .live(&mask.table)
15907            .and_then(|entry| {
15908                entry
15909                    .schema
15910                    .columns
15911                    .iter()
15912                    .find(|column| column.id == mask.column)
15913            })
15914            .ok_or_else(|| {
15915                MongrelError::NotFound(format!(
15916                    "mask column {} on {:?} not found",
15917                    mask.column, mask.table
15918                ))
15919            })?;
15920        if matches!(
15921            mask.strategy,
15922            crate::security::MaskStrategy::Redact { .. } | crate::security::MaskStrategy::Sha256
15923        ) && !matches!(column.ty, TypeId::Bytes | TypeId::Enum { .. })
15924        {
15925            return Err(MongrelError::InvalidArgument(format!(
15926                "mask {:?} requires a string/bytes column",
15927                mask.name
15928            )));
15929        }
15930    }
15931    Ok(())
15932}
15933
15934fn validate_security_expression(
15935    expression: &crate::security::SecurityExpr,
15936    schema: &Schema,
15937) -> Result<()> {
15938    use crate::security::SecurityExpr;
15939    match expression {
15940        SecurityExpr::True => Ok(()),
15941        SecurityExpr::ColumnEqCurrentUser { column }
15942        | SecurityExpr::ColumnEqValue { column, .. } => {
15943            if schema
15944                .columns
15945                .iter()
15946                .any(|candidate| candidate.id == *column)
15947            {
15948                Ok(())
15949            } else {
15950                Err(MongrelError::InvalidArgument(format!(
15951                    "security expression references unknown column id {column}"
15952                )))
15953            }
15954        }
15955        SecurityExpr::And { left, right } | SecurityExpr::Or { left, right } => {
15956            validate_security_expression(left, schema)?;
15957            validate_security_expression(right, schema)
15958        }
15959        SecurityExpr::Not { expression } => validate_security_expression(expression, schema),
15960    }
15961}
15962
15963/// Remove canonical numeric table directories that no catalog generation owns.
15964fn sweep_unreferenced_table_dirs(root: &Path, cat: &Catalog) -> Result<()> {
15965    let referenced = cat
15966        .tables
15967        .iter()
15968        .filter(|entry| matches!(entry.state, TableState::Live | TableState::Building { .. }))
15969        .map(|entry| entry.table_id)
15970        .collect::<HashSet<_>>();
15971    let tables_dir = root.join(TABLES_DIR);
15972    let entries = match std::fs::read_dir(&tables_dir) {
15973        Ok(entries) => entries,
15974        Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(()),
15975        Err(error) => return Err(error.into()),
15976    };
15977    for entry in entries {
15978        let entry = entry?;
15979        if !entry.file_type()?.is_dir() {
15980            continue;
15981        }
15982        let file_name = entry.file_name();
15983        let Some(name) = file_name.to_str() else {
15984            continue;
15985        };
15986        let Ok(table_id) = name.parse::<u64>() else {
15987            continue;
15988        };
15989        if name != table_id.to_string() {
15990            continue;
15991        }
15992        if !referenced.contains(&table_id) {
15993            crate::durable_file::remove_directory_all(&entry.path())?;
15994        }
15995    }
15996    Ok(())
15997}
15998
15999/// Sweep stale `_txn/<txn_id>/` dirs from every table (spec §8.5, review fix
16000/// #14). These dirs hold pending uniform-epoch runs from large transactions
16001/// that were aborted or crashed before commit. On open, all such dirs are safe
16002/// to remove because committed txns moved their runs to `_runs/` at publish.
16003fn sweep_pending_txn_dirs(root: &Path, cat: &Catalog) {
16004    for entry in &cat.tables {
16005        let txn_dir = root
16006            .join(TABLES_DIR)
16007            .join(entry.table_id.to_string())
16008            .join("_txn");
16009        if txn_dir.exists() {
16010            let _ = std::fs::remove_dir_all(&txn_dir);
16011        }
16012    }
16013}
16014
16015#[cfg(test)]
16016mod write_permission_tests {
16017    use super::*;
16018    use crate::txn::Staged;
16019
16020    struct NoopExternalBridge;
16021
16022    impl ExternalTriggerBridge for NoopExternalBridge {
16023        fn apply_trigger_external_write(
16024            &self,
16025            _entry: &ExternalTableEntry,
16026            base_state: Vec<u8>,
16027            _op: ExternalTriggerWrite,
16028        ) -> Result<ExternalTriggerWriteResult> {
16029            Ok(ExternalTriggerWriteResult::new(base_state))
16030        }
16031    }
16032
16033    fn assert_txn_namespace_full<T>(result: Result<T>) {
16034        assert!(matches!(result, Err(MongrelError::Full(_))));
16035    }
16036
16037    #[test]
16038    fn every_begin_api_preserves_transaction_id_exhaustion_without_wal_mutation() {
16039        let directory = tempfile::tempdir().unwrap();
16040        let database = Database::create(directory.path()).unwrap();
16041        let generation = (*database.next_txn_id.lock() >> 32).saturating_add(1);
16042        *database.next_txn_id.lock() = generation << 32;
16043        let before = crate::wal::SharedWal::replay(directory.path())
16044            .unwrap()
16045            .len();
16046        let bridge = NoopExternalBridge;
16047
16048        assert_txn_namespace_full(database.begin().commit());
16049        assert_txn_namespace_full(database.begin_as(None).commit_with_row_ids());
16050        assert_txn_namespace_full(
16051            database
16052                .begin_with_isolation(crate::txn::IsolationLevel::Serializable)
16053                .commit(),
16054        );
16055        assert_txn_namespace_full(
16056            database
16057                .begin_with_external_trigger_bridge(&bridge)
16058                .commit(),
16059        );
16060        assert_txn_namespace_full(
16061            database
16062                .begin_with_external_trigger_bridge_as(&bridge, None)
16063                .commit_controlled(&crate::ExecutionControl::new(None), || Ok(())),
16064        );
16065
16066        assert_eq!(
16067            crate::wal::SharedWal::replay(directory.path())
16068                .unwrap()
16069                .len(),
16070            before
16071        );
16072        drop(database);
16073        Database::open(directory.path()).unwrap();
16074    }
16075
16076    #[test]
16077    fn recovered_storage_identity_mismatch_does_not_mutate_directory() {
16078        let directory = tempfile::tempdir().unwrap();
16079        let table_dir = directory.path().join("7");
16080        crate::durable_file::create_directory_all(&table_dir).unwrap();
16081        let original_schema = test_schema();
16082        crate::engine::write_schema(&table_dir, &original_schema).unwrap();
16083        let mut manifest = crate::manifest::Manifest::new(8, original_schema.schema_id);
16084        crate::manifest::write_atomic(&table_dir, &mut manifest, None).unwrap();
16085        let schema_path = table_dir.join(crate::engine::SCHEMA_FILENAME);
16086        let original_bytes = std::fs::read(&schema_path).unwrap();
16087
16088        let mut replacement_schema = original_schema;
16089        replacement_schema.schema_id += 1;
16090        assert!(matches!(
16091            ensure_recovered_table_storage(None, None, &table_dir, 7, &replacement_schema, None,),
16092            Err(MongrelError::Conflict(_))
16093        ));
16094
16095        assert_eq!(std::fs::read(schema_path).unwrap(), original_bytes);
16096        assert!(!table_dir.join(crate::engine::WAL_DIR).exists());
16097        assert!(!table_dir.join(crate::engine::RUNS_DIR).exists());
16098        assert_eq!(crate::manifest::read(&table_dir, None).unwrap().table_id, 8);
16099    }
16100
16101    #[test]
16102    fn catalog_table_missing_storage_fails_without_recreating_it() {
16103        let directory = tempfile::tempdir().unwrap();
16104        let table_dir = {
16105            let database = Database::create(directory.path()).unwrap();
16106            database.create_table("docs", test_schema()).unwrap();
16107            directory
16108                .path()
16109                .join(TABLES_DIR)
16110                .join(database.table_id("docs").unwrap().to_string())
16111        };
16112        std::fs::remove_dir_all(&table_dir).unwrap();
16113
16114        assert!(matches!(
16115            Database::open(directory.path()),
16116            Err(MongrelError::NotFound(_))
16117        ));
16118        assert!(!table_dir.exists());
16119    }
16120
16121    #[test]
16122    fn authentication_and_principal_resolution_share_one_catalog_snapshot() {
16123        let directory = tempfile::tempdir().unwrap();
16124        let database = std::sync::Arc::new(
16125            Database::create_with_credentials(directory.path(), "admin", "admin-password").unwrap(),
16126        );
16127        database.create_user("alice", "old-password").unwrap();
16128        let old_identity = database.user_identity("alice").unwrap();
16129        let (verified_tx, verified_rx) = std::sync::mpsc::channel();
16130        let (resume_tx, resume_rx) = std::sync::mpsc::channel();
16131        let (mutation_started_tx, mutation_started_rx) = std::sync::mpsc::channel();
16132        let (mutation_done_tx, mutation_done_rx) = std::sync::mpsc::channel();
16133
16134        std::thread::scope(|scope| {
16135            let authenticate = {
16136                let database = std::sync::Arc::clone(&database);
16137                scope.spawn(move || {
16138                    database.authenticate_principal_inner("alice", "old-password", || {
16139                        verified_tx.send(()).unwrap();
16140                        resume_rx.recv().unwrap();
16141                    })
16142                })
16143            };
16144            verified_rx.recv().unwrap();
16145            let mutate = {
16146                let database = std::sync::Arc::clone(&database);
16147                scope.spawn(move || {
16148                    mutation_started_tx.send(()).unwrap();
16149                    database.drop_user("alice").unwrap();
16150                    database.create_user("alice", "new-password").unwrap();
16151                    mutation_done_tx.send(()).unwrap();
16152                })
16153            };
16154            mutation_started_rx.recv().unwrap();
16155            assert!(mutation_done_rx
16156                .recv_timeout(std::time::Duration::from_millis(50))
16157                .is_err());
16158            resume_tx.send(()).unwrap();
16159            let principal = authenticate.join().unwrap().unwrap().unwrap();
16160            assert_eq!((principal.user_id, principal.created_epoch), old_identity);
16161            mutate.join().unwrap();
16162        });
16163
16164        assert_ne!(database.user_identity("alice").unwrap(), old_identity);
16165        assert!(database
16166            .authenticate_principal("alice", "old-password")
16167            .unwrap()
16168            .is_none());
16169        assert!(database
16170            .authenticate_principal("alice", "new-password")
16171            .unwrap()
16172            .is_some());
16173    }
16174
16175    #[test]
16176    fn homogeneous_batch_summarizes_to_one_permission_decision() {
16177        let staging = (0..10_050)
16178            .map(|_| {
16179                (
16180                    7,
16181                    Staged::Put(vec![(2, Value::Int64(2)), (1, Value::Int64(1))]),
16182                )
16183            })
16184            .collect::<Vec<_>>();
16185
16186        let needs = summarize_write_permissions(&staging);
16187        let table = needs.get(&7).unwrap();
16188        assert_eq!(needs.len(), 1);
16189        assert!(table.insert);
16190        assert_eq!(table.insert_columns, [1, 2]);
16191        assert!(!table.update);
16192        assert!(!table.delete);
16193        assert!(!table.truncate);
16194    }
16195
16196    #[test]
16197    fn mixed_writes_union_columns_and_preserve_empty_operations() {
16198        let staging = vec![
16199            (7, Staged::Put(vec![(2, Value::Int64(2))])),
16200            (7, Staged::Put(vec![(1, Value::Int64(1))])),
16201            (
16202                7,
16203                Staged::Update {
16204                    row_id: RowId(1),
16205                    new_row: vec![(1, Value::Int64(1)), (2, Value::Int64(2))],
16206                    changed_columns: vec![2],
16207                },
16208            ),
16209            (7, Staged::Delete(RowId(2))),
16210            (8, Staged::Truncate),
16211        ];
16212
16213        let needs = summarize_write_permissions(&staging);
16214        let table = needs.get(&7).unwrap();
16215        assert_eq!(table.insert_columns, [1, 2]);
16216        assert!(table.update);
16217        assert_eq!(table.update_columns, [2]);
16218        assert!(table.delete);
16219        assert!(needs.get(&8).unwrap().truncate);
16220    }
16221
16222    #[test]
16223    fn final_permission_decisions_do_not_scale_with_rows() {
16224        let credentialless_dir = tempfile::tempdir().unwrap();
16225        let credentialless = Database::create(credentialless_dir.path()).unwrap();
16226        credentialless.create_table("docs", test_schema()).unwrap();
16227        WRITE_PERMISSION_DECISIONS.with(|decisions| decisions.set(0));
16228        credentialless
16229            .validate_write_permissions(&puts(credentialless.table_id("docs").unwrap()), None, None)
16230            .unwrap();
16231        WRITE_PERMISSION_DECISIONS.with(|decisions| assert_eq!(decisions.get(), 0));
16232
16233        let authenticated_dir = tempfile::tempdir().unwrap();
16234        let authenticated =
16235            Database::create_with_credentials(authenticated_dir.path(), "admin", "admin-password")
16236                .unwrap();
16237        authenticated.create_table("docs", test_schema()).unwrap();
16238        let admin = authenticated.resolve_principal("admin").unwrap();
16239        WRITE_PERMISSION_DECISIONS.with(|decisions| decisions.set(0));
16240        authenticated
16241            .validate_write_permissions(
16242                &puts(authenticated.table_id("docs").unwrap()),
16243                Some(&admin),
16244                None,
16245            )
16246            .unwrap();
16247        WRITE_PERMISSION_DECISIONS.with(|decisions| assert_eq!(decisions.get(), 1));
16248    }
16249
16250    #[test]
16251    fn delete_batch_checks_permission_once_when_staged_and_once_when_committed() {
16252        let dir = tempfile::tempdir().unwrap();
16253        let db = Database::create_with_credentials(dir.path(), "admin", "admin-password").unwrap();
16254        db.create_table("docs", test_schema()).unwrap();
16255        let admin = db.resolve_principal("admin").unwrap();
16256        TABLE_PERMISSION_DECISIONS.with(|decisions| decisions.set(0));
16257
16258        let mut transaction = db.begin_as(Some(admin));
16259        transaction
16260            .delete_batch("docs", (0..100).map(RowId).collect())
16261            .unwrap();
16262        transaction.commit().unwrap();
16263
16264        TABLE_PERMISSION_DECISIONS.with(|decisions| assert_eq!(decisions.get(), 2));
16265    }
16266
16267    #[test]
16268    fn truncate_validation_checks_admin_once_for_all_tables() {
16269        let dir = tempfile::tempdir().unwrap();
16270        let db = Database::create_with_credentials(dir.path(), "admin", "admin-password").unwrap();
16271        db.create_table("first", test_schema()).unwrap();
16272        db.create_table("second", test_schema()).unwrap();
16273        let admin = db.resolve_principal("admin").unwrap();
16274        let staging = vec![
16275            (db.table_id("first").unwrap(), Staged::Truncate),
16276            (db.table_id("second").unwrap(), Staged::Truncate),
16277        ];
16278
16279        TABLE_PERMISSION_DECISIONS.with(|decisions| decisions.set(0));
16280        db.validate_write_permissions(&staging, Some(&admin), None)
16281            .unwrap();
16282        TABLE_PERMISSION_DECISIONS.with(|decisions| assert_eq!(decisions.get(), 1));
16283    }
16284
16285    #[test]
16286    fn one_table_commit_batches_structural_work() {
16287        let dir = tempfile::tempdir().unwrap();
16288        let db = Database::create(dir.path()).unwrap();
16289        db.create_table("docs", test_schema()).unwrap();
16290        let table_id = db.table_id("docs").unwrap();
16291
16292        AUTO_INCREMENT_TABLE_LOCKS.with(|count| count.set(0));
16293        PREBUILD_TABLE_LOCKS.with(|count| count.set(0));
16294        PUBLISH_TABLE_LOCKS.with(|count| count.set(0));
16295        COMMIT_MANIFEST_WRITES.with(|count| count.set(0));
16296        db.transaction(|transaction| {
16297            for id in 0..100 {
16298                transaction.put("docs", vec![(1, Value::Int64(id))])?;
16299            }
16300            Ok(())
16301        })
16302        .unwrap();
16303
16304        AUTO_INCREMENT_TABLE_LOCKS.with(|count| assert_eq!(count.get(), 2));
16305        PREBUILD_TABLE_LOCKS.with(|count| assert_eq!(count.get(), 1));
16306        PUBLISH_TABLE_LOCKS.with(|count| assert_eq!(count.get(), 1));
16307        COMMIT_MANIFEST_WRITES.with(|count| assert_eq!(count.get(), 1));
16308
16309        let puts = crate::wal::SharedWal::replay(dir.path())
16310            .unwrap()
16311            .into_iter()
16312            .filter_map(|record| match record.op {
16313                crate::wal::Op::Put { table_id: id, rows } if id == table_id => Some(
16314                    bincode::deserialize::<Vec<crate::memtable::Row>>(&rows)
16315                        .unwrap()
16316                        .len(),
16317                ),
16318                _ => None,
16319            })
16320            .collect::<Vec<_>>();
16321        assert_eq!(puts, [100]);
16322
16323        let row_ids = db
16324            .table("docs")
16325            .unwrap()
16326            .lock()
16327            .visible_rows(db.snapshot().0)
16328            .unwrap()
16329            .into_iter()
16330            .take(2)
16331            .map(|row| row.row_id)
16332            .collect::<Vec<_>>();
16333        PREBUILD_TABLE_LOCKS.with(|count| count.set(0));
16334        PUBLISH_TABLE_LOCKS.with(|count| count.set(0));
16335        COMMIT_MANIFEST_WRITES.with(|count| count.set(0));
16336        db.transaction(|transaction| {
16337            for row_id in row_ids {
16338                transaction.delete("docs", row_id)?;
16339            }
16340            Ok(())
16341        })
16342        .unwrap();
16343        PREBUILD_TABLE_LOCKS.with(|count| assert_eq!(count.get(), 1));
16344        PUBLISH_TABLE_LOCKS.with(|count| assert_eq!(count.get(), 1));
16345        COMMIT_MANIFEST_WRITES.with(|count| assert_eq!(count.get(), 1));
16346
16347        let deletes = crate::wal::SharedWal::replay(dir.path())
16348            .unwrap()
16349            .into_iter()
16350            .filter_map(|record| match record.op {
16351                crate::wal::Op::Delete {
16352                    table_id: id,
16353                    row_ids,
16354                } if id == table_id => Some(row_ids.len()),
16355                _ => None,
16356            })
16357            .collect::<Vec<_>>();
16358        assert_eq!(deletes, [2]);
16359    }
16360
16361    fn puts(table_id: u64) -> Vec<(u64, Staged)> {
16362        (0..10_050)
16363            .map(|id| (table_id, Staged::Put(vec![(1, Value::Int64(id))])))
16364            .collect()
16365    }
16366
16367    fn test_schema() -> Schema {
16368        Schema {
16369            columns: vec![ColumnDef {
16370                id: 1,
16371                name: "id".into(),
16372                ty: TypeId::Int64,
16373                flags: crate::schema::ColumnFlags::empty()
16374                    .with(crate::schema::ColumnFlags::PRIMARY_KEY),
16375                default_value: None,
16376            }],
16377            ..Schema::default()
16378        }
16379    }
16380}
16381
16382#[cfg(test)]
16383mod cdc_bounds_tests {
16384    use super::*;
16385
16386    #[test]
16387    fn retained_byte_limit_rejects_without_allocating_payload() {
16388        let mut retained = 0;
16389        let error = charge_cdc_bytes(
16390            &mut retained,
16391            CDC_MAX_RETAINED_BYTES.saturating_add(1),
16392            "CDC retained bytes",
16393        )
16394        .unwrap_err();
16395        assert!(matches!(
16396            error,
16397            MongrelError::ResourceLimitExceeded {
16398                resource: "CDC retained bytes",
16399                ..
16400            }
16401        ));
16402    }
16403
16404    #[test]
16405    fn row_json_estimate_accounts_for_byte_array_expansion() {
16406        let row = crate::memtable::Row::new(RowId(1), Epoch(1))
16407            .with_column(1, Value::Bytes(vec![0; 1024]));
16408        assert!(cdc_row_json_bytes(&row) >= 1024 * std::mem::size_of::<serde_json::Value>());
16409    }
16410}
16411
16412#[cfg(test)]
16413mod generation_metrics_tests {
16414    use super::*;
16415    use crate::schema::{ColumnDef, ColumnFlags, Schema, TypeId};
16416
16417    #[test]
16418    fn legacy_cow_fallback_is_measured() {
16419        let dir = tempfile::tempdir().unwrap();
16420        let table = Table::create(
16421            dir.path(),
16422            Schema {
16423                columns: vec![ColumnDef {
16424                    id: 1,
16425                    name: "id".into(),
16426                    ty: TypeId::Int64,
16427                    flags: ColumnFlags::empty().with(ColumnFlags::PRIMARY_KEY),
16428                    default_value: None,
16429                }],
16430                ..Schema::default()
16431            },
16432            1,
16433        )
16434        .unwrap();
16435        let handle = TableHandle::from_table(table);
16436        let held = match &handle.inner {
16437            TableHandleInner::CopyOnWrite(slot) => Arc::clone(&slot.read()),
16438            TableHandleInner::Direct(_) => unreachable!(),
16439        };
16440
16441        handle.lock().set_sync_byte_threshold(1);
16442
16443        let stats = handle.generation_stats();
16444        assert_eq!(stats.cow_clone_count, 1);
16445        assert!(stats.estimated_cow_clone_bytes > 0);
16446        drop(held);
16447    }
16448}
16449
16450#[cfg(test)]
16451mod trigger_engine_tests {
16452    use super::*;
16453
16454    fn event_with(new_cells: &[(u16, Value)], old_cells: &[(u16, Value)]) -> WriteEvent {
16455        WriteEvent {
16456            table: "test".into(),
16457            kind: TriggerEvent::Insert,
16458            new: Some(TriggerRowImage {
16459                columns: new_cells.iter().cloned().collect(),
16460            }),
16461            old: Some(TriggerRowImage {
16462                columns: old_cells.iter().cloned().collect(),
16463            }),
16464            changed_columns: Vec::new(),
16465            op_indices: Vec::new(),
16466            put_idx: None,
16467            trigger_stack: Vec::new(),
16468        }
16469    }
16470
16471    fn event_insert(new_cells: &[(u16, Value)]) -> WriteEvent {
16472        WriteEvent {
16473            table: "test".into(),
16474            kind: TriggerEvent::Insert,
16475            new: Some(TriggerRowImage {
16476                columns: new_cells.iter().cloned().collect(),
16477            }),
16478            old: None,
16479            changed_columns: Vec::new(),
16480            op_indices: Vec::new(),
16481            put_idx: None,
16482            trigger_stack: Vec::new(),
16483        }
16484    }
16485
16486    #[test]
16487    fn value_order_int64_vs_float64() {
16488        assert_eq!(
16489            value_order(&Value::Int64(5), &Value::Float64(5.0)),
16490            Some(std::cmp::Ordering::Equal)
16491        );
16492        assert_eq!(
16493            value_order(&Value::Int64(5), &Value::Float64(3.0)),
16494            Some(std::cmp::Ordering::Greater)
16495        );
16496        assert_eq!(
16497            value_order(&Value::Int64(2), &Value::Float64(3.0)),
16498            Some(std::cmp::Ordering::Less)
16499        );
16500    }
16501
16502    #[test]
16503    fn value_order_null_returns_none() {
16504        assert_eq!(value_order(&Value::Int64(5), &Value::Null), None);
16505        assert_eq!(value_order(&Value::Null, &Value::Int64(5)), None);
16506        assert_eq!(value_order(&Value::Null, &Value::Null), None);
16507    }
16508
16509    #[test]
16510    fn value_order_cross_group_returns_none() {
16511        assert_eq!(
16512            value_order(&Value::Int64(5), &Value::Bytes(b"x".to_vec())),
16513            None
16514        );
16515        assert_eq!(value_order(&Value::Bool(true), &Value::Int64(1)), None);
16516        assert_eq!(
16517            value_order(
16518                &Value::Embedding(vec![1.0, 2.0]),
16519                &Value::Embedding(vec![1.0, 2.0])
16520            ),
16521            None
16522        );
16523    }
16524
16525    #[test]
16526    fn eval_trigger_expr_ranges_and_booleans() {
16527        let expr = TriggerExpr::And {
16528            left: Box::new(TriggerExpr::Gt {
16529                left: TriggerValue::NewColumn(1),
16530                right: TriggerValue::Literal(Value::Int64(0)),
16531            }),
16532            right: Box::new(TriggerExpr::Lte {
16533                left: TriggerValue::NewColumn(1),
16534                right: TriggerValue::Literal(Value::Int64(100)),
16535            }),
16536        };
16537        assert!(eval_trigger_expr(&expr, &event_insert(&[(1, Value::Int64(50))])).unwrap());
16538        assert!(!eval_trigger_expr(&expr, &event_insert(&[(1, Value::Int64(200))])).unwrap());
16539        assert!(!eval_trigger_expr(&expr, &event_insert(&[(1, Value::Null)])).unwrap());
16540
16541        let or_expr = TriggerExpr::Or {
16542            left: Box::new(TriggerExpr::Lt {
16543                left: TriggerValue::NewColumn(1),
16544                right: TriggerValue::Literal(Value::Int64(0)),
16545            }),
16546            right: Box::new(TriggerExpr::Not(Box::new(TriggerExpr::IsNull(
16547                TriggerValue::OldColumn(2),
16548            )))),
16549        };
16550        assert!(eval_trigger_expr(
16551            &or_expr,
16552            &event_with(&[(1, Value::Int64(5))], &[(2, Value::Int64(99))])
16553        )
16554        .unwrap());
16555        assert!(!eval_trigger_expr(
16556            &or_expr,
16557            &event_with(&[(1, Value::Int64(5))], &[(2, Value::Null)])
16558        )
16559        .unwrap());
16560
16561        assert!(eval_trigger_expr(
16562            &TriggerExpr::Value(TriggerValue::Literal(Value::Bool(true))),
16563            &event_insert(&[])
16564        )
16565        .unwrap());
16566        assert!(!eval_trigger_expr(
16567            &TriggerExpr::Value(TriggerValue::Literal(Value::Bool(false))),
16568            &event_insert(&[])
16569        )
16570        .unwrap());
16571        assert!(!eval_trigger_expr(
16572            &TriggerExpr::Value(TriggerValue::Literal(Value::Null)),
16573            &event_insert(&[])
16574        )
16575        .unwrap());
16576    }
16577}