Skip to main content

mongreldb_core/
database.rs

1//! Multi-table `Database` container (spec §5, §6, §10).
2//!
3//! Owns the shared services — catalog, dual-counter epoch authority, shared
4//! raw/decoded page caches, snapshot-retention registry, and the DB-wide KEK —
5//! and mounts per-table [`Table`] engines under `tables/<id>/` that borrow them.
6//! P1 scope: per-table WALs remain (collapsed into one shared WAL in P2); the
7//! win here is one consistent commit clock across tables and one reopen path.
8
9use crate::catalog::{self, Catalog, CatalogEntry, TableState, META_DEK_LEN};
10use crate::engine::{SharedCtx, Table};
11use crate::epoch::{Epoch, EpochAuthority, EpochGuard, MaintenanceReceipt, Snapshot};
12use crate::error::{MongrelError, Result};
13use crate::external_table::ExternalTableEntry;
14use crate::memtable::Value;
15use crate::procedure::{
16    ProcedureCallOutput, ProcedureCallResult, ProcedureCallRow, ProcedureCondition, ProcedureEntry,
17    ProcedureStep, ProcedureValue, StoredProcedure,
18};
19use crate::retention::{OwnedSnapshotGuard, SnapshotGuard, SnapshotRegistry};
20use crate::rowid::RowId;
21use crate::schema::{AlterColumn, ColumnDef, Schema, TypeId};
22use crate::trigger::{
23    StoredTrigger, TriggerCondition, TriggerConfig, TriggerEntry, TriggerEvent, TriggerExpr,
24    TriggerRaiseAction, TriggerStep, TriggerTarget, TriggerTiming, TriggerValue,
25};
26use parking_lot::{Mutex, RwLock};
27use std::collections::{HashMap, HashSet, VecDeque};
28use std::io::{Read, Write};
29use std::path::{Path, PathBuf};
30use std::sync::atomic::{AtomicBool, AtomicU32, AtomicU64, AtomicUsize, Ordering};
31use std::sync::Arc;
32
33pub const TABLES_DIR: &str = "tables";
34pub const VTAB_DIR: &str = "_vtab";
35pub const META_DIR: &str = "_meta";
36pub const KEYS_FILENAME: &str = "keys";
37pub const HISTORY_RETENTION_FILENAME: &str = "history_retention";
38pub const CTAS_BUILD_TABLE_PREFIX: &str = "__mongreldb_ctas_build_";
39
40/// Sentinel `table_id` for `CheckIssue`s that concern the shared WAL rather
41/// than any table. `u64::MAX` is never allocated to a real table (the catalog
42/// mints ids from 0 upward), so [`Database::doctor`] can safely skip them.
43pub const WAL_TABLE_ID: u64 = u64::MAX;
44/// Sentinel `table_id` for `CheckIssue`s that concern external-table module
45/// state instead of an ordinary table.
46pub const EXTERNAL_TABLE_ID: u64 = u64::MAX - 1;
47
48fn advance_security_version(catalog: &mut Catalog) -> Result<()> {
49    catalog.security_version = catalog.security_version.checked_add(1).ok_or_else(|| {
50        MongrelError::Conflict("security catalog version space is exhausted".into())
51    })?;
52    Ok(())
53}
54
55fn process_locked_paths() -> &'static Mutex<HashSet<PathBuf>> {
56    static LOCKED_PATHS: std::sync::OnceLock<Mutex<HashSet<PathBuf>>> = std::sync::OnceLock::new();
57    LOCKED_PATHS.get_or_init(|| Mutex::new(HashSet::new()))
58}
59
60struct DatabaseFileLock {
61    bootstrap_file: std::fs::File,
62    legacy_file: Option<std::fs::File>,
63    canonical_path: PathBuf,
64    durable_root: Option<Arc<crate::durable_file::DurableRoot>>,
65}
66
67impl Drop for DatabaseFileLock {
68    fn drop(&mut self) {
69        if let Some(file) = &self.legacy_file {
70            let _ = fs2::FileExt::unlock(file);
71        }
72        let _ = fs2::FileExt::unlock(&self.bootstrap_file);
73        process_locked_paths().lock().remove(&self.canonical_path);
74    }
75}
76
77fn commit_prepare_checkpoint(
78    control: Option<&crate::ExecutionControl>,
79    index: usize,
80) -> Result<()> {
81    if index.is_multiple_of(256) {
82        if let Some(control) = control {
83            control.checkpoint()?;
84        }
85    }
86    Ok(())
87}
88
89fn finish_controlled_commit_attempt(
90    result: Result<Epoch>,
91    after_commit: &mut Option<&mut dyn FnMut(Option<Epoch>) -> Result<()>>,
92) -> Result<Epoch> {
93    let Some(after_commit) = after_commit.as_mut() else {
94        return result;
95    };
96    match result {
97        Ok(epoch) => match (**after_commit)(Some(epoch)) {
98            Ok(()) => Ok(epoch),
99            Err(error) => Err(MongrelError::DurableCommit {
100                epoch: epoch.0,
101                message: error.to_string(),
102            }),
103        },
104        Err(MongrelError::DurableCommit { epoch, message }) => {
105            let callback_error = (**after_commit)(Some(Epoch(epoch))).err();
106            Err(MongrelError::DurableCommit {
107                epoch,
108                message: callback_error
109                    .map(|error| format!("{message}; commit callback: {error}"))
110                    .unwrap_or(message),
111            })
112        }
113        Err(error) => match (**after_commit)(None) {
114            Ok(()) => Err(error),
115            Err(callback_error) => Err(MongrelError::Other(format!(
116                "{error}; commit callback: {callback_error}"
117            ))),
118        },
119    }
120}
121
122fn current_unix_nanos() -> u64 {
123    std::time::SystemTime::now()
124        .duration_since(std::time::UNIX_EPOCH)
125        .unwrap_or_default()
126        .as_nanos() as u64
127}
128
129#[cfg(feature = "encryption")]
130fn read_encryption_salt(
131    root: &crate::durable_file::DurableRoot,
132) -> Result<[u8; crate::encryption::SALT_LEN]> {
133    let mut file = root
134        .open_regular(Path::new(META_DIR).join(KEYS_FILENAME))
135        .map_err(|error| MongrelError::NotFound(format!("encryption salt file: {error}")))?;
136    let length = file.metadata()?.len();
137    if length != crate::encryption::SALT_LEN as u64 {
138        return Err(MongrelError::Encryption(format!(
139            "invalid encryption salt length: got {length}, expected {}",
140            crate::encryption::SALT_LEN
141        )));
142    }
143    let mut salt = [0_u8; crate::encryption::SALT_LEN];
144    file.read_exact(&mut salt)?;
145    Ok(salt)
146}
147
148fn incremental_aggregate_cache_key(
149    table: &str,
150    conditions: &[crate::query::Condition],
151    column: Option<u16>,
152    agg: crate::engine::NativeAgg,
153    principal: Option<&crate::auth::Principal>,
154    security_version: u64,
155) -> u64 {
156    use std::hash::{Hash, Hasher};
157    let projection = column.as_ref().map(std::slice::from_ref);
158    let query_key = crate::query::canonical_query_key(conditions, projection, security_version);
159    let mut hasher = std::collections::hash_map::DefaultHasher::new();
160    table.hash(&mut hasher);
161    query_key.hash(&mut hasher);
162    match agg {
163        crate::engine::NativeAgg::Count => 0u8,
164        crate::engine::NativeAgg::Sum => 1,
165        crate::engine::NativeAgg::Min => 2,
166        crate::engine::NativeAgg::Max => 3,
167        crate::engine::NativeAgg::Avg => 4,
168    }
169    .hash(&mut hasher);
170    if let Some(principal) = principal {
171        principal.user_id.hash(&mut hasher);
172        principal.created_epoch.hash(&mut hasher);
173        principal.username.hash(&mut hasher);
174        principal.is_admin.hash(&mut hasher);
175        let mut roles = principal.roles.clone();
176        roles.sort_unstable();
177        roles.hash(&mut hasher);
178    }
179    hasher.finish()
180}
181
182fn read_history_retention(
183    root: &crate::durable_file::DurableRoot,
184    current_epoch: Epoch,
185) -> Result<(u64, Epoch)> {
186    const MAX_HISTORY_RETENTION_BYTES: u64 = 128;
187    let file = match root.open_regular(Path::new(META_DIR).join(HISTORY_RETENTION_FILENAME)) {
188        Ok(file) => file,
189        Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
190            return Ok((0, current_epoch));
191        }
192        Err(error) => return Err(error.into()),
193    };
194    let length = file.metadata()?.len();
195    if length > MAX_HISTORY_RETENTION_BYTES {
196        return Err(MongrelError::ResourceLimitExceeded {
197            resource: "history retention bytes",
198            requested: usize::try_from(length).unwrap_or(usize::MAX),
199            limit: MAX_HISTORY_RETENTION_BYTES as usize,
200        });
201    }
202    let mut bytes = Vec::with_capacity(length as usize);
203    file.take(MAX_HISTORY_RETENTION_BYTES + 1)
204        .read_to_end(&mut bytes)?;
205    if bytes.len() as u64 != length {
206        return Err(MongrelError::Other(
207            "history retention length changed while reading".into(),
208        ));
209    }
210    let text = std::str::from_utf8(&bytes)
211        .map_err(|error| MongrelError::Other(format!("history retention encoding: {error}")))?;
212    let mut fields = text.split_whitespace();
213    let epochs = fields
214        .next()
215        .ok_or_else(|| MongrelError::Other("history retention file is empty".into()))?
216        .parse::<u64>()
217        .map_err(|error| MongrelError::Other(format!("history retention epochs: {error}")))?;
218    let start = fields
219        .next()
220        .ok_or_else(|| MongrelError::Other("history retention start is missing".into()))?
221        .parse::<u64>()
222        .map_err(|error| MongrelError::Other(format!("history retention start: {error}")))?;
223    if fields.next().is_some() || start > current_epoch.0 {
224        return Err(MongrelError::Other(
225            "history retention file has trailing fields or a future start epoch".into(),
226        ));
227    }
228    Ok((epochs, Epoch(start)))
229}
230
231fn write_history_retention<F>(
232    root: &Path,
233    epochs: u64,
234    start: Epoch,
235    after_publish: F,
236) -> Result<()>
237where
238    F: FnOnce(),
239{
240    let meta = root.join(META_DIR);
241    let path = meta.join(HISTORY_RETENTION_FILENAME);
242    let bytes = format!("{epochs} {}\n", start.0);
243    crate::durable_file::write_atomic_with_after(&path, bytes.as_bytes(), after_publish)?;
244    Ok(())
245}
246
247struct PreparedBackupDestination {
248    parent: crate::durable_file::DurableRoot,
249    destination_name: std::ffi::OsString,
250    destination_path: PathBuf,
251    stage_name: std::ffi::OsString,
252    stage: Option<Box<crate::durable_file::DurableRoot>>,
253}
254
255fn prepare_backup_destination(
256    source: &Path,
257    destination: &Path,
258) -> Result<PreparedBackupDestination> {
259    let destination_name = destination
260        .file_name()
261        .ok_or_else(|| MongrelError::InvalidArgument("invalid backup destination".into()))?
262        .to_os_string();
263    let requested_parent = destination
264        .parent()
265        .filter(|path| !path.as_os_str().is_empty())
266        .unwrap_or_else(|| Path::new("."));
267    crate::durable_file::create_directory_all(requested_parent)?;
268    let parent = crate::durable_file::DurableRoot::open(requested_parent)?;
269    prepare_backup_destination_in(source, &parent, &destination_name)
270}
271
272fn prepare_backup_destination_in(
273    source: &Path,
274    parent: &crate::durable_file::DurableRoot,
275    destination_name: &std::ffi::OsStr,
276) -> Result<PreparedBackupDestination> {
277    let source = source.canonicalize()?;
278    if parent.canonical_path().starts_with(&source) {
279        return Err(MongrelError::InvalidArgument(
280            "backup destination must not be inside the source database".into(),
281        ));
282    }
283    if parent.entry_exists(Path::new(&destination_name))? {
284        return Err(MongrelError::Conflict(format!(
285            "backup destination already exists: {}",
286            parent.canonical_path().join(destination_name).display()
287        )));
288    }
289    let mut stage_name = None;
290    for _ in 0..128 {
291        let mut nonce = [0_u8; 8];
292        crate::encryption::fill_random(&mut nonce)?;
293        let suffix = nonce
294            .iter()
295            .map(|byte| format!("{byte:02x}"))
296            .collect::<String>();
297        let name = std::ffi::OsString::from(format!(
298            ".{}.backup-stage-{}-{suffix}",
299            destination_name.to_string_lossy(),
300            std::process::id()
301        ));
302        match parent.create_directory_new(Path::new(&name)) {
303            Ok(()) => {
304                stage_name = Some(name);
305                break;
306            }
307            Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => continue,
308            Err(error) => return Err(error.into()),
309        }
310    }
311    let stage_name = stage_name
312        .ok_or_else(|| MongrelError::Conflict("could not allocate backup staging path".into()))?;
313    let stage = parent.open_directory(Path::new(&stage_name))?;
314    Ok(PreparedBackupDestination {
315        destination_path: parent.canonical_path().join(destination_name),
316        destination_name: destination_name.to_os_string(),
317        stage_name,
318        stage: Some(Box::new(stage)),
319        parent: parent.try_clone()?,
320    })
321}
322
323fn copy_backup_boundary(
324    source_root: &Path,
325    destination_root: &crate::durable_file::DurableRoot,
326    deferred_runs: &HashSet<PathBuf>,
327    copied: &mut Vec<PathBuf>,
328    control: Option<&crate::ExecutionControl>,
329) -> Result<()> {
330    let mut visited = 0;
331    crate::durable_file::walk_regular_files_nofollow(
332        source_root,
333        |relative, is_directory| {
334            if visited % 256 == 0 {
335                if let Some(control) = control {
336                    control.checkpoint()?;
337                }
338            }
339            visited += 1;
340            if backup_path_excluded(relative) {
341                return Ok(false);
342            }
343            if is_directory {
344                return Ok(true);
345            }
346            if deferred_runs.contains(relative) {
347                return Ok(false);
348            }
349            Ok(!(relative
350                .parent()
351                .and_then(Path::file_name)
352                .is_some_and(|parent| parent == "_runs")
353                && relative
354                    .extension()
355                    .is_some_and(|extension| extension == "sr")))
356        },
357        |relative| {
358            destination_root.create_directory_all(relative)?;
359            Ok(())
360        },
361        |relative, source| {
362            destination_root.copy_new_from(relative, source)?;
363            copied.push(relative.to_path_buf());
364            Ok(())
365        },
366    )
367}
368
369fn backup_path_excluded(relative: &Path) -> bool {
370    relative == Path::new("_meta/.lock")
371        || relative == Path::new("_meta/replica")
372        || relative == Path::new("_meta/repl_epoch")
373        || relative == Path::new(crate::backup::BACKUP_MANIFEST_PATH)
374        || relative.components().any(|component| {
375            matches!(component, std::path::Component::Normal(name) if name == "_cache" || name == "_txn" || name == "backup-pins")
376        })
377}
378
379#[derive(Debug, Clone)]
380pub enum ExternalTriggerWrite {
381    Insert {
382        table: String,
383        cells: Vec<(u16, Value)>,
384    },
385    UpdateByPk {
386        table: String,
387        pk: Value,
388        cells: Vec<(u16, Value)>,
389    },
390    DeleteByPk {
391        table: String,
392        pk: Value,
393    },
394}
395
396impl ExternalTriggerWrite {
397    fn table(&self) -> &str {
398        match self {
399            Self::Insert { table, .. }
400            | Self::UpdateByPk { table, .. }
401            | Self::DeleteByPk { table, .. } => table,
402        }
403    }
404}
405
406#[derive(Debug, Clone, PartialEq)]
407pub enum ExternalTriggerBaseWrite {
408    Put {
409        table: String,
410        cells: Vec<(u16, Value)>,
411    },
412    Delete {
413        table: String,
414        row_id: RowId,
415    },
416}
417
418#[derive(Debug, Clone, PartialEq)]
419pub struct ExternalTriggerWriteResult {
420    pub state: Vec<u8>,
421    pub base_writes: Vec<ExternalTriggerBaseWrite>,
422}
423
424impl ExternalTriggerWriteResult {
425    pub fn new(state: Vec<u8>) -> Self {
426        Self {
427            state,
428            base_writes: Vec::new(),
429        }
430    }
431}
432
433pub trait ExternalTriggerBridge: Send + Sync {
434    fn apply_trigger_external_write(
435        &self,
436        entry: &ExternalTableEntry,
437        base_state: Vec<u8>,
438        op: ExternalTriggerWrite,
439    ) -> Result<ExternalTriggerWriteResult>;
440}
441
442/// A pending uniform-epoch run written during a large transaction (spec §8.5).
443struct SpilledRun {
444    table_id: u64,
445    run_id: u128,
446    pending_path: PathBuf,
447    final_path: PathBuf,
448    rows: Vec<crate::memtable::Row>,
449    row_count: u64,
450    min_rid: u64,
451    max_rid: u64,
452    content_hash: [u8; 32],
453}
454
455const SPILLED_WAL_PAYLOAD_MAX_BYTES: usize = 24 * 1024 * 1024;
456const SPILLED_WAL_TOTAL_MAX_BYTES: usize = 256 * 1024 * 1024;
457
458fn encode_spilled_row_chunks(
459    rows: &[crate::memtable::Row],
460    total_bytes: &mut usize,
461    total_limit: usize,
462    control: Option<&crate::ExecutionControl>,
463) -> Result<Vec<Vec<u8>>> {
464    let mut output = Vec::new();
465    let mut start = 0;
466    while start < rows.len() {
467        // Bincode's sequence length prefix is a u64 with the workspace's
468        // fixed-int options. `serialized_size` computes exact row sizes
469        // without first allocating one transaction-sized buffer.
470        let mut estimated_bytes = std::mem::size_of::<u64>();
471        let mut end = start;
472        while end < rows.len() {
473            if end % 256 == 0 {
474                if let Some(control) = control {
475                    control.checkpoint()?;
476                }
477            }
478            let row_bytes =
479                usize::try_from(bincode::serialized_size(&rows[end])?).map_err(|_| {
480                    MongrelError::ResourceLimitExceeded {
481                        resource: "spilled WAL row bytes",
482                        requested: usize::MAX,
483                        limit: SPILLED_WAL_PAYLOAD_MAX_BYTES,
484                    }
485                })?;
486            let next_bytes = estimated_bytes.checked_add(row_bytes).ok_or(
487                MongrelError::ResourceLimitExceeded {
488                    resource: "spilled WAL row bytes",
489                    requested: usize::MAX,
490                    limit: SPILLED_WAL_PAYLOAD_MAX_BYTES,
491                },
492            )?;
493            if next_bytes > SPILLED_WAL_PAYLOAD_MAX_BYTES {
494                break;
495            }
496            estimated_bytes = next_bytes;
497            end += 1;
498        }
499        if end == start {
500            return Err(MongrelError::ResourceLimitExceeded {
501                resource: "spilled WAL row bytes",
502                requested: estimated_bytes.saturating_add(1),
503                limit: SPILLED_WAL_PAYLOAD_MAX_BYTES,
504            });
505        }
506        let payload = bincode::serialize(&rows[start..end])?;
507        if payload.len() > SPILLED_WAL_PAYLOAD_MAX_BYTES {
508            return Err(MongrelError::ResourceLimitExceeded {
509                resource: "spilled WAL row bytes",
510                requested: payload.len(),
511                limit: SPILLED_WAL_PAYLOAD_MAX_BYTES,
512            });
513        }
514        let requested = total_bytes.checked_add(payload.len()).unwrap_or(usize::MAX);
515        if requested > total_limit {
516            return Err(MongrelError::ResourceLimitExceeded {
517                resource: "spilled WAL transaction bytes",
518                requested,
519                limit: total_limit,
520            });
521        }
522        *total_bytes = requested;
523        output.push(payload);
524        start = end;
525    }
526    Ok(output)
527}
528
529#[cfg(test)]
530mod spilled_wal_encoding_tests {
531    use super::*;
532
533    #[test]
534    fn logical_spill_payload_has_a_total_bound() {
535        let rows = (0..4)
536            .map(|row_id| crate::memtable::Row {
537                row_id: crate::rowid::RowId(row_id),
538                committed_epoch: Epoch::ZERO,
539                columns: [(1, Value::Bytes(vec![0; 64]))].into_iter().collect(),
540                deleted: false,
541            })
542            .collect::<Vec<_>>();
543        let mut total = 0;
544        let error = encode_spilled_row_chunks(&rows, &mut total, 32, None).unwrap_err();
545        assert!(matches!(
546            error,
547            MongrelError::ResourceLimitExceeded {
548                resource: "spilled WAL transaction bytes",
549                ..
550            }
551        ));
552    }
553}
554
555/// Move spill files to their final names before the WAL commit. Dropping this
556/// guard restores pending names while commit is still known not to have begun.
557/// It is disarmed immediately before the first WAL append, where the outcome
558/// can become ambiguous and recovery may need the final names.
559struct PreparedRunLinks {
560    links: Vec<(PathBuf, PathBuf)>,
561    armed: bool,
562}
563
564impl PreparedRunLinks {
565    fn prepare(spilled: &[SpilledRun]) -> Result<Self> {
566        let mut guard = Self {
567            links: Vec::with_capacity(spilled.len()),
568            armed: true,
569        };
570        for run in spilled {
571            crate::durable_file::rename(&run.pending_path, &run.final_path)?;
572            guard
573                .links
574                .push((run.pending_path.clone(), run.final_path.clone()));
575        }
576        Ok(guard)
577    }
578
579    fn disarm(&mut self) {
580        self.armed = false;
581        for (pending, _) in &self.links {
582            if let Some(parent) = pending.parent() {
583                let _ = std::fs::remove_dir_all(parent);
584            }
585        }
586    }
587}
588
589impl Drop for PreparedRunLinks {
590    fn drop(&mut self) {
591        if !self.armed {
592            return;
593        }
594        for (pending, final_path) in self.links.iter().rev() {
595            let _ = std::fs::rename(final_path, pending);
596        }
597    }
598}
599
600struct TableApplyBatch {
601    table_id: u64,
602    handle: TableHandle,
603    ops: Vec<crate::txn::StagedOp>,
604}
605
606#[derive(Debug, Clone)]
607struct TriggerRowImage {
608    columns: HashMap<u16, Value>,
609}
610
611impl TriggerRowImage {
612    fn from_row(row: crate::memtable::Row) -> Self {
613        Self {
614            columns: row.columns,
615        }
616    }
617
618    fn from_cells(cells: &[(u16, Value)]) -> Self {
619        Self {
620            columns: cells.iter().cloned().collect(),
621        }
622    }
623}
624
625#[derive(Debug, Clone)]
626struct WriteEvent {
627    table: String,
628    kind: TriggerEvent,
629    old: Option<TriggerRowImage>,
630    new: Option<TriggerRowImage>,
631    changed_columns: Vec<u16>,
632    op_indices: Vec<usize>,
633    put_idx: Option<usize>,
634    trigger_stack: Vec<String>,
635}
636
637#[derive(Default)]
638struct TriggerExpansion {
639    before: Vec<(u64, crate::txn::Staged)>,
640    before_stacks: Vec<Vec<String>>,
641    before_external: Vec<ExternalTriggerWrite>,
642    after: Vec<(u64, crate::txn::Staged)>,
643    after_stacks: Vec<Vec<String>>,
644    after_external: Vec<ExternalTriggerWrite>,
645    ignored_indices: std::collections::BTreeSet<usize>,
646}
647
648#[derive(Clone, PartialEq)]
649struct TriggerCatalogBinding {
650    triggers: Vec<TriggerEntry>,
651    tables: Vec<(String, u64, u64)>,
652    external_tables: Vec<(String, u64, u64)>,
653}
654
655fn trigger_catalog_binding(catalog: &Catalog) -> Option<TriggerCatalogBinding> {
656    let mut triggers = catalog
657        .triggers
658        .iter()
659        .filter(|entry| entry.trigger.enabled)
660        .cloned()
661        .collect::<Vec<_>>();
662    if triggers.is_empty() {
663        return None;
664    }
665    triggers.sort_by(|left, right| left.trigger.name.cmp(&right.trigger.name));
666    let mut tables = catalog
667        .tables
668        .iter()
669        .filter(|entry| matches!(entry.state, TableState::Live))
670        .map(|entry| (entry.name.clone(), entry.table_id, entry.schema.schema_id))
671        .collect::<Vec<_>>();
672    tables.sort_unstable();
673    let mut external_tables = catalog
674        .external_tables
675        .iter()
676        .map(|entry| {
677            (
678                entry.name.clone(),
679                entry.created_epoch,
680                entry.declared_schema.schema_id,
681            )
682        })
683        .collect::<Vec<_>>();
684    external_tables.sort_unstable();
685    Some(TriggerCatalogBinding {
686        triggers,
687        tables,
688        external_tables,
689    })
690}
691
692struct TriggerProgramOutput<'a> {
693    added: &'a mut Vec<(u64, crate::txn::Staged)>,
694    added_stacks: &'a mut Vec<Vec<String>>,
695    added_external: &'a mut Vec<ExternalTriggerWrite>,
696    ignored_indices: &'a mut std::collections::BTreeSet<usize>,
697}
698
699#[derive(Debug, Clone, Copy, PartialEq, Eq)]
700enum TriggerProgramOutcome {
701    Continue,
702    Ignore,
703}
704
705/// An integrity issue found by [`Database::check`] (spec §16).
706#[derive(Debug, Clone)]
707pub struct CheckIssue {
708    pub table_id: u64,
709    pub table_name: String,
710    pub severity: String,
711    pub description: String,
712}
713
714/// One optimistic authorization snapshot for a complete scored read.
715#[derive(Debug, Clone)]
716pub struct AuthorizedReadSnapshot {
717    pub table: String,
718    pub table_snapshot: Snapshot,
719    pub data_generation: u64,
720    pub security_version: u64,
721    pub allowed_row_ids: Option<HashSet<RowId>>,
722}
723
724/// Exact table/security generation used by one successful authorized read.
725#[derive(Debug, Clone, Copy, PartialEq, Eq)]
726pub struct AuthorizedReadStamp {
727    pub table_id: u64,
728    pub schema_id: u64,
729    pub data_generation: u64,
730    pub security_version: u64,
731    pub snapshot: Snapshot,
732}
733
734type RlsCacheKey = (String, u64, u64, String);
735
736/// Runtime statistics for the byte-bounded RLS candidate cache.
737#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
738pub struct RlsCacheStats {
739    pub entries: usize,
740    pub bytes: usize,
741    pub hits: u64,
742    pub misses: u64,
743    pub evictions: u64,
744    pub build_nanos: u64,
745    pub rows_evaluated: u64,
746}
747
748const RLS_CACHE_MAX_BYTES: usize = 64 * 1024 * 1024;
749const CDC_MAX_WAL_RECORDS: usize = 1_000_000;
750const CDC_MAX_WAL_REPLAY_BYTES: usize = 256 * 1024 * 1024;
751const CDC_MAX_EVENTS: usize = 100_000;
752const CDC_MAX_ROWS: usize = 1_000_000;
753const CDC_MAX_INLINE_PAYLOAD_BYTES: usize = 32 * 1024 * 1024;
754const CDC_MAX_RETAINED_BYTES: usize = 256 * 1024 * 1024;
755
756fn charge_cdc_bytes(total: &mut usize, amount: usize, resource: &'static str) -> Result<()> {
757    let requested = total.saturating_add(amount);
758    if requested > CDC_MAX_RETAINED_BYTES {
759        return Err(MongrelError::ResourceLimitExceeded {
760            resource,
761            requested,
762            limit: CDC_MAX_RETAINED_BYTES,
763        });
764    }
765    *total = requested;
766    Ok(())
767}
768
769fn cdc_row_storage_bytes(row: &crate::memtable::Row) -> usize {
770    usize::try_from(row.estimated_bytes())
771        .unwrap_or(usize::MAX)
772        .saturating_add(std::mem::size_of::<crate::memtable::Row>())
773}
774
775fn cdc_row_json_bytes(row: &crate::memtable::Row) -> usize {
776    let value_slot = std::mem::size_of::<serde_json::Value>();
777    row.columns.values().fold(512_usize, |bytes, value| {
778        let values = match value {
779            Value::Bytes(values) => values.len(),
780            Value::Json(values) => values.len(),
781            Value::Embedding(values) => values.len(),
782            _ => 1,
783        };
784        bytes.saturating_add(values.saturating_mul(value_slot))
785    })
786}
787
788fn cdc_rows_json_bytes(rows: &[crate::memtable::Row]) -> usize {
789    rows.iter().fold(0_usize, |bytes, row| {
790        bytes.saturating_add(cdc_row_json_bytes(row))
791    })
792}
793
794#[derive(Default)]
795struct RlsCache {
796    entries: HashMap<RlsCacheKey, (Arc<HashSet<RowId>>, usize)>,
797    lru: VecDeque<RlsCacheKey>,
798    bytes: usize,
799    hits: u64,
800    misses: u64,
801    evictions: u64,
802    build_nanos: u64,
803    rows_evaluated: u64,
804}
805
806impl RlsCache {
807    fn get(&mut self, key: &RlsCacheKey) -> Option<Arc<HashSet<RowId>>> {
808        let value = self.entries.get(key).map(|(value, _)| Arc::clone(value));
809        if value.is_some() {
810            self.hits = self.hits.saturating_add(1);
811            self.touch(key);
812        } else {
813            self.misses = self.misses.saturating_add(1);
814        }
815        value
816    }
817
818    fn insert(&mut self, key: RlsCacheKey, value: Arc<HashSet<RowId>>) {
819        let bytes = key
820            .0
821            .len()
822            .saturating_add(key.3.len())
823            .saturating_add(
824                value
825                    .capacity()
826                    .saturating_mul(std::mem::size_of::<RowId>() * 3),
827            )
828            .saturating_add(std::mem::size_of::<RlsCacheKey>());
829        if bytes > RLS_CACHE_MAX_BYTES {
830            return;
831        }
832        if let Some((_, old_bytes)) = self.entries.remove(&key) {
833            self.bytes = self.bytes.saturating_sub(old_bytes);
834        }
835        self.lru.retain(|candidate| candidate != &key);
836        while self.bytes.saturating_add(bytes) > RLS_CACHE_MAX_BYTES {
837            let Some(oldest) = self.lru.pop_front() else {
838                break;
839            };
840            if let Some((_, old_bytes)) = self.entries.remove(&oldest) {
841                self.bytes = self.bytes.saturating_sub(old_bytes);
842                self.evictions = self.evictions.saturating_add(1);
843            }
844        }
845        self.bytes = self.bytes.saturating_add(bytes);
846        self.lru.push_back(key.clone());
847        self.entries.insert(key, (value, bytes));
848    }
849
850    fn touch(&mut self, key: &RlsCacheKey) {
851        self.lru.retain(|candidate| candidate != key);
852        self.lru.push_back(key.clone());
853    }
854
855    fn stats(&self) -> RlsCacheStats {
856        RlsCacheStats {
857            entries: self.entries.len(),
858            bytes: self.bytes,
859            hits: self.hits,
860            misses: self.misses,
861            evictions: self.evictions,
862            build_nanos: self.build_nanos,
863            rows_evaluated: self.rows_evaluated,
864        }
865    }
866}
867
868/// Mounted table with immutable, structurally shared scored-read generations.
869#[derive(Clone)]
870pub struct TableHandle {
871    inner: TableHandleInner,
872    generation_metrics: Arc<TableGenerationMetrics>,
873}
874
875#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
876pub struct TableGenerationStats {
877    pub active_read_generations: usize,
878    pub max_live_read_generations: usize,
879    pub cow_clone_count: u64,
880    pub cow_clone_nanos: u64,
881    pub estimated_cow_clone_bytes: u64,
882    pub writer_wait_nanos: u64,
883}
884
885#[derive(Default)]
886#[doc(hidden)]
887pub struct TableGenerationMetrics {
888    active_read_generations: AtomicUsize,
889    max_live_read_generations: AtomicUsize,
890    cow_clone_count: AtomicU64,
891    cow_clone_nanos: AtomicU64,
892    estimated_cow_clone_bytes: AtomicU64,
893    writer_wait_nanos: AtomicU64,
894}
895
896impl TableGenerationMetrics {
897    fn activate(self: &Arc<Self>, table: Table) -> Arc<TableReadGeneration> {
898        let active = self.active_read_generations.fetch_add(1, Ordering::Relaxed) + 1;
899        self.max_live_read_generations
900            .fetch_max(active, Ordering::Relaxed);
901        Arc::new(TableReadGeneration {
902            table,
903            metrics: Arc::clone(self),
904        })
905    }
906
907    fn stats(&self) -> TableGenerationStats {
908        TableGenerationStats {
909            active_read_generations: self.active_read_generations.load(Ordering::Relaxed),
910            max_live_read_generations: self.max_live_read_generations.load(Ordering::Relaxed),
911            cow_clone_count: self.cow_clone_count.load(Ordering::Relaxed),
912            cow_clone_nanos: self.cow_clone_nanos.load(Ordering::Relaxed),
913            estimated_cow_clone_bytes: self.estimated_cow_clone_bytes.load(Ordering::Relaxed),
914            writer_wait_nanos: self.writer_wait_nanos.load(Ordering::Relaxed),
915        }
916    }
917}
918
919/// Immutable, structurally shared snapshot used by scored readers.
920pub struct TableReadGeneration {
921    table: Table,
922    metrics: Arc<TableGenerationMetrics>,
923}
924
925impl std::ops::Deref for TableReadGeneration {
926    type Target = Table;
927
928    fn deref(&self) -> &Self::Target {
929        &self.table
930    }
931}
932
933impl Drop for TableReadGeneration {
934    fn drop(&mut self) {
935        self.metrics
936            .active_read_generations
937            .fetch_sub(1, Ordering::Relaxed);
938    }
939}
940
941#[derive(Clone)]
942enum TableHandleInner {
943    CopyOnWrite(Arc<RwLock<Arc<Table>>>),
944    Direct(Arc<Mutex<Table>>),
945}
946
947pub enum TableGuard<'a> {
948    CopyOnWrite {
949        table: parking_lot::RwLockWriteGuard<'a, Arc<Table>>,
950        metrics: Arc<TableGenerationMetrics>,
951    },
952    Direct {
953        table: parking_lot::MutexGuard<'a, Table>,
954    },
955}
956
957impl TableHandle {
958    fn new(table: Table) -> Self {
959        Self {
960            inner: TableHandleInner::CopyOnWrite(Arc::new(RwLock::new(Arc::new(table)))),
961            generation_metrics: Arc::new(TableGenerationMetrics::default()),
962        }
963    }
964
965    pub fn from_table(table: Table) -> Self {
966        Self::new(table)
967    }
968
969    pub fn lock(&self) -> TableGuard<'_> {
970        let started = std::time::Instant::now();
971        let guard = match &self.inner {
972            TableHandleInner::CopyOnWrite(table) => TableGuard::CopyOnWrite {
973                table: table.write(),
974                metrics: Arc::clone(&self.generation_metrics),
975            },
976            TableHandleInner::Direct(table) => TableGuard::Direct {
977                table: table.lock(),
978            },
979        };
980        self.generation_metrics.writer_wait_nanos.fetch_add(
981            started.elapsed().as_nanos().min(u128::from(u64::MAX)) as u64,
982            Ordering::Relaxed,
983        );
984        guard
985    }
986
987    fn try_lock_for(&self, timeout: std::time::Duration) -> Option<TableGuard<'_>> {
988        let started = std::time::Instant::now();
989        let guard = match &self.inner {
990            TableHandleInner::CopyOnWrite(table) => {
991                table
992                    .try_write_for(timeout)
993                    .map(|table| TableGuard::CopyOnWrite {
994                        table,
995                        metrics: Arc::clone(&self.generation_metrics),
996                    })
997            }
998            TableHandleInner::Direct(table) => table
999                .try_lock_for(timeout)
1000                .map(|table| TableGuard::Direct { table }),
1001        };
1002        self.generation_metrics.writer_wait_nanos.fetch_add(
1003            started.elapsed().as_nanos().min(u128::from(u64::MAX)) as u64,
1004            Ordering::Relaxed,
1005        );
1006        guard
1007    }
1008
1009    pub fn ptr_eq(&self, other: &Self) -> bool {
1010        match (&self.inner, &other.inner) {
1011            (TableHandleInner::CopyOnWrite(left), TableHandleInner::CopyOnWrite(right)) => {
1012                Arc::ptr_eq(left, right)
1013            }
1014            (TableHandleInner::Direct(left), TableHandleInner::Direct(right)) => {
1015                Arc::ptr_eq(left, right)
1016            }
1017            _ => false,
1018        }
1019    }
1020
1021    pub fn read_generation_with_context(
1022        &self,
1023        context: Option<&crate::query::AiExecutionContext>,
1024    ) -> Result<(Arc<TableReadGeneration>, Snapshot)> {
1025        let mut table = if let Some(context) = context {
1026            loop {
1027                context.checkpoint()?;
1028                let wait = context
1029                    .remaining_duration()
1030                    .unwrap_or(std::time::Duration::from_millis(5))
1031                    .min(std::time::Duration::from_millis(5));
1032                if let Some(table) = self.try_lock_for(wait) {
1033                    break table;
1034                }
1035            }
1036        } else {
1037            self.lock()
1038        };
1039        let snapshot = table.snapshot();
1040        let generation = table.clone_read_generation()?;
1041        Ok((self.generation_metrics.activate(generation), snapshot))
1042    }
1043
1044    pub fn generation_stats(&self) -> TableGenerationStats {
1045        self.generation_metrics.stats()
1046    }
1047}
1048
1049impl From<Arc<Mutex<Table>>> for TableHandle {
1050    fn from(table: Arc<Mutex<Table>>) -> Self {
1051        Self {
1052            inner: TableHandleInner::Direct(table),
1053            generation_metrics: Arc::new(TableGenerationMetrics::default()),
1054        }
1055    }
1056}
1057
1058impl std::ops::Deref for TableGuard<'_> {
1059    type Target = Table;
1060
1061    fn deref(&self) -> &Self::Target {
1062        match self {
1063            Self::CopyOnWrite { table, .. } => table.as_ref(),
1064            Self::Direct { table } => table,
1065        }
1066    }
1067}
1068
1069impl std::ops::DerefMut for TableGuard<'_> {
1070    fn deref_mut(&mut self) -> &mut Self::Target {
1071        match self {
1072            Self::CopyOnWrite { table, metrics } => {
1073                if Arc::strong_count(table) > 1 || Arc::weak_count(table) > 0 {
1074                    let estimated_bytes = table.estimated_clone_bytes();
1075                    let started = std::time::Instant::now();
1076                    let table = Arc::make_mut(table);
1077                    metrics.cow_clone_count.fetch_add(1, Ordering::Relaxed);
1078                    metrics.cow_clone_nanos.fetch_add(
1079                        started.elapsed().as_nanos().min(u128::from(u64::MAX)) as u64,
1080                        Ordering::Relaxed,
1081                    );
1082                    metrics
1083                        .estimated_cow_clone_bytes
1084                        .fetch_add(estimated_bytes, Ordering::Relaxed);
1085                    table
1086                } else {
1087                    Arc::make_mut(table)
1088                }
1089            }
1090            Self::Direct { table } => table,
1091        }
1092    }
1093}
1094
1095#[derive(Clone, Debug)]
1096pub struct ReadAuthorization {
1097    pub operation: crate::auth::ColumnOperation,
1098    pub columns: Vec<u16>,
1099    pub permissions: Vec<crate::auth::Permission>,
1100}
1101
1102#[derive(Default, Debug)]
1103struct TableWritePermissionNeeds {
1104    insert: bool,
1105    insert_columns: Vec<u16>,
1106    update: bool,
1107    update_columns: Vec<u16>,
1108    delete: bool,
1109    truncate: bool,
1110}
1111
1112#[cfg(test)]
1113thread_local! {
1114    static WRITE_PERMISSION_DECISIONS: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
1115    static AUTO_INCREMENT_TABLE_LOCKS: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
1116    static PREBUILD_TABLE_LOCKS: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
1117    static PUBLISH_TABLE_LOCKS: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
1118    static COMMIT_MANIFEST_WRITES: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
1119    static TABLE_PERMISSION_DECISIONS: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
1120}
1121
1122fn summarize_write_permissions(
1123    staging: &[(u64, crate::txn::Staged)],
1124) -> HashMap<u64, TableWritePermissionNeeds> {
1125    use crate::txn::Staged;
1126
1127    let mut needs = HashMap::<u64, TableWritePermissionNeeds>::new();
1128    for (table_id, operation) in staging {
1129        let table = needs.entry(*table_id).or_default();
1130        match operation {
1131            Staged::Put(cells) => {
1132                table.insert = true;
1133                table
1134                    .insert_columns
1135                    .extend(cells.iter().map(|(column, _)| *column));
1136            }
1137            Staged::Update {
1138                changed_columns, ..
1139            } => {
1140                table.update = true;
1141                table.update_columns.extend(changed_columns);
1142            }
1143            Staged::Delete(_) => table.delete = true,
1144            Staged::Truncate => table.truncate = true,
1145        }
1146    }
1147    for table in needs.values_mut() {
1148        table.insert_columns.sort_unstable();
1149        table.insert_columns.dedup();
1150        table.update_columns.sort_unstable();
1151        table.update_columns.dedup();
1152    }
1153    needs
1154}
1155
1156struct SecurityCoordinator {
1157    /// Lock order: security gate -> commit lock -> shared WAL -> table locks.
1158    gate: RwLock<()>,
1159    version: AtomicU64,
1160}
1161
1162fn security_coordinator(root: &Path, version: u64) -> Arc<SecurityCoordinator> {
1163    static COORDINATORS: std::sync::OnceLock<
1164        Mutex<HashMap<PathBuf, std::sync::Weak<SecurityCoordinator>>>,
1165    > = std::sync::OnceLock::new();
1166
1167    let root = root.canonicalize().unwrap_or_else(|_| root.to_path_buf());
1168    let mut coordinators = COORDINATORS
1169        .get_or_init(|| Mutex::new(HashMap::new()))
1170        .lock();
1171    coordinators.retain(|_, coordinator| coordinator.strong_count() > 0);
1172    if let Some(coordinator) = coordinators.get(&root).and_then(std::sync::Weak::upgrade) {
1173        return coordinator;
1174    }
1175    let coordinator = Arc::new(SecurityCoordinator {
1176        gate: RwLock::new(()),
1177        version: AtomicU64::new(version),
1178    });
1179    coordinators.insert(root, Arc::downgrade(&coordinator));
1180    coordinator
1181}
1182
1183pub fn lock_table_with_context<'a>(
1184    handle: &'a TableHandle,
1185    context: Option<&crate::query::AiExecutionContext>,
1186) -> Result<TableGuard<'a>> {
1187    let Some(context) = context else {
1188        return Ok(handle.lock());
1189    };
1190    loop {
1191        context.checkpoint()?;
1192        let wait = context
1193            .remaining_duration()
1194            .unwrap_or(std::time::Duration::from_millis(5))
1195            .min(std::time::Duration::from_millis(5));
1196        if let Some(guard) = handle.try_lock_for(wait) {
1197            return Ok(guard);
1198        }
1199    }
1200}
1201
1202/// Knobs for [`Database::open_with_options`].
1203///
1204/// All fields default to the same values the convenience
1205/// [`Database::open`] / [`Database::open_encrypted`] / etc. constructors use,
1206/// so `OpenOptions::default()` round-trips the historical behavior exactly.
1207#[derive(Clone, Debug, Default)]
1208pub struct OpenOptions {
1209    /// Maximum time, in milliseconds, to wait for the cross-process database
1210    /// lock (`_meta/.lock`) before failing the open with `MongrelError::Io`.
1211    ///
1212    /// `0` (the default) preserves the historical fail-fast semantics: a
1213    /// single `try_lock_exclusive` call, no retry, no sleep. SQLite-style
1214    /// `busy_timeout` semantics kick in once this is non-zero — the open
1215    /// sleeps with progressively wider backoff (1ms → 10ms → 50ms) until
1216    /// either the lock is acquired or `lock_timeout_ms` elapses, at which
1217    /// point the open returns the same `Io(WouldBlock)` error the fail-fast
1218    /// path would.
1219    ///
1220    /// Only the cross-process lock is affected. Mounted tables, page-cache
1221    /// misses, and WAL appends already serialize through in-process locks
1222    /// that handle their own contention.
1223    pub lock_timeout_ms: u32,
1224}
1225
1226impl OpenOptions {
1227    /// Set [`OpenOptions::lock_timeout_ms`]. `0` keeps the fail-fast default;
1228    /// SQLite-style applications typically pick 1_000 – 5_000ms.
1229    pub fn with_lock_timeout_ms(mut self, ms: u32) -> Self {
1230        self.lock_timeout_ms = ms;
1231        self
1232    }
1233}
1234
1235/// A multi-table database: one catalog, one epoch clock, shared caches, a
1236/// shared WAL, and a live map of name → `Arc<Table>`.
1237pub struct Database {
1238    root: PathBuf,
1239    durable_root: Arc<crate::durable_file::DurableRoot>,
1240    /// Set by `_meta/replica`; user writes are rejected on follower copies.
1241    read_only: bool,
1242    catalog: RwLock<Catalog>,
1243    security_coordinator: Arc<SecurityCoordinator>,
1244    security_catalog_disk_reads: AtomicU64,
1245    rls_cache: Mutex<RlsCache>,
1246    epoch: Arc<EpochAuthority>,
1247    snapshots: Arc<SnapshotRegistry>,
1248    page_cache: Arc<crate::cache::Sharded<crate::cache::PageCache>>,
1249    decoded_cache: Arc<crate::cache::Sharded<crate::cache::DecodedPageCache>>,
1250    commit_lock: Arc<Mutex<()>>,
1251    /// One shared WAL multiplexing every table's records (spec §7.2). Owned
1252    /// behind a `Mutex` so the transaction layer can append + group-sync. Shared
1253    /// (via `Arc`) with every mounted `Table` so single-table `put`/`commit`
1254    /// writes also land in this one WAL (B1 — one WAL per database).
1255    shared_wal: Arc<Mutex<crate::wal::SharedWal>>,
1256    /// Monotonic per-open transaction-id counter. Scoped by `open_generation`
1257    /// in P2.7; here it just needs to be unique within an open. Shared with
1258    /// mounted tables so their auto-commit txn ids never alias cross-table ones.
1259    next_txn_id: Arc<Mutex<u64>>,
1260    tables: RwLock<HashMap<u64, TableHandle>>,
1261    kek: Option<Arc<crate::encryption::Kek>>,
1262    /// Serializes DDL (create/drop table); data commits serialize through
1263    /// `commit_lock` shared via `SharedCtx`.
1264    ddl_lock: Mutex<()>,
1265    meta_dek: Option<[u8; META_DEK_LEN]>,
1266    /// P3.4: when staged bytes per table exceed this, write a uniform-epoch
1267    /// pending run to `_txn/<txn_id>/` instead of streaming Put records (§8.5).
1268    spill_threshold: std::sync::atomic::AtomicU64,
1269    /// P3.1: write-key → commit_epoch for first-committer-wins conflict
1270    /// detection (spec §9.2).
1271    conflicts: crate::txn::ConflictIndex,
1272    /// P3.1: min read_epoch of all in-flight txns, drives conflict-index
1273    /// pruning (spec §9.2, review fix #12).
1274    active_txns: crate::txn::ActiveTxns,
1275    /// P3.2: set on fsync error — all subsequent writes fail fast (spec §9.3e).
1276    /// Shared with mounted tables so a single-table commit also honors poison.
1277    poisoned: Arc<std::sync::atomic::AtomicBool>,
1278    /// P3.2: group-commit coordinator. The sequencer appends under the WAL lock
1279    /// but defers the fsync to one leader here, so concurrent commits share a
1280    /// single fsync (spec §9.3). Shared with mounted tables.
1281    group: Arc<crate::txn::GroupCommit>,
1282    /// P3.6: txn ids currently spilling into `_txn/<id>/`. GC never deletes a
1283    /// live spill's pending dir (review fix #14, spec §6.4).
1284    active_spills: Arc<crate::retention::ActiveSpills>,
1285    /// A write lock captures a consistent bootstrap image; transaction commits
1286    /// hold a read lock across spill preparation, WAL append, and publish.
1287    replication_barrier: parking_lot::RwLock<()>,
1288    /// Number of rotated WAL segments retained for lagging followers.
1289    replication_wal_retention_segments: AtomicUsize,
1290    /// Live immutable run files used by online backups or scored read
1291    /// generations. GC cannot unlink them until every owning guard drops.
1292    backup_pins: Arc<Mutex<HashMap<(u64, u128), usize>>>,
1293    /// Test-only barrier invoked after a transaction writes its spill runs but
1294    /// before the sequencer/publish, so tests can race `gc()` against an
1295    /// in-flight spill. `None` in production.
1296    #[doc(hidden)]
1297    spill_hook: Mutex<Option<Box<dyn Fn() + Send + Sync>>>,
1298    /// Test seam after the security read gate is held and before WAL append.
1299    #[doc(hidden)]
1300    security_commit_hook: Mutex<Option<Box<dyn Fn() + Send + Sync>>>,
1301    /// Test seam after transaction preparation and before catalog generation
1302    /// validation under the commit sequencer.
1303    #[doc(hidden)]
1304    catalog_commit_hook: Mutex<Option<Box<dyn Fn() + Send + Sync>>>,
1305    /// Test seam after a backup boundary is captured and before pinned runs are
1306    /// copied. Lets tests compact+GC the source at the worst possible moment.
1307    #[doc(hidden)]
1308    backup_hook: Mutex<Option<Box<dyn Fn() + Send + Sync>>>,
1309    replication_hook: Mutex<Option<Box<dyn Fn() + Send + Sync>>>,
1310    trigger_recursive: AtomicBool,
1311    trigger_max_depth: AtomicU32,
1312    trigger_max_loop_iterations: AtomicU32,
1313    /// Exclusive cross-process lock held for the database's lifetime to prevent
1314    /// two processes from opening the same directory concurrently.
1315    _lock: Option<DatabaseFileLock>,
1316    /// Lightweight channel for ephemeral SQL NOTIFY messages. Durable row CDC
1317    /// is reconstructed from the WAL by [`Database::change_events_since`].
1318    notify: tokio::sync::broadcast::Sender<ChangeEvent>,
1319    /// Commit-time wake-up for durable CDC consumers. Payloads are reconstructed
1320    /// from the WAL, so lagged receivers lose only a wake-up, never data.
1321    change_wake: tokio::sync::broadcast::Sender<()>,
1322    /// The authenticated principal for this handle. `None` on databases
1323    /// opened without credentials (the default — `require_auth = false`),
1324    /// `Some` on credentialed opens. Consulted by every enforcement point
1325    /// when the catalog's `require_auth` flag is set. Behind an `RwLock`
1326    /// because the access pattern is read-heavy: every `require()` call
1327    /// reads the principal, while writes happen only at open, `enable_auth`,
1328    /// and `refresh_principal`. This matches the engine's existing use of
1329    /// `RwLock` for `catalog` and `tables`.
1330    /// See `docs/15-credential-enforcement.md`.
1331    principal: RwLock<Option<crate::auth::Principal>>,
1332    /// Shared, cloneable handle to the auth state (require_auth flag from the
1333    /// catalog + the principal). Cloned into every mounted `Table` so the
1334    /// Table layer can enforce permissions without holding a reference back
1335    /// to `Database` (which would create a cycle). `AuthState` is already
1336    /// cheaply cloneable (inner `Arc`), so no outer `Arc` is needed.
1337    auth_state: crate::auth_state::AuthState,
1338}
1339
1340struct RunPins {
1341    pins: Arc<Mutex<HashMap<(u64, u128), usize>>>,
1342    runs: Vec<(u64, u128)>,
1343}
1344
1345struct BackupFilePins {
1346    root: PathBuf,
1347}
1348
1349struct PendingTableDir {
1350    path: PathBuf,
1351    armed: bool,
1352}
1353
1354impl PendingTableDir {
1355    fn new(path: PathBuf) -> Self {
1356        Self { path, armed: true }
1357    }
1358
1359    fn disarm(&mut self) {
1360        self.armed = false;
1361    }
1362}
1363
1364impl Drop for PendingTableDir {
1365    fn drop(&mut self) {
1366        if self.armed {
1367            let _ = std::fs::remove_dir_all(&self.path);
1368        }
1369    }
1370}
1371
1372impl Drop for BackupFilePins {
1373    fn drop(&mut self) {
1374        let _ = std::fs::remove_dir_all(&self.root);
1375    }
1376}
1377
1378impl Drop for RunPins {
1379    fn drop(&mut self) {
1380        let mut pins = self.pins.lock();
1381        for run in &self.runs {
1382            if let Some(count) = pins.get_mut(run) {
1383                *count -= 1;
1384                if *count == 0 {
1385                    pins.remove(run);
1386                }
1387            }
1388        }
1389    }
1390}
1391
1392/// A durable data-change event reconstructed from committed WAL records, or an
1393/// ephemeral SQL `NOTIFY` event when `id` is `None`.
1394#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
1395pub struct ChangeEvent {
1396    pub id: Option<String>,
1397    pub channel: String,
1398    pub table_id: Option<u64>,
1399    pub table: String,
1400    pub op: String,
1401    pub epoch: u64,
1402    pub txn_id: Option<u64>,
1403    pub message: Option<String>,
1404    pub data: Option<serde_json::Value>,
1405}
1406
1407#[derive(Debug, Clone)]
1408pub struct CdcBatch {
1409    pub events: Vec<ChangeEvent>,
1410    pub current_epoch: u64,
1411    pub earliest_epoch: Option<u64>,
1412    pub gap: bool,
1413}
1414
1415/// Manual `Debug` for `Database` — surfaces the diagnostics-relevant fields
1416/// (root, epoch, table count, encryption/auth state) without requiring every
1417/// internal type (Table, GroupCommit, broadcast sender, etc.) to impl Debug.
1418/// The raw field types carry locks, trait objects, and channels that have no
1419/// useful `Debug` output, so a hand-written impl is clearer than peppering
1420/// `#[allow(dead_code)]` skip attributes across two dozen fields.
1421impl std::fmt::Debug for Database {
1422    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1423        let cat = self.catalog.read();
1424        let principal_guard = self.principal.read();
1425        let principal: &str = principal_guard
1426            .as_ref()
1427            .map(|p| p.username.as_str())
1428            .unwrap_or("<none>");
1429        f.debug_struct("Database")
1430            .field("root", &self.root)
1431            .field("db_epoch", &cat.db_epoch)
1432            .field("open_generation", &"sidecar")
1433            .field("tables", &cat.tables.len())
1434            .field("visible_epoch", &self.epoch.visible().0)
1435            .field("encrypted", &self.kek.is_some())
1436            .field("require_auth", &cat.require_auth)
1437            .field("principal", &principal)
1438            .finish()
1439    }
1440}
1441
1442impl Database {
1443    fn canonical_lock_target(root: &Path) -> std::io::Result<(PathBuf, PathBuf)> {
1444        if let Ok(canonical) = root.canonicalize() {
1445            let lock_dir = canonical.parent().ok_or_else(|| {
1446                std::io::Error::new(
1447                    std::io::ErrorKind::InvalidInput,
1448                    "database root must have a parent directory",
1449                )
1450            })?;
1451            return Ok((canonical.clone(), lock_dir.to_path_buf()));
1452        }
1453
1454        let absolute = if root.is_absolute() {
1455            root.to_path_buf()
1456        } else {
1457            std::env::current_dir()?.join(root)
1458        };
1459        let mut cursor = absolute.as_path();
1460        let mut suffix = Vec::new();
1461        while !cursor.exists() {
1462            let name = cursor.file_name().ok_or_else(|| {
1463                std::io::Error::new(
1464                    std::io::ErrorKind::NotFound,
1465                    format!("no existing ancestor for database root {}", root.display()),
1466                )
1467            })?;
1468            suffix.push(name.to_os_string());
1469            cursor = cursor.parent().ok_or_else(|| {
1470                std::io::Error::new(
1471                    std::io::ErrorKind::NotFound,
1472                    format!("no existing ancestor for database root {}", root.display()),
1473                )
1474            })?;
1475        }
1476        let lock_dir = cursor.canonicalize()?;
1477        let mut canonical = lock_dir.clone();
1478        for component in suffix.iter().rev() {
1479            canonical.push(component);
1480        }
1481        Ok((canonical, lock_dir))
1482    }
1483
1484    fn acquire_database_lock(root: &Path, timeout_ms: u32) -> Result<DatabaseFileLock> {
1485        use std::hash::{Hash, Hasher};
1486
1487        let (canonical_path, lock_dir) = Self::canonical_lock_target(root)?;
1488        {
1489            let mut locked = process_locked_paths().lock();
1490            if !locked.insert(canonical_path.clone()) {
1491                return Err(MongrelError::DatabaseLocked {
1492                    path: root.to_path_buf(),
1493                    message: "already open in this process".into(),
1494                });
1495            }
1496        }
1497
1498        let mut hasher = std::collections::hash_map::DefaultHasher::new();
1499        canonical_path.hash(&mut hasher);
1500        let lock_path = lock_dir.join(format!(".mongreldb-{:016x}.lock", hasher.finish()));
1501        let file = match std::fs::OpenOptions::new()
1502            .create(true)
1503            .truncate(false)
1504            .write(true)
1505            .open(lock_path)
1506        {
1507            Ok(file) => file,
1508            Err(error) => {
1509                process_locked_paths().lock().remove(&canonical_path);
1510                return Err(error.into());
1511            }
1512        };
1513        if let Err(error) = Self::fs_lock_exclusive(&file, timeout_ms) {
1514            process_locked_paths().lock().remove(&canonical_path);
1515            return Err(MongrelError::DatabaseLocked {
1516                path: root.to_path_buf(),
1517                message: error.to_string(),
1518            });
1519        }
1520        Ok(DatabaseFileLock {
1521            bootstrap_file: file,
1522            legacy_file: None,
1523            canonical_path,
1524            durable_root: None,
1525        })
1526    }
1527
1528    fn acquire_legacy_database_lock(
1529        lock: &mut DatabaseFileLock,
1530        root: &Path,
1531        timeout_ms: u32,
1532    ) -> Result<()> {
1533        let durable_root = lock
1534            .durable_root
1535            .as_ref()
1536            .ok_or_else(|| MongrelError::Other("database root descriptor was not pinned".into()))?;
1537        let file = durable_root.open_lock_file(Path::new(META_DIR).join(".lock"))?;
1538        if let Err(error) = Self::fs_lock_exclusive(&file, timeout_ms) {
1539            return Err(MongrelError::DatabaseLocked {
1540                path: root.to_path_buf(),
1541                message: error.to_string(),
1542            });
1543        }
1544        lock.legacy_file = Some(file);
1545        Ok(())
1546    }
1547
1548    fn pin_or_create_database_root(path: &Path) -> Result<crate::durable_file::DurableRoot> {
1549        if path.exists() {
1550            return crate::durable_file::DurableRoot::open(path).map_err(Into::into);
1551        }
1552        let mut ancestor = path;
1553        while !ancestor.exists() {
1554            ancestor = ancestor.parent().ok_or_else(|| {
1555                MongrelError::NotFound(format!(
1556                    "no existing ancestor for database root {}",
1557                    path.display()
1558                ))
1559            })?;
1560        }
1561        let relative = path.strip_prefix(ancestor).map_err(|error| {
1562            MongrelError::InvalidArgument(format!("invalid database root: {error}"))
1563        })?;
1564        crate::durable_file::DurableRoot::open(ancestor)?
1565            .create_directory_all_pinned(relative)
1566            .map_err(Into::into)
1567    }
1568
1569    fn begin_create(root: impl AsRef<Path>) -> Result<(PathBuf, DatabaseFileLock)> {
1570        let requested_root = root.as_ref();
1571        let mut lock = Self::acquire_database_lock(requested_root, 0)?;
1572        let root = lock.canonical_path.clone();
1573        Self::reject_existing_database(&root)?;
1574        let durable_root = Arc::new(Self::pin_or_create_database_root(&root)?);
1575        if durable_root.canonical_path() != lock.canonical_path {
1576            return Err(MongrelError::Conflict(
1577                "database root changed while it was being created".into(),
1578            ));
1579        }
1580        durable_root.create_directory_all(META_DIR)?;
1581        lock.durable_root = Some(durable_root);
1582        let io_root = lock
1583            .durable_root
1584            .as_ref()
1585            .ok_or_else(|| MongrelError::Other("database root descriptor was not pinned".into()))?
1586            .io_path()?;
1587        Self::acquire_legacy_database_lock(&mut lock, &io_root, 0)?;
1588        Self::reject_existing_database(&io_root)?;
1589        Ok((io_root, lock))
1590    }
1591
1592    fn begin_open(
1593        root: impl AsRef<Path>,
1594        lock_timeout_ms: u32,
1595    ) -> Result<(PathBuf, DatabaseFileLock)> {
1596        let root = root.as_ref();
1597        let durable_root = crate::durable_file::DurableRoot::open(root).map_err(|error| {
1598            if error.kind() == std::io::ErrorKind::NotFound {
1599                MongrelError::NotFound(format!("database root {}: {error}", root.display()))
1600            } else {
1601                error.into()
1602            }
1603        })?;
1604        Self::begin_open_durable(durable_root, lock_timeout_ms)
1605    }
1606
1607    fn begin_open_durable(
1608        durable_root: crate::durable_file::DurableRoot,
1609        lock_timeout_ms: u32,
1610    ) -> Result<(PathBuf, DatabaseFileLock)> {
1611        let io_root = durable_root.io_path()?;
1612        let current_root = io_root.canonicalize()?;
1613        let mut lock = Self::acquire_database_lock(&current_root, lock_timeout_ms)?;
1614        lock.durable_root = Some(Arc::new(durable_root));
1615        let io_root = lock
1616            .durable_root
1617            .as_ref()
1618            .ok_or_else(|| MongrelError::Other("database root descriptor was not pinned".into()))?
1619            .io_path()?;
1620        if lock
1621            .durable_root
1622            .as_ref()
1623            .ok_or_else(|| MongrelError::Other("database root descriptor was not pinned".into()))?
1624            .open_directory(META_DIR)
1625            .is_err()
1626        {
1627            return Err(MongrelError::NotFound(format!(
1628                "no database metadata found at {:?}",
1629                current_root
1630            )));
1631        }
1632        Self::acquire_legacy_database_lock(&mut lock, &io_root, lock_timeout_ms)?;
1633        Ok((io_root, lock))
1634    }
1635
1636    /// Create a fresh plaintext database at `root`.
1637    pub fn create(root: impl AsRef<Path>) -> Result<Self> {
1638        let (root, lock) = Self::begin_create(root)?;
1639        Self::create_inner(root, None, lock)
1640    }
1641
1642    /// Create a fresh encrypted database, deriving the DB-wide KEK from a
1643    /// passphrase (Argon2id + HKDF). The salt is persisted at `_meta/keys`.
1644    #[cfg(feature = "encryption")]
1645    pub fn create_encrypted(root: impl AsRef<Path>, passphrase: &str) -> Result<Self> {
1646        let (root, lock) = Self::begin_create(root)?;
1647        let salt = crate::encryption::random_salt()?;
1648        crate::durable_file::write_atomic(&root.join(META_DIR).join(KEYS_FILENAME), &salt)?;
1649        let kek = Arc::new(crate::encryption::Kek::derive(passphrase, &salt)?);
1650        Self::create_inner(root, Some(kek), lock)
1651    }
1652
1653    /// Create a fresh encrypted database, deriving the DB-wide KEK from a raw
1654    /// high-entropy key via HKDF. The salt is persisted at `_meta/keys`.
1655    #[cfg(feature = "encryption")]
1656    pub fn create_with_key(root: impl AsRef<Path>, key: &[u8]) -> Result<Self> {
1657        let (root, lock) = Self::begin_create(root)?;
1658        let salt = crate::encryption::random_salt()?;
1659        crate::durable_file::write_atomic(&root.join(META_DIR).join(KEYS_FILENAME), &salt)?;
1660        let kek = Arc::new(crate::encryption::Kek::from_raw_key(key, &salt)?);
1661        Self::create_inner(root, Some(kek), lock)
1662    }
1663
1664    fn create_inner(
1665        root: PathBuf,
1666        kek: Option<Arc<crate::encryption::Kek>>,
1667        lock: DatabaseFileLock,
1668    ) -> Result<Self> {
1669        crate::durable_file::create_directory_all(&root.join(TABLES_DIR))?;
1670        let meta_dek = crate::encryption::meta_dek_for(kek.as_deref());
1671        let cat = Catalog::empty();
1672        catalog::write_atomic(&root, &cat, meta_dek.as_ref())?;
1673        Self::finish_open(root, cat, kek, meta_dek, false, None, None, None, lock)
1674    }
1675
1676    /// Open an existing plaintext database.
1677    pub fn open(root: impl AsRef<Path>) -> Result<Self> {
1678        Self::open_inner(root, None, None)
1679    }
1680
1681    /// Open an existing encrypted database with a passphrase.
1682    #[cfg(feature = "encryption")]
1683    pub fn open_encrypted(root: impl AsRef<Path>, passphrase: &str) -> Result<Self> {
1684        let (root, lock) = Self::begin_open(root, 0)?;
1685        let salt = read_encryption_salt(lock.durable_root.as_deref().ok_or_else(|| {
1686            MongrelError::Other("database root descriptor was not pinned".into())
1687        })?)?;
1688        let kek = Arc::new(crate::encryption::Kek::derive(passphrase, &salt)?);
1689        Self::open_inner_locked(root, Some(kek), lock)
1690    }
1691
1692    /// Open an existing encrypted database with a configurable cross-process
1693    /// lock timeout. Mirrors [`open_with_options`](Self::open_with_options).
1694    #[cfg(feature = "encryption")]
1695    pub fn open_encrypted_with_options(
1696        root: impl AsRef<Path>,
1697        passphrase: &str,
1698        options: OpenOptions,
1699    ) -> Result<Self> {
1700        let (root, lock) = Self::begin_open(root, options.lock_timeout_ms)?;
1701        let salt = read_encryption_salt(lock.durable_root.as_deref().ok_or_else(|| {
1702            MongrelError::Other("database root descriptor was not pinned".into())
1703        })?)?;
1704        let kek = Arc::new(crate::encryption::Kek::derive(passphrase, &salt)?);
1705        Self::open_inner_locked(root, Some(kek), lock)
1706    }
1707
1708    /// Open an existing encrypted database using a raw high-entropy key.
1709    #[cfg(feature = "encryption")]
1710    pub fn open_with_key(root: impl AsRef<Path>, key: &[u8]) -> Result<Self> {
1711        let (root, lock) = Self::begin_open(root, 0)?;
1712        let salt = read_encryption_salt(lock.durable_root.as_deref().ok_or_else(|| {
1713            MongrelError::Other("database root descriptor was not pinned".into())
1714        })?)?;
1715        let kek = Arc::new(crate::encryption::Kek::from_raw_key(key, &salt)?);
1716        Self::open_inner_locked(root, Some(kek), lock)
1717    }
1718
1719    /// Open an existing plaintext database that has `require_auth = true`,
1720    /// verifying the supplied credentials up front and caching the resolved
1721    /// [`Principal`] on the returned handle. Every subsequent operation will
1722    /// be checked against that principal.
1723    ///
1724    /// Returns [`MongrelError::AuthNotRequired`] if the database does not have
1725    /// `require_auth` enabled — callers must pick the matching constructor for
1726    /// the database's mode. Returns [`MongrelError::InvalidCredentials`] on a
1727    /// bad username/password.
1728    ///
1729    /// See `docs/15-credential-enforcement.md`.
1730    pub fn open_with_credentials(
1731        root: impl AsRef<Path>,
1732        username: &str,
1733        password: &str,
1734    ) -> Result<Self> {
1735        Self::open_inner_with_credentials(root, None, username, password)
1736    }
1737
1738    /// Open with credentials and a configurable cross-process lock timeout.
1739    /// Mirrors [`open_with_options`](Self::open_with_options) for the
1740    /// credentialed path.
1741    pub fn open_with_credentials_and_options(
1742        root: impl AsRef<Path>,
1743        username: &str,
1744        password: &str,
1745        options: OpenOptions,
1746    ) -> Result<Self> {
1747        Self::open_inner_with_credentials_and_lock_timeout(
1748            root,
1749            None,
1750            username,
1751            password,
1752            options.lock_timeout_ms,
1753        )
1754    }
1755
1756    /// Open an existing encrypted database that has `require_auth = true`,
1757    /// combining the encryption passphrase flow with credential verification.
1758    #[cfg(feature = "encryption")]
1759    pub fn open_encrypted_with_credentials(
1760        root: impl AsRef<Path>,
1761        passphrase: &str,
1762        username: &str,
1763        password: &str,
1764    ) -> Result<Self> {
1765        let (root, lock) = Self::begin_open(root, 0)?;
1766        let salt = read_encryption_salt(lock.durable_root.as_deref().ok_or_else(|| {
1767            MongrelError::Other("database root descriptor was not pinned".into())
1768        })?)?;
1769        let kek = Arc::new(crate::encryption::Kek::derive(passphrase, &salt)?);
1770        Self::open_inner_with_credentials_locked(root, Some(kek), username, password, lock)
1771    }
1772
1773    /// Open an encrypted + credentialed database with a configurable
1774    /// cross-process lock timeout. Mirrors
1775    /// [`open_encrypted_with_options`](Self::open_encrypted_with_options).
1776    #[cfg(feature = "encryption")]
1777    pub fn open_encrypted_with_credentials_and_options(
1778        root: impl AsRef<Path>,
1779        passphrase: &str,
1780        username: &str,
1781        password: &str,
1782        options: OpenOptions,
1783    ) -> Result<Self> {
1784        let (root, lock) = Self::begin_open(root, options.lock_timeout_ms)?;
1785        let salt = read_encryption_salt(lock.durable_root.as_deref().ok_or_else(|| {
1786            MongrelError::Other("database root descriptor was not pinned".into())
1787        })?)?;
1788        let kek = Arc::new(crate::encryption::Kek::derive(passphrase, &salt)?);
1789        Self::open_inner_with_credentials_locked(root, Some(kek), username, password, lock)
1790    }
1791
1792    /// Open an existing database with non-default [`OpenOptions`].
1793    ///
1794    /// Use this when you need cross-process lock retries (`lock_timeout_ms`)
1795    /// rather than the fail-fast default. The other open constructors keep
1796    /// their previous defaults; use their `*_with_options` variants when they
1797    /// need the same timeout behavior.
1798    pub fn open_with_options(root: impl AsRef<Path>, options: OpenOptions) -> Result<Self> {
1799        // No encryption, no auth; encrypted and credentialed paths have their
1800        // own `*_with_options` constructors.
1801        Self::open_inner_with_lock_timeout(root, None, None, options.lock_timeout_ms)
1802    }
1803
1804    fn open_inner_with_lock_timeout(
1805        root: impl AsRef<Path>,
1806        kek: Option<Arc<crate::encryption::Kek>>,
1807        _meta_dek_override: Option<[u8; META_DEK_LEN]>,
1808        lock_timeout_ms: u32,
1809    ) -> Result<Self> {
1810        let (root, lock) = Self::begin_open(root, lock_timeout_ms)?;
1811        Self::open_inner_locked(root, kek, lock)
1812    }
1813
1814    fn open_inner_locked(
1815        root: PathBuf,
1816        kek: Option<Arc<crate::encryption::Kek>>,
1817        lock: DatabaseFileLock,
1818    ) -> Result<Self> {
1819        let meta_dek = crate::encryption::meta_dek_for(kek.as_deref());
1820        let mut cat = catalog::read_durable(
1821            lock.durable_root.as_deref().ok_or_else(|| {
1822                MongrelError::Other("database root descriptor was not pinned".into())
1823            })?,
1824            meta_dek.as_ref(),
1825        )?
1826        .ok_or_else(|| MongrelError::NotFound(format!("no catalog found at {:?}", root)))?;
1827        let recovery_checkpoint = cat.clone();
1828
1829        // CATALOG is only a checkpoint. Authentication must use the
1830        // authoritative catalog after committed WAL DDL/security replay.
1831        let wal_dek = crate::encryption::wal_dek_for(kek.as_deref());
1832        let recovery_records = crate::wal::SharedWal::replay_durable_with_dek(
1833            lock.durable_root.as_deref().ok_or_else(|| {
1834                MongrelError::Other("database root descriptor was not pinned".into())
1835            })?,
1836            wal_dek.as_ref(),
1837        )?;
1838        recover_ddl_from_records(
1839            &root,
1840            Some(lock.durable_root.as_deref().ok_or_else(|| {
1841                MongrelError::Other("database root descriptor was not pinned".into())
1842            })?),
1843            &mut cat,
1844            meta_dek.as_ref(),
1845            false,
1846            None,
1847            &recovery_records,
1848        )?;
1849        Self::finish_open(
1850            root,
1851            cat,
1852            kek,
1853            meta_dek,
1854            true,
1855            Some(recovery_checkpoint),
1856            Some(recovery_records),
1857            None,
1858            lock,
1859        )
1860    }
1861
1862    /// Shared credentialed-open inner: read the catalog, verify the database
1863    /// requires auth, verify the password, resolve the principal, and pass
1864    /// everything to `finish_open` in one shot. This avoids the chicken-and-egg
1865    /// problem where `finish_open`'s fail-closed check (`require_auth &&
1866    /// principal.is_none()`) would fire before a post-open `authenticate()`
1867    /// could supply the principal.
1868    fn open_inner_with_credentials(
1869        root: impl AsRef<Path>,
1870        kek: Option<Arc<crate::encryption::Kek>>,
1871        username: &str,
1872        password: &str,
1873    ) -> Result<Self> {
1874        Self::open_inner_with_credentials_and_lock_timeout(root, kek, username, password, 0)
1875    }
1876
1877    /// Credentialed-open with an explicit cross-process lock timeout. The
1878    /// timeout is opt-in: callers that don't pass `OpenOptions` keep the
1879    /// historical fail-fast behavior via the wrapper above.
1880    fn open_inner_with_credentials_and_lock_timeout(
1881        root: impl AsRef<Path>,
1882        kek: Option<Arc<crate::encryption::Kek>>,
1883        username: &str,
1884        password: &str,
1885        lock_timeout_ms: u32,
1886    ) -> Result<Self> {
1887        let (root, lock) = Self::begin_open(root, lock_timeout_ms)?;
1888        Self::open_inner_with_credentials_locked(root, kek, username, password, lock)
1889    }
1890
1891    fn open_inner_with_credentials_locked(
1892        root: PathBuf,
1893        kek: Option<Arc<crate::encryption::Kek>>,
1894        username: &str,
1895        password: &str,
1896        lock: DatabaseFileLock,
1897    ) -> Result<Self> {
1898        let meta_dek = crate::encryption::meta_dek_for(kek.as_deref());
1899        let mut cat = catalog::read_durable(
1900            lock.durable_root.as_deref().ok_or_else(|| {
1901                MongrelError::Other("database root descriptor was not pinned".into())
1902            })?,
1903            meta_dek.as_ref(),
1904        )?
1905        .ok_or_else(|| MongrelError::NotFound(format!("no catalog found at {:?}", root)))?;
1906        let recovery_checkpoint = cat.clone();
1907
1908        // Never verify against a stale checkpoint. A committed password,
1909        // user, role, or auth-mode change in WAL is authoritative.
1910        let wal_dek = crate::encryption::wal_dek_for(kek.as_deref());
1911        let recovery_records = crate::wal::SharedWal::replay_durable_with_dek(
1912            lock.durable_root.as_deref().ok_or_else(|| {
1913                MongrelError::Other("database root descriptor was not pinned".into())
1914            })?,
1915            wal_dek.as_ref(),
1916        )?;
1917        recover_ddl_from_records(
1918            &root,
1919            Some(lock.durable_root.as_deref().ok_or_else(|| {
1920                MongrelError::Other("database root descriptor was not pinned".into())
1921            })?),
1922            &mut cat,
1923            meta_dek.as_ref(),
1924            false,
1925            None,
1926            &recovery_records,
1927        )?;
1928
1929        // Fail early if the database is not in require_auth mode — the caller
1930        // picked the wrong constructor.
1931        if !cat.require_auth {
1932            return Err(MongrelError::AuthNotRequired);
1933        }
1934
1935        // Verify credentials against the on-disk catalog before constructing
1936        // the full Database handle. This reads users/hashes directly from the
1937        // loaded catalog rather than going through the Database::verify_user
1938        // method (which requires a constructed Database).
1939        let user = cat
1940            .users
1941            .iter()
1942            .find(|u| u.username == username)
1943            .filter(|u| !u.password_hash.is_empty())
1944            .ok_or_else(|| MongrelError::InvalidCredentials {
1945                username: username.to_string(),
1946            })?;
1947        let password_ok = crate::auth::verify_password(password, &user.password_hash)
1948            .map_err(MongrelError::Other)?;
1949        if !password_ok {
1950            return Err(MongrelError::InvalidCredentials {
1951                username: username.to_string(),
1952            });
1953        }
1954
1955        // Resolve the principal from the catalog (roles + permissions).
1956        let principal =
1957            Self::resolve_principal_from_catalog(&cat, &user.username).ok_or_else(|| {
1958                MongrelError::InvalidCredentials {
1959                    username: username.to_string(),
1960                }
1961            })?;
1962
1963        Self::finish_open(
1964            root,
1965            cat,
1966            kek,
1967            meta_dek,
1968            true,
1969            Some(recovery_checkpoint),
1970            Some(recovery_records),
1971            Some(principal),
1972            lock,
1973        )
1974    }
1975
1976    /// Create a fresh plaintext database with `require_auth = true` and a
1977    /// single admin user. The returned handle is already authenticated as
1978    /// that admin — every subsequent operation is checked against the admin
1979    /// principal (which bypasses all permission checks via `is_admin`).
1980    ///
1981    /// This is the bootstrap path: there is no window where the database
1982    /// requires auth but has no users.
1983    ///
1984    /// See `docs/15-credential-enforcement.md`.
1985    pub fn create_with_credentials(
1986        root: impl AsRef<Path>,
1987        admin_username: &str,
1988        admin_password: &str,
1989    ) -> Result<Self> {
1990        let (root, lock) = Self::begin_create(root)?;
1991        Self::create_inner_with_credentials(root, None, admin_username, admin_password, lock)
1992    }
1993
1994    /// Create a fresh encrypted database with `require_auth = true` and a
1995    /// single admin user. Composes encryption-at-rest with credential
1996    /// enforcement.
1997    #[cfg(feature = "encryption")]
1998    pub fn create_encrypted_with_credentials(
1999        root: impl AsRef<Path>,
2000        passphrase: &str,
2001        admin_username: &str,
2002        admin_password: &str,
2003    ) -> Result<Self> {
2004        let (root, lock) = Self::begin_create(root)?;
2005        let salt = crate::encryption::random_salt()?;
2006        crate::durable_file::write_atomic(&root.join(META_DIR).join(KEYS_FILENAME), &salt)?;
2007        let kek = Arc::new(crate::encryption::Kek::derive(passphrase, &salt)?);
2008        Self::create_inner_with_credentials(root, Some(kek), admin_username, admin_password, lock)
2009    }
2010
2011    fn create_inner_with_credentials(
2012        root: PathBuf,
2013        kek: Option<Arc<crate::encryption::Kek>>,
2014        admin_username: &str,
2015        admin_password: &str,
2016        lock: DatabaseFileLock,
2017    ) -> Result<Self> {
2018        crate::durable_file::create_directory_all(&root.join(TABLES_DIR))?;
2019        let meta_dek = crate::encryption::meta_dek_for(kek.as_deref());
2020
2021        // Build the initial catalog with require_auth = true and one admin user.
2022        let password_hash =
2023            crate::auth::hash_password(admin_password).map_err(MongrelError::Other)?;
2024        let mut cat = Catalog::empty();
2025        cat.require_auth = true;
2026        cat.next_user_id = 2;
2027        cat.users.push(crate::auth::UserEntry {
2028            id: 1,
2029            username: admin_username.to_string(),
2030            password_hash,
2031            roles: Vec::new(),
2032            is_admin: true,
2033            created_epoch: 0,
2034        });
2035        catalog::write_atomic(&root, &cat, meta_dek.as_ref())?;
2036
2037        // The handle is constructed already authenticated as the admin user
2038        // it just created — no separate verify step needed.
2039        let admin_principal = crate::auth::Principal {
2040            user_id: 1,
2041            created_epoch: 0,
2042            username: admin_username.to_string(),
2043            is_admin: true,
2044            roles: Vec::new(),
2045            permissions: Vec::new(),
2046        };
2047        Self::finish_open(
2048            root,
2049            cat,
2050            kek,
2051            meta_dek,
2052            false,
2053            None,
2054            None,
2055            Some(admin_principal),
2056            lock,
2057        )
2058    }
2059
2060    fn reject_existing_database(root: &Path) -> Result<()> {
2061        // Refuse to overwrite an existing database. If CATALOG exists, the
2062        // directory already contains a real database; replacing it destroys data.
2063        if root.join(catalog::CATALOG_FILENAME).exists() {
2064            return Err(MongrelError::InvalidArgument(format!(
2065                "database already exists at {}; use Database::open() to open it, \
2066                 or remove the directory first",
2067                root.display()
2068            )));
2069        }
2070        Ok(())
2071    }
2072
2073    fn open_inner(
2074        root: impl AsRef<Path>,
2075        kek: Option<Arc<crate::encryption::Kek>>,
2076        _meta_dek_override: Option<[u8; META_DEK_LEN]>,
2077    ) -> Result<Self> {
2078        Self::open_inner_with_lock_timeout(root, kek, None, 0)
2079    }
2080
2081    /// Internal recovery open for a staging directory explicitly marked as a
2082    /// read-only replica. It bypasses user authentication only so PITR can
2083    /// replay auth-mode and password transitions; it is not public API.
2084    pub(crate) fn open_replica_recovery_durable(
2085        root: &crate::durable_file::DurableRoot,
2086    ) -> Result<Self> {
2087        let (root, lock) = Self::begin_open_durable(root.try_clone()?, 0)?;
2088        Self::open_replica_recovery_inner(root, None, lock)
2089    }
2090
2091    #[cfg(feature = "encryption")]
2092    pub(crate) fn open_encrypted_replica_recovery_durable(
2093        root: &crate::durable_file::DurableRoot,
2094        passphrase: &str,
2095    ) -> Result<Self> {
2096        let (root_path, lock) = Self::begin_open_durable(root.try_clone()?, 0)?;
2097        let salt = read_encryption_salt(root)?;
2098        let kek = Arc::new(crate::encryption::Kek::derive(passphrase, &salt)?);
2099        Self::open_replica_recovery_inner(root_path, Some(kek), lock)
2100    }
2101
2102    fn open_replica_recovery_inner(
2103        root: PathBuf,
2104        kek: Option<Arc<crate::encryption::Kek>>,
2105        lock: DatabaseFileLock,
2106    ) -> Result<Self> {
2107        if !root.join(META_DIR).join("replica").is_file() {
2108            return Err(MongrelError::InvalidArgument(
2109                "recovery auth bypass requires a marked replica staging directory".into(),
2110            ));
2111        }
2112        let meta_dek = crate::encryption::meta_dek_for(kek.as_deref());
2113        let mut cat = catalog::read_durable(
2114            lock.durable_root.as_deref().ok_or_else(|| {
2115                MongrelError::Other("database root descriptor was not pinned".into())
2116            })?,
2117            meta_dek.as_ref(),
2118        )?
2119        .ok_or_else(|| MongrelError::NotFound(format!("no catalog found at {:?}", root)))?;
2120        let recovery_checkpoint = cat.clone();
2121        let wal_dek = crate::encryption::wal_dek_for(kek.as_deref());
2122        let recovery_records = crate::wal::SharedWal::replay_durable_with_dek(
2123            lock.durable_root.as_deref().ok_or_else(|| {
2124                MongrelError::Other("database root descriptor was not pinned".into())
2125            })?,
2126            wal_dek.as_ref(),
2127        )?;
2128        recover_ddl_from_records(
2129            &root,
2130            Some(lock.durable_root.as_deref().ok_or_else(|| {
2131                MongrelError::Other("database root descriptor was not pinned".into())
2132            })?),
2133            &mut cat,
2134            meta_dek.as_ref(),
2135            false,
2136            None,
2137            &recovery_records,
2138        )?;
2139        let principal = if cat.require_auth {
2140            cat.users
2141                .iter()
2142                .find(|user| user.is_admin)
2143                .and_then(|user| Self::resolve_principal_from_catalog(&cat, &user.username))
2144                .ok_or_else(|| {
2145                    MongrelError::Schema(
2146                        "authenticated replica catalog has no recoverable admin".into(),
2147                    )
2148                })?
2149                .into()
2150        } else {
2151            None
2152        };
2153        Self::finish_open(
2154            root,
2155            cat,
2156            kek,
2157            meta_dek,
2158            true,
2159            Some(recovery_checkpoint),
2160            Some(recovery_records),
2161            principal,
2162            lock,
2163        )
2164    }
2165
2166    /// Acquire an exclusive advisory lock on `f`, retrying on `EAGAIN`/`EWOULDBLOCK`
2167    /// until `timeout_ms` elapses, mirroring SQLite's `busy_timeout` semantics.
2168    ///
2169    /// `timeout_ms == 0` is the fail-fast path: a single `try_lock_exclusive` call,
2170    /// no retry, no sleep. Existing open paths rely on that fail-fast default for
2171    /// backwards compatibility — opt in with `OpenOptions::lock_timeout_ms`.
2172    ///
2173    /// Backoff schedule: 1ms → 10ms → 50ms → 50ms → ... until `timeout_ms`.
2174    /// Total elapsed (not just sleep time) is bounded by `timeout_ms`, so the
2175    /// caller never blocks past its budget even at the tail of a busy lock
2176    /// holder's lock-window.
2177    fn fs_lock_exclusive(f: &std::fs::File, timeout_ms: u32) -> std::io::Result<()> {
2178        use fs2::FileExt;
2179        if timeout_ms == 0 {
2180            return f.try_lock_exclusive();
2181        }
2182        // Per-call deadline so a single stray 50ms sleep can't overshoot the budget.
2183        let deadline =
2184            std::time::Instant::now() + std::time::Duration::from_millis(timeout_ms as u64);
2185        let mut next_sleep = std::time::Duration::from_millis(1);
2186        loop {
2187            match f.try_lock_exclusive() {
2188                Ok(()) => return Ok(()),
2189                Err(e) if e.kind() == std::io::ErrorKind::WouldBlock => {
2190                    let now = std::time::Instant::now();
2191                    if now >= deadline {
2192                        return Err(std::io::Error::new(
2193                            std::io::ErrorKind::WouldBlock,
2194                            format!("could not acquire database lock within {timeout_ms}ms"),
2195                        ));
2196                    }
2197                    let remaining = deadline - now;
2198                    let sleep = next_sleep.min(remaining);
2199                    std::thread::sleep(sleep);
2200                    // Cap the per-iteration sleep so a single back-off step
2201                    // never overshoots the remaining budget.
2202                    next_sleep = next_sleep
2203                        .saturating_mul(10)
2204                        .min(std::time::Duration::from_millis(50));
2205                }
2206                Err(e) => return Err(e),
2207            }
2208        }
2209    }
2210
2211    #[allow(clippy::too_many_arguments)]
2212    fn finish_open(
2213        root: PathBuf,
2214        cat: Catalog,
2215        kek: Option<Arc<crate::encryption::Kek>>,
2216        meta_dek: Option<[u8; META_DEK_LEN]>,
2217        existing: bool,
2218        recovery_checkpoint: Option<Catalog>,
2219        recovery_records: Option<Vec<crate::wal::Record>>,
2220        principal: Option<crate::auth::Principal>,
2221        lock: DatabaseFileLock,
2222    ) -> Result<Self> {
2223        let durable_root = Arc::clone(lock.durable_root.as_ref().ok_or_else(|| {
2224            MongrelError::Other("database root descriptor was not pinned".into())
2225        })?);
2226        let read_only = if existing {
2227            match durable_root.open_regular(Path::new(META_DIR).join("replica")) {
2228                Ok(_) => true,
2229                Err(error) if error.kind() == std::io::ErrorKind::NotFound => false,
2230                Err(error) => return Err(error.into()),
2231            }
2232        } else {
2233            false
2234        };
2235        let recovered_catalog = cat;
2236        let mut cat = recovered_catalog.clone();
2237        let abandoned = if existing && !read_only {
2238            let abandoned = cat
2239                .tables
2240                .iter()
2241                .filter(|entry| matches!(entry.state, TableState::Building { .. }))
2242                .map(|entry| entry.table_id)
2243                .collect::<Vec<_>>();
2244            for entry in &mut cat.tables {
2245                if abandoned.contains(&entry.table_id) {
2246                    entry.state = TableState::Dropped {
2247                        at_epoch: cat.db_epoch,
2248                    };
2249                }
2250            }
2251            abandoned
2252        } else {
2253            Vec::new()
2254        };
2255        let wal_dek = crate::encryption::wal_dek_for(kek.as_deref());
2256        let recovery_records = match (existing, recovery_records) {
2257            (true, Some(records)) => records,
2258            (true, None) => {
2259                return Err(MongrelError::Other(
2260                    "existing open has no validated WAL recovery plan".into(),
2261                ))
2262            }
2263            (false, _) => Vec::new(),
2264        };
2265        let (history_epochs, history_start) =
2266            read_history_retention(&durable_root, Epoch(cat.db_epoch))?;
2267        let open_generation = if existing {
2268            let checkpoint = recovery_checkpoint.as_ref().ok_or_else(|| {
2269                MongrelError::Other("existing open has no catalog recovery checkpoint".into())
2270            })?;
2271            let recovered_table_ids = cat
2272                .tables
2273                .iter()
2274                .filter(|entry| {
2275                    checkpoint
2276                        .tables
2277                        .iter()
2278                        .all(|checkpoint| checkpoint.table_id != entry.table_id)
2279                })
2280                .map(|entry| entry.table_id)
2281                .collect::<HashSet<_>>();
2282            let reconciled_table_ids = cat
2283                .tables
2284                .iter()
2285                .filter(|entry| {
2286                    checkpoint
2287                        .tables
2288                        .iter()
2289                        .find(|checkpoint| checkpoint.table_id == entry.table_id)
2290                        .is_some_and(|checkpoint| {
2291                            crate::wal::DdlOp::encode_schema(&checkpoint.schema).ok()
2292                                != crate::wal::DdlOp::encode_schema(&entry.schema).ok()
2293                        })
2294                })
2295                .map(|entry| entry.table_id)
2296                .collect::<HashSet<_>>();
2297            validate_shared_wal_recovery_plan(
2298                &durable_root,
2299                &cat,
2300                &recovered_table_ids,
2301                &reconciled_table_ids,
2302                meta_dek.as_ref(),
2303                kek.clone(),
2304                &recovery_records,
2305            )?;
2306            let retained_generation = recovery_records
2307                .iter()
2308                .filter(|record| record.txn_id != crate::wal::SYSTEM_TXN_ID)
2309                .map(|record| record.txn_id >> 32)
2310                .max()
2311                .unwrap_or(0);
2312            let head_generation =
2313                crate::wal::SharedWal::durable_open_generation(&durable_root, wal_dek.as_ref())?;
2314            let durable_floor = match head_generation {
2315                Some(head) if retained_generation > head => {
2316                    return Err(MongrelError::CorruptWal {
2317                        offset: retained_generation,
2318                        reason: format!(
2319                            "retained transaction generation {retained_generation} exceeds WAL head generation {head}"
2320                        ),
2321                    })
2322                }
2323                Some(head) => head,
2324                None => retained_generation,
2325            };
2326            let stored = catalog::read_generation(&durable_root)?;
2327            if stored.is_some_and(|generation| generation < durable_floor) {
2328                return Err(MongrelError::Other(format!(
2329                    "open-generation {stored:?} precedes durable WAL generation {durable_floor}"
2330                )));
2331            }
2332            let bumped = stored
2333                .unwrap_or(durable_floor)
2334                .max(durable_floor)
2335                .checked_add(1)
2336                .ok_or_else(|| MongrelError::Full("open-generation namespace exhausted".into()))?;
2337            if bumped > u32::MAX as u64 {
2338                return Err(MongrelError::Full(
2339                    "open-generation namespace exhausted".into(),
2340                ));
2341            }
2342            bumped
2343        } else {
2344            0
2345        };
2346        let principal = if cat.require_auth {
2347            let supplied = principal.as_ref().ok_or(MongrelError::AuthRequired)?;
2348            Some(
2349                Self::resolve_bound_principal_from_catalog(&cat, supplied)
2350                    .ok_or(MongrelError::AuthRequired)?,
2351            )
2352        } else {
2353            principal
2354        };
2355        let mut table_roots = HashMap::<u64, Arc<crate::durable_file::DurableRoot>>::new();
2356        if existing {
2357            for entry in &cat.tables {
2358                if !matches!(entry.state, TableState::Live) {
2359                    continue;
2360                }
2361                match durable_root
2362                    .open_directory(Path::new(TABLES_DIR).join(entry.table_id.to_string()))
2363                {
2364                    Ok(root) => {
2365                        table_roots.insert(entry.table_id, Arc::new(root));
2366                    }
2367                    Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
2368                    Err(error) => return Err(error.into()),
2369                }
2370            }
2371        }
2372
2373        // No database-tree mutation occurs above this point. DDL, row payloads,
2374        // immutable runs, auth state, retention, and generation state have all
2375        // been validated against the authoritative recovered catalog.
2376        if existing {
2377            let mut applied = recovery_checkpoint.ok_or_else(|| {
2378                MongrelError::Other("existing open has no catalog recovery checkpoint".into())
2379            })?;
2380            recover_ddl_from_records(
2381                &root,
2382                Some(&durable_root),
2383                &mut applied,
2384                meta_dek.as_ref(),
2385                true,
2386                Some(&table_roots),
2387                &recovery_records,
2388            )?;
2389            let catalog_value = |catalog: &Catalog| {
2390                serde_json::to_value(catalog)
2391                    .map_err(|error| MongrelError::Other(format!("catalog compare: {error}")))
2392            };
2393            if catalog_value(&applied)? != catalog_value(&recovered_catalog)? {
2394                return Err(MongrelError::CorruptWal {
2395                    offset: 0,
2396                    reason: "validated and applied DDL recovery plans differ".into(),
2397                });
2398            }
2399            if catalog_value(&cat)? != catalog_value(&applied)? {
2400                catalog::write_atomic(&root, &cat, meta_dek.as_ref())?;
2401            }
2402            validate_catalog_table_storage(&durable_root, &cat, meta_dek.as_ref())?;
2403            if !read_only {
2404                sweep_unreferenced_table_dirs(&root, &cat)?;
2405            }
2406            match durable_root.remove_directory_all(Path::new(META_DIR).join("backup-pins")) {
2407                Ok(()) => {}
2408                Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
2409                Err(error) => return Err(error.into()),
2410            }
2411        }
2412
2413        let epoch = Arc::new(EpochAuthority::new(cat.db_epoch));
2414        let snapshots = Arc::new(SnapshotRegistry::new());
2415        snapshots.configure_history(history_epochs, history_start);
2416        let page_cache = Arc::new(crate::cache::Sharded::new(
2417            crate::cache::CACHE_SHARDS,
2418            || {
2419                crate::cache::PageCache::new(
2420                    crate::engine::PAGE_CACHE_CAPACITY / crate::cache::CACHE_SHARDS as u64,
2421                )
2422            },
2423        ));
2424        let decoded_cache = Arc::new(crate::cache::Sharded::new(
2425            crate::cache::CACHE_SHARDS,
2426            || {
2427                crate::cache::DecodedPageCache::new(
2428                    crate::engine::DECODED_CACHE_CAPACITY / crate::cache::CACHE_SHARDS as u64,
2429                )
2430            },
2431        ));
2432        let commit_lock = Arc::new(Mutex::new(()));
2433        let shared_wal = Arc::new(Mutex::new(if existing {
2434            crate::wal::SharedWal::open_durable_root_validated(
2435                Arc::clone(&durable_root),
2436                Epoch(cat.db_epoch),
2437                wal_dek.clone(),
2438                Some(&recovery_records),
2439            )?
2440        } else {
2441            crate::wal::SharedWal::create_with_durable_root(
2442                Arc::clone(&durable_root),
2443                Epoch(cat.db_epoch),
2444                wal_dek.clone(),
2445            )?
2446        }));
2447        // Shared write-path state handed to every mounted table so single-table
2448        // `put`/`commit` writes route through the one shared WAL, the one group-
2449        // commit coordinator, and the one poison flag (B1).
2450        let poisoned = Arc::new(std::sync::atomic::AtomicBool::new(false));
2451        let group = Arc::new(crate::txn::GroupCommit::new(
2452            shared_wal.lock().durable_seq(),
2453        ));
2454        let (change_wake, _change_rx) = tokio::sync::broadcast::channel(256);
2455        // Final base value is set after the open-generation bump below; tables
2456        // only draw ids once the user issues a write (post-open), so the
2457        // placeholder is never observed.
2458        let txn_ids = Arc::new(Mutex::new(1u64));
2459        let _ = abandoned;
2460
2461        // Build the shared auth state early — it's cloned into every mounted
2462        // Table's SharedCtx so the Table layer can enforce permissions without
2463        // a reference back to Database. The `require_auth` flag is mirrored
2464        // from the catalog; `enable_auth` / `refresh_principal` update it live.
2465        let auth_state = crate::auth_state::AuthState::new(cat.require_auth, principal.clone());
2466        let security_coordinator = security_coordinator(&root, cat.security_version);
2467        let auth_checker: Option<Arc<dyn crate::auth_state::TableAuthChecker>> = Some(Arc::new(
2468            crate::auth_state::DefaultTableAuthChecker::new(auth_state.clone()),
2469        ));
2470
2471        // Open every live table against the shared context. Mounted tables have
2472        // no private WAL (B1) — `open_in` just loads the manifest/runs and
2473        // advances the shared epoch authority to its manifest epoch, so the
2474        // final shared watermark is the max across all tables. All of a mounted
2475        // table's committed records are replayed below from the shared WAL.
2476        let mut tables: HashMap<u64, TableHandle> = HashMap::new();
2477        for entry in &cat.tables {
2478            if !matches!(entry.state, TableState::Live) {
2479                continue;
2480            }
2481            let table_root = match table_roots.remove(&entry.table_id) {
2482                Some(root) => root,
2483                None => Arc::new(
2484                    durable_root
2485                        .open_directory(Path::new(TABLES_DIR).join(entry.table_id.to_string()))?,
2486                ),
2487            };
2488            let tdir = table_root.io_path()?;
2489            let ctx = SharedCtx {
2490                root_guard: Some(table_root),
2491                epoch: Arc::clone(&epoch),
2492                page_cache: Arc::clone(&page_cache),
2493                decoded_cache: Arc::clone(&decoded_cache),
2494                snapshots: Arc::clone(&snapshots),
2495                kek: kek.clone(),
2496                commit_lock: Arc::clone(&commit_lock),
2497                shared: Some(crate::engine::SharedWalCtx {
2498                    wal: Arc::clone(&shared_wal),
2499                    group: Arc::clone(&group),
2500                    poisoned: Arc::clone(&poisoned),
2501                    txn_ids: Arc::clone(&txn_ids),
2502                    change_wake: change_wake.clone(),
2503                }),
2504                table_name: Some(entry.name.clone()),
2505                auth: auth_checker.clone(),
2506                read_only,
2507            };
2508            let t = Table::open_in(&tdir, ctx)?;
2509            tables.insert(entry.table_id, TableHandle::new(t));
2510        }
2511
2512        // Recover transaction writes from the shared WAL (spec §15). This is the
2513        // single durability source for mounted tables: it applies every committed
2514        // record — both single-table `Table::commit` writes and cross-table
2515        // transactions — gated by each table's `flushed_epoch` (records already
2516        // durable in a run are not re-applied).
2517        if existing {
2518            recover_shared_wal(&durable_root, &tables, &cat, &epoch, &recovery_records)?;
2519            reconcile_recovered_table_metadata(&tables, epoch.visible())?;
2520            if read_only {
2521                crate::replication::reconcile_replica_epoch_durable(
2522                    &durable_root,
2523                    epoch.visible().0,
2524                )?;
2525            }
2526            // P3.4: sweep stale `_txn/<txn_id>/` dirs left by aborted/crashed
2527            // large transactions (spec §8.5, review fix #14).
2528            sweep_pending_txn_dirs(&root, &cat);
2529        }
2530
2531        // Persist only after all semantic recovery and table mounting succeeds.
2532        catalog::write_generation(&durable_root, open_generation)?;
2533        shared_wal.lock().seal_open_generation(open_generation)?;
2534        crate::replication::replication_identity_durable(&durable_root)?;
2535        let next_txn_id = (open_generation << 32) | 1;
2536        // Seed the shared txn-id allocator now that the generation is final.
2537        *txn_ids.lock() = next_txn_id;
2538
2539        Ok(Self {
2540            root,
2541            durable_root,
2542            read_only,
2543            catalog: RwLock::new(cat),
2544            security_coordinator,
2545            security_catalog_disk_reads: AtomicU64::new(0),
2546            rls_cache: Mutex::new(RlsCache::default()),
2547            epoch,
2548            snapshots,
2549            page_cache,
2550            decoded_cache,
2551            commit_lock,
2552            shared_wal,
2553            next_txn_id: txn_ids,
2554            tables: RwLock::new(tables),
2555            kek,
2556            ddl_lock: Mutex::new(()),
2557            meta_dek,
2558            conflicts: crate::txn::ConflictIndex::new(),
2559            active_txns: crate::txn::ActiveTxns::new(),
2560            poisoned,
2561            group,
2562            spill_threshold: std::sync::atomic::AtomicU64::new(64 * 1024 * 1024),
2563            active_spills: Arc::new(crate::retention::ActiveSpills::new()),
2564            replication_barrier: parking_lot::RwLock::new(()),
2565            replication_wal_retention_segments: AtomicUsize::new(0),
2566            backup_pins: Arc::new(Mutex::new(HashMap::new())),
2567            spill_hook: Mutex::new(None),
2568            security_commit_hook: Mutex::new(None),
2569            catalog_commit_hook: Mutex::new(None),
2570            backup_hook: Mutex::new(None),
2571            replication_hook: Mutex::new(None),
2572            trigger_recursive: AtomicBool::new(TriggerConfig::default().recursive_triggers),
2573            trigger_max_depth: AtomicU32::new(TriggerConfig::default().max_depth),
2574            trigger_max_loop_iterations: AtomicU32::new(
2575                TriggerConfig::default().max_loop_iterations,
2576            ),
2577            _lock: Some(lock),
2578            notify: {
2579                let (tx, _rx) = tokio::sync::broadcast::channel(256);
2580                tx
2581            },
2582            change_wake,
2583            principal: RwLock::new(principal),
2584            auth_state,
2585        })
2586    }
2587
2588    /// The current reader-visible epoch.
2589    pub fn visible_epoch(&self) -> Epoch {
2590        self.epoch.visible()
2591    }
2592
2593    /// Clone the in-memory catalog (for diagnostics / tests).
2594    pub fn catalog_snapshot(&self) -> Catalog {
2595        self.catalog.read().clone()
2596    }
2597
2598    /// Read SQLite-compatible application metadata persisted in the catalog.
2599    pub fn sql_pragma_i64(&self, key: &str) -> Result<Option<i64>> {
2600        let catalog = self.catalog.read();
2601        match key {
2602            "user_version" => Ok(catalog.user_version),
2603            "application_id" => Ok(catalog.application_id),
2604            _ => Err(MongrelError::InvalidArgument(format!(
2605                "unsupported persistent SQL pragma {key:?}"
2606            ))),
2607        }
2608    }
2609
2610    /// Persist SQLite-compatible application metadata and return its exact
2611    /// publication epoch. An unchanged value performs no durable write.
2612    pub fn set_sql_pragma_i64_with_epoch(&self, key: &str, value: i64) -> Result<Option<Epoch>> {
2613        self.set_sql_pragma_i64_with_epoch_inner(key, value, None)
2614    }
2615
2616    pub fn set_sql_pragma_i64_with_epoch_controlled<F>(
2617        &self,
2618        key: &str,
2619        value: i64,
2620        mut before_commit: F,
2621    ) -> Result<Option<Epoch>>
2622    where
2623        F: FnMut() -> Result<()>,
2624    {
2625        self.set_sql_pragma_i64_with_epoch_inner(key, value, Some(&mut before_commit))
2626    }
2627
2628    fn set_sql_pragma_i64_with_epoch_inner(
2629        &self,
2630        key: &str,
2631        value: i64,
2632        before_commit: Option<&mut dyn FnMut() -> Result<()>>,
2633    ) -> Result<Option<Epoch>> {
2634        use crate::wal::DdlOp;
2635
2636        self.require(&crate::auth::Permission::Ddl)?;
2637        if self.read_only {
2638            return Err(MongrelError::ReadOnlyReplica);
2639        }
2640        if self.poisoned.load(Ordering::Relaxed) {
2641            return Err(MongrelError::Other(
2642                "database poisoned by fsync error".into(),
2643            ));
2644        }
2645        let _ddl = self.ddl_lock.lock();
2646        let _security_write = self.security_write()?;
2647        self.require(&crate::auth::Permission::Ddl)?;
2648        let mut next_catalog = self.catalog.read().clone();
2649        let target = match key {
2650            "user_version" => &mut next_catalog.user_version,
2651            "application_id" => &mut next_catalog.application_id,
2652            _ => {
2653                return Err(MongrelError::InvalidArgument(format!(
2654                    "unsupported persistent SQL pragma {key:?}"
2655                )))
2656            }
2657        };
2658        if *target == Some(value) {
2659            return Ok(None);
2660        }
2661        *target = Some(value);
2662
2663        let _commit = self.commit_lock.lock();
2664        let epoch = self.epoch.bump_assigned();
2665        let mut epoch_guard = EpochGuard::new(self.epoch.as_ref(), epoch);
2666        let txn_id = self.alloc_txn_id()?;
2667        next_catalog.db_epoch = next_catalog.db_epoch.max(epoch.0);
2668        let commit_seq = {
2669            let mut wal = self.shared_wal.lock();
2670            if let Some(before_commit) = before_commit {
2671                before_commit()?;
2672            }
2673            let append: Result<u64> = (|| {
2674                wal.append(
2675                    txn_id,
2676                    WAL_TABLE_ID,
2677                    crate::wal::Op::Ddl(DdlOp::SetSqlPragma {
2678                        key: key.to_string(),
2679                        value,
2680                    }),
2681                )?;
2682                append_catalog_snapshot(&mut wal, txn_id, &next_catalog)?;
2683                wal.append_commit(txn_id, epoch, &[])
2684            })();
2685            append.map_err(|error| self.commit_outcome_unknown(epoch, error))?
2686        };
2687        self.await_durable_commit(commit_seq, epoch)?;
2688        let checkpoint = self.checkpoint_catalog_after_durable(next_catalog);
2689        self.finish_durable_publish(epoch, &mut epoch_guard, checkpoint)?;
2690        Ok(Some(epoch))
2691    }
2692
2693    pub fn materialized_view(&self, name: &str) -> Option<crate::catalog::MaterializedViewEntry> {
2694        self.catalog
2695            .read()
2696            .materialized_views
2697            .iter()
2698            .find(|definition| definition.name == name)
2699            .cloned()
2700    }
2701
2702    pub fn materialized_views(&self) -> Vec<crate::catalog::MaterializedViewEntry> {
2703        self.catalog.read().materialized_views.clone()
2704    }
2705
2706    pub fn security_catalog(&self) -> crate::security::SecurityCatalog {
2707        self.catalog.read().security.clone()
2708    }
2709
2710    pub fn security_active_for(&self, table: &str) -> bool {
2711        self.catalog.read().security.table_has_security(table)
2712    }
2713
2714    fn refresh_security_catalog_if_stale(&self, expected_version: u64) -> Result<()> {
2715        if self.catalog.read().security_version == expected_version {
2716            return Ok(());
2717        }
2718        self.security_catalog_disk_reads
2719            .fetch_add(1, Ordering::Relaxed);
2720        let fresh = catalog::read_durable(&self.durable_root, self.meta_dek.as_ref())?
2721            .ok_or_else(|| MongrelError::NotFound("catalog vanished during write".into()))?;
2722        let principal = self.principal.read().clone();
2723        let principal = if fresh.require_auth {
2724            principal
2725                .as_ref()
2726                .and_then(|principal| Self::resolve_bound_principal_from_catalog(&fresh, principal))
2727        } else {
2728            principal
2729        };
2730        self.auth_state.set_require_auth(fresh.require_auth);
2731        *self.catalog.write() = fresh;
2732        *self.principal.write() = principal.clone();
2733        self.auth_state.set_principal(principal);
2734        Ok(())
2735    }
2736
2737    fn security_write(&self) -> Result<parking_lot::RwLockWriteGuard<'_, ()>> {
2738        let guard = self.security_coordinator.gate.write();
2739        let version = self.security_coordinator.version.load(Ordering::Acquire);
2740        self.refresh_security_catalog_if_stale(version)?;
2741        Ok(guard)
2742    }
2743
2744    /// Commit an exact catalog image through the shared WAL, then checkpoint it.
2745    /// The WAL image is the authoritative PITR and replication delta; CATALOG is
2746    /// only its restart checkpoint.
2747    fn publish_catalog_candidate(
2748        &self,
2749        catalog: Catalog,
2750        epoch: Epoch,
2751        epoch_guard: &mut EpochGuard<'_>,
2752        before_publish: Option<&mut dyn FnMut() -> Result<()>>,
2753    ) -> Result<()> {
2754        self.publish_catalog_candidate_with_prelude(
2755            catalog,
2756            epoch,
2757            epoch_guard,
2758            before_publish,
2759            Vec::new(),
2760        )
2761    }
2762
2763    fn publish_catalog_candidate_with_prelude(
2764        &self,
2765        catalog: Catalog,
2766        epoch: Epoch,
2767        epoch_guard: &mut EpochGuard<'_>,
2768        mut before_publish: Option<&mut dyn FnMut() -> Result<()>>,
2769        prelude: Vec<(u64, crate::wal::Op)>,
2770    ) -> Result<()> {
2771        use crate::wal::DdlOp;
2772
2773        if self.read_only {
2774            return Err(MongrelError::ReadOnlyReplica);
2775        }
2776        if self.poisoned.load(Ordering::Relaxed) {
2777            return Err(MongrelError::Other(
2778                "database poisoned by fsync error".into(),
2779            ));
2780        }
2781        if let Some(before_publish) = before_publish.as_mut() {
2782            (**before_publish)()?;
2783        }
2784        if catalog.db_epoch != epoch.0 {
2785            return Err(MongrelError::InvalidArgument(format!(
2786                "catalog epoch {} does not match commit epoch {}",
2787                catalog.db_epoch, epoch.0
2788            )));
2789        }
2790        {
2791            let current = self.catalog.read();
2792            validate_catalog_transition(&current, &catalog)?;
2793        }
2794        validate_recovered_catalog(&catalog)?;
2795        let catalog_json = DdlOp::encode_catalog(&catalog)?;
2796        let txn_id = self.alloc_txn_id()?;
2797        let commit_seq = {
2798            let mut wal = self.shared_wal.lock();
2799            let append: Result<u64> = (|| {
2800                for (table_id, op) in prelude {
2801                    wal.append(txn_id, table_id, op)?;
2802                }
2803                wal.append(
2804                    txn_id,
2805                    WAL_TABLE_ID,
2806                    crate::wal::Op::Ddl(DdlOp::CatalogSnapshot { catalog_json }),
2807                )?;
2808                wal.append_commit(txn_id, epoch, &[])
2809            })();
2810            append.map_err(|error| self.commit_outcome_unknown(epoch, error))?
2811        };
2812        self.await_durable_commit(commit_seq, epoch)?;
2813        let checkpoint = self.checkpoint_catalog_after_durable(catalog);
2814        self.finish_durable_publish(epoch, epoch_guard, checkpoint)
2815    }
2816
2817    /// A WAL commit is already durable. Publish the matching catalog in memory
2818    /// even when its checkpoint rewrite fails; recovery can rebuild the file,
2819    /// while the live handle must never continue with pre-commit metadata.
2820    fn checkpoint_catalog_after_durable(&self, catalog: Catalog) -> Result<()> {
2821        let checkpoint = catalog::write_atomic(&self.root, &catalog, self.meta_dek.as_ref());
2822        let version = catalog.security_version;
2823        let principal = self.principal.read().clone();
2824        let principal = if catalog.require_auth {
2825            principal.as_ref().and_then(|principal| {
2826                Self::resolve_bound_principal_from_catalog(&catalog, principal)
2827            })
2828        } else {
2829            principal
2830        };
2831        *self.catalog.write() = catalog;
2832        self.security_coordinator
2833            .version
2834            .store(version, Ordering::Release);
2835        self.auth_state
2836            .set_require_auth(self.catalog.read().require_auth);
2837        *self.principal.write() = principal.clone();
2838        self.auth_state.set_principal(principal);
2839        checkpoint
2840    }
2841
2842    fn finish_durable_publish(
2843        &self,
2844        epoch: Epoch,
2845        epoch_guard: &mut EpochGuard<'_>,
2846        post_step: Result<()>,
2847    ) -> Result<()> {
2848        self.epoch.publish_in_order(epoch);
2849        epoch_guard.disarm();
2850        match post_step {
2851            Ok(()) => Ok(()),
2852            Err(error) => {
2853                self.poisoned.store(true, Ordering::Relaxed);
2854                Err(MongrelError::DurableCommit {
2855                    epoch: epoch.0,
2856                    message: error.to_string(),
2857                })
2858            }
2859        }
2860    }
2861
2862    /// Wait for a commit marker to reach stable storage. A failed append/fsync
2863    /// acknowledgement is ambiguous, so poison the live handle and preserve
2864    /// the assigned epoch in a structured unknown-outcome error.
2865    fn await_durable_commit(&self, commit_seq: u64, epoch: Epoch) -> Result<()> {
2866        match self.group.await_durable(&self.shared_wal, commit_seq) {
2867            Ok(()) => Ok(()),
2868            Err(error) => {
2869                self.poisoned.store(true, Ordering::Relaxed);
2870                Err(MongrelError::CommitOutcomeUnknown {
2871                    epoch: epoch.0,
2872                    message: error.to_string(),
2873                })
2874            }
2875        }
2876    }
2877
2878    fn commit_outcome_unknown(&self, epoch: Epoch, error: impl std::fmt::Display) -> MongrelError {
2879        self.poisoned.store(true, Ordering::Relaxed);
2880        MongrelError::CommitOutcomeUnknown {
2881            epoch: epoch.0,
2882            message: error.to_string(),
2883        }
2884    }
2885
2886    /// Persist a complete validated RLS/masking catalog through the WAL.
2887    pub fn set_security_catalog(&self, security: crate::security::SecurityCatalog) -> Result<()> {
2888        self.set_security_catalog_as_with_epoch(security, None)
2889            .map(|_| ())
2890    }
2891
2892    /// Persist security policy changes on behalf of an explicit request principal.
2893    pub fn set_security_catalog_as(
2894        &self,
2895        security: crate::security::SecurityCatalog,
2896        principal: Option<&crate::auth::Principal>,
2897    ) -> Result<()> {
2898        self.set_security_catalog_as_with_epoch(security, principal)
2899            .map(|_| ())
2900    }
2901
2902    /// Persist security policy changes and return the exact publication epoch.
2903    pub fn set_security_catalog_as_with_epoch(
2904        &self,
2905        security: crate::security::SecurityCatalog,
2906        principal: Option<&crate::auth::Principal>,
2907    ) -> Result<Epoch> {
2908        self.set_security_catalog_as_with_epoch_inner(security, principal, None)
2909    }
2910
2911    /// Persist security policy changes, entering the commit fence immediately
2912    /// before the first WAL record can become visible to recovery.
2913    pub fn set_security_catalog_as_with_epoch_controlled<F>(
2914        &self,
2915        security: crate::security::SecurityCatalog,
2916        principal: Option<&crate::auth::Principal>,
2917        mut before_commit: F,
2918    ) -> Result<Epoch>
2919    where
2920        F: FnMut() -> Result<()>,
2921    {
2922        self.set_security_catalog_as_with_epoch_inner(security, principal, Some(&mut before_commit))
2923    }
2924
2925    fn set_security_catalog_as_with_epoch_inner(
2926        &self,
2927        security: crate::security::SecurityCatalog,
2928        principal: Option<&crate::auth::Principal>,
2929        before_commit: Option<&mut dyn FnMut() -> Result<()>>,
2930    ) -> Result<Epoch> {
2931        use crate::wal::DdlOp;
2932        use std::sync::atomic::Ordering;
2933
2934        self.require_for(principal, &crate::auth::Permission::Admin)?;
2935        if self.poisoned.load(Ordering::Relaxed) {
2936            return Err(MongrelError::Other(
2937                "database poisoned by fsync error".into(),
2938            ));
2939        }
2940        let _ddl = self.ddl_lock.lock();
2941        // DDL serializes first; write-path order after that is security gate ->
2942        // commit lock -> shared WAL.
2943        let _security_write = self.security_write()?;
2944        self.require_for(principal, &crate::auth::Permission::Admin)?;
2945        let mut next_catalog = self.catalog.read().clone();
2946        validate_security_catalog(&next_catalog, &security)?;
2947        let payload = DdlOp::encode_security(&security)?;
2948        let _commit = self.commit_lock.lock();
2949        let epoch = self.epoch.bump_assigned();
2950        let mut epoch_guard = EpochGuard::new(self.epoch.as_ref(), epoch);
2951        let txn_id = self.alloc_txn_id()?;
2952        next_catalog.security = security;
2953        advance_security_version(&mut next_catalog)?;
2954        next_catalog.db_epoch = next_catalog.db_epoch.max(epoch.0);
2955        let commit_seq = {
2956            let mut wal = self.shared_wal.lock();
2957            if let Some(before_commit) = before_commit {
2958                before_commit()?;
2959            }
2960            let append: Result<u64> = (|| {
2961                wal.append(
2962                    txn_id,
2963                    WAL_TABLE_ID,
2964                    crate::wal::Op::Ddl(DdlOp::SetSecurityCatalog {
2965                        security_json: payload,
2966                    }),
2967                )?;
2968                append_catalog_snapshot(&mut wal, txn_id, &next_catalog)?;
2969                wal.append_commit(txn_id, epoch, &[])
2970            })();
2971            append.map_err(|error| self.commit_outcome_unknown(epoch, error))?
2972        };
2973        self.await_durable_commit(commit_seq, epoch)?;
2974        let checkpoint = self.checkpoint_catalog_after_durable(next_catalog);
2975        self.finish_durable_publish(epoch, &mut epoch_guard, checkpoint)?;
2976        Ok(epoch)
2977    }
2978
2979    pub fn require_for(
2980        &self,
2981        principal: Option<&crate::auth::Principal>,
2982        permission: &crate::auth::Permission,
2983    ) -> Result<()> {
2984        let Some(principal) = principal else {
2985            return self.require(permission);
2986        };
2987        let resolved;
2988        let principal = if self.auth_state.require_auth() || principal.user_id != 0 {
2989            resolved = Self::resolve_bound_principal_from_catalog(&self.catalog.read(), principal)
2990                .ok_or(MongrelError::AuthRequired)?;
2991            &resolved
2992        } else {
2993            principal
2994        };
2995        #[cfg(test)]
2996        TABLE_PERMISSION_DECISIONS.with(|decisions| decisions.set(decisions.get() + 1));
2997        if principal.has_permission(permission) {
2998            Ok(())
2999        } else {
3000            Err(MongrelError::PermissionDenied {
3001                required: permission.clone(),
3002                principal: principal.username.clone(),
3003            })
3004        }
3005    }
3006
3007    /// Recheck the exact operation principal while the caller holds the
3008    /// security gate. This deliberately performs no refresh or nested gate
3009    /// acquisition.
3010    fn require_exact_principal_current(
3011        &self,
3012        principal: Option<&crate::auth::Principal>,
3013        permission: &crate::auth::Permission,
3014    ) -> Result<()> {
3015        let catalog = self.catalog.read();
3016        if !catalog.require_auth {
3017            return Ok(());
3018        }
3019        let supplied = principal.ok_or(MongrelError::AuthRequired)?;
3020        let current = Self::resolve_bound_principal_from_catalog(&catalog, supplied)
3021            .ok_or(MongrelError::AuthRequired)?;
3022        if current.has_permission(permission) {
3023            Ok(())
3024        } else {
3025            Err(MongrelError::PermissionDenied {
3026                required: permission.clone(),
3027                principal: current.username,
3028            })
3029        }
3030    }
3031
3032    pub(crate) fn with_exact_principal_current<T, F>(
3033        &self,
3034        principal: Option<&crate::auth::Principal>,
3035        permission: &crate::auth::Permission,
3036        operation: F,
3037    ) -> Result<T>
3038    where
3039        F: FnOnce() -> Result<T>,
3040    {
3041        let _security = self.security_coordinator.gate.read();
3042        self.require_exact_principal_current(principal, permission)?;
3043        operation()
3044    }
3045
3046    pub fn principal_snapshot(&self) -> Option<crate::auth::Principal> {
3047        self.principal.read().clone()
3048    }
3049
3050    #[cfg(test)]
3051    pub(crate) fn set_cached_principal_for_test(&self, principal: Option<crate::auth::Principal>) {
3052        *self.principal.write() = principal.clone();
3053        self.auth_state.set_principal(principal);
3054    }
3055
3056    pub fn require_columns_for(
3057        &self,
3058        table: &str,
3059        operation: crate::auth::ColumnOperation,
3060        column_ids: &[u16],
3061        principal: Option<&crate::auth::Principal>,
3062    ) -> Result<()> {
3063        if principal.is_none() && !self.auth_state.require_auth() {
3064            return Ok(());
3065        }
3066        let cached = self.principal.read().clone();
3067        let principal = principal.or(cached.as_ref());
3068        let Some(principal) = principal else {
3069            let permission = match operation {
3070                crate::auth::ColumnOperation::Select => crate::auth::Permission::Select {
3071                    table: table.to_string(),
3072                },
3073                crate::auth::ColumnOperation::Insert => crate::auth::Permission::Insert {
3074                    table: table.to_string(),
3075                },
3076                crate::auth::ColumnOperation::Update => crate::auth::Permission::Update {
3077                    table: table.to_string(),
3078                },
3079            };
3080            return self.require(&permission);
3081        };
3082        let catalog = self.catalog.read();
3083        let resolved;
3084        let principal = if catalog.require_auth || principal.user_id != 0 {
3085            resolved = Self::resolve_bound_principal_from_catalog(&catalog, principal)
3086                .ok_or(MongrelError::AuthRequired)?;
3087            &resolved
3088        } else {
3089            principal
3090        };
3091        let schema = &catalog
3092            .live(table)
3093            .ok_or_else(|| MongrelError::NotFound(format!("table {table:?} not found")))?
3094            .schema;
3095        Self::require_columns_for_principal(table, schema, operation, column_ids, principal)
3096    }
3097
3098    fn require_columns_for_principal(
3099        table: &str,
3100        schema: &Schema,
3101        operation: crate::auth::ColumnOperation,
3102        column_ids: &[u16],
3103        principal: &crate::auth::Principal,
3104    ) -> Result<()> {
3105        #[cfg(test)]
3106        WRITE_PERMISSION_DECISIONS.with(|decisions| decisions.set(decisions.get() + 1));
3107        match principal.column_access(table, operation) {
3108            crate::auth::ColumnAccess::All => Ok(()),
3109            crate::auth::ColumnAccess::Columns(allowed) => {
3110                let denied = column_ids.iter().find_map(|column_id| {
3111                    schema
3112                        .columns
3113                        .iter()
3114                        .find(|column| column.id == *column_id)
3115                        .filter(|column| !allowed.contains(&column.name))
3116                });
3117                if denied.is_none() {
3118                    Ok(())
3119                } else {
3120                    Err(MongrelError::PermissionDenied {
3121                        required: match operation {
3122                            crate::auth::ColumnOperation::Select => {
3123                                crate::auth::Permission::SelectColumns {
3124                                    table: table.to_string(),
3125                                    columns: denied
3126                                        .into_iter()
3127                                        .map(|column| column.name.clone())
3128                                        .collect(),
3129                                }
3130                            }
3131                            crate::auth::ColumnOperation::Insert => {
3132                                crate::auth::Permission::InsertColumns {
3133                                    table: table.to_string(),
3134                                    columns: denied
3135                                        .into_iter()
3136                                        .map(|column| column.name.clone())
3137                                        .collect(),
3138                                }
3139                            }
3140                            crate::auth::ColumnOperation::Update => {
3141                                crate::auth::Permission::UpdateColumns {
3142                                    table: table.to_string(),
3143                                    columns: denied
3144                                        .into_iter()
3145                                        .map(|column| column.name.clone())
3146                                        .collect(),
3147                                }
3148                            }
3149                        },
3150                        principal: principal.username.clone(),
3151                    })
3152                }
3153            }
3154            crate::auth::ColumnAccess::Denied => Err(MongrelError::PermissionDenied {
3155                required: match operation {
3156                    crate::auth::ColumnOperation::Select => crate::auth::Permission::Select {
3157                        table: table.to_string(),
3158                    },
3159                    crate::auth::ColumnOperation::Insert => crate::auth::Permission::Insert {
3160                        table: table.to_string(),
3161                    },
3162                    crate::auth::ColumnOperation::Update => crate::auth::Permission::Update {
3163                        table: table.to_string(),
3164                    },
3165                },
3166                principal: principal.username.clone(),
3167            }),
3168        }
3169    }
3170
3171    pub fn select_column_ids_for(
3172        &self,
3173        table: &str,
3174        principal: Option<&crate::auth::Principal>,
3175    ) -> Result<Vec<u16>> {
3176        let catalog = self.catalog.read();
3177        let columns = catalog
3178            .live(table)
3179            .ok_or_else(|| MongrelError::NotFound(format!("table {table:?} not found")))?
3180            .schema
3181            .columns
3182            .iter()
3183            .map(|column| (column.id, column.name.clone()))
3184            .collect::<Vec<_>>();
3185        let principal = self.principal_for_authorized_read(&catalog, principal, false)?;
3186        drop(catalog);
3187        let Some(principal) = principal.as_ref() else {
3188            self.require(&crate::auth::Permission::Select {
3189                table: table.to_string(),
3190            })?;
3191            return Ok(columns.iter().map(|(id, _)| *id).collect());
3192        };
3193        match principal.column_access(table, crate::auth::ColumnOperation::Select) {
3194            crate::auth::ColumnAccess::All => Ok(columns.iter().map(|(id, _)| *id).collect()),
3195            crate::auth::ColumnAccess::Columns(allowed) => Ok(columns
3196                .iter()
3197                .filter(|(_, name)| allowed.contains(name))
3198                .map(|(id, _)| *id)
3199                .collect()),
3200            crate::auth::ColumnAccess::Denied => Err(MongrelError::PermissionDenied {
3201                required: crate::auth::Permission::Select {
3202                    table: table.to_string(),
3203                },
3204                principal: principal.username.clone(),
3205            }),
3206        }
3207    }
3208
3209    pub fn secure_rows_for(
3210        &self,
3211        table: &str,
3212        rows: Vec<crate::memtable::Row>,
3213        principal: Option<&crate::auth::Principal>,
3214    ) -> Result<Vec<crate::memtable::Row>> {
3215        self.secure_rows_for_with_context(table, rows, principal, None)
3216    }
3217
3218    pub fn secure_rows_for_with_context(
3219        &self,
3220        table: &str,
3221        rows: Vec<crate::memtable::Row>,
3222        principal: Option<&crate::auth::Principal>,
3223        context: Option<&crate::query::AiExecutionContext>,
3224    ) -> Result<Vec<crate::memtable::Row>> {
3225        let (security, principal) = {
3226            let catalog = self.catalog.read();
3227            (
3228                catalog.security.clone(),
3229                self.principal_for_authorized_read(&catalog, principal, false)?,
3230            )
3231        };
3232        if !security.table_has_security(table) {
3233            return Ok(rows);
3234        }
3235        let principal = principal.as_ref().ok_or(MongrelError::AuthRequired)?;
3236        let mut output = Vec::new();
3237        for mut row in rows {
3238            if let Some(context) = context {
3239                context.consume(1)?;
3240            }
3241            if security.row_allowed(
3242                table,
3243                crate::security::PolicyCommand::Select,
3244                &row,
3245                principal,
3246                false,
3247            ) {
3248                security.apply_masks(table, &mut row, principal);
3249                output.push(row);
3250            }
3251        }
3252        Ok(output)
3253    }
3254
3255    /// Apply column masks to already RLS-authorized scored hits without a
3256    /// second row gather or policy evaluation.
3257    pub fn mask_search_hits_for(
3258        &self,
3259        table: &str,
3260        hits: &mut [crate::query::SearchHit],
3261        principal: Option<&crate::auth::Principal>,
3262    ) -> Result<()> {
3263        let (security, principal) = {
3264            let catalog = self.catalog.read();
3265            (
3266                catalog.security.clone(),
3267                self.principal_for_authorized_read(&catalog, principal, false)?,
3268            )
3269        };
3270        if !security.table_has_security(table) {
3271            return Ok(());
3272        }
3273        let principal = principal.as_ref().ok_or(MongrelError::AuthRequired)?;
3274        for hit in hits {
3275            security.apply_masks_to_cells(table, &mut hit.cells, principal);
3276        }
3277        Ok(())
3278    }
3279
3280    /// Apply masks to rows already admitted by candidate-aware RLS.
3281    pub fn mask_rows_for(
3282        &self,
3283        table: &str,
3284        rows: &mut [crate::memtable::Row],
3285        principal: Option<&crate::auth::Principal>,
3286    ) -> Result<()> {
3287        let (security, principal) = {
3288            let catalog = self.catalog.read();
3289            (
3290                catalog.security.clone(),
3291                self.principal_for_authorized_read(&catalog, principal, false)?,
3292            )
3293        };
3294        if !security.table_has_security(table) {
3295            return Ok(());
3296        }
3297        let principal = principal.as_ref().ok_or(MongrelError::AuthRequired)?;
3298        for row in rows {
3299            security.apply_masks(table, row, principal);
3300        }
3301        Ok(())
3302    }
3303
3304    /// Row IDs allowed to enter scored ranking. `None` means no RLS filter.
3305    pub fn authorized_candidate_ids_for(
3306        &self,
3307        table: &str,
3308        principal: Option<&crate::auth::Principal>,
3309    ) -> Result<Option<std::collections::HashSet<RowId>>> {
3310        Ok(self
3311            .authorized_read_snapshot(table, principal)?
3312            .allowed_row_ids)
3313    }
3314
3315    fn allowed_row_ids_locked(
3316        &self,
3317        table_name: &str,
3318        table: &Table,
3319        table_snapshot: Snapshot,
3320        security_state: (&crate::security::SecurityCatalog, u64),
3321        principal: Option<&crate::auth::Principal>,
3322        context: Option<&crate::query::AiExecutionContext>,
3323    ) -> Result<Option<Arc<HashSet<RowId>>>> {
3324        let (security, security_version) = security_state;
3325        if !security.rls_enabled(table_name) {
3326            return Ok(None);
3327        }
3328        let authorization_started = std::time::Instant::now();
3329        let principal = principal.ok_or(MongrelError::AuthRequired)?;
3330        let mut roles = principal.roles.clone();
3331        roles.sort_unstable();
3332        let principal_key = format!(
3333            "{}:{}:{}:{}:{roles:?}",
3334            principal.user_id, principal.created_epoch, principal.username, principal.is_admin
3335        );
3336        let cache_key = (
3337            table_name.to_string(),
3338            table.data_generation(),
3339            security_version,
3340            principal_key,
3341        );
3342        if let Some(allowed) = self.rls_cache.lock().get(&cache_key) {
3343            crate::trace::QueryTrace::record(|trace| {
3344                trace.rls_cache_hit = true;
3345                trace.authorization_nanos = trace
3346                    .authorization_nanos
3347                    .saturating_add(authorization_started.elapsed().as_nanos() as u64);
3348            });
3349            return Ok(Some(allowed));
3350        }
3351        if let Some(context) = context {
3352            context.checkpoint()?;
3353        }
3354        // ponytail: full RLS universe scan; replace with policy-column candidate checks if RLS search throughput matters.
3355        let started = std::time::Instant::now();
3356        let rows = table.visible_rows(table_snapshot)?;
3357        let rows_evaluated = rows.len() as u64;
3358        let mut allowed = HashSet::new();
3359        for chunk in rows.chunks(256) {
3360            if let Some(context) = context {
3361                context.consume(chunk.len())?;
3362            }
3363            allowed.extend(chunk.iter().filter_map(|row| {
3364                security
3365                    .row_allowed(
3366                        table_name,
3367                        crate::security::PolicyCommand::Select,
3368                        row,
3369                        principal,
3370                        false,
3371                    )
3372                    .then_some(row.row_id)
3373            }));
3374        }
3375        let allowed = Arc::new(allowed);
3376        let mut cache = self.rls_cache.lock();
3377        cache.build_nanos = cache
3378            .build_nanos
3379            .saturating_add(started.elapsed().as_nanos() as u64);
3380        cache.rows_evaluated = cache.rows_evaluated.saturating_add(rows_evaluated);
3381        cache.insert(cache_key, Arc::clone(&allowed));
3382        crate::trace::QueryTrace::record(|trace| {
3383            trace.rls_rows_evaluated = trace
3384                .rls_rows_evaluated
3385                .saturating_add(rows_evaluated as usize);
3386            trace.authorization_nanos = trace
3387                .authorization_nanos
3388                .saturating_add(authorization_started.elapsed().as_nanos() as u64);
3389        });
3390        Ok(Some(allowed))
3391    }
3392
3393    fn principal_for_authorized_read(
3394        &self,
3395        catalog: &Catalog,
3396        principal: Option<&crate::auth::Principal>,
3397        catalog_bound: bool,
3398    ) -> Result<Option<crate::auth::Principal>> {
3399        let principal = principal.cloned().or_else(|| self.principal.read().clone());
3400        let Some(principal) = principal else {
3401            return Ok(None);
3402        };
3403        if catalog.require_auth || catalog_bound || principal.user_id != 0 {
3404            return Self::resolve_bound_principal_from_catalog(catalog, &principal)
3405                .map(Some)
3406                .ok_or(MongrelError::AuthRequired);
3407        }
3408        Ok(Some(principal))
3409    }
3410
3411    /// Run authorization, candidate generation, ranking, and materialization
3412    /// while holding one table generation. Security changes cause a bounded
3413    /// retry before any result is published.
3414    pub fn with_authorized_read<T, F>(
3415        &self,
3416        table_name: &str,
3417        principal: Option<&crate::auth::Principal>,
3418        catalog_bound: bool,
3419        read: F,
3420    ) -> Result<T>
3421    where
3422        F: FnMut(
3423            &mut Table,
3424            Snapshot,
3425            Option<&HashSet<RowId>>,
3426            Option<&crate::auth::Principal>,
3427        ) -> Result<T>,
3428    {
3429        self.with_authorized_read_context(
3430            table_name,
3431            principal,
3432            catalog_bound,
3433            None,
3434            None,
3435            None,
3436            read,
3437        )
3438    }
3439
3440    #[allow(clippy::too_many_arguments)]
3441    pub fn with_authorized_read_context<T, F>(
3442        &self,
3443        table_name: &str,
3444        principal: Option<&crate::auth::Principal>,
3445        catalog_bound: bool,
3446        authorization: Option<&ReadAuthorization>,
3447        context: Option<&crate::query::AiExecutionContext>,
3448        snapshot_override: Option<Snapshot>,
3449        read: F,
3450    ) -> Result<T>
3451    where
3452        F: FnMut(
3453            &mut Table,
3454            Snapshot,
3455            Option<&HashSet<RowId>>,
3456            Option<&crate::auth::Principal>,
3457        ) -> Result<T>,
3458    {
3459        self.with_authorized_read_context_stamped(
3460            table_name,
3461            principal,
3462            catalog_bound,
3463            authorization,
3464            context,
3465            snapshot_override,
3466            read,
3467        )
3468        .map(|(result, _)| result)
3469    }
3470
3471    #[allow(clippy::too_many_arguments)]
3472    pub fn with_authorized_read_context_stamped<T, F>(
3473        &self,
3474        table_name: &str,
3475        principal: Option<&crate::auth::Principal>,
3476        catalog_bound: bool,
3477        authorization: Option<&ReadAuthorization>,
3478        context: Option<&crate::query::AiExecutionContext>,
3479        snapshot_override: Option<Snapshot>,
3480        mut read: F,
3481    ) -> Result<(T, AuthorizedReadStamp)>
3482    where
3483        F: FnMut(
3484            &mut Table,
3485            Snapshot,
3486            Option<&HashSet<RowId>>,
3487            Option<&crate::auth::Principal>,
3488        ) -> Result<T>,
3489    {
3490        if principal.is_none() && self.principal.read().is_some() {
3491            self.refresh_principal()?;
3492        }
3493        const RETRIES: usize = 3;
3494        let handle = self.table(table_name)?;
3495        for attempt in 0..RETRIES {
3496            crate::trace::QueryTrace::record(|trace| {
3497                trace.authorization_retries = attempt;
3498            });
3499            let (security, security_version, effective_principal) = {
3500                let catalog = self.catalog.read();
3501                (
3502                    catalog.security.clone(),
3503                    catalog.security_version,
3504                    self.principal_for_authorized_read(&catalog, principal, catalog_bound)?,
3505                )
3506            };
3507            if let Some(authorization) = authorization {
3508                for permission in &authorization.permissions {
3509                    self.require_for(effective_principal.as_ref(), permission)?;
3510                }
3511                self.require_columns_for(
3512                    table_name,
3513                    authorization.operation,
3514                    &authorization.columns,
3515                    effective_principal.as_ref(),
3516                )?;
3517            }
3518            let result = {
3519                let mut table = lock_table_with_context(&handle, context)?;
3520                let snapshot = snapshot_override.unwrap_or_else(|| table.snapshot());
3521                let allowed = self.allowed_row_ids_locked(
3522                    table_name,
3523                    &table,
3524                    snapshot,
3525                    (&security, security_version),
3526                    effective_principal.as_ref(),
3527                    context,
3528                )?;
3529                let stamp = AuthorizedReadStamp {
3530                    table_id: table.table_id(),
3531                    schema_id: table.schema().schema_id,
3532                    data_generation: table.data_generation(),
3533                    security_version,
3534                    snapshot,
3535                };
3536                let result = read(
3537                    &mut table,
3538                    snapshot,
3539                    allowed.as_deref(),
3540                    effective_principal.as_ref(),
3541                )?;
3542                (result, stamp)
3543            };
3544            if let Some(context) = context {
3545                context.checkpoint()?;
3546            }
3547            if self.catalog.read().security_version == security_version {
3548                return Ok(result);
3549            }
3550            if attempt + 1 == RETRIES {
3551                return Err(MongrelError::Conflict(
3552                    "security policy changed during scored read".into(),
3553                ));
3554            }
3555        }
3556        Err(MongrelError::Conflict(
3557            "authorization retry loop exhausted".into(),
3558        ))
3559    }
3560
3561    fn with_authorized_aggregate_table<T, F>(
3562        &self,
3563        table_name: &str,
3564        columns: &[u16],
3565        principal: Option<&crate::auth::Principal>,
3566        catalog_bound: bool,
3567        allow_table_security: bool,
3568        mut aggregate: F,
3569    ) -> Result<T>
3570    where
3571        F: FnMut(
3572            &mut Table,
3573            Option<&crate::security::CandidateAuthorization<'_>>,
3574            Option<&crate::auth::Principal>,
3575            u64,
3576        ) -> Result<T>,
3577    {
3578        if principal.is_none() && self.principal.read().is_some() {
3579            self.refresh_principal()?;
3580        }
3581        const RETRIES: usize = 3;
3582        let handle = self.table(table_name)?;
3583        for attempt in 0..RETRIES {
3584            let (security, security_version, effective_principal) = {
3585                let catalog = self.catalog.read();
3586                (
3587                    catalog.security.clone(),
3588                    catalog.security_version,
3589                    self.principal_for_authorized_read(&catalog, principal, catalog_bound)?,
3590                )
3591            };
3592            self.require_columns_for(
3593                table_name,
3594                crate::auth::ColumnOperation::Select,
3595                columns,
3596                effective_principal.as_ref(),
3597            )?;
3598            if !allow_table_security && security.table_has_security(table_name) {
3599                return Err(MongrelError::InvalidArgument(
3600                    "incremental aggregate is unsupported while RLS or column masks are active"
3601                        .into(),
3602                ));
3603            }
3604            let result = {
3605                let mut table = handle.lock();
3606                let authorization = if security.rls_enabled(table_name) {
3607                    Some(crate::security::CandidateAuthorization {
3608                        table: table_name,
3609                        security: &security,
3610                        principal: effective_principal
3611                            .as_ref()
3612                            .ok_or(MongrelError::AuthRequired)?,
3613                    })
3614                } else {
3615                    None
3616                };
3617                aggregate(
3618                    &mut table,
3619                    authorization.as_ref(),
3620                    effective_principal.as_ref(),
3621                    security_version,
3622                )?
3623            };
3624            if self.catalog.read().security_version == security_version {
3625                return Ok(result);
3626            }
3627            if attempt + 1 == RETRIES {
3628                return Err(MongrelError::Conflict(
3629                    "security policy changed during aggregate read".into(),
3630                ));
3631            }
3632        }
3633        Err(MongrelError::Conflict(
3634            "aggregate authorization retry loop exhausted".into(),
3635        ))
3636    }
3637
3638    /// Scored-read authorization that evaluates RLS only for approximate
3639    /// candidates. This avoids a full-table policy scan on cache misses while
3640    /// preserving one table generation and security-version retry.
3641    pub fn with_authorized_scored_read_context<T, F>(
3642        &self,
3643        table_name: &str,
3644        principal: Option<&crate::auth::Principal>,
3645        catalog_bound: bool,
3646        authorization: Option<&ReadAuthorization>,
3647        context: Option<&crate::query::AiExecutionContext>,
3648        mut read: F,
3649    ) -> Result<T>
3650    where
3651        F: FnMut(
3652            &mut Table,
3653            Snapshot,
3654            Option<&crate::security::CandidateAuthorization<'_>>,
3655            Option<&crate::auth::Principal>,
3656        ) -> Result<T>,
3657    {
3658        self.with_authorized_scored_read_context_at(
3659            table_name,
3660            principal,
3661            catalog_bound,
3662            authorization,
3663            context,
3664            None,
3665            |table, snapshot, authorization, principal| {
3666                let mut table = table.clone();
3667                read(&mut table, snapshot, authorization, principal)
3668            },
3669        )
3670    }
3671
3672    #[allow(clippy::too_many_arguments)]
3673    pub fn with_authorized_scored_read_context_at<T, F>(
3674        &self,
3675        table_name: &str,
3676        principal: Option<&crate::auth::Principal>,
3677        catalog_bound: bool,
3678        authorization: Option<&ReadAuthorization>,
3679        context: Option<&crate::query::AiExecutionContext>,
3680        snapshot_override: Option<Snapshot>,
3681        read: F,
3682    ) -> Result<T>
3683    where
3684        F: FnMut(
3685            &Table,
3686            Snapshot,
3687            Option<&crate::security::CandidateAuthorization<'_>>,
3688            Option<&crate::auth::Principal>,
3689        ) -> Result<T>,
3690    {
3691        self.with_authorized_scored_read_context_at_stamped(
3692            table_name,
3693            principal,
3694            catalog_bound,
3695            authorization,
3696            context,
3697            snapshot_override,
3698            read,
3699        )
3700        .map(|(result, _)| result)
3701    }
3702
3703    #[allow(clippy::too_many_arguments)]
3704    pub fn with_authorized_scored_read_context_at_stamped<T, F>(
3705        &self,
3706        table_name: &str,
3707        principal: Option<&crate::auth::Principal>,
3708        catalog_bound: bool,
3709        authorization: Option<&ReadAuthorization>,
3710        context: Option<&crate::query::AiExecutionContext>,
3711        snapshot_override: Option<Snapshot>,
3712        mut read: F,
3713    ) -> Result<(T, AuthorizedReadStamp)>
3714    where
3715        F: FnMut(
3716            &Table,
3717            Snapshot,
3718            Option<&crate::security::CandidateAuthorization<'_>>,
3719            Option<&crate::auth::Principal>,
3720        ) -> Result<T>,
3721    {
3722        if principal.is_none() && self.principal.read().is_some() {
3723            self.refresh_principal()?;
3724        }
3725        const RETRIES: usize = 3;
3726        let handle = self.table(table_name)?;
3727        for attempt in 0..RETRIES {
3728            if let Some(context) = context {
3729                context.checkpoint()?;
3730            }
3731            crate::trace::QueryTrace::record(|trace| {
3732                trace.authorization_retries = attempt;
3733            });
3734            let (security, security_version, effective_principal) = {
3735                let catalog = self.catalog.read();
3736                (
3737                    catalog.security.clone(),
3738                    catalog.security_version,
3739                    self.principal_for_authorized_read(&catalog, principal, catalog_bound)?,
3740                )
3741            };
3742            if let Some(authorization) = authorization {
3743                for permission in &authorization.permissions {
3744                    self.require_for(effective_principal.as_ref(), permission)?;
3745                }
3746                self.require_columns_for(
3747                    table_name,
3748                    authorization.operation,
3749                    &authorization.columns,
3750                    effective_principal.as_ref(),
3751                )?;
3752            }
3753            let result = {
3754                let (table, snapshot, _snapshot_guard, _run_pins) =
3755                    self.scored_read_generation(&handle, context, snapshot_override)?;
3756                let candidate_authorization = if security.rls_enabled(table_name) {
3757                    Some(crate::security::CandidateAuthorization {
3758                        table: table_name,
3759                        security: &security,
3760                        principal: effective_principal
3761                            .as_ref()
3762                            .ok_or(MongrelError::AuthRequired)?,
3763                    })
3764                } else {
3765                    None
3766                };
3767                let stamp = AuthorizedReadStamp {
3768                    table_id: table.table_id(),
3769                    schema_id: table.schema().schema_id,
3770                    data_generation: table.data_generation(),
3771                    security_version,
3772                    snapshot,
3773                };
3774                let result = read(
3775                    table.as_ref(),
3776                    snapshot,
3777                    candidate_authorization.as_ref(),
3778                    effective_principal.as_ref(),
3779                )?;
3780                (result, stamp)
3781            };
3782            if let Some(context) = context {
3783                context.checkpoint()?;
3784            }
3785            if self.catalog.read().security_version == security_version {
3786                return Ok(result);
3787            }
3788            if attempt + 1 == RETRIES {
3789                return Err(MongrelError::Conflict(
3790                    "security policy changed during scored read".into(),
3791                ));
3792            }
3793        }
3794        Err(MongrelError::Conflict(
3795            "scored-read authorization retry loop exhausted".into(),
3796        ))
3797    }
3798
3799    fn scored_read_generation(
3800        &self,
3801        handle: &TableHandle,
3802        context: Option<&crate::query::AiExecutionContext>,
3803        snapshot_override: Option<Snapshot>,
3804    ) -> Result<(
3805        Arc<TableReadGeneration>,
3806        Snapshot,
3807        crate::retention::OwnedSnapshotGuard,
3808        RunPins,
3809    )> {
3810        let mut table = if let Some(context) = context {
3811            loop {
3812                context.checkpoint()?;
3813                let wait = context
3814                    .remaining_duration()
3815                    .unwrap_or(std::time::Duration::from_millis(5))
3816                    .min(std::time::Duration::from_millis(5));
3817                if let Some(table) = handle.try_lock_for(wait) {
3818                    break table;
3819                }
3820            }
3821        } else {
3822            handle.lock()
3823        };
3824        let (snapshot, snapshot_guard) = if let Some(snapshot) = snapshot_override {
3825            self.snapshot_at_owned(snapshot.epoch)?
3826        } else {
3827            let snapshot = table.snapshot();
3828            let guard = self.snapshots.register_owned(snapshot.epoch);
3829            (snapshot, guard)
3830        };
3831        let table_id = table.table_id();
3832        let run_keys: Vec<_> = table
3833            .active_run_ids()
3834            .map(|run_id| (table_id, run_id))
3835            .collect();
3836        let generation = handle
3837            .generation_metrics
3838            .activate(table.clone_read_generation()?);
3839        let run_pins = self.pin_runs(&run_keys);
3840        Ok((generation, snapshot, snapshot_guard, run_pins))
3841    }
3842
3843    fn pin_runs(&self, runs: &[(u64, u128)]) -> RunPins {
3844        let mut pins = self.backup_pins.lock();
3845        for run in runs {
3846            *pins.entry(*run).or_insert(0) += 1;
3847        }
3848        drop(pins);
3849        RunPins {
3850            pins: Arc::clone(&self.backup_pins),
3851            runs: runs.to_vec(),
3852        }
3853    }
3854
3855    /// Execute a native conjunctive read with the database principal's row
3856    /// policy, column grants, and masks applied. Raw [`Table`] methods remain
3857    /// policy-unaware; language bindings must use this boundary for reads.
3858    pub fn query_for_current_principal(
3859        &self,
3860        table_name: &str,
3861        query: &crate::query::Query,
3862        projection: Option<&[u16]>,
3863    ) -> Result<Vec<crate::memtable::Row>> {
3864        let condition_columns = crate::query::condition_columns(&query.conditions);
3865        self.with_authorized_read(
3866            table_name,
3867            None,
3868            true,
3869            |table, snapshot, allowed, principal| {
3870                let allowed_columns = self.select_column_ids_for(table_name, principal)?;
3871                self.require_columns_for(
3872                    table_name,
3873                    crate::auth::ColumnOperation::Select,
3874                    &condition_columns,
3875                    principal,
3876                )?;
3877                if let Some(projection) = projection {
3878                    self.require_columns_for(
3879                        table_name,
3880                        crate::auth::ColumnOperation::Select,
3881                        projection,
3882                        principal,
3883                    )?;
3884                }
3885                let mut rows = table.query_at_with_allowed(query, snapshot, allowed)?;
3886                let projection =
3887                    projection.map(|columns| columns.iter().copied().collect::<HashSet<_>>());
3888                for row in &mut rows {
3889                    row.columns.retain(|column, _| {
3890                        allowed_columns.contains(column)
3891                            && projection
3892                                .as_ref()
3893                                .is_none_or(|projection| projection.contains(column))
3894                    });
3895                }
3896                self.secure_rows_for(table_name, rows, principal)
3897            },
3898        )
3899    }
3900
3901    /// Execute a secured native read with cooperative cancellation across
3902    /// authorization, candidate generation, materialization, masking, and
3903    /// projection.
3904    pub fn query_for_current_principal_controlled(
3905        &self,
3906        table_name: &str,
3907        query: &crate::query::Query,
3908        projection: Option<&[u16]>,
3909        control: &crate::ExecutionControl,
3910    ) -> Result<Vec<crate::memtable::Row>> {
3911        self.query_for_principal_controlled(table_name, query, projection, None, true, control)
3912    }
3913
3914    fn query_for_principal_controlled(
3915        &self,
3916        table_name: &str,
3917        query: &crate::query::Query,
3918        projection: Option<&[u16]>,
3919        principal: Option<&crate::auth::Principal>,
3920        catalog_bound: bool,
3921        control: &crate::ExecutionControl,
3922    ) -> Result<Vec<crate::memtable::Row>> {
3923        control.checkpoint()?;
3924        let context = crate::query::AiExecutionContext::with_control(
3925            control.clone(),
3926            usize::MAX,
3927            crate::query::MAX_FUSED_CANDIDATES,
3928        );
3929        let condition_columns = crate::query::condition_columns(&query.conditions);
3930        self.with_authorized_read_context(
3931            table_name,
3932            principal,
3933            catalog_bound,
3934            None,
3935            Some(&context),
3936            None,
3937            |table, snapshot, allowed, principal| {
3938                control.checkpoint()?;
3939                let allowed_columns = self.select_column_ids_for(table_name, principal)?;
3940                self.require_columns_for(
3941                    table_name,
3942                    crate::auth::ColumnOperation::Select,
3943                    &condition_columns,
3944                    principal,
3945                )?;
3946                if let Some(projection) = projection {
3947                    self.require_columns_for(
3948                        table_name,
3949                        crate::auth::ColumnOperation::Select,
3950                        projection,
3951                        principal,
3952                    )?;
3953                }
3954                let rows =
3955                    table.query_at_with_allowed_controlled(query, snapshot, allowed, control)?;
3956                let projection =
3957                    projection.map(|columns| columns.iter().copied().collect::<HashSet<_>>());
3958                let mut projected = Vec::with_capacity(rows.len());
3959                for (index, mut row) in rows.into_iter().enumerate() {
3960                    if index & 255 == 0 {
3961                        control.checkpoint()?;
3962                    }
3963                    row.columns.retain(|column, _| {
3964                        allowed_columns.contains(column)
3965                            && projection
3966                                .as_ref()
3967                                .is_none_or(|projection| projection.contains(column))
3968                    });
3969                    projected.push(row);
3970                }
3971                self.secure_rows_for_with_context(table_name, projected, principal, Some(&context))
3972            },
3973        )
3974    }
3975
3976    /// Reservoir aggregate with column grants, RLS, masks, and security-version
3977    /// retry applied at the database boundary.
3978    pub fn approx_aggregate_for_current_principal(
3979        &self,
3980        table_name: &str,
3981        conditions: &[crate::query::Condition],
3982        column: Option<u16>,
3983        agg: crate::engine::ApproxAgg,
3984        z: f64,
3985    ) -> Result<Option<crate::engine::ApproxResult>> {
3986        if !z.is_finite() || z <= 0.0 {
3987            return Err(MongrelError::InvalidArgument(
3988                "z must be finite and > 0".into(),
3989            ));
3990        }
3991        let mut columns = crate::query::condition_columns(conditions);
3992        columns.extend(column);
3993        columns.sort_unstable();
3994        columns.dedup();
3995        self.with_authorized_aggregate_table(
3996            table_name,
3997            &columns,
3998            None,
3999            true,
4000            true,
4001            |table, authorization, _, _| {
4002                table.approx_aggregate_with_candidate_authorization(
4003                    conditions,
4004                    column,
4005                    agg,
4006                    z,
4007                    authorization,
4008                )
4009            },
4010        )
4011    }
4012
4013    /// Incremental aggregate over an append-only table. Active RLS or masks are
4014    /// rejected because the table-global delta cache cannot safely represent a
4015    /// secured row universe.
4016    pub fn incremental_aggregate_for_current_principal(
4017        &self,
4018        table_name: &str,
4019        conditions: &[crate::query::Condition],
4020        column: Option<u16>,
4021        agg: crate::engine::NativeAgg,
4022    ) -> Result<crate::engine::IncrementalAggResult> {
4023        self.incremental_aggregate_for_principal(table_name, conditions, column, agg, None, true)
4024    }
4025
4026    /// Incremental aggregate using an explicit request principal. A
4027    /// catalog-bound principal is re-resolved on every retry so live grants,
4028    /// revocations, RLS, and masks cannot reuse a stale cache entry.
4029    pub fn incremental_aggregate_for_principal(
4030        &self,
4031        table_name: &str,
4032        conditions: &[crate::query::Condition],
4033        column: Option<u16>,
4034        agg: crate::engine::NativeAgg,
4035        principal: Option<&crate::auth::Principal>,
4036        catalog_bound: bool,
4037    ) -> Result<crate::engine::IncrementalAggResult> {
4038        let mut columns = crate::query::condition_columns(conditions);
4039        columns.extend(column);
4040        columns.sort_unstable();
4041        columns.dedup();
4042        self.with_authorized_aggregate_table(
4043            table_name,
4044            &columns,
4045            principal,
4046            catalog_bound,
4047            false,
4048            |table, _, principal, security_version| {
4049                let cache_key = incremental_aggregate_cache_key(
4050                    table_name,
4051                    conditions,
4052                    column,
4053                    agg,
4054                    principal,
4055                    security_version,
4056                );
4057                table.aggregate_incremental(cache_key, conditions, column, agg)
4058            },
4059        )
4060    }
4061
4062    /// Read one row with the database principal's row policy, column grants,
4063    /// and masks applied.
4064    pub fn get_for_current_principal(
4065        &self,
4066        table_name: &str,
4067        row_id: RowId,
4068    ) -> Result<Option<crate::memtable::Row>> {
4069        self.with_authorized_read(
4070            table_name,
4071            None,
4072            true,
4073            |table, snapshot, allowed, principal| {
4074                let allowed_columns = self.select_column_ids_for(table_name, principal)?;
4075                let Some(row) = table.get(row_id, snapshot) else {
4076                    return Ok(None);
4077                };
4078                if allowed.is_some_and(|allowed| !allowed.contains(&row.row_id)) {
4079                    return Ok(None);
4080                }
4081                let mut rows = self.secure_rows_for(table_name, vec![row], principal)?;
4082                if let Some(row) = rows.first_mut() {
4083                    row.columns
4084                        .retain(|column, _| allowed_columns.contains(column));
4085                }
4086                Ok(rows.pop())
4087            },
4088        )
4089    }
4090
4091    /// Run exact ANN reranking over only rows authorized for this database
4092    /// handle. The embedding column still requires normal column access.
4093    pub fn ann_rerank_for_current_principal(
4094        &self,
4095        table_name: &str,
4096        request: &crate::query::AnnRerankRequest,
4097    ) -> Result<Vec<crate::query::AnnRerankHit>> {
4098        self.with_authorized_scored_read_context_at(
4099            table_name,
4100            None,
4101            true,
4102            Some(&ReadAuthorization {
4103                operation: crate::auth::ColumnOperation::Select,
4104                columns: vec![request.column_id],
4105                permissions: Vec::new(),
4106            }),
4107            None,
4108            None,
4109            |table, snapshot, authorization, principal| {
4110                self.require_columns_for(
4111                    table_name,
4112                    crate::auth::ColumnOperation::Select,
4113                    &[request.column_id],
4114                    principal,
4115                )?;
4116                table.ann_rerank_at_with_candidate_authorization_on_generation(
4117                    request,
4118                    snapshot,
4119                    authorization,
4120                    None,
4121                )
4122            },
4123        )
4124    }
4125
4126    /// Capture one table snapshot and the security version used to authorize it.
4127    /// The caller must validate the returned version before publishing results.
4128    pub fn authorized_read_snapshot(
4129        &self,
4130        table: &str,
4131        principal: Option<&crate::auth::Principal>,
4132    ) -> Result<AuthorizedReadSnapshot> {
4133        let (security, security_version, effective_principal) = {
4134            let catalog = self.catalog.read();
4135            (
4136                catalog.security.clone(),
4137                catalog.security_version,
4138                self.principal_for_authorized_read(&catalog, principal, false)?,
4139            )
4140        };
4141        let handle = self.table(table)?;
4142        let (table_snapshot, data_generation, allowed_row_ids) = {
4143            let table_handle = handle.lock();
4144            let table_snapshot = table_handle.snapshot();
4145            let data_generation = table_handle.data_generation();
4146            let allowed = self.allowed_row_ids_locked(
4147                table,
4148                &table_handle,
4149                table_snapshot,
4150                (&security, security_version),
4151                effective_principal.as_ref(),
4152                None,
4153            )?;
4154            (
4155                table_snapshot,
4156                data_generation,
4157                allowed.map(|allowed| (*allowed).clone()),
4158            )
4159        };
4160        Ok(AuthorizedReadSnapshot {
4161            table: table.to_string(),
4162            table_snapshot,
4163            data_generation,
4164            security_version,
4165            allowed_row_ids,
4166        })
4167    }
4168
4169    pub fn authorized_read_snapshot_valid(&self, snapshot: &AuthorizedReadSnapshot) -> bool {
4170        if self.catalog.read().security_version != snapshot.security_version {
4171            return false;
4172        }
4173        self.table(&snapshot.table)
4174            .ok()
4175            .is_some_and(|table| table.lock().data_generation() == snapshot.data_generation)
4176    }
4177
4178    pub fn rls_cache_stats(&self) -> RlsCacheStats {
4179        self.rls_cache.lock().stats()
4180    }
4181
4182    /// Read visible rows with column authorization, RLS, and masks applied.
4183    pub fn rows_for(
4184        &self,
4185        table: &str,
4186        principal: Option<&crate::auth::Principal>,
4187    ) -> Result<Vec<crate::memtable::Row>> {
4188        if principal.is_none() && self.principal.read().is_some() {
4189            self.refresh_principal()?;
4190        }
4191        let allowed = self.select_column_ids_for(table, principal)?;
4192        let handle = self.table(table)?;
4193        let rows = {
4194            let table = handle.lock();
4195            table.visible_rows(table.snapshot())?
4196        };
4197        let mut rows = self.secure_rows_for(table, rows, principal)?;
4198        for row in &mut rows {
4199            row.columns.retain(|column, _| allowed.contains(column));
4200        }
4201        Ok(rows)
4202    }
4203
4204    /// Historical rows use the current principal and security catalog against
4205    /// the row values visible at the requested snapshot.
4206    pub fn rows_at_epoch_for_current_principal(
4207        &self,
4208        table_name: &str,
4209        snapshot: Snapshot,
4210    ) -> Result<Vec<crate::memtable::Row>> {
4211        self.with_authorized_read_context(
4212            table_name,
4213            None,
4214            true,
4215            Some(&ReadAuthorization {
4216                operation: crate::auth::ColumnOperation::Select,
4217                columns: Vec::new(),
4218                permissions: Vec::new(),
4219            }),
4220            None,
4221            Some(snapshot),
4222            |table, snapshot, allowed, principal| {
4223                let allowed_columns = self.select_column_ids_for(table_name, principal)?;
4224                let mut rows = table.visible_rows(snapshot)?;
4225                if let Some(allowed) = allowed {
4226                    rows.retain(|row| allowed.contains(&row.row_id));
4227                }
4228                rows = self.secure_rows_for(table_name, rows, principal)?;
4229                for row in &mut rows {
4230                    row.columns
4231                        .retain(|column, _| allowed_columns.contains(column));
4232                }
4233                Ok(rows)
4234            },
4235        )
4236    }
4237
4238    /// Count rows visible to a principal without bypassing RLS.
4239    pub fn count_for(
4240        &self,
4241        table: &str,
4242        principal: Option<&crate::auth::Principal>,
4243    ) -> Result<u64> {
4244        if principal.is_none() && self.principal.read().is_some() {
4245            self.refresh_principal()?;
4246        }
4247        self.select_column_ids_for(table, principal)?;
4248        if self.security_active_for(table) {
4249            Ok(self.rows_for(table, principal)?.len() as u64)
4250        } else {
4251            Ok(self.table(table)?.lock().count())
4252        }
4253    }
4254
4255    /// Authorize and write one native-API row for an explicit principal.
4256    pub fn put_for(
4257        &self,
4258        table: &str,
4259        mut cells: Vec<(u16, crate::memtable::Value)>,
4260        principal: Option<&crate::auth::Principal>,
4261    ) -> Result<RowId> {
4262        let columns = cells.iter().map(|(column, _)| *column).collect::<Vec<_>>();
4263        self.require_columns_for(
4264            table,
4265            crate::auth::ColumnOperation::Insert,
4266            &columns,
4267            principal,
4268        )?;
4269        let handle = self.table(table)?;
4270        let mut table_handle = handle.lock();
4271        table_handle.fill_auto_inc(&mut cells)?;
4272        table_handle.apply_defaults(&mut cells)?;
4273        let mut row = crate::memtable::Row::new(RowId(0), self.epoch.visible());
4274        row.columns.extend(cells.iter().cloned());
4275        self.check_row_policy_for(
4276            table,
4277            crate::security::PolicyCommand::Insert,
4278            &row,
4279            true,
4280            principal,
4281        )?;
4282        table_handle.put(cells)
4283    }
4284
4285    pub fn check_row_policy_for(
4286        &self,
4287        table: &str,
4288        command: crate::security::PolicyCommand,
4289        row: &crate::memtable::Row,
4290        check_new: bool,
4291        principal: Option<&crate::auth::Principal>,
4292    ) -> Result<()> {
4293        let security = self.catalog.read().security.clone();
4294        if !security.rls_enabled(table) {
4295            return Ok(());
4296        }
4297        let cached = self.principal.read().clone();
4298        let principal = principal
4299            .or(cached.as_ref())
4300            .ok_or(MongrelError::AuthRequired)?;
4301        if security.row_allowed(table, command, row, principal, check_new) {
4302            return Ok(());
4303        }
4304        let required = match command {
4305            crate::security::PolicyCommand::Insert => crate::auth::Permission::Insert {
4306                table: table.to_string(),
4307            },
4308            crate::security::PolicyCommand::Update => crate::auth::Permission::Update {
4309                table: table.to_string(),
4310            },
4311            crate::security::PolicyCommand::Select => crate::auth::Permission::Select {
4312                table: table.to_string(),
4313            },
4314            crate::security::PolicyCommand::Delete | crate::security::PolicyCommand::All => {
4315                crate::auth::Permission::Delete {
4316                    table: table.to_string(),
4317                }
4318            }
4319        };
4320        Err(MongrelError::PermissionDenied {
4321            required,
4322            principal: principal.username.clone(),
4323        })
4324    }
4325
4326    /// Durably create or replace a materialized-view definition after its
4327    /// physical table has been populated.
4328    pub fn set_materialized_view(
4329        &self,
4330        definition: crate::catalog::MaterializedViewEntry,
4331    ) -> Result<()> {
4332        self.set_materialized_view_with_epoch(definition)
4333            .map(|_| ())
4334    }
4335
4336    /// Durably create or replace a materialized-view definition and return its epoch.
4337    pub fn set_materialized_view_with_epoch(
4338        &self,
4339        definition: crate::catalog::MaterializedViewEntry,
4340    ) -> Result<Epoch> {
4341        use crate::wal::DdlOp;
4342        use std::sync::atomic::Ordering;
4343
4344        self.require(&crate::auth::Permission::Ddl)?;
4345        if self.poisoned.load(Ordering::Relaxed) {
4346            return Err(MongrelError::Other(
4347                "database poisoned by fsync error".into(),
4348            ));
4349        }
4350        if definition.name.is_empty() || definition.query.trim().is_empty() {
4351            return Err(MongrelError::InvalidArgument(
4352                "materialized view name and query must not be empty".into(),
4353            ));
4354        }
4355
4356        let _ddl = self.ddl_lock.lock();
4357        let _security_write = self.security_write()?;
4358        self.require(&crate::auth::Permission::Ddl)?;
4359        let table_id = self
4360            .catalog
4361            .read()
4362            .live(&definition.name)
4363            .ok_or_else(|| {
4364                MongrelError::NotFound(format!(
4365                    "materialized view table {:?} not found",
4366                    definition.name
4367                ))
4368            })?
4369            .table_id;
4370        let definition_json = DdlOp::encode_materialized_view(&definition)?;
4371        let _commit = self.commit_lock.lock();
4372        let epoch = self.epoch.bump_assigned();
4373        let mut epoch_guard = EpochGuard::new(self.epoch.as_ref(), epoch);
4374        let txn_id = self.alloc_txn_id()?;
4375        let mut next_catalog = self.catalog.read().clone();
4376        if let Some(existing) = next_catalog
4377            .materialized_views
4378            .iter_mut()
4379            .find(|existing| existing.name == definition.name)
4380        {
4381            *existing = definition.clone();
4382        } else {
4383            next_catalog.materialized_views.push(definition.clone());
4384        }
4385        next_catalog.db_epoch = next_catalog.db_epoch.max(epoch.0);
4386        let commit_seq = {
4387            let mut wal = self.shared_wal.lock();
4388            let append: Result<u64> = (|| {
4389                wal.append(
4390                    txn_id,
4391                    table_id,
4392                    crate::wal::Op::Ddl(DdlOp::SetMaterializedView {
4393                        name: definition.name.clone(),
4394                        definition_json,
4395                    }),
4396                )?;
4397                append_catalog_snapshot(&mut wal, txn_id, &next_catalog)?;
4398                wal.append_commit(txn_id, epoch, &[])
4399            })();
4400            append.map_err(|error| self.commit_outcome_unknown(epoch, error))?
4401        };
4402        self.await_durable_commit(commit_seq, epoch)?;
4403
4404        let checkpoint = self.checkpoint_catalog_after_durable(next_catalog);
4405        self.finish_durable_publish(epoch, &mut epoch_guard, checkpoint)?;
4406        Ok(epoch)
4407    }
4408
4409    /// The filesystem root this database was opened/created at.
4410    pub fn root(&self) -> &Path {
4411        self.durable_root.canonical_path()
4412    }
4413
4414    /// Open a descriptor-pinned view of this database root for durable
4415    /// extension state such as server idempotency receipts.
4416    pub fn durable_root(&self) -> Arc<crate::durable_file::DurableRoot> {
4417        Arc::clone(&self.durable_root)
4418    }
4419
4420    /// Domain-separated authentication key for server idempotency state.
4421    /// Encrypted databases derive it from the in-memory KEK. Plain databases
4422    /// return `None`; their server persists a random key under the pinned root.
4423    #[cfg(feature = "encryption")]
4424    pub fn derive_server_idempotency_key(&self) -> Option<zeroize::Zeroizing<[u8; 32]>> {
4425        self.kek
4426            .as_deref()
4427            .map(|kek| kek.derive_subkey(b"mongreldb/server/idempotency/v1"))
4428    }
4429
4430    #[cfg(not(feature = "encryption"))]
4431    pub fn derive_server_idempotency_key(&self) -> Option<zeroize::Zeroizing<[u8; 32]>> {
4432        None
4433    }
4434
4435    pub fn is_read_only_replica(&self) -> bool {
4436        self.read_only
4437    }
4438
4439    /// Reject reads whose backing state may require WAL recovery after a
4440    /// post-commit publication failure. Ordinary table/catalog state is made
4441    /// coherent before poison; file-backed external modules use this gate.
4442    pub fn ensure_consistent_read(&self) -> Result<()> {
4443        if self.poisoned.load(Ordering::Relaxed) {
4444            return Err(MongrelError::Other(
4445                "database poisoned by post-commit failure; reopen required".into(),
4446            ));
4447        }
4448        Ok(())
4449    }
4450
4451    pub fn set_replication_wal_retention_segments(&self, segments: usize) {
4452        self.replication_wal_retention_segments
4453            .store(segments, std::sync::atomic::Ordering::Relaxed);
4454    }
4455
4456    /// Capture a consistent bootstrap image. DDL, transaction spill/publish,
4457    /// direct table commits, compaction, and WAL append are quiesced while the
4458    /// file image is read. WAL records newer than manifests remain sufficient
4459    /// for recovery, so no flush or compaction is required.
4460    pub fn replication_snapshot(&self) -> Result<crate::replication::ReplicationSnapshot> {
4461        let admin = crate::auth::Permission::Admin;
4462        self.require(&admin)?;
4463        let operation_principal = self.principal_snapshot();
4464        let _barrier = self.replication_barrier.write();
4465        let _ddl = self.ddl_lock.lock();
4466        let _security = self.security_coordinator.gate.read();
4467        self.require_exact_principal_current(operation_principal.as_ref(), &admin)?;
4468        let mut handles: Vec<_> = self
4469            .tables
4470            .read()
4471            .iter()
4472            .map(|(id, handle)| (*id, handle.clone()))
4473            .collect();
4474        handles.sort_by_key(|(id, _)| *id);
4475        let _table_guards: Vec<_> = handles.iter().map(|(_, handle)| handle.lock()).collect();
4476        let _commit = self.commit_lock.lock();
4477        let mut wal = self.shared_wal.lock();
4478        wal.group_sync()?;
4479        let wal_dek = crate::encryption::wal_dek_for(self.kek.as_deref());
4480        let records = crate::wal::SharedWal::replay_with_dek(&self.root, wal_dek.as_ref())?;
4481        let epoch = records
4482            .iter()
4483            .filter_map(|record| match &record.op {
4484                crate::wal::Op::TxnCommit { epoch, .. } => Some(*epoch),
4485                _ => None,
4486            })
4487            .max()
4488            .unwrap_or(0)
4489            .max(self.epoch.committed().0);
4490        let files = crate::replication::capture_files(&self.root)?;
4491        let source_id = crate::replication::replication_identity_durable(&self.durable_root)?;
4492        drop(wal);
4493        Ok(crate::replication::ReplicationSnapshot::new(
4494            source_id, epoch, files,
4495        ))
4496    }
4497
4498    /// Create an online, directly-openable backup at `destination`.
4499    ///
4500    /// The short boundary phase quiesces commits/DDL, syncs the WAL, copies
4501    /// mutable metadata, and pins the exact immutable runs named by the copied
4502    /// manifests. Writers resume while those runs stream into a sibling staging
4503    /// directory. A checksummed backup manifest is written last, then the stage
4504    /// is atomically renamed into place.
4505    pub fn hot_backup(&self, destination: impl AsRef<Path>) -> Result<crate::backup::BackupReport> {
4506        let control = crate::ExecutionControl::new(None);
4507        self.hot_backup_controlled(destination, &control, || true)
4508    }
4509
4510    pub(crate) fn hot_backup_to_durable_child(
4511        &self,
4512        parent: &crate::durable_file::DurableRoot,
4513        child: &Path,
4514        control: &crate::ExecutionControl,
4515    ) -> Result<crate::backup::BackupReport> {
4516        let mut components = child.components();
4517        if !matches!(components.next(), Some(std::path::Component::Normal(_)))
4518            || components.next().is_some()
4519        {
4520            return Err(MongrelError::InvalidArgument(
4521                "durable backup child must be one relative path component".into(),
4522            ));
4523        }
4524        let destination_name = child.file_name().ok_or_else(|| {
4525            MongrelError::InvalidArgument("durable backup child has no filename".into())
4526        })?;
4527        let prepared = prepare_backup_destination_in(&self.root, parent, destination_name)?;
4528        self.hot_backup_prepared(prepared, control, || true)
4529    }
4530
4531    /// Build a backup cooperatively, then invoke `before_publish` immediately
4532    /// before the staging directory is atomically renamed into place.
4533    #[doc(hidden)]
4534    pub fn hot_backup_controlled<F>(
4535        &self,
4536        destination: impl AsRef<Path>,
4537        control: &crate::ExecutionControl,
4538        before_publish: F,
4539    ) -> Result<crate::backup::BackupReport>
4540    where
4541        F: FnOnce() -> bool,
4542    {
4543        let prepared = prepare_backup_destination(&self.root, destination.as_ref())?;
4544        self.hot_backup_prepared(prepared, control, before_publish)
4545    }
4546
4547    fn hot_backup_prepared<F>(
4548        &self,
4549        mut prepared: PreparedBackupDestination,
4550        control: &crate::ExecutionControl,
4551        before_publish: F,
4552    ) -> Result<crate::backup::BackupReport>
4553    where
4554        F: FnOnce() -> bool,
4555    {
4556        let admin = crate::auth::Permission::Admin;
4557        self.require(&admin)?;
4558        let operation_principal = self.principal_snapshot();
4559        control.checkpoint()?;
4560        let destination = prepared.destination_path.clone();
4561        let mut before_publish = Some(before_publish);
4562
4563        let outcome = (|| {
4564            control.checkpoint()?;
4565            let barrier = self.replication_barrier.write();
4566            let ddl = self.ddl_lock.lock();
4567            let security = self.security_coordinator.gate.read();
4568            self.require_exact_principal_current(operation_principal.as_ref(), &admin)?;
4569            let mut handles: Vec<_> = self
4570                .tables
4571                .read()
4572                .iter()
4573                .map(|(id, handle)| (*id, handle.clone()))
4574                .collect();
4575            handles.sort_by_key(|(id, _)| *id);
4576            let table_guards: Vec<_> = handles.iter().map(|(_, handle)| handle.lock()).collect();
4577            let commit = self.commit_lock.lock();
4578            let mut wal = self.shared_wal.lock();
4579            wal.group_sync()?;
4580            let epoch = self.epoch.committed().0;
4581            let boundary_unix_nanos = current_unix_nanos();
4582
4583            let pin_nonce = std::time::SystemTime::now()
4584                .duration_since(std::time::UNIX_EPOCH)
4585                .unwrap_or_default()
4586                .as_nanos();
4587            let file_pin_root = self
4588                .root
4589                .join(META_DIR)
4590                .join("backup-pins")
4591                .join(format!("{}-{pin_nonce}", std::process::id()));
4592            std::fs::create_dir_all(&file_pin_root)?;
4593            let _file_pins = BackupFilePins {
4594                root: file_pin_root.clone(),
4595            };
4596            let mut run_files = Vec::new();
4597            for (index, (table_id, _)) in handles.iter().enumerate() {
4598                if index % 256 == 0 {
4599                    control.checkpoint()?;
4600                }
4601                let table = &table_guards[index];
4602                for (run_index, run) in table.run_refs().iter().enumerate() {
4603                    if run_index % 256 == 0 {
4604                        control.checkpoint()?;
4605                    }
4606                    let source = table.run_path(run.run_id as u64);
4607                    let relative = Path::new(TABLES_DIR)
4608                        .join(table_id.to_string())
4609                        .join(crate::engine::RUNS_DIR)
4610                        .join(format!("r-{}.sr", run.run_id));
4611                    let pinned = file_pin_root.join(format!("{table_id}-{}.sr", run.run_id));
4612                    if std::fs::hard_link(&source, &pinned).is_err() {
4613                        crate::backup::copy_file_synced(&source, &pinned)?;
4614                    }
4615                    run_files.push(((*table_id, run.run_id), pinned, relative));
4616                }
4617            }
4618            crate::durable_file::sync_directory(&file_pin_root)?;
4619            let run_keys: Vec<_> = run_files.iter().map(|(key, _, _)| *key).collect();
4620            {
4621                let mut pins = self.backup_pins.lock();
4622                for key in &run_keys {
4623                    *pins.entry(*key).or_insert(0) += 1;
4624                }
4625            }
4626            let _run_pins = RunPins {
4627                pins: Arc::clone(&self.backup_pins),
4628                runs: run_keys,
4629            };
4630            let deferred: HashSet<_> = run_files
4631                .iter()
4632                .map(|(_, _, relative)| relative.clone())
4633                .collect();
4634            let mut copied = Vec::new();
4635            copy_backup_boundary(
4636                &self.root,
4637                prepared.stage.as_deref().ok_or_else(|| {
4638                    MongrelError::Other("backup staging root was already released".into())
4639                })?,
4640                &deferred,
4641                &mut copied,
4642                Some(control),
4643            )?;
4644
4645            drop(wal);
4646            drop(commit);
4647            drop(table_guards);
4648            drop(security);
4649            drop(ddl);
4650            drop(barrier);
4651
4652            if let Some(hook) = self.backup_hook.lock().as_ref() {
4653                hook();
4654            }
4655            for (index, (_, source, relative)) in run_files.into_iter().enumerate() {
4656                if index % 256 == 0 {
4657                    control.checkpoint()?;
4658                }
4659                let mut source = crate::durable_file::open_regular_nofollow(&source)?;
4660                prepared
4661                    .stage
4662                    .as_deref()
4663                    .ok_or_else(|| {
4664                        MongrelError::Other("backup staging root was already released".into())
4665                    })?
4666                    .copy_new_from(&relative, &mut source)?;
4667                copied.push(relative);
4668            }
4669
4670            let manifest = crate::backup::BackupManifest::create_controlled_durable(
4671                prepared.stage.as_deref().ok_or_else(|| {
4672                    MongrelError::Other("backup staging root was already released".into())
4673                })?,
4674                epoch,
4675                &copied,
4676                control,
4677            )?;
4678            manifest.write_to_durable(prepared.stage.as_deref().ok_or_else(|| {
4679                MongrelError::Other("backup staging root was already released".into())
4680            })?)?;
4681            control.checkpoint()?;
4682            let publish = before_publish.take().ok_or_else(|| {
4683                MongrelError::Other("backup publication callback already consumed".into())
4684            })?;
4685            if !publish() {
4686                return Err(MongrelError::Cancelled);
4687            }
4688            let final_security = self.security_coordinator.gate.read();
4689            self.require_exact_principal_current(operation_principal.as_ref(), &admin)?;
4690            // Windows pins directories without delete sharing. Release the
4691            // stage handle before renaming that directory, while the parent
4692            // remains descriptor-pinned for the no-replace publication.
4693            drop(prepared.stage.take().ok_or_else(|| {
4694                MongrelError::Other("backup staging root was already released".into())
4695            })?);
4696            let published = std::cell::Cell::new(false);
4697            if let Err(error) = prepared.parent.rename_directory_new_with_after(
4698                Path::new(&prepared.stage_name),
4699                &prepared.parent,
4700                Path::new(&prepared.destination_name),
4701                || published.set(true),
4702            ) {
4703                if published.get() {
4704                    return Err(MongrelError::CommitOutcomeUnknown {
4705                        epoch,
4706                        message: format!("backup publication was not durable: {error}"),
4707                    });
4708                }
4709                return Err(error.into());
4710            }
4711            drop(final_security);
4712            Ok(crate::backup::BackupReport {
4713                destination,
4714                epoch,
4715                boundary_unix_nanos,
4716                files: manifest.files.len(),
4717                bytes: manifest.total_bytes(),
4718            })
4719        })();
4720
4721        if outcome.is_err() {
4722            drop(prepared.stage.take());
4723            let _ = prepared
4724                .parent
4725                .remove_directory_all(Path::new(&prepared.stage_name));
4726        }
4727        outcome
4728    }
4729
4730    /// Return complete committed transactions after `since_epoch`. A gap or a
4731    /// transaction backed by a spilled run requires a fresh bootstrap image.
4732    pub fn replication_batch_since(
4733        &self,
4734        since_epoch: u64,
4735    ) -> Result<crate::replication::ReplicationBatch> {
4736        use crate::wal::Op;
4737
4738        let admin = crate::auth::Permission::Admin;
4739        self.require(&admin)?;
4740        let operation_principal = self.principal_snapshot();
4741
4742        let mut wal = self.shared_wal.lock();
4743        wal.group_sync()?;
4744        let wal_dek = crate::encryption::wal_dek_for(self.kek.as_deref());
4745        let records = crate::wal::SharedWal::replay_with_dek(&self.root, wal_dek.as_ref())?;
4746        drop(wal);
4747
4748        let commits: HashMap<u64, u64> = records
4749            .iter()
4750            .filter_map(|record| match &record.op {
4751                Op::TxnCommit { epoch, .. } => Some((record.txn_id, *epoch)),
4752                _ => None,
4753            })
4754            .collect();
4755        let earliest_epoch = commits.values().copied().min();
4756        let current_epoch = commits
4757            .values()
4758            .copied()
4759            .max()
4760            .unwrap_or(0)
4761            .max(self.epoch.committed().0);
4762        let selected: HashSet<u64> = commits
4763            .iter()
4764            .filter_map(|(txn_id, epoch)| (*epoch > since_epoch).then_some(*txn_id))
4765            .collect();
4766        let retention_gap = since_epoch < current_epoch
4767            && since_epoch < crate::replication::replication_wal_floor(&self.root)?;
4768        let spilled = records.iter().any(|record| {
4769            selected.contains(&record.txn_id)
4770                && matches!(
4771                    &record.op,
4772                    Op::TxnCommit { added_runs, .. } if !added_runs.is_empty()
4773                )
4774        });
4775        let records = records
4776            .into_iter()
4777            .filter(|record| record.txn_id != crate::wal::SYSTEM_TXN_ID)
4778            .filter(|record| selected.contains(&record.txn_id))
4779            .collect();
4780        let source_id = crate::replication::replication_identity_durable(&self.durable_root)?;
4781        let batch = crate::replication::ReplicationBatch::complete_for_source(
4782            source_id,
4783            since_epoch,
4784            current_epoch,
4785            earliest_epoch,
4786            retention_gap,
4787            spilled,
4788            records,
4789        )?;
4790        if let Some(hook) = self.replication_hook.lock().as_ref() {
4791            hook();
4792        }
4793        let _security = self.security_coordinator.gate.read();
4794        self.require_exact_principal_current(operation_principal.as_ref(), &admin)?;
4795        Ok(batch)
4796    }
4797
4798    /// Durably append a leader batch to a follower's local WAL and checkpoint
4799    /// its catalog metadata. Security changes apply to this live handle before
4800    /// success returns. The caller must reopen to mount new table state.
4801    pub fn append_replication_batch(
4802        &self,
4803        batch: &crate::replication::ReplicationBatch,
4804    ) -> Result<u64> {
4805        use crate::wal::Op;
4806
4807        if !self.read_only {
4808            return Err(MongrelError::InvalidArgument(
4809                "replication batches may only target a marked replica".into(),
4810            ));
4811        }
4812        let current = crate::replication::replica_epoch(&self.root)?;
4813        if batch.is_source_bound() {
4814            let source_id = crate::replication::replica_source_id_durable(&self.durable_root)?;
4815            if batch.source_id != source_id {
4816                return Err(MongrelError::Conflict(
4817                    "replication batch source does not match follower binding".into(),
4818                ));
4819            }
4820        }
4821        if batch.requires_snapshot {
4822            return Err(MongrelError::Conflict(
4823                "replication snapshot required for this batch".into(),
4824            ));
4825        }
4826        batch.validate_proof()?;
4827        if batch.from_epoch != current {
4828            if batch.from_epoch < current && batch.current_epoch == current {
4829                let wal_dek = crate::encryption::wal_dek_for(self.kek.as_deref());
4830                let _wal = self.shared_wal.lock();
4831                let existing: HashSet<(u64, u64)> =
4832                    crate::wal::SharedWal::replay_with_dek(&self.root, wal_dek.as_ref())?
4833                        .into_iter()
4834                        .filter_map(|record| match record.op {
4835                            Op::TxnCommit { epoch, .. } => Some((record.txn_id, epoch)),
4836                            _ => None,
4837                        })
4838                        .collect();
4839                let already_applied = batch.records.iter().all(|record| match &record.op {
4840                    Op::TxnCommit { epoch, .. } => existing.contains(&(record.txn_id, *epoch)),
4841                    _ => true,
4842                });
4843                if already_applied {
4844                    return Ok(current);
4845                }
4846            }
4847            return Err(MongrelError::Conflict(format!(
4848                "replication batch starts at epoch {}, follower is at epoch {current}",
4849                batch.from_epoch
4850            )));
4851        }
4852        if batch.current_epoch < current {
4853            return Err(MongrelError::InvalidArgument(format!(
4854                "replication batch current epoch {} precedes follower epoch {current}",
4855                batch.current_epoch
4856            )));
4857        }
4858        let records = &batch.records;
4859        let mut commits = HashMap::new();
4860        let mut commit_epochs = HashSet::new();
4861        let mut commit_timestamps = HashMap::new();
4862        for record in records {
4863            match &record.op {
4864                Op::TxnCommit { epoch, added_runs } => {
4865                    if !added_runs.is_empty() {
4866                        return Err(MongrelError::Conflict(
4867                            "replication snapshot required for spilled-run transaction".into(),
4868                        ));
4869                    }
4870                    if commits.insert(record.txn_id, *epoch).is_some() {
4871                        return Err(MongrelError::InvalidArgument(format!(
4872                            "duplicate commit for replication transaction {}",
4873                            record.txn_id
4874                        )));
4875                    }
4876                    if *epoch <= current || *epoch > batch.current_epoch {
4877                        return Err(MongrelError::InvalidArgument(format!(
4878                            "replication commit epoch {epoch} is outside ({current}, {}]",
4879                            batch.current_epoch
4880                        )));
4881                    }
4882                    if !commit_epochs.insert(*epoch) {
4883                        return Err(MongrelError::InvalidArgument(format!(
4884                            "duplicate replication commit epoch {epoch}"
4885                        )));
4886                    }
4887                }
4888                Op::CommitTimestamp { unix_nanos } => {
4889                    commit_timestamps.insert(record.txn_id, *unix_nanos);
4890                }
4891                _ => {}
4892            }
4893        }
4894        for record in records {
4895            if record.txn_id == crate::wal::SYSTEM_TXN_ID
4896                || matches!(&record.op, Op::TxnAbort | Op::Flush { .. })
4897            {
4898                return Err(MongrelError::InvalidArgument(
4899                    "replication batch contains a non-committed record".into(),
4900                ));
4901            }
4902            if !commits.contains_key(&record.txn_id) {
4903                return Err(MongrelError::InvalidArgument(format!(
4904                    "incomplete replication transaction {}",
4905                    record.txn_id
4906                )));
4907            }
4908        }
4909        let target_epoch = commits
4910            .values()
4911            .copied()
4912            .filter(|epoch| *epoch > current)
4913            .max()
4914            .unwrap_or(current);
4915        if target_epoch != batch.current_epoch {
4916            return Err(MongrelError::InvalidArgument(format!(
4917                "replication batch ends at epoch {target_epoch}, expected {}",
4918                batch.current_epoch
4919            )));
4920        }
4921        let mut selected: HashSet<u64> = commits
4922            .iter()
4923            .filter_map(|(txn_id, epoch)| (*epoch > current).then_some(*txn_id))
4924            .collect();
4925        if selected.is_empty() {
4926            return Ok(current);
4927        }
4928        let mut wal = self.shared_wal.lock();
4929        wal.group_sync()?;
4930        let wal_dek = crate::encryption::wal_dek_for(self.kek.as_deref());
4931        let existing: HashSet<(u64, u64)> =
4932            crate::wal::SharedWal::replay_with_dek(&self.root, wal_dek.as_ref())?
4933                .into_iter()
4934                .filter_map(|record| match record.op {
4935                    Op::TxnCommit { epoch, .. } => Some((record.txn_id, epoch)),
4936                    _ => None,
4937                })
4938                .collect();
4939        selected.retain(|txn_id| {
4940            commits
4941                .get(txn_id)
4942                .is_some_and(|epoch| !existing.contains(&(*txn_id, *epoch)))
4943        });
4944        for record in records {
4945            if !selected.contains(&record.txn_id) {
4946                continue;
4947            }
4948            match &record.op {
4949                Op::TxnCommit { epoch, added_runs } => {
4950                    let timestamp = commit_timestamps
4951                        .get(&record.txn_id)
4952                        .copied()
4953                        .unwrap_or_else(current_unix_nanos);
4954                    wal.append_commit_at(record.txn_id, Epoch(*epoch), added_runs, timestamp)?;
4955                }
4956                Op::TxnAbort | Op::Flush { .. } | Op::CommitTimestamp { .. } => {}
4957                op => {
4958                    wal.append(record.txn_id, 0, op.clone())?;
4959                }
4960            }
4961        }
4962        if !selected.is_empty() {
4963            wal.group_sync()?;
4964        }
4965        drop(wal);
4966
4967        // Auth mode is selected before `finish_open` replays the WAL. Make the
4968        // catalog transition durable and publish security state to this live
4969        // handle before reporting success.
4970        let mut recovered_catalog = self.catalog.read().clone();
4971        if let Err(error) = recover_ddl_from_wal(
4972            &self.root,
4973            Some(&self.durable_root),
4974            &mut recovered_catalog,
4975            self.meta_dek.as_ref(),
4976            wal_dek.as_ref(),
4977            true,
4978            None,
4979        ) {
4980            return Err(MongrelError::DurableCommit {
4981                epoch: target_epoch,
4982                message: format!(
4983                    "replication WAL is durable but catalog checkpoint failed: {error}"
4984                ),
4985            });
4986        }
4987        let _security = self.security_coordinator.gate.write();
4988        let old_security_version = self.catalog.read().security_version;
4989        let security_changed = old_security_version != recovered_catalog.security_version
4990            || self.catalog.read().require_auth != recovered_catalog.require_auth;
4991        let require_auth = recovered_catalog.require_auth;
4992        let principal = if security_changed {
4993            None
4994        } else {
4995            self.principal.read().as_ref().and_then(|principal| {
4996                Self::resolve_bound_principal_from_catalog(&recovered_catalog, principal)
4997            })
4998        };
4999        if require_auth {
5000            self.auth_state.set_require_auth(true);
5001        }
5002        self.auth_state.set_principal(principal.clone());
5003        *self.principal.write() = principal;
5004        let security_version = recovered_catalog.security_version;
5005        *self.catalog.write() = recovered_catalog;
5006        self.security_coordinator
5007            .version
5008            .store(security_version, Ordering::Release);
5009        if !require_auth {
5010            self.auth_state.set_require_auth(false);
5011        }
5012        if let Err(error) =
5013            crate::replication::reconcile_replica_epoch_durable(&self.durable_root, target_epoch)
5014        {
5015            return Err(MongrelError::DurableCommit {
5016                epoch: target_epoch,
5017                message: format!(
5018                    "replication WAL and catalog are durable but follower watermark failed: {error}"
5019                ),
5020            });
5021        }
5022        Ok(target_epoch)
5023    }
5024
5025    /// Resolve a table name → id (live tables only). pub(crate) so the
5026    /// transaction layer can stage by name.
5027    pub fn table_id(&self, name: &str) -> Result<u64> {
5028        let cat = self.catalog.read();
5029        cat.live(name)
5030            .map(|e| e.table_id)
5031            .ok_or_else(|| MongrelError::NotFound(format!("table {name:?} not found")))
5032    }
5033
5034    /// Return the stable table id and current schema generation from one
5035    /// catalog snapshot. Callers can bind retries to this identity so a table
5036    /// dropped and recreated under the same name is never mistaken for the
5037    /// original resource.
5038    pub fn table_identity(&self, name: &str) -> Result<(u64, u64)> {
5039        let catalog = self.catalog.read();
5040        catalog
5041            .live(name)
5042            .map(|entry| (entry.table_id, entry.schema.schema_id))
5043            .ok_or_else(|| MongrelError::NotFound(format!("table {name:?} not found")))
5044    }
5045
5046    pub(crate) fn building_table_id(&self, name: &str) -> Result<u64> {
5047        self.catalog
5048            .read()
5049            .building(name)
5050            .map(|entry| entry.table_id)
5051            .ok_or_else(|| MongrelError::NotFound(format!("building table {name:?} not found")))
5052    }
5053
5054    pub fn procedures(&self) -> Vec<StoredProcedure> {
5055        self.catalog
5056            .read()
5057            .procedures
5058            .iter()
5059            .map(|p| p.procedure.clone())
5060            .collect()
5061    }
5062
5063    pub fn procedure(&self, name: &str) -> Option<StoredProcedure> {
5064        self.catalog
5065            .read()
5066            .procedures
5067            .iter()
5068            .find(|p| p.procedure.name == name)
5069            .map(|p| p.procedure.clone())
5070    }
5071
5072    pub fn create_procedure(&self, procedure: StoredProcedure) -> Result<StoredProcedure> {
5073        self.create_procedure_inner(procedure, None)
5074    }
5075
5076    pub fn create_procedure_controlled<F>(
5077        &self,
5078        procedure: StoredProcedure,
5079        mut before_publish: F,
5080    ) -> Result<StoredProcedure>
5081    where
5082        F: FnMut() -> Result<()>,
5083    {
5084        self.create_procedure_inner(procedure, Some(&mut before_publish))
5085    }
5086
5087    fn create_procedure_inner(
5088        &self,
5089        mut procedure: StoredProcedure,
5090        before_publish: Option<&mut dyn FnMut() -> Result<()>>,
5091    ) -> Result<StoredProcedure> {
5092        self.require(&crate::auth::Permission::Ddl)?;
5093        let _g = self.ddl_lock.lock();
5094        let _security_write = self.security_write()?;
5095        self.require(&crate::auth::Permission::Ddl)?;
5096        procedure.validate()?;
5097        self.validate_procedure_references(&procedure)?;
5098        {
5099            let cat = self.catalog.read();
5100            if cat
5101                .procedures
5102                .iter()
5103                .any(|p| p.procedure.name == procedure.name)
5104            {
5105                return Err(MongrelError::InvalidArgument(format!(
5106                    "procedure {:?} already exists",
5107                    procedure.name
5108                )));
5109            }
5110        }
5111        let commit_lock = Arc::clone(&self.commit_lock);
5112        let _c = commit_lock.lock();
5113        let epoch = self.epoch.bump_assigned();
5114        let mut _epoch_guard = EpochGuard::new(self.epoch.as_ref(), epoch);
5115        procedure.created_epoch = epoch.0;
5116        procedure.updated_epoch = epoch.0;
5117        let mut next_catalog = self.catalog.read().clone();
5118        next_catalog
5119            .procedures
5120            .push(ProcedureEntry::from(procedure.clone()));
5121        next_catalog.db_epoch = epoch.0;
5122        self.publish_catalog_candidate(next_catalog, epoch, &mut _epoch_guard, before_publish)?;
5123        Ok(procedure)
5124    }
5125
5126    pub fn create_or_replace_procedure(
5127        &self,
5128        procedure: StoredProcedure,
5129    ) -> Result<StoredProcedure> {
5130        self.create_or_replace_procedure_inner(procedure, None)
5131    }
5132
5133    pub fn create_or_replace_procedure_controlled<F>(
5134        &self,
5135        procedure: StoredProcedure,
5136        mut before_publish: F,
5137    ) -> Result<StoredProcedure>
5138    where
5139        F: FnMut() -> Result<()>,
5140    {
5141        self.create_or_replace_procedure_inner(procedure, Some(&mut before_publish))
5142    }
5143
5144    fn create_or_replace_procedure_inner(
5145        &self,
5146        procedure: StoredProcedure,
5147        before_publish: Option<&mut dyn FnMut() -> Result<()>>,
5148    ) -> Result<StoredProcedure> {
5149        self.require(&crate::auth::Permission::Ddl)?;
5150        let _g = self.ddl_lock.lock();
5151        let _security_write = self.security_write()?;
5152        self.require(&crate::auth::Permission::Ddl)?;
5153        procedure.validate()?;
5154        self.validate_procedure_references(&procedure)?;
5155        let commit_lock = Arc::clone(&self.commit_lock);
5156        let _c = commit_lock.lock();
5157        let epoch = self.epoch.bump_assigned();
5158        let mut _epoch_guard = EpochGuard::new(self.epoch.as_ref(), epoch);
5159        let mut next_catalog = self.catalog.read().clone();
5160        let replaced = {
5161            let next = match next_catalog
5162                .procedures
5163                .iter()
5164                .position(|p| p.procedure.name == procedure.name)
5165            {
5166                Some(idx) => {
5167                    let next = next_catalog.procedures[idx]
5168                        .procedure
5169                        .replaced(procedure.clone(), epoch.0)?;
5170                    next_catalog.procedures[idx] = ProcedureEntry::from(next.clone());
5171                    next
5172                }
5173                None => {
5174                    let mut next = procedure;
5175                    next.created_epoch = epoch.0;
5176                    next.updated_epoch = epoch.0;
5177                    next_catalog
5178                        .procedures
5179                        .push(ProcedureEntry::from(next.clone()));
5180                    next
5181                }
5182            };
5183            next_catalog.db_epoch = epoch.0;
5184            next
5185        };
5186        self.publish_catalog_candidate(next_catalog, epoch, &mut _epoch_guard, before_publish)?;
5187        Ok(replaced)
5188    }
5189
5190    pub fn drop_procedure(&self, name: &str) -> Result<()> {
5191        self.drop_procedure_with_epoch(name).map(|_| ())
5192    }
5193
5194    pub fn drop_procedure_with_epoch(&self, name: &str) -> Result<Epoch> {
5195        self.drop_procedure_with_epoch_inner(name, None)
5196    }
5197
5198    pub fn drop_procedure_with_epoch_controlled<F>(
5199        &self,
5200        name: &str,
5201        mut before_publish: F,
5202    ) -> Result<Epoch>
5203    where
5204        F: FnMut() -> Result<()>,
5205    {
5206        self.drop_procedure_with_epoch_inner(name, Some(&mut before_publish))
5207    }
5208
5209    fn drop_procedure_with_epoch_inner(
5210        &self,
5211        name: &str,
5212        before_publish: Option<&mut dyn FnMut() -> Result<()>>,
5213    ) -> Result<Epoch> {
5214        self.require(&crate::auth::Permission::Ddl)?;
5215        let _g = self.ddl_lock.lock();
5216        let _security_write = self.security_write()?;
5217        self.require(&crate::auth::Permission::Ddl)?;
5218        let commit_lock = Arc::clone(&self.commit_lock);
5219        let _c = commit_lock.lock();
5220        let epoch = self.epoch.bump_assigned();
5221        let mut _epoch_guard = EpochGuard::new(self.epoch.as_ref(), epoch);
5222        let mut next_catalog = self.catalog.read().clone();
5223        let before = next_catalog.procedures.len();
5224        next_catalog.procedures.retain(|p| p.procedure.name != name);
5225        if next_catalog.procedures.len() == before {
5226            return Err(MongrelError::NotFound(format!(
5227                "procedure {name:?} not found"
5228            )));
5229        }
5230        next_catalog.db_epoch = epoch.0;
5231        self.publish_catalog_candidate(next_catalog, epoch, &mut _epoch_guard, before_publish)?;
5232        Ok(epoch)
5233    }
5234
5235    // ── User / role / credentials management ─────────────────────────────
5236
5237    /// List all catalog users (password hashes included — callers should not
5238    /// serialize them externally).
5239    pub fn users(&self) -> Vec<crate::auth::UserEntry> {
5240        self.catalog.read().users.clone()
5241    }
5242
5243    /// Resolve only the stable, non-secret identity fields needed to scope
5244    /// request receipts. Password hashes never leave the catalog lock.
5245    pub fn user_identity(&self, username: &str) -> Option<(u64, u64)> {
5246        self.catalog
5247            .read()
5248            .users
5249            .iter()
5250            .find(|user| user.username == username)
5251            .map(|user| (user.id, user.created_epoch))
5252    }
5253
5254    /// Current catalog authorization generation. Retry bindings can include
5255    /// this value to fail closed after roles, grants, or row policies change.
5256    pub fn security_version(&self) -> u64 {
5257        self.catalog.read().security_version
5258    }
5259
5260    /// List all catalog roles.
5261    pub fn roles(&self) -> Vec<crate::auth::RoleEntry> {
5262        self.catalog.read().roles.clone()
5263    }
5264
5265    /// Create a new user with an Argon2id-hashed password.
5266    pub fn create_user(&self, username: &str, password: &str) -> Result<crate::auth::UserEntry> {
5267        self.require(&crate::auth::Permission::Admin)?;
5268        let hash = crate::auth::hash_password(password).map_err(MongrelError::Other)?;
5269        self.create_user_with_password_hash(username, hash)
5270    }
5271
5272    /// Create a user from a password hash prepared before a commit fence.
5273    pub fn create_user_with_password_hash(
5274        &self,
5275        username: &str,
5276        hash: String,
5277    ) -> Result<crate::auth::UserEntry> {
5278        self.create_user_with_password_hash_inner(username, hash, None)
5279    }
5280
5281    pub fn create_user_with_password_hash_controlled<F>(
5282        &self,
5283        username: &str,
5284        hash: String,
5285        mut before_publish: F,
5286    ) -> Result<crate::auth::UserEntry>
5287    where
5288        F: FnMut() -> Result<()>,
5289    {
5290        self.create_user_with_password_hash_inner(username, hash, Some(&mut before_publish))
5291    }
5292
5293    fn create_user_with_password_hash_inner(
5294        &self,
5295        username: &str,
5296        hash: String,
5297        before_publish: Option<&mut dyn FnMut() -> Result<()>>,
5298    ) -> Result<crate::auth::UserEntry> {
5299        self.require(&crate::auth::Permission::Admin)?;
5300        let _ddl = self.ddl_lock.lock();
5301        let _security_write = self.security_write()?;
5302        self.require(&crate::auth::Permission::Admin)?;
5303        let _commit = self.commit_lock.lock();
5304        let epoch = self.epoch.bump_assigned();
5305        let mut _epoch_guard = EpochGuard::new(self.epoch.as_ref(), epoch);
5306        let mut next_catalog = self.catalog.read().clone();
5307        if next_catalog.users.iter().any(|u| u.username == username) {
5308            return Err(MongrelError::InvalidArgument(format!(
5309                "user {username:?} already exists"
5310            )));
5311        }
5312        next_catalog.next_user_id = next_catalog.next_user_id.max(1);
5313        let id = next_catalog.next_user_id;
5314        next_catalog.next_user_id = id
5315            .checked_add(1)
5316            .ok_or_else(|| MongrelError::Full("user-id namespace exhausted".into()))?;
5317        let entry = crate::auth::UserEntry {
5318            id,
5319            username: username.into(),
5320            password_hash: hash,
5321            roles: Vec::new(),
5322            is_admin: false,
5323            created_epoch: epoch.0,
5324        };
5325        next_catalog.users.push(entry.clone());
5326        advance_security_version(&mut next_catalog)?;
5327        next_catalog.db_epoch = epoch.0;
5328        self.publish_catalog_candidate(next_catalog, epoch, &mut _epoch_guard, before_publish)?;
5329        Ok(entry)
5330    }
5331
5332    /// Drop a user by username.
5333    pub fn drop_user(&self, username: &str) -> Result<()> {
5334        self.drop_user_with_epoch(username).map(|_| ())
5335    }
5336
5337    pub fn drop_user_with_epoch(&self, username: &str) -> Result<Epoch> {
5338        self.drop_user_with_epoch_inner(username, None)
5339    }
5340
5341    pub fn drop_user_with_epoch_controlled<F>(
5342        &self,
5343        username: &str,
5344        mut before_publish: F,
5345    ) -> Result<Epoch>
5346    where
5347        F: FnMut() -> Result<()>,
5348    {
5349        self.drop_user_with_epoch_inner(username, Some(&mut before_publish))
5350    }
5351
5352    fn drop_user_with_epoch_inner(
5353        &self,
5354        username: &str,
5355        before_publish: Option<&mut dyn FnMut() -> Result<()>>,
5356    ) -> Result<Epoch> {
5357        self.require(&crate::auth::Permission::Admin)?;
5358        let _ddl = self.ddl_lock.lock();
5359        let _security_write = self.security_write()?;
5360        self.require(&crate::auth::Permission::Admin)?;
5361        let _commit = self.commit_lock.lock();
5362        let epoch = self.epoch.bump_assigned();
5363        let mut _epoch_guard = EpochGuard::new(self.epoch.as_ref(), epoch);
5364        let mut next_catalog = self.catalog.read().clone();
5365        let before = next_catalog.users.len();
5366        next_catalog.users.retain(|u| u.username != username);
5367        if next_catalog.users.len() == before {
5368            return Err(MongrelError::NotFound(format!(
5369                "user {username:?} not found"
5370            )));
5371        }
5372        advance_security_version(&mut next_catalog)?;
5373        next_catalog.db_epoch = epoch.0;
5374        self.publish_catalog_candidate(next_catalog, epoch, &mut _epoch_guard, before_publish)?;
5375        Ok(epoch)
5376    }
5377
5378    /// Change a user's password.
5379    pub fn alter_user_password(&self, username: &str, new_password: &str) -> Result<()> {
5380        self.alter_user_password_with_epoch(username, new_password)
5381            .map(|_| ())
5382    }
5383
5384    pub fn alter_user_password_with_epoch(
5385        &self,
5386        username: &str,
5387        new_password: &str,
5388    ) -> Result<Epoch> {
5389        self.require(&crate::auth::Permission::Admin)?;
5390        let hash = crate::auth::hash_password(new_password).map_err(MongrelError::Other)?;
5391        self.alter_user_password_hash_with_epoch(username, hash)
5392    }
5393
5394    pub fn alter_user_password_hash_with_epoch(
5395        &self,
5396        username: &str,
5397        hash: String,
5398    ) -> Result<Epoch> {
5399        self.alter_user_password_hash_with_epoch_inner(username, hash, None)
5400    }
5401
5402    pub fn alter_user_password_hash_with_epoch_controlled<F>(
5403        &self,
5404        username: &str,
5405        hash: String,
5406        mut before_publish: F,
5407    ) -> Result<Epoch>
5408    where
5409        F: FnMut() -> Result<()>,
5410    {
5411        self.alter_user_password_hash_with_epoch_inner(username, hash, Some(&mut before_publish))
5412    }
5413
5414    fn alter_user_password_hash_with_epoch_inner(
5415        &self,
5416        username: &str,
5417        hash: String,
5418        before_publish: Option<&mut dyn FnMut() -> Result<()>>,
5419    ) -> Result<Epoch> {
5420        self.require(&crate::auth::Permission::Admin)?;
5421        let _ddl = self.ddl_lock.lock();
5422        let _security_write = self.security_write()?;
5423        self.require(&crate::auth::Permission::Admin)?;
5424        let _commit = self.commit_lock.lock();
5425        let epoch = self.epoch.bump_assigned();
5426        let mut _epoch_guard = EpochGuard::new(self.epoch.as_ref(), epoch);
5427        let mut next_catalog = self.catalog.read().clone();
5428        let user = next_catalog
5429            .users
5430            .iter_mut()
5431            .find(|u| u.username == username)
5432            .ok_or_else(|| MongrelError::NotFound(format!("user {username:?} not found")))?;
5433        user.password_hash = hash;
5434        advance_security_version(&mut next_catalog)?;
5435        next_catalog.db_epoch = epoch.0;
5436        self.publish_catalog_candidate(next_catalog, epoch, &mut _epoch_guard, before_publish)?;
5437        Ok(epoch)
5438    }
5439
5440    /// Verify credentials. Returns `Some(entry)` on success, `None` on
5441    /// mismatch, `Err` on engine error.
5442    pub fn verify_user(
5443        &self,
5444        username: &str,
5445        password: &str,
5446    ) -> Result<Option<crate::auth::UserEntry>> {
5447        let cat = self.catalog.read();
5448        let Some(user) = cat.users.iter().find(|u| u.username == username) else {
5449            return Ok(None);
5450        };
5451        if user.password_hash.is_empty() {
5452            return Ok(None);
5453        }
5454        let ok = crate::auth::verify_password(password, &user.password_hash)
5455            .map_err(MongrelError::Other)?;
5456        if ok {
5457            Ok(Some(user.clone()))
5458        } else {
5459            Ok(None)
5460        }
5461    }
5462
5463    /// Authenticate and resolve one immutable principal from the same catalog
5464    /// snapshot. Username reuse cannot bridge the password check and principal
5465    /// resolution.
5466    pub fn authenticate_principal(
5467        &self,
5468        username: &str,
5469        password: &str,
5470    ) -> Result<Option<crate::auth::Principal>> {
5471        self.authenticate_principal_inner(username, password, || {})
5472    }
5473
5474    fn authenticate_principal_inner<F>(
5475        &self,
5476        username: &str,
5477        password: &str,
5478        after_verify: F,
5479    ) -> Result<Option<crate::auth::Principal>>
5480    where
5481        F: FnOnce(),
5482    {
5483        let catalog = self.catalog.read();
5484        let Some(user) = catalog.users.iter().find(|user| user.username == username) else {
5485            return Ok(None);
5486        };
5487        if user.password_hash.is_empty()
5488            || !crate::auth::verify_password(password, &user.password_hash)
5489                .map_err(MongrelError::Other)?
5490        {
5491            return Ok(None);
5492        }
5493        after_verify();
5494        Ok(Self::resolve_user_principal_from_catalog(&catalog, user))
5495    }
5496
5497    /// Grant admin privileges to a user (bypasses all permission checks).
5498    pub fn set_user_admin(&self, username: &str, is_admin: bool) -> Result<()> {
5499        self.set_user_admin_with_epoch(username, is_admin)
5500            .map(|_| ())
5501    }
5502
5503    pub fn set_user_admin_with_epoch(
5504        &self,
5505        username: &str,
5506        is_admin: bool,
5507    ) -> Result<Option<Epoch>> {
5508        self.set_user_admin_with_epoch_inner(username, is_admin, None)
5509    }
5510
5511    pub fn set_user_admin_with_epoch_controlled<F>(
5512        &self,
5513        username: &str,
5514        is_admin: bool,
5515        mut before_publish: F,
5516    ) -> Result<Option<Epoch>>
5517    where
5518        F: FnMut() -> Result<()>,
5519    {
5520        self.set_user_admin_with_epoch_inner(username, is_admin, Some(&mut before_publish))
5521    }
5522
5523    fn set_user_admin_with_epoch_inner(
5524        &self,
5525        username: &str,
5526        is_admin: bool,
5527        before_publish: Option<&mut dyn FnMut() -> Result<()>>,
5528    ) -> Result<Option<Epoch>> {
5529        self.require(&crate::auth::Permission::Admin)?;
5530        let _ddl = self.ddl_lock.lock();
5531        let _security_write = self.security_write()?;
5532        self.require(&crate::auth::Permission::Admin)?;
5533        let _commit = self.commit_lock.lock();
5534        let mut next_catalog = self.catalog.read().clone();
5535        let user = next_catalog
5536            .users
5537            .iter_mut()
5538            .find(|u| u.username == username)
5539            .ok_or_else(|| MongrelError::NotFound(format!("user {username:?} not found")))?;
5540        if user.is_admin == is_admin {
5541            return Ok(None);
5542        }
5543        user.is_admin = is_admin;
5544        let epoch = self.epoch.bump_assigned();
5545        let mut _epoch_guard = EpochGuard::new(self.epoch.as_ref(), epoch);
5546        advance_security_version(&mut next_catalog)?;
5547        next_catalog.db_epoch = epoch.0;
5548        self.publish_catalog_candidate(next_catalog, epoch, &mut _epoch_guard, before_publish)?;
5549        Ok(Some(epoch))
5550    }
5551
5552    /// Create a new role.
5553    pub fn create_role(&self, name: &str) -> Result<crate::auth::RoleEntry> {
5554        self.create_role_inner(name, None)
5555    }
5556
5557    pub fn create_role_controlled<F>(
5558        &self,
5559        name: &str,
5560        mut before_publish: F,
5561    ) -> Result<crate::auth::RoleEntry>
5562    where
5563        F: FnMut() -> Result<()>,
5564    {
5565        self.create_role_inner(name, Some(&mut before_publish))
5566    }
5567
5568    fn create_role_inner(
5569        &self,
5570        name: &str,
5571        before_publish: Option<&mut dyn FnMut() -> Result<()>>,
5572    ) -> Result<crate::auth::RoleEntry> {
5573        self.require(&crate::auth::Permission::Admin)?;
5574        let _ddl = self.ddl_lock.lock();
5575        let _security_write = self.security_write()?;
5576        self.require(&crate::auth::Permission::Admin)?;
5577        let _commit = self.commit_lock.lock();
5578        let epoch = self.epoch.bump_assigned();
5579        let mut _epoch_guard = EpochGuard::new(self.epoch.as_ref(), epoch);
5580        let mut next_catalog = self.catalog.read().clone();
5581        if next_catalog.roles.iter().any(|r| r.name == name) {
5582            return Err(MongrelError::InvalidArgument(format!(
5583                "role {name:?} already exists"
5584            )));
5585        }
5586        let entry = crate::auth::RoleEntry {
5587            name: name.into(),
5588            permissions: Vec::new(),
5589            created_epoch: epoch.0,
5590        };
5591        next_catalog.roles.push(entry.clone());
5592        advance_security_version(&mut next_catalog)?;
5593        next_catalog.db_epoch = epoch.0;
5594        self.publish_catalog_candidate(next_catalog, epoch, &mut _epoch_guard, before_publish)?;
5595        Ok(entry)
5596    }
5597
5598    /// Drop a role by name.
5599    pub fn drop_role(&self, name: &str) -> Result<()> {
5600        self.drop_role_with_epoch(name).map(|_| ())
5601    }
5602
5603    pub fn drop_role_with_epoch(&self, name: &str) -> Result<Epoch> {
5604        self.drop_role_with_epoch_inner(name, None)
5605    }
5606
5607    pub fn drop_role_with_epoch_controlled<F>(
5608        &self,
5609        name: &str,
5610        mut before_publish: F,
5611    ) -> Result<Epoch>
5612    where
5613        F: FnMut() -> Result<()>,
5614    {
5615        self.drop_role_with_epoch_inner(name, Some(&mut before_publish))
5616    }
5617
5618    fn drop_role_with_epoch_inner(
5619        &self,
5620        name: &str,
5621        before_publish: Option<&mut dyn FnMut() -> Result<()>>,
5622    ) -> Result<Epoch> {
5623        self.require(&crate::auth::Permission::Admin)?;
5624        let _ddl = self.ddl_lock.lock();
5625        let _security_write = self.security_write()?;
5626        self.require(&crate::auth::Permission::Admin)?;
5627        let _commit = self.commit_lock.lock();
5628        let epoch = self.epoch.bump_assigned();
5629        let mut _epoch_guard = EpochGuard::new(self.epoch.as_ref(), epoch);
5630        let mut next_catalog = self.catalog.read().clone();
5631        let before = next_catalog.roles.len();
5632        next_catalog.roles.retain(|r| r.name != name);
5633        if next_catalog.roles.len() == before {
5634            return Err(MongrelError::NotFound(format!("role {name:?} not found")));
5635        }
5636        for user in &mut next_catalog.users {
5637            user.roles.retain(|r| r != name);
5638        }
5639        advance_security_version(&mut next_catalog)?;
5640        next_catalog.db_epoch = epoch.0;
5641        self.publish_catalog_candidate(next_catalog, epoch, &mut _epoch_guard, before_publish)?;
5642        Ok(epoch)
5643    }
5644
5645    /// Grant a role to a user.
5646    pub fn grant_role(&self, username: &str, role_name: &str) -> Result<()> {
5647        self.grant_role_with_epoch(username, role_name).map(|_| ())
5648    }
5649
5650    pub fn grant_role_with_epoch(&self, username: &str, role_name: &str) -> Result<Option<Epoch>> {
5651        self.grant_role_with_epoch_inner(username, role_name, None)
5652    }
5653
5654    pub fn grant_role_with_epoch_controlled<F>(
5655        &self,
5656        username: &str,
5657        role_name: &str,
5658        mut before_publish: F,
5659    ) -> Result<Option<Epoch>>
5660    where
5661        F: FnMut() -> Result<()>,
5662    {
5663        self.grant_role_with_epoch_inner(username, role_name, Some(&mut before_publish))
5664    }
5665
5666    fn grant_role_with_epoch_inner(
5667        &self,
5668        username: &str,
5669        role_name: &str,
5670        before_publish: Option<&mut dyn FnMut() -> Result<()>>,
5671    ) -> Result<Option<Epoch>> {
5672        self.require(&crate::auth::Permission::Admin)?;
5673        let _ddl = self.ddl_lock.lock();
5674        let _security_write = self.security_write()?;
5675        self.require(&crate::auth::Permission::Admin)?;
5676        let _commit = self.commit_lock.lock();
5677        let mut next_catalog = self.catalog.read().clone();
5678        if !next_catalog.roles.iter().any(|r| r.name == role_name) {
5679            return Err(MongrelError::NotFound(format!(
5680                "role {role_name:?} not found"
5681            )));
5682        }
5683        let user = next_catalog
5684            .users
5685            .iter_mut()
5686            .find(|u| u.username == username)
5687            .ok_or_else(|| MongrelError::NotFound(format!("user {username:?} not found")))?;
5688        if user.roles.iter().any(|role| role == role_name) {
5689            return Ok(None);
5690        }
5691        user.roles.push(role_name.into());
5692        let epoch = self.epoch.bump_assigned();
5693        let mut _epoch_guard = EpochGuard::new(self.epoch.as_ref(), epoch);
5694        advance_security_version(&mut next_catalog)?;
5695        next_catalog.db_epoch = epoch.0;
5696        self.publish_catalog_candidate(next_catalog, epoch, &mut _epoch_guard, before_publish)?;
5697        Ok(Some(epoch))
5698    }
5699
5700    /// Revoke a role from a user.
5701    pub fn revoke_role(&self, username: &str, role_name: &str) -> Result<()> {
5702        self.revoke_role_with_epoch(username, role_name).map(|_| ())
5703    }
5704
5705    pub fn revoke_role_with_epoch(&self, username: &str, role_name: &str) -> Result<Option<Epoch>> {
5706        self.revoke_role_with_epoch_inner(username, role_name, None)
5707    }
5708
5709    pub fn revoke_role_with_epoch_controlled<F>(
5710        &self,
5711        username: &str,
5712        role_name: &str,
5713        mut before_publish: F,
5714    ) -> Result<Option<Epoch>>
5715    where
5716        F: FnMut() -> Result<()>,
5717    {
5718        self.revoke_role_with_epoch_inner(username, role_name, Some(&mut before_publish))
5719    }
5720
5721    fn revoke_role_with_epoch_inner(
5722        &self,
5723        username: &str,
5724        role_name: &str,
5725        before_publish: Option<&mut dyn FnMut() -> Result<()>>,
5726    ) -> Result<Option<Epoch>> {
5727        self.require(&crate::auth::Permission::Admin)?;
5728        let _ddl = self.ddl_lock.lock();
5729        let _security_write = self.security_write()?;
5730        self.require(&crate::auth::Permission::Admin)?;
5731        let _commit = self.commit_lock.lock();
5732        let mut next_catalog = self.catalog.read().clone();
5733        let user = next_catalog
5734            .users
5735            .iter_mut()
5736            .find(|u| u.username == username)
5737            .ok_or_else(|| MongrelError::NotFound(format!("user {username:?} not found")))?;
5738        let before = user.roles.len();
5739        user.roles.retain(|r| r != role_name);
5740        if user.roles.len() == before {
5741            return Ok(None);
5742        }
5743        let epoch = self.epoch.bump_assigned();
5744        let mut _epoch_guard = EpochGuard::new(self.epoch.as_ref(), epoch);
5745        advance_security_version(&mut next_catalog)?;
5746        next_catalog.db_epoch = epoch.0;
5747        self.publish_catalog_candidate(next_catalog, epoch, &mut _epoch_guard, before_publish)?;
5748        Ok(Some(epoch))
5749    }
5750
5751    /// Grant a permission to a role.
5752    pub fn grant_permission(
5753        &self,
5754        role_name: &str,
5755        permission: crate::auth::Permission,
5756    ) -> Result<()> {
5757        self.grant_permission_with_epoch(role_name, permission)
5758            .map(|_| ())
5759    }
5760
5761    pub fn grant_permission_with_epoch(
5762        &self,
5763        role_name: &str,
5764        permission: crate::auth::Permission,
5765    ) -> Result<Option<Epoch>> {
5766        self.grant_permission_with_epoch_inner(role_name, permission, None)
5767    }
5768
5769    pub fn grant_permission_with_epoch_controlled<F>(
5770        &self,
5771        role_name: &str,
5772        permission: crate::auth::Permission,
5773        mut before_publish: F,
5774    ) -> Result<Option<Epoch>>
5775    where
5776        F: FnMut() -> Result<()>,
5777    {
5778        self.grant_permission_with_epoch_inner(role_name, permission, Some(&mut before_publish))
5779    }
5780
5781    fn grant_permission_with_epoch_inner(
5782        &self,
5783        role_name: &str,
5784        permission: crate::auth::Permission,
5785        before_publish: Option<&mut dyn FnMut() -> Result<()>>,
5786    ) -> Result<Option<Epoch>> {
5787        self.require(&crate::auth::Permission::Admin)?;
5788        let _ddl = self.ddl_lock.lock();
5789        let _security_write = self.security_write()?;
5790        self.require(&crate::auth::Permission::Admin)?;
5791        let _commit = self.commit_lock.lock();
5792        let mut next_catalog = self.catalog.read().clone();
5793        let role = next_catalog
5794            .roles
5795            .iter_mut()
5796            .find(|r| r.name == role_name)
5797            .ok_or_else(|| MongrelError::NotFound(format!("role {role_name:?} not found")))?;
5798        let before = role.permissions.clone();
5799        merge_permission(&mut role.permissions, permission);
5800        if role.permissions == before {
5801            return Ok(None);
5802        }
5803        let epoch = self.epoch.bump_assigned();
5804        let mut _epoch_guard = EpochGuard::new(self.epoch.as_ref(), epoch);
5805        advance_security_version(&mut next_catalog)?;
5806        next_catalog.db_epoch = epoch.0;
5807        self.publish_catalog_candidate(next_catalog, epoch, &mut _epoch_guard, before_publish)?;
5808        Ok(Some(epoch))
5809    }
5810
5811    /// Revoke a permission from a role.
5812    pub fn revoke_permission(
5813        &self,
5814        role_name: &str,
5815        permission: crate::auth::Permission,
5816    ) -> Result<()> {
5817        self.revoke_permission_with_epoch(role_name, permission)
5818            .map(|_| ())
5819    }
5820
5821    pub fn revoke_permission_with_epoch(
5822        &self,
5823        role_name: &str,
5824        permission: crate::auth::Permission,
5825    ) -> Result<Option<Epoch>> {
5826        self.revoke_permission_with_epoch_inner(role_name, permission, None)
5827    }
5828
5829    pub fn revoke_permission_with_epoch_controlled<F>(
5830        &self,
5831        role_name: &str,
5832        permission: crate::auth::Permission,
5833        mut before_publish: F,
5834    ) -> Result<Option<Epoch>>
5835    where
5836        F: FnMut() -> Result<()>,
5837    {
5838        self.revoke_permission_with_epoch_inner(role_name, permission, Some(&mut before_publish))
5839    }
5840
5841    fn revoke_permission_with_epoch_inner(
5842        &self,
5843        role_name: &str,
5844        permission: crate::auth::Permission,
5845        before_publish: Option<&mut dyn FnMut() -> Result<()>>,
5846    ) -> Result<Option<Epoch>> {
5847        self.require(&crate::auth::Permission::Admin)?;
5848        let _ddl = self.ddl_lock.lock();
5849        let _security_write = self.security_write()?;
5850        self.require(&crate::auth::Permission::Admin)?;
5851        let _commit = self.commit_lock.lock();
5852        let mut next_catalog = self.catalog.read().clone();
5853        let role = next_catalog
5854            .roles
5855            .iter_mut()
5856            .find(|r| r.name == role_name)
5857            .ok_or_else(|| MongrelError::NotFound(format!("role {role_name:?} not found")))?;
5858        let before = role.permissions.clone();
5859        revoke_permission_from(&mut role.permissions, &permission);
5860        if role.permissions == before {
5861            return Ok(None);
5862        }
5863        let epoch = self.epoch.bump_assigned();
5864        let mut _epoch_guard = EpochGuard::new(self.epoch.as_ref(), epoch);
5865        advance_security_version(&mut next_catalog)?;
5866        next_catalog.db_epoch = epoch.0;
5867        self.publish_catalog_candidate(next_catalog, epoch, &mut _epoch_guard, before_publish)?;
5868        Ok(Some(epoch))
5869    }
5870
5871    /// Resolve a user into a [`crate::auth::Principal`] by collecting all
5872    /// permissions from their roles. Returns `None` if the user doesn't exist.
5873    pub fn resolve_principal(&self, username: &str) -> Option<crate::auth::Principal> {
5874        let cat = self.catalog.read();
5875        Self::resolve_principal_from_catalog(&cat, username)
5876    }
5877
5878    /// Re-resolve only when the immutable user identity still exists. This is
5879    /// the server/session validation path; username reuse never matches.
5880    pub fn resolve_current_principal(
5881        &self,
5882        principal: &crate::auth::Principal,
5883    ) -> Option<crate::auth::Principal> {
5884        Self::resolve_bound_principal_from_catalog(&self.catalog.read(), principal)
5885    }
5886
5887    /// Resolve a username to a [`Principal`] directly from a catalog snapshot,
5888    /// without needing a constructed `Database`. Used by the credentialed open
5889    /// path (which must verify credentials before the `Database` exists) and
5890    /// by [`resolve_principal`](Self::resolve_principal).
5891    fn resolve_principal_from_catalog(
5892        cat: &Catalog,
5893        username: &str,
5894    ) -> Option<crate::auth::Principal> {
5895        let user = cat.users.iter().find(|u| u.username == username)?;
5896        Self::resolve_user_principal_from_catalog(cat, user)
5897    }
5898
5899    fn resolve_bound_principal_from_catalog(
5900        cat: &Catalog,
5901        principal: &crate::auth::Principal,
5902    ) -> Option<crate::auth::Principal> {
5903        let user = cat.users.iter().find(|user| {
5904            user.id == principal.user_id
5905                && user.created_epoch == principal.created_epoch
5906                && user.username == principal.username
5907        })?;
5908        Self::resolve_user_principal_from_catalog(cat, user)
5909    }
5910
5911    fn resolve_user_principal_from_catalog(
5912        cat: &Catalog,
5913        user: &crate::auth::UserEntry,
5914    ) -> Option<crate::auth::Principal> {
5915        let mut permissions = Vec::new();
5916        for role_name in &user.roles {
5917            if let Some(role) = cat.roles.iter().find(|r| &r.name == role_name) {
5918                permissions.extend(role.permissions.iter().cloned());
5919            }
5920        }
5921        Some(crate::auth::Principal {
5922            user_id: user.id,
5923            created_epoch: user.created_epoch,
5924            username: user.username.clone(),
5925            is_admin: user.is_admin,
5926            roles: user.roles.clone(),
5927            permissions,
5928        })
5929    }
5930
5931    /// Check whether a user has a specific permission (via their roles).
5932    pub fn check_permission(&self, username: &str, permission: &crate::auth::Permission) -> bool {
5933        match self.resolve_principal(username) {
5934            Some(p) => p.has_permission(permission),
5935            None => false,
5936        }
5937    }
5938
5939    /// Returns `true` if this database's catalog has `require_auth = true`.
5940    /// When true, every operation consults the cached [`Principal`] via
5941    /// [`require`](Self::require).
5942    pub fn require_auth_enabled(&self) -> bool {
5943        self.catalog.read().require_auth
5944    }
5945
5946    /// A snapshot of the cached principal for this handle, if any. `None` for
5947    /// databases opened without credentials (the default). Returns a clone
5948    /// because the principal lives behind an `RwLock`.
5949    pub fn principal(&self) -> Option<crate::auth::Principal> {
5950        self.principal.read().clone()
5951    }
5952
5953    /// Build a `TableAuthChecker` from the current auth state. Used when
5954    /// mounting a new table (`create_table`) so the table inherits the
5955    /// database's enforcement configuration. The checker reads the live
5956    /// `require_auth` flag and cached principal, so changes via `enable_auth`
5957    /// / `refresh_principal` propagate to already-mounted tables.
5958    fn table_auth_checker(&self) -> Option<Arc<dyn crate::auth_state::TableAuthChecker>> {
5959        Some(Arc::new(crate::auth_state::DefaultTableAuthChecker::new(
5960            self.auth_state.clone(),
5961        )))
5962    }
5963
5964    /// Re-resolve the cached principal from the shared current catalog.
5965    /// Long-lived
5966    /// handles (e.g. a daemon) call this after a `REVOKE` or role change —
5967    /// possibly made by a different handle to the same database — to pick up
5968    /// the new effective permissions without re-verifying the password.
5969    ///
5970    /// The process-wide security version reloads from disk only when another
5971    /// handle published a newer catalog. The username is taken from
5972    /// the existing cached principal; if the user has since been dropped,
5973    /// returns [`MongrelError::InvalidCredentials`].
5974    ///
5975    /// No-op (returns `Ok(())`) on a credentialless database, or on a
5976    /// credentialed database whose cached principal is `None`.
5977    pub fn refresh_principal(&self) -> Result<()> {
5978        let previous = match self.principal.read().clone() {
5979            Some(principal) => principal,
5980            None => return Ok(()),
5981        };
5982        let observed_version = self.security_coordinator.version.load(Ordering::Acquire);
5983        self.refresh_security_catalog_if_stale(observed_version)?;
5984        let cat = self.catalog.read();
5985        match Self::resolve_bound_principal_from_catalog(&cat, &previous) {
5986            Some(p) => {
5987                *self.principal.write() = Some(p.clone());
5988                // Update the shared auth state so mounted Tables see the new
5989                // permissions immediately (Tables read from AuthState, not from
5990                // self.principal).
5991                self.auth_state.set_principal(Some(p));
5992                Ok(())
5993            }
5994            None => Err(MongrelError::InvalidCredentials {
5995                username: previous.username,
5996            }),
5997        }
5998    }
5999
6000    /// Number of security-catalog disk reloads performed by this open handle.
6001    /// Initial open reads are excluded.
6002    pub fn security_catalog_disk_read_count(&self) -> u64 {
6003        self.security_catalog_disk_reads.load(Ordering::Relaxed)
6004    }
6005
6006    /// Convert a credentialless database to a credentialed one: create the
6007    /// first admin user, set `require_auth = true`, and cache the admin
6008    /// principal on this handle so subsequent operations on the same handle
6009    /// continue to work. After this call, the database can only be reopened
6010    /// via `open_with_credentials` / `open_encrypted_with_credentials`.
6011    ///
6012    /// Refuses if the database already has `require_auth = true`. This is
6013    /// the conversion path for existing databases; for fresh databases,
6014    /// `create_with_credentials` sets everything up atomically.
6015    ///
6016    /// See `docs/15-credential-enforcement.md`.
6017    pub fn enable_auth(&self, admin_username: &str, admin_password: &str) -> Result<()> {
6018        let password_hash =
6019            crate::auth::hash_password(admin_password).map_err(MongrelError::Other)?;
6020        let _ddl = self.ddl_lock.lock();
6021        let _security_write = self.security_write()?;
6022        let _commit = self.commit_lock.lock();
6023        let epoch = self.epoch.bump_assigned();
6024        let mut _epoch_guard = EpochGuard::new(self.epoch.as_ref(), epoch);
6025        let mut next_catalog = self.catalog.read().clone();
6026        if next_catalog.require_auth {
6027            return Err(MongrelError::InvalidArgument(
6028                "database already has require_auth enabled".into(),
6029            ));
6030        }
6031        if next_catalog
6032            .users
6033            .iter()
6034            .any(|u| u.username == admin_username)
6035        {
6036            return Err(MongrelError::InvalidArgument(format!(
6037                "user {admin_username:?} already exists"
6038            )));
6039        }
6040        next_catalog.next_user_id = next_catalog.next_user_id.max(1);
6041        let id = next_catalog.next_user_id;
6042        next_catalog.next_user_id = id
6043            .checked_add(1)
6044            .ok_or_else(|| MongrelError::Full("user-id namespace exhausted".into()))?;
6045        next_catalog.users.push(crate::auth::UserEntry {
6046            id,
6047            username: admin_username.to_string(),
6048            password_hash,
6049            roles: Vec::new(),
6050            is_admin: true,
6051            created_epoch: epoch.0,
6052        });
6053        next_catalog.require_auth = true;
6054        advance_security_version(&mut next_catalog)?;
6055        next_catalog.db_epoch = epoch.0;
6056        let publish = self.publish_catalog_candidate(next_catalog, epoch, &mut _epoch_guard, None);
6057        // Cache the admin principal on this handle + update the shared auth
6058        // state whenever rename published, even if directory fsync was
6059        // inconclusive.
6060        if publish.is_ok() || matches!(&publish, Err(MongrelError::CommitOutcomeUnknown { .. })) {
6061            let principal = crate::auth::Principal {
6062                user_id: id,
6063                created_epoch: epoch.0,
6064                username: admin_username.to_string(),
6065                is_admin: true,
6066                roles: Vec::new(),
6067                permissions: Vec::new(),
6068            };
6069            *self.principal.write() = Some(principal.clone());
6070            self.auth_state.set_principal(Some(principal));
6071        }
6072        publish
6073    }
6074
6075    /// Disable `require_auth` on this database, reverting it to credentialless
6076    /// mode. This is the **recovery** path — it requires the handle to already
6077    /// be open (and therefore already authenticated if `require_auth` was on).
6078    ///
6079    /// After this call, the database can be reopened with plain
6080    /// [`open`](Self::open) / [`open_encrypted`](Self::open_encrypted) without
6081    /// credentials. All existing users and roles are preserved in the catalog
6082    /// (so `require_auth` can be re-enabled without recreating them), but they
6083    /// are no longer consulted for enforcement.
6084    ///
6085    /// For true **offline** recovery (when credentials are lost and no
6086    /// authenticated handle is available), the caller opens the database
6087    /// directly via the catalog file (filesystem access required) and calls
6088    /// this method — see the CLI's `auth disable-offline` command.
6089    ///
6090    /// See `docs/15-credential-enforcement.md` §4.7.
6091    pub fn disable_auth(&self) -> Result<()> {
6092        let _ddl = self.ddl_lock.lock();
6093        let _security_write = self.security_write()?;
6094        let _commit = self.commit_lock.lock();
6095        let epoch = self.epoch.bump_assigned();
6096        let mut _epoch_guard = EpochGuard::new(self.epoch.as_ref(), epoch);
6097        let mut next_catalog = self.catalog.read().clone();
6098        if !next_catalog.require_auth {
6099            return Err(MongrelError::InvalidArgument(
6100                "database does not have require_auth enabled".into(),
6101            ));
6102        }
6103        next_catalog.require_auth = false;
6104        advance_security_version(&mut next_catalog)?;
6105        next_catalog.db_epoch = epoch.0;
6106        let publish = self.publish_catalog_candidate(next_catalog, epoch, &mut _epoch_guard, None);
6107        // Clear the cached principal — enforcement is now off.
6108        if publish.is_ok() || matches!(&publish, Err(MongrelError::CommitOutcomeUnknown { .. })) {
6109            *self.principal.write() = None;
6110        }
6111        publish
6112    }
6113
6114    /// Enforcement check: if the catalog has `require_auth = true`, verify
6115    /// that the cached principal satisfies `perm`. Called by every
6116    /// enforcement point (DDL, admin, maintenance, and — in Phase 2 —
6117    /// Table/Transaction/MongrelSession operations).
6118    ///
6119    /// On a credentialless database this is a no-op (`Ok(())`).
6120    pub fn require(&self, perm: &crate::auth::Permission) -> Result<()> {
6121        if self.read_only && !matches!(perm, crate::auth::Permission::Select { .. }) {
6122            return Err(MongrelError::ReadOnlyReplica);
6123        }
6124        if self.principal.read().is_some() {
6125            self.refresh_principal().map_err(|error| match error {
6126                MongrelError::InvalidCredentials { .. } => MongrelError::AuthRequired,
6127                error => error,
6128            })?;
6129        }
6130        if !self.catalog.read().require_auth {
6131            return Ok(());
6132        }
6133        let guard = self.principal.read();
6134        let p = guard.as_ref().ok_or(MongrelError::AuthRequired)?;
6135        if p.has_permission(perm) {
6136            Ok(())
6137        } else {
6138            Err(MongrelError::PermissionDenied {
6139                required: perm.clone(),
6140                principal: p.username.clone(),
6141            })
6142        }
6143    }
6144
6145    /// Convenience: enforce a table-level permission (`Select`/`Insert`/
6146    /// `Update`/`Delete`) by table name. Used by the Transaction layer and
6147    /// other callers that know the operation kind + table name but don't want
6148    /// to construct the full `Permission` enum value themselves.
6149    pub fn require_table(
6150        &self,
6151        table: &str,
6152        perm: crate::auth_state::RequiredPermission,
6153    ) -> Result<()> {
6154        self.require(&perm.into_permission(table))
6155    }
6156
6157    pub fn triggers(&self) -> Vec<StoredTrigger> {
6158        self.catalog
6159            .read()
6160            .triggers
6161            .iter()
6162            .map(|t| t.trigger.clone())
6163            .collect()
6164    }
6165
6166    pub fn trigger(&self, name: &str) -> Option<StoredTrigger> {
6167        self.catalog
6168            .read()
6169            .triggers
6170            .iter()
6171            .find(|t| t.trigger.name == name)
6172            .map(|t| t.trigger.clone())
6173    }
6174
6175    pub fn create_trigger(&self, trigger: StoredTrigger) -> Result<StoredTrigger> {
6176        self.create_trigger_inner(trigger, None, None)
6177    }
6178
6179    pub fn create_trigger_controlled<F>(
6180        &self,
6181        trigger: StoredTrigger,
6182        mut before_publish: F,
6183    ) -> Result<StoredTrigger>
6184    where
6185        F: FnMut() -> Result<()>,
6186    {
6187        self.create_trigger_inner(trigger, None, Some(&mut before_publish))
6188    }
6189
6190    pub fn create_trigger_as_controlled<F>(
6191        &self,
6192        trigger: StoredTrigger,
6193        principal: Option<&crate::auth::Principal>,
6194        mut before_publish: F,
6195    ) -> Result<StoredTrigger>
6196    where
6197        F: FnMut() -> Result<()>,
6198    {
6199        self.create_trigger_inner(trigger, principal, Some(&mut before_publish))
6200    }
6201
6202    fn create_trigger_inner(
6203        &self,
6204        mut trigger: StoredTrigger,
6205        principal: Option<&crate::auth::Principal>,
6206        before_publish: Option<&mut dyn FnMut() -> Result<()>>,
6207    ) -> Result<StoredTrigger> {
6208        self.require_for(principal, &crate::auth::Permission::Ddl)?;
6209        let _g = self.ddl_lock.lock();
6210        let _security_write = self.security_write()?;
6211        self.require_for(principal, &crate::auth::Permission::Ddl)?;
6212        trigger.validate()?;
6213        self.validate_trigger_references(&trigger)
6214            .map_err(trigger_validation_error)?;
6215        {
6216            let cat = self.catalog.read();
6217            if cat.triggers.iter().any(|t| t.trigger.name == trigger.name) {
6218                return Err(MongrelError::TriggerValidation(format!(
6219                    "trigger {:?} already exists",
6220                    trigger.name
6221                )));
6222            }
6223        }
6224        let commit_lock = Arc::clone(&self.commit_lock);
6225        let _c = commit_lock.lock();
6226        let epoch = self.epoch.bump_assigned();
6227        let mut _epoch_guard = EpochGuard::new(self.epoch.as_ref(), epoch);
6228        trigger.created_epoch = epoch.0;
6229        trigger.updated_epoch = epoch.0;
6230        let mut next_catalog = self.catalog.read().clone();
6231        next_catalog
6232            .triggers
6233            .push(TriggerEntry::from(trigger.clone()));
6234        next_catalog.db_epoch = epoch.0;
6235        self.publish_catalog_candidate(next_catalog, epoch, &mut _epoch_guard, before_publish)?;
6236        Ok(trigger)
6237    }
6238
6239    pub fn create_or_replace_trigger(&self, trigger: StoredTrigger) -> Result<StoredTrigger> {
6240        self.create_or_replace_trigger_inner(trigger, None, None)
6241    }
6242
6243    pub fn create_or_replace_trigger_controlled<F>(
6244        &self,
6245        trigger: StoredTrigger,
6246        mut before_publish: F,
6247    ) -> Result<StoredTrigger>
6248    where
6249        F: FnMut() -> Result<()>,
6250    {
6251        self.create_or_replace_trigger_inner(trigger, None, Some(&mut before_publish))
6252    }
6253
6254    pub fn create_or_replace_trigger_as_controlled<F>(
6255        &self,
6256        trigger: StoredTrigger,
6257        principal: Option<&crate::auth::Principal>,
6258        mut before_publish: F,
6259    ) -> Result<StoredTrigger>
6260    where
6261        F: FnMut() -> Result<()>,
6262    {
6263        self.create_or_replace_trigger_inner(trigger, principal, Some(&mut before_publish))
6264    }
6265
6266    fn create_or_replace_trigger_inner(
6267        &self,
6268        trigger: StoredTrigger,
6269        principal: Option<&crate::auth::Principal>,
6270        before_publish: Option<&mut dyn FnMut() -> Result<()>>,
6271    ) -> Result<StoredTrigger> {
6272        self.require_for(principal, &crate::auth::Permission::Ddl)?;
6273        let _g = self.ddl_lock.lock();
6274        let _security_write = self.security_write()?;
6275        self.require_for(principal, &crate::auth::Permission::Ddl)?;
6276        trigger.validate()?;
6277        self.validate_trigger_references(&trigger)
6278            .map_err(trigger_validation_error)?;
6279        let commit_lock = Arc::clone(&self.commit_lock);
6280        let _c = commit_lock.lock();
6281        let epoch = self.epoch.bump_assigned();
6282        let mut _epoch_guard = EpochGuard::new(self.epoch.as_ref(), epoch);
6283        let mut next_catalog = self.catalog.read().clone();
6284        let replaced = {
6285            let next = match next_catalog
6286                .triggers
6287                .iter()
6288                .position(|t| t.trigger.name == trigger.name)
6289            {
6290                Some(idx) => {
6291                    let next = next_catalog.triggers[idx]
6292                        .trigger
6293                        .replaced(trigger.clone(), epoch.0)?;
6294                    next_catalog.triggers[idx] = TriggerEntry::from(next.clone());
6295                    next
6296                }
6297                None => {
6298                    let mut next = trigger;
6299                    next.created_epoch = epoch.0;
6300                    next.updated_epoch = epoch.0;
6301                    next_catalog.triggers.push(TriggerEntry::from(next.clone()));
6302                    next
6303                }
6304            };
6305            next_catalog.db_epoch = epoch.0;
6306            next
6307        };
6308        self.publish_catalog_candidate(next_catalog, epoch, &mut _epoch_guard, before_publish)?;
6309        Ok(replaced)
6310    }
6311
6312    pub fn drop_trigger(&self, name: &str) -> Result<()> {
6313        self.drop_trigger_with_epoch(name).map(|_| ())
6314    }
6315
6316    /// Drop one trigger and return the exact catalog publication epoch.
6317    pub fn drop_trigger_with_epoch(&self, name: &str) -> Result<Epoch> {
6318        self.drop_triggers_with_epoch(&[name.to_string()])
6319    }
6320
6321    pub fn drop_trigger_with_epoch_controlled<F>(
6322        &self,
6323        name: &str,
6324        before_publish: F,
6325    ) -> Result<Epoch>
6326    where
6327        F: FnMut() -> Result<()>,
6328    {
6329        self.drop_triggers_with_epoch_controlled(&[name.to_string()], before_publish)
6330    }
6331
6332    /// Atomically drop several triggers in one catalog publication.
6333    pub fn drop_triggers_with_epoch(&self, names: &[String]) -> Result<Epoch> {
6334        self.drop_triggers_with_epoch_inner(names, None, None)
6335    }
6336
6337    pub fn drop_triggers_with_epoch_controlled<F>(
6338        &self,
6339        names: &[String],
6340        mut before_publish: F,
6341    ) -> Result<Epoch>
6342    where
6343        F: FnMut() -> Result<()>,
6344    {
6345        self.drop_triggers_with_epoch_inner(names, None, Some(&mut before_publish))
6346    }
6347
6348    pub fn drop_triggers_with_epoch_as_controlled<F>(
6349        &self,
6350        names: &[String],
6351        principal: Option<&crate::auth::Principal>,
6352        mut before_publish: F,
6353    ) -> Result<Epoch>
6354    where
6355        F: FnMut() -> Result<()>,
6356    {
6357        self.drop_triggers_with_epoch_inner(names, principal, Some(&mut before_publish))
6358    }
6359
6360    fn drop_triggers_with_epoch_inner(
6361        &self,
6362        names: &[String],
6363        principal: Option<&crate::auth::Principal>,
6364        before_publish: Option<&mut dyn FnMut() -> Result<()>>,
6365    ) -> Result<Epoch> {
6366        self.require_for(principal, &crate::auth::Permission::Ddl)?;
6367        if names.is_empty() {
6368            return Err(MongrelError::InvalidArgument(
6369                "at least one trigger name is required".into(),
6370            ));
6371        }
6372        let _g = self.ddl_lock.lock();
6373        let _security_write = self.security_write()?;
6374        self.require_for(principal, &crate::auth::Permission::Ddl)?;
6375        {
6376            let cat = self.catalog.read();
6377            for name in names {
6378                if !cat.triggers.iter().any(|t| t.trigger.name == *name) {
6379                    return Err(MongrelError::NotFound(format!(
6380                        "trigger {name:?} not found"
6381                    )));
6382                }
6383            }
6384        }
6385        let commit_lock = Arc::clone(&self.commit_lock);
6386        let _c = commit_lock.lock();
6387        let epoch = self.epoch.bump_assigned();
6388        let mut _epoch_guard = EpochGuard::new(self.epoch.as_ref(), epoch);
6389        let mut next_catalog = self.catalog.read().clone();
6390        next_catalog
6391            .triggers
6392            .retain(|trigger| !names.contains(&trigger.trigger.name));
6393        next_catalog.db_epoch = epoch.0;
6394        self.publish_catalog_candidate(next_catalog, epoch, &mut _epoch_guard, before_publish)?;
6395        Ok(epoch)
6396    }
6397
6398    pub fn external_tables(&self) -> Vec<ExternalTableEntry> {
6399        self.catalog.read().external_tables.clone()
6400    }
6401
6402    pub fn external_table(&self, name: &str) -> Option<ExternalTableEntry> {
6403        self.catalog
6404            .read()
6405            .external_tables
6406            .iter()
6407            .find(|t| t.name == name)
6408            .cloned()
6409    }
6410
6411    pub fn create_external_table(&self, entry: ExternalTableEntry) -> Result<ExternalTableEntry> {
6412        self.create_external_table_inner(entry, None)
6413    }
6414
6415    pub fn create_external_table_controlled<F>(
6416        &self,
6417        entry: ExternalTableEntry,
6418        mut before_publish: F,
6419    ) -> Result<ExternalTableEntry>
6420    where
6421        F: FnMut() -> Result<()>,
6422    {
6423        self.create_external_table_inner(entry, Some(&mut before_publish))
6424    }
6425
6426    fn create_external_table_inner(
6427        &self,
6428        mut entry: ExternalTableEntry,
6429        before_publish: Option<&mut dyn FnMut() -> Result<()>>,
6430    ) -> Result<ExternalTableEntry> {
6431        self.require(&crate::auth::Permission::Ddl)?;
6432        let _g = self.ddl_lock.lock();
6433        let _security_write = self.security_write()?;
6434        self.require(&crate::auth::Permission::Ddl)?;
6435        entry.validate()?;
6436        {
6437            let cat = self.catalog.read();
6438            if cat.live(&entry.name).is_some()
6439                || cat.external_tables.iter().any(|t| t.name == entry.name)
6440            {
6441                return Err(MongrelError::InvalidArgument(format!(
6442                    "table {:?} already exists",
6443                    entry.name
6444                )));
6445            }
6446        }
6447        let commit_lock = Arc::clone(&self.commit_lock);
6448        let _c = commit_lock.lock();
6449        // A prior durable drop may have left connector state behind if its
6450        // cleanup failed or the process crashed. Never let a new table with
6451        // the same name inherit that stale state.
6452        crate::durable_file::create_directory(&self.root.join(VTAB_DIR))?;
6453        crate::durable_file::remove_directory_all(&self.root.join(VTAB_DIR).join(&entry.name))?;
6454        let epoch = self.epoch.bump_assigned();
6455        let mut _epoch_guard = EpochGuard::new(self.epoch.as_ref(), epoch);
6456        entry.created_epoch = epoch.0;
6457        let mut next_catalog = self.catalog.read().clone();
6458        next_catalog.external_tables.push(entry.clone());
6459        next_catalog.db_epoch = epoch.0;
6460        self.publish_catalog_candidate_with_prelude(
6461            next_catalog,
6462            epoch,
6463            &mut _epoch_guard,
6464            before_publish,
6465            vec![(
6466                EXTERNAL_TABLE_ID,
6467                crate::wal::Op::Ddl(crate::wal::DdlOp::ResetExternalTableState {
6468                    name: entry.name.clone(),
6469                    generation_epoch: epoch.0,
6470                }),
6471            )],
6472        )?;
6473        Ok(entry)
6474    }
6475
6476    pub fn drop_external_table(&self, name: &str) -> Result<()> {
6477        self.drop_external_table_with_epoch(name).map(|_| ())
6478    }
6479
6480    /// Drop an external table and return the exact catalog publication epoch.
6481    pub fn drop_external_table_with_epoch(&self, name: &str) -> Result<Epoch> {
6482        self.drop_external_table_with_epoch_inner(name, None)
6483    }
6484
6485    pub fn drop_external_table_with_epoch_controlled<F>(
6486        &self,
6487        name: &str,
6488        mut before_publish: F,
6489    ) -> Result<Epoch>
6490    where
6491        F: FnMut() -> Result<()>,
6492    {
6493        self.drop_external_table_with_epoch_inner(name, Some(&mut before_publish))
6494    }
6495
6496    fn drop_external_table_with_epoch_inner(
6497        &self,
6498        name: &str,
6499        before_publish: Option<&mut dyn FnMut() -> Result<()>>,
6500    ) -> Result<Epoch> {
6501        self.require(&crate::auth::Permission::Ddl)?;
6502        let _g = self.ddl_lock.lock();
6503        let _security_write = self.security_write()?;
6504        self.require(&crate::auth::Permission::Ddl)?;
6505        let commit_lock = Arc::clone(&self.commit_lock);
6506        let _c = commit_lock.lock();
6507        let epoch = self.epoch.bump_assigned();
6508        let mut _epoch_guard = EpochGuard::new(self.epoch.as_ref(), epoch);
6509        let mut next_catalog = self.catalog.read().clone();
6510        let before = next_catalog.external_tables.len();
6511        next_catalog.external_tables.retain(|t| t.name != name);
6512        if next_catalog.external_tables.len() == before {
6513            return Err(MongrelError::NotFound(format!(
6514                "external table {name:?} not found"
6515            )));
6516        }
6517        next_catalog.db_epoch = epoch.0;
6518        self.publish_catalog_candidate_with_prelude(
6519            next_catalog,
6520            epoch,
6521            &mut _epoch_guard,
6522            before_publish,
6523            vec![(
6524                EXTERNAL_TABLE_ID,
6525                crate::wal::Op::Ddl(crate::wal::DdlOp::ResetExternalTableState {
6526                    name: name.to_string(),
6527                    generation_epoch: epoch.0,
6528                }),
6529            )],
6530        )?;
6531        let state_dir = self.root.join(VTAB_DIR).join(name);
6532        if let Err(error) = crate::durable_file::remove_directory_all(&state_dir) {
6533            return Err(MongrelError::DurableCommit {
6534                epoch: epoch.0,
6535                message: format!(
6536                    "external table was dropped but connector-state cleanup failed: {error}"
6537                ),
6538            });
6539        }
6540        Ok(epoch)
6541    }
6542
6543    pub fn commit_external_table_state(&self, name: &str, state: &[u8]) -> Result<Epoch> {
6544        let txn_id = self.alloc_txn_id()?;
6545        let (principal, catalog_bound) = self.transaction_principal_snapshot();
6546        self.commit_transaction_with_external_states(
6547            txn_id,
6548            self.epoch.visible(),
6549            Vec::new(),
6550            vec![(name.to_string(), state.to_vec())],
6551            Vec::new(),
6552            principal,
6553            catalog_bound,
6554            None,
6555        )
6556        .map(|(epoch, _)| epoch)
6557    }
6558
6559    pub fn trigger_config(&self) -> TriggerConfig {
6560        use std::sync::atomic::Ordering;
6561        TriggerConfig {
6562            recursive_triggers: self.trigger_recursive.load(Ordering::Relaxed),
6563            max_depth: self.trigger_max_depth.load(Ordering::Relaxed),
6564            max_loop_iterations: self.trigger_max_loop_iterations.load(Ordering::Relaxed),
6565        }
6566    }
6567
6568    pub fn set_trigger_config(&self, config: TriggerConfig) -> Result<()> {
6569        use std::sync::atomic::Ordering;
6570        if config.max_depth == 0 {
6571            return Err(MongrelError::InvalidArgument(
6572                "trigger max_depth must be greater than 0".into(),
6573            ));
6574        }
6575        self.trigger_recursive
6576            .store(config.recursive_triggers, Ordering::Relaxed);
6577        self.trigger_max_depth
6578            .store(config.max_depth, Ordering::Relaxed);
6579        self.trigger_max_loop_iterations
6580            .store(config.max_loop_iterations, Ordering::Relaxed);
6581        Ok(())
6582    }
6583
6584    pub fn set_recursive_triggers(&self, recursive: bool) {
6585        use std::sync::atomic::Ordering;
6586        self.trigger_recursive.store(recursive, Ordering::Relaxed);
6587    }
6588
6589    /// Subscribe to ephemeral SQL NOTIFY messages. Durable row changes use
6590    /// [`Self::change_events_since`], with [`Self::subscribe_change_commits`]
6591    /// as a low-latency wake-up.
6592    pub fn subscribe_changes(&self) -> tokio::sync::broadcast::Receiver<ChangeEvent> {
6593        self.notify.subscribe()
6594    }
6595
6596    pub fn subscribe_change_commits(&self) -> tokio::sync::broadcast::Receiver<()> {
6597        self.change_wake.subscribe()
6598    }
6599
6600    /// Reconstruct committed row changes from the retained shared WAL. Event
6601    /// ids are stable `<commit_epoch>:<operation_index>` pairs. A caller that
6602    /// resumes before the oldest retained commit receives `gap = true` and
6603    /// must rebootstrap instead of silently skipping changes.
6604    pub fn change_events_since(&self, last_event_id: Option<&str>) -> Result<CdcBatch> {
6605        let control = crate::ExecutionControl::new(None);
6606        self.change_events_since_controlled(last_event_id, &control)
6607    }
6608
6609    /// Reconstruct committed changes with cooperative cancellation and bounds.
6610    pub fn change_events_since_controlled(
6611        &self,
6612        last_event_id: Option<&str>,
6613        control: &crate::ExecutionControl,
6614    ) -> Result<CdcBatch> {
6615        use crate::wal::Op;
6616
6617        control.checkpoint()?;
6618        let resume = match last_event_id {
6619            Some(id) => {
6620                let (epoch, index) = id.split_once(':').ok_or_else(|| {
6621                    MongrelError::InvalidArgument(format!(
6622                        "invalid CDC event id {id:?}; expected <epoch>:<index>"
6623                    ))
6624                })?;
6625                Some((
6626                    epoch.parse::<u64>().map_err(|error| {
6627                        MongrelError::InvalidArgument(format!("invalid CDC epoch: {error}"))
6628                    })?,
6629                    index.parse::<u32>().map_err(|error| {
6630                        MongrelError::InvalidArgument(format!("invalid CDC index: {error}"))
6631                    })?,
6632                ))
6633            }
6634            None => None,
6635        };
6636
6637        let mut wal = self.shared_wal.lock();
6638        wal.group_sync()?;
6639        let wal_dek = crate::encryption::wal_dek_for(self.kek.as_deref());
6640        let records = crate::wal::SharedWal::replay_with_dek_controlled(
6641            &self.root,
6642            wal_dek.as_ref(),
6643            control,
6644            CDC_MAX_WAL_RECORDS,
6645            CDC_MAX_WAL_REPLAY_BYTES,
6646        )?;
6647        drop(wal);
6648        control.checkpoint()?;
6649
6650        let mut commits: HashMap<u64, (u64, Vec<crate::wal::AddedRun>)> = HashMap::new();
6651        let mut spilled_payloads: HashMap<(u64, u64), Vec<&[u8]>> = HashMap::new();
6652        for (index, record) in records.iter().enumerate() {
6653            if index % 256 == 0 {
6654                control.checkpoint()?;
6655            }
6656            if let Op::TxnCommit { epoch, added_runs } = &record.op {
6657                commits.insert(record.txn_id, (*epoch, added_runs.clone()));
6658            }
6659            if let Op::SpilledRows { table_id, rows } = &record.op {
6660                spilled_payloads
6661                    .entry((record.txn_id, *table_id))
6662                    .or_default()
6663                    .push(rows);
6664            }
6665        }
6666        let earliest_epoch = commits.values().map(|(epoch, _)| *epoch).min();
6667        let current_epoch = self.epoch.committed().0;
6668        let retention_floor = crate::replication::replication_wal_floor(&self.root)?;
6669        let gap = resume.is_some_and(|(epoch, _)| {
6670            retention_floor != 0 && epoch <= retention_floor && epoch <= current_epoch
6671        });
6672        if gap {
6673            return Ok(CdcBatch {
6674                events: Vec::new(),
6675                current_epoch,
6676                earliest_epoch,
6677                gap: true,
6678            });
6679        }
6680
6681        let table_names: HashMap<u64, String> = self
6682            .catalog
6683            .read()
6684            .tables
6685            .iter()
6686            .map(|entry| (entry.table_id, entry.name.clone()))
6687            .collect();
6688        let mut before_images: HashMap<(u64, u64, u64), crate::memtable::Row> = HashMap::new();
6689        let mut retained_bytes = 0_usize;
6690        for (index, record) in records.iter().enumerate() {
6691            if index % 256 == 0 {
6692                control.checkpoint()?;
6693            }
6694            if !commits.contains_key(&record.txn_id) {
6695                continue;
6696            }
6697            let Op::BeforeImage {
6698                table_id,
6699                row_id,
6700                row,
6701            } = &record.op
6702            else {
6703                continue;
6704            };
6705            if row.len() > CDC_MAX_INLINE_PAYLOAD_BYTES {
6706                return Err(MongrelError::ResourceLimitExceeded {
6707                    resource: "CDC before-image bytes",
6708                    requested: row.len(),
6709                    limit: CDC_MAX_INLINE_PAYLOAD_BYTES,
6710                });
6711            }
6712            let before: crate::memtable::Row = bincode::deserialize(row)?;
6713            if before_images.len() >= CDC_MAX_ROWS {
6714                return Err(MongrelError::ResourceLimitExceeded {
6715                    resource: "CDC before-image rows",
6716                    requested: before_images.len().saturating_add(1),
6717                    limit: CDC_MAX_ROWS,
6718                });
6719            }
6720            charge_cdc_bytes(
6721                &mut retained_bytes,
6722                cdc_row_storage_bytes(&before),
6723                "CDC retained bytes",
6724            )?;
6725            before_images.insert((record.txn_id, *table_id, row_id.0), before);
6726        }
6727        let mut operation_indices: HashMap<u64, u32> = HashMap::new();
6728        let mut events = Vec::new();
6729        let mut decoded_rows = before_images.len();
6730        for (record_index, record) in records.iter().enumerate() {
6731            if record_index % 256 == 0 {
6732                control.checkpoint()?;
6733            }
6734            let Some((commit_epoch, _)) = commits.get(&record.txn_id) else {
6735                continue;
6736            };
6737            let event = match &record.op {
6738                Op::Put { table_id, rows } => {
6739                    if rows.len() > CDC_MAX_INLINE_PAYLOAD_BYTES {
6740                        return Err(MongrelError::ResourceLimitExceeded {
6741                            resource: "CDC inline row bytes",
6742                            requested: rows.len(),
6743                            limit: CDC_MAX_INLINE_PAYLOAD_BYTES,
6744                        });
6745                    }
6746                    let rows: Vec<crate::memtable::Row> = bincode::deserialize(rows)?;
6747                    decoded_rows = decoded_rows.saturating_add(rows.len());
6748                    if decoded_rows > CDC_MAX_ROWS {
6749                        return Err(MongrelError::ResourceLimitExceeded {
6750                            resource: "CDC decoded rows",
6751                            requested: decoded_rows,
6752                            limit: CDC_MAX_ROWS,
6753                        });
6754                    }
6755                    let event_bytes = cdc_rows_json_bytes(&rows).saturating_add(512);
6756                    let mut peak_bytes = retained_bytes;
6757                    charge_cdc_bytes(&mut peak_bytes, event_bytes, "CDC retained event bytes")?;
6758                    let data = serde_json::to_value(rows)
6759                        .map_err(|error| MongrelError::Other(format!("CDC JSON: {error}")))?;
6760                    Some((*table_id, "put", data, event_bytes))
6761                }
6762                Op::Delete { table_id, row_ids } => {
6763                    let before = row_ids
6764                        .iter()
6765                        .filter_map(|row_id| {
6766                            before_images
6767                                .get(&(record.txn_id, *table_id, row_id.0))
6768                                .cloned()
6769                        })
6770                        .collect::<Vec<_>>();
6771                    let event_bytes = cdc_rows_json_bytes(&before)
6772                        .saturating_add(
6773                            row_ids
6774                                .len()
6775                                .saturating_mul(std::mem::size_of::<serde_json::Value>()),
6776                        )
6777                        .saturating_add(512);
6778                    let mut peak_bytes = retained_bytes;
6779                    charge_cdc_bytes(&mut peak_bytes, event_bytes, "CDC retained event bytes")?;
6780                    Some((
6781                        *table_id,
6782                        "delete",
6783                        serde_json::json!({
6784                            "row_ids": row_ids.iter().map(|row_id| row_id.0).collect::<Vec<_>>(),
6785                            "before": before,
6786                        }),
6787                        event_bytes,
6788                    ))
6789                }
6790                Op::TruncateTable { table_id } => {
6791                    Some((*table_id, "truncate", serde_json::Value::Null, 512))
6792                }
6793                _ => None,
6794            };
6795            if let Some((table_id, op, data, event_bytes)) = event {
6796                let index = operation_indices.entry(record.txn_id).or_insert(0);
6797                let event_position = (*commit_epoch, *index);
6798                *index = index.saturating_add(1);
6799                if resume.is_some_and(|position| event_position <= position) {
6800                    continue;
6801                }
6802                if events.len() >= CDC_MAX_EVENTS {
6803                    return Err(MongrelError::ResourceLimitExceeded {
6804                        resource: "CDC events",
6805                        requested: events.len().saturating_add(1),
6806                        limit: CDC_MAX_EVENTS,
6807                    });
6808                }
6809                charge_cdc_bytes(&mut retained_bytes, event_bytes, "CDC retained event bytes")?;
6810                events.push(ChangeEvent {
6811                    id: Some(format!("{}:{}", event_position.0, event_position.1)),
6812                    channel: "changes".into(),
6813                    table_id: Some(table_id),
6814                    table: table_names.get(&table_id).cloned().unwrap_or_default(),
6815                    op: op.into(),
6816                    epoch: *commit_epoch,
6817                    txn_id: Some(record.txn_id),
6818                    message: None,
6819                    data: Some(data),
6820                });
6821            }
6822            if let Op::TxnCommit { added_runs, .. } = &record.op {
6823                for run in added_runs {
6824                    control.checkpoint()?;
6825                    let index = operation_indices.entry(record.txn_id).or_insert(0);
6826                    let event_position = (*commit_epoch, *index);
6827                    *index = index.saturating_add(1);
6828                    if resume.is_some_and(|position| event_position <= position) {
6829                        continue;
6830                    }
6831                    let mut rows = if let Some(payloads) =
6832                        spilled_payloads.get(&(record.txn_id, run.table_id))
6833                    {
6834                        let mut rows = Vec::new();
6835                        for payload in payloads {
6836                            control.checkpoint()?;
6837                            if payload.len() > CDC_MAX_INLINE_PAYLOAD_BYTES {
6838                                return Err(MongrelError::ResourceLimitExceeded {
6839                                    resource: "CDC spilled row bytes",
6840                                    requested: payload.len(),
6841                                    limit: CDC_MAX_INLINE_PAYLOAD_BYTES,
6842                                });
6843                            }
6844                            let chunk: Vec<crate::memtable::Row> = bincode::deserialize(payload)?;
6845                            if decoded_rows
6846                                .saturating_add(rows.len())
6847                                .saturating_add(chunk.len())
6848                                > CDC_MAX_ROWS
6849                            {
6850                                return Err(MongrelError::ResourceLimitExceeded {
6851                                    resource: "CDC decoded rows",
6852                                    requested: decoded_rows
6853                                        .saturating_add(rows.len())
6854                                        .saturating_add(chunk.len()),
6855                                    limit: CDC_MAX_ROWS,
6856                                });
6857                            }
6858                            rows.extend(chunk);
6859                        }
6860                        rows
6861                    } else {
6862                        let Some(handle) = self.tables.read().get(&run.table_id).cloned() else {
6863                            return Ok(CdcBatch {
6864                                events: Vec::new(),
6865                                current_epoch,
6866                                earliest_epoch,
6867                                gap: true,
6868                            });
6869                        };
6870                        let table = handle.lock();
6871                        let mut reader = match table.open_reader(run.run_id) {
6872                            Ok(reader) => reader,
6873                            Err(_) => {
6874                                return Ok(CdcBatch {
6875                                    events: Vec::new(),
6876                                    current_epoch,
6877                                    earliest_epoch,
6878                                    gap: true,
6879                                })
6880                            }
6881                        };
6882                        let remaining = CDC_MAX_ROWS.saturating_sub(decoded_rows);
6883                        let rows = reader.all_rows_controlled(control, remaining)?;
6884                        drop(reader);
6885                        drop(table);
6886                        rows
6887                    };
6888                    for row in &mut rows {
6889                        row.committed_epoch = Epoch(*commit_epoch);
6890                    }
6891                    decoded_rows = decoded_rows.saturating_add(rows.len());
6892                    let event_bytes = cdc_rows_json_bytes(&rows).saturating_add(768);
6893                    charge_cdc_bytes(&mut retained_bytes, event_bytes, "CDC retained event bytes")?;
6894                    if events.len() >= CDC_MAX_EVENTS {
6895                        return Err(MongrelError::ResourceLimitExceeded {
6896                            resource: "CDC events",
6897                            requested: events.len().saturating_add(1),
6898                            limit: CDC_MAX_EVENTS,
6899                        });
6900                    }
6901                    events.push(ChangeEvent {
6902                        id: Some(format!("{}:{}", event_position.0, event_position.1)),
6903                        channel: "changes".into(),
6904                        table_id: Some(run.table_id),
6905                        table: table_names.get(&run.table_id).cloned().unwrap_or_default(),
6906                        op: "put_run".into(),
6907                        epoch: *commit_epoch,
6908                        txn_id: Some(record.txn_id),
6909                        message: None,
6910                        data: Some(serde_json::json!({
6911                            "run_id": run.run_id.to_string(),
6912                            "row_count": run.row_count,
6913                            "min_row_id": run.min_row_id,
6914                            "max_row_id": run.max_row_id,
6915                            "rows": rows,
6916                        })),
6917                    });
6918                }
6919            }
6920        }
6921        control.checkpoint()?;
6922        Ok(CdcBatch {
6923            events,
6924            current_epoch,
6925            earliest_epoch,
6926            gap: false,
6927        })
6928    }
6929
6930    /// Publish a notification message on a named channel. Reaches all active
6931    /// subscribers (daemon `/events`, application listeners).
6932    pub fn notify(&self, channel: &str, message: Option<String>) {
6933        let _ = self.notify.send(ChangeEvent {
6934            id: None,
6935            channel: channel.to_string(),
6936            table_id: None,
6937            table: String::new(),
6938            op: "notify".into(),
6939            epoch: self.epoch.visible().0,
6940            txn_id: None,
6941            message,
6942            data: None,
6943        });
6944    }
6945
6946    pub fn call_procedure(
6947        &self,
6948        name: &str,
6949        args: HashMap<String, crate::Value>,
6950    ) -> Result<ProcedureCallResult> {
6951        self.call_procedure_as(name, args, None)
6952    }
6953
6954    pub fn call_procedure_as(
6955        &self,
6956        name: &str,
6957        args: HashMap<String, crate::Value>,
6958        principal: Option<&crate::auth::Principal>,
6959    ) -> Result<ProcedureCallResult> {
6960        let control = crate::ExecutionControl::new(None);
6961        self.call_procedure_as_controlled(name, args, principal, &control, || true)
6962    }
6963
6964    /// Execute only the exact procedure revision previously authorized by the
6965    /// caller. A dropped or replaced definition fails closed.
6966    #[doc(hidden)]
6967    pub fn call_procedure_as_bound(
6968        &self,
6969        expected: &StoredProcedure,
6970        args: HashMap<String, crate::Value>,
6971        principal: Option<&crate::auth::Principal>,
6972    ) -> Result<ProcedureCallResult> {
6973        self.require_for(principal, &crate::auth::Permission::All)?;
6974        let procedure = self.procedure(&expected.name).ok_or_else(|| {
6975            MongrelError::NotFound(format!("procedure {:?} not found", expected.name))
6976        })?;
6977        if &procedure != expected {
6978            return Err(MongrelError::Conflict(format!(
6979                "procedure {:?} changed after request authorization",
6980                expected.name
6981            )));
6982        }
6983        let control = crate::ExecutionControl::new(None);
6984        self.execute_procedure_as_controlled(procedure, args, principal, &control, || true)
6985    }
6986
6987    /// Execute a procedure with cooperative cancellation during preparation.
6988    /// `before_commit` runs after every procedure step has succeeded and
6989    /// immediately before a write procedure commits. Returning `false` aborts
6990    /// the transaction without publishing it.
6991    #[doc(hidden)]
6992    pub fn call_procedure_as_controlled<F>(
6993        &self,
6994        name: &str,
6995        args: HashMap<String, crate::Value>,
6996        principal: Option<&crate::auth::Principal>,
6997        control: &crate::ExecutionControl,
6998        before_commit: F,
6999    ) -> Result<ProcedureCallResult>
7000    where
7001        F: FnOnce() -> bool,
7002    {
7003        // v1 requires ALL to call procedures on a require_auth database; a
7004        // finer SECURITY DEFINER-style marker is a future extension (spec §9
7005        // decision 1).
7006        self.require_for(principal, &crate::auth::Permission::All)?;
7007        let procedure = self
7008            .procedure(name)
7009            .ok_or_else(|| MongrelError::NotFound(format!("procedure {name:?} not found")))?;
7010        self.execute_procedure_as_controlled(procedure, args, principal, control, before_commit)
7011    }
7012
7013    fn execute_procedure_as_controlled<F>(
7014        &self,
7015        procedure: StoredProcedure,
7016        args: HashMap<String, crate::Value>,
7017        principal: Option<&crate::auth::Principal>,
7018        control: &crate::ExecutionControl,
7019        before_commit: F,
7020    ) -> Result<ProcedureCallResult>
7021    where
7022        F: FnOnce() -> bool,
7023    {
7024        let args = bind_procedure_args(&procedure, args)?;
7025        let has_writes = procedure.body.steps.iter().any(ProcedureStep::is_write);
7026        let mut outputs: HashMap<String, ProcedureCallOutput> = HashMap::new();
7027        if has_writes {
7028            let mut tx = self.begin_as(principal.cloned());
7029            let run = (|| {
7030                for (step_index, step) in procedure.body.steps.iter().enumerate() {
7031                    if step_index % 256 == 0 {
7032                        control.checkpoint()?;
7033                    }
7034                    let output = self.execute_procedure_step(
7035                        step,
7036                        &args,
7037                        &outputs,
7038                        Some(&mut tx),
7039                        principal,
7040                        Some(control),
7041                    )?;
7042                    outputs.insert(step.id().to_string(), output);
7043                }
7044                control.checkpoint()?;
7045                eval_return_output(&procedure.body.return_value, &args, &outputs)
7046            })();
7047            match run {
7048                Ok(output) => {
7049                    control.checkpoint()?;
7050                    if !before_commit() {
7051                        tx.rollback();
7052                        return Err(MongrelError::Cancelled);
7053                    }
7054                    let epoch = tx.commit()?.0;
7055                    Ok(ProcedureCallResult {
7056                        epoch: Some(epoch),
7057                        output,
7058                    })
7059                }
7060                Err(e) => {
7061                    tx.rollback();
7062                    Err(e)
7063                }
7064            }
7065        } else {
7066            for (step_index, step) in procedure.body.steps.iter().enumerate() {
7067                if step_index % 256 == 0 {
7068                    control.checkpoint()?;
7069                }
7070                let output = self.execute_procedure_step(
7071                    step,
7072                    &args,
7073                    &outputs,
7074                    None,
7075                    principal,
7076                    Some(control),
7077                )?;
7078                outputs.insert(step.id().to_string(), output);
7079            }
7080            control.checkpoint()?;
7081            Ok(ProcedureCallResult {
7082                epoch: None,
7083                output: eval_return_output(&procedure.body.return_value, &args, &outputs)?,
7084            })
7085        }
7086    }
7087
7088    fn execute_procedure_step(
7089        &self,
7090        step: &ProcedureStep,
7091        args: &HashMap<String, crate::Value>,
7092        outputs: &HashMap<String, ProcedureCallOutput>,
7093        tx: Option<&mut crate::txn::Transaction<'_>>,
7094        principal: Option<&crate::auth::Principal>,
7095        control: Option<&crate::ExecutionControl>,
7096    ) -> Result<ProcedureCallOutput> {
7097        if let Some(control) = control {
7098            control.checkpoint()?;
7099        }
7100        match step {
7101            ProcedureStep::NativeQuery {
7102                table,
7103                conditions,
7104                projection,
7105                limit,
7106                ..
7107            } => {
7108                let mut q = crate::Query::new();
7109                for condition in conditions {
7110                    q = q.and(eval_condition(condition, args, outputs)?);
7111                }
7112                let fallback_control = crate::ExecutionControl::new(None);
7113                let query_control = control.unwrap_or(&fallback_control);
7114                let mut rows = self.query_for_principal_controlled(
7115                    table,
7116                    &q,
7117                    projection.as_deref(),
7118                    principal,
7119                    false,
7120                    query_control,
7121                )?;
7122                if let Some(limit) = limit {
7123                    rows.truncate(*limit);
7124                }
7125                let mut output = Vec::with_capacity(rows.len());
7126                for (row_index, row) in rows.into_iter().enumerate() {
7127                    if row_index % 256 == 0 {
7128                        if let Some(control) = control {
7129                            control.checkpoint()?;
7130                        }
7131                    }
7132                    output.push(ProcedureCallRow {
7133                        row_id: Some(row.row_id),
7134                        columns: row.columns,
7135                    });
7136                }
7137                Ok(ProcedureCallOutput::Rows(output))
7138            }
7139            ProcedureStep::Put {
7140                table,
7141                cells,
7142                returning,
7143                ..
7144            } => {
7145                let tx = tx.ok_or_else(|| {
7146                    MongrelError::InvalidArgument(
7147                        "write procedure step requires a transaction".into(),
7148                    )
7149                })?;
7150                let cells = eval_cells(cells, args, outputs)?;
7151                if *returning {
7152                    let out = tx.put_returning(table, cells)?;
7153                    Ok(ProcedureCallOutput::Row(ProcedureCallRow {
7154                        row_id: None,
7155                        columns: out.row.columns.into_iter().collect(),
7156                    }))
7157                } else {
7158                    tx.put(table, cells)?;
7159                    Ok(ProcedureCallOutput::Null)
7160                }
7161            }
7162            ProcedureStep::Upsert {
7163                table,
7164                cells,
7165                update_cells,
7166                returning,
7167                ..
7168            } => {
7169                let tx = tx.ok_or_else(|| {
7170                    MongrelError::InvalidArgument(
7171                        "write procedure step requires a transaction".into(),
7172                    )
7173                })?;
7174                let cells = eval_cells(cells, args, outputs)?;
7175                let action = match update_cells {
7176                    Some(update_cells) => {
7177                        crate::UpsertAction::DoUpdate(eval_cells(update_cells, args, outputs)?)
7178                    }
7179                    None => crate::UpsertAction::DoNothing,
7180                };
7181                let out = tx.upsert(table, cells, action)?;
7182                if *returning {
7183                    Ok(ProcedureCallOutput::Row(ProcedureCallRow {
7184                        row_id: None,
7185                        columns: out.row.columns.into_iter().collect(),
7186                    }))
7187                } else {
7188                    Ok(ProcedureCallOutput::Null)
7189                }
7190            }
7191            ProcedureStep::DeleteByPk { table, pk, .. } => {
7192                let tx = tx.ok_or_else(|| {
7193                    MongrelError::InvalidArgument(
7194                        "write procedure step requires a transaction".into(),
7195                    )
7196                })?;
7197                let pk = eval_value(pk, args, outputs)?;
7198                let handle = self.table(table)?;
7199                let row_id = handle.lock().lookup_pk(&pk.encode_key()).ok_or_else(|| {
7200                    MongrelError::NotFound("procedure delete_by_pk target not found".into())
7201                })?;
7202                tx.delete(table, row_id)?;
7203                Ok(ProcedureCallOutput::Scalar(crate::Value::Bool(true)))
7204            }
7205            ProcedureStep::DeleteRows { .. } => Err(MongrelError::InvalidArgument(
7206                "DeleteRows procedure step is not supported by the core executor yet".into(),
7207            )),
7208            ProcedureStep::SqlQuery { .. } => Err(MongrelError::InvalidArgument(
7209                "SqlQuery procedure step must be executed by mongreldb-query".into(),
7210            )),
7211        }
7212    }
7213
7214    fn validate_procedure_references(&self, procedure: &StoredProcedure) -> Result<()> {
7215        let cat = self.catalog.read();
7216        for step in &procedure.body.steps {
7217            let Some(table_name) = step.table() else {
7218                continue;
7219            };
7220            let schema = &cat
7221                .live(table_name)
7222                .ok_or_else(|| {
7223                    MongrelError::InvalidArgument(format!(
7224                        "procedure {:?} references unknown table {table_name:?}",
7225                        procedure.name
7226                    ))
7227                })?
7228                .schema;
7229            match step {
7230                ProcedureStep::NativeQuery {
7231                    conditions,
7232                    projection,
7233                    ..
7234                } => {
7235                    for condition in conditions {
7236                        validate_condition_columns(condition, schema)?;
7237                    }
7238                    if let Some(projection) = projection {
7239                        for id in projection {
7240                            validate_column_id(*id, schema)?;
7241                        }
7242                    }
7243                }
7244                ProcedureStep::Put { cells, .. } => {
7245                    for cell in cells {
7246                        validate_column_id(cell.column_id, schema)?;
7247                    }
7248                }
7249                ProcedureStep::Upsert {
7250                    cells,
7251                    update_cells,
7252                    ..
7253                } => {
7254                    for cell in cells {
7255                        validate_column_id(cell.column_id, schema)?;
7256                    }
7257                    if let Some(update_cells) = update_cells {
7258                        for cell in update_cells {
7259                            validate_column_id(cell.column_id, schema)?;
7260                        }
7261                    }
7262                }
7263                ProcedureStep::DeleteByPk { .. } => {
7264                    if schema.primary_key().is_none() {
7265                        return Err(MongrelError::InvalidArgument(format!(
7266                            "procedure {:?} references DeleteByPk on table {table_name:?} without a primary key",
7267                            procedure.name
7268                        )));
7269                    }
7270                }
7271                ProcedureStep::DeleteRows { .. } | ProcedureStep::SqlQuery { .. } => {}
7272            }
7273        }
7274        Ok(())
7275    }
7276
7277    fn validate_trigger_references(&self, trigger: &StoredTrigger) -> Result<()> {
7278        let cat = self.catalog.read();
7279        let target_schema = match &trigger.target {
7280            TriggerTarget::Table(target_name) => cat
7281                .live(target_name)
7282                .ok_or_else(|| {
7283                    MongrelError::InvalidArgument(format!(
7284                        "trigger {:?} references unknown target table {target_name:?}",
7285                        trigger.name
7286                    ))
7287                })?
7288                .schema
7289                .clone(),
7290            TriggerTarget::View(_) => Schema {
7291                columns: trigger.target_columns.clone(),
7292                ..Schema::default()
7293            },
7294        };
7295        for col in &trigger.update_of {
7296            if target_schema.column(col).is_none() {
7297                return Err(MongrelError::InvalidArgument(format!(
7298                    "trigger {:?} UPDATE OF references unknown column {col:?}",
7299                    trigger.name
7300                )));
7301            }
7302        }
7303        if let Some(expr) = &trigger.when {
7304            validate_trigger_expr(expr, &target_schema, trigger.event)?;
7305        }
7306        let mut select_schemas: HashMap<String, &Schema> = HashMap::new();
7307        for step in &trigger.program.steps {
7308            if matches!(step, TriggerStep::SetNew { .. }) && trigger.timing != TriggerTiming::Before
7309            {
7310                return Err(MongrelError::InvalidArgument(
7311                    "SetNew trigger steps are only valid in BEFORE triggers".into(),
7312                ));
7313            }
7314            validate_trigger_step(
7315                step,
7316                &cat,
7317                &target_schema,
7318                trigger.event,
7319                &mut select_schemas,
7320            )?;
7321        }
7322        Ok(())
7323    }
7324
7325    /// Begin a new transaction reading at the current visible epoch.
7326    pub fn begin(&self) -> crate::txn::Transaction<'_> {
7327        self.begin_with_isolation(crate::txn::IsolationLevel::default())
7328    }
7329
7330    fn transaction_principal_snapshot(&self) -> (Option<crate::auth::Principal>, bool) {
7331        let principal = self.principal.read().clone();
7332        let catalog_bound = principal.as_ref().is_some_and(|principal| {
7333            let catalog = self.catalog.read();
7334            catalog.require_auth || principal.user_id != 0
7335        });
7336        (principal, catalog_bound)
7337    }
7338
7339    pub fn begin_as(
7340        &self,
7341        principal: Option<crate::auth::Principal>,
7342    ) -> crate::txn::Transaction<'_> {
7343        let catalog_bound = principal.as_ref().is_some_and(|principal| {
7344            let catalog = self.catalog.read();
7345            catalog.require_auth || principal.user_id != 0
7346        });
7347        let txn_id = self.alloc_txn_id();
7348        let read = Snapshot::at(self.epoch.visible());
7349        crate::txn::Transaction::new(self, txn_id, read).with_principal(principal, catalog_bound)
7350    }
7351
7352    /// Begin a transaction with a specific isolation level.
7353    pub fn begin_with_isolation(
7354        &self,
7355        level: crate::txn::IsolationLevel,
7356    ) -> crate::txn::Transaction<'_> {
7357        let txn_id = self.alloc_txn_id();
7358        let epoch = match level {
7359            crate::txn::IsolationLevel::ReadCommitted => self.epoch.visible(),
7360            _ => self.epoch.visible(),
7361        };
7362        let read = Snapshot::at(epoch);
7363        let (principal, catalog_bound) = self.transaction_principal_snapshot();
7364        crate::txn::Transaction::new(self, txn_id, read).with_principal(principal, catalog_bound)
7365    }
7366
7367    /// Begin a transaction whose trigger programs may route external-table DML
7368    /// through an application/query-layer module bridge.
7369    pub fn begin_with_external_trigger_bridge<'a>(
7370        &'a self,
7371        bridge: &'a dyn ExternalTriggerBridge,
7372    ) -> crate::txn::Transaction<'a> {
7373        let txn_id = self.alloc_txn_id();
7374        let read = Snapshot::at(self.epoch.visible());
7375        let (principal, catalog_bound) = self.transaction_principal_snapshot();
7376        crate::txn::Transaction::new(self, txn_id, read)
7377            .with_external_trigger_bridge(bridge)
7378            .with_principal(principal, catalog_bound)
7379    }
7380
7381    pub fn begin_with_external_trigger_bridge_as<'a>(
7382        &'a self,
7383        bridge: &'a dyn ExternalTriggerBridge,
7384        principal: Option<crate::auth::Principal>,
7385    ) -> crate::txn::Transaction<'a> {
7386        let catalog_bound = principal.as_ref().is_some_and(|principal| {
7387            let catalog = self.catalog.read();
7388            catalog.require_auth || principal.user_id != 0
7389        });
7390        let txn_id = self.alloc_txn_id();
7391        let read = Snapshot::at(self.epoch.visible());
7392        crate::txn::Transaction::new(self, txn_id, read)
7393            .with_external_trigger_bridge(bridge)
7394            .with_principal(principal, catalog_bound)
7395    }
7396
7397    /// Run `f` in a transaction; commit on `Ok`, rollback on `Err`.
7398    pub fn transaction<T>(
7399        &self,
7400        f: impl FnOnce(&mut crate::txn::Transaction) -> Result<T>,
7401    ) -> Result<T> {
7402        let mut tx = self.begin();
7403        match f(&mut tx) {
7404            Ok(out) => {
7405                tx.commit()?;
7406                Ok(out)
7407            }
7408            Err(e) => {
7409                tx.rollback();
7410                Err(e)
7411            }
7412        }
7413    }
7414
7415    pub fn transaction_with_row_ids<T>(
7416        &self,
7417        f: impl FnOnce(&mut crate::txn::Transaction) -> Result<T>,
7418    ) -> Result<(T, Vec<RowId>)> {
7419        let mut tx = self.begin();
7420        match f(&mut tx) {
7421            Ok(output) => {
7422                let (_, row_ids) = tx.commit_with_row_ids()?;
7423                Ok((output, row_ids))
7424            }
7425            Err(error) => {
7426                tx.rollback();
7427                Err(error)
7428            }
7429        }
7430    }
7431
7432    pub fn transaction_for_current_principal<T>(
7433        &self,
7434        f: impl FnOnce(&mut crate::txn::Transaction) -> Result<T>,
7435    ) -> Result<T> {
7436        if self.principal.read().is_some() {
7437            self.refresh_principal()?;
7438        }
7439        let mut transaction = self.begin_as(self.principal.read().clone());
7440        match f(&mut transaction) {
7441            Ok(output) => {
7442                transaction.commit()?;
7443                Ok(output)
7444            }
7445            Err(error) => {
7446                transaction.rollback();
7447                Err(error)
7448            }
7449        }
7450    }
7451
7452    pub fn transaction_for_current_principal_with_epoch<T>(
7453        &self,
7454        f: impl FnOnce(&mut crate::txn::Transaction) -> Result<T>,
7455    ) -> Result<(Epoch, T)> {
7456        if self.principal.read().is_some() {
7457            self.refresh_principal()?;
7458        }
7459        let mut transaction = self.begin_as(self.principal.read().clone());
7460        match f(&mut transaction) {
7461            Ok(output) => {
7462                let epoch = transaction.commit()?;
7463                Ok((epoch, output))
7464            }
7465            Err(error) => {
7466                transaction.rollback();
7467                Err(error)
7468            }
7469        }
7470    }
7471
7472    pub fn transaction_with_row_ids_for_current_principal<T>(
7473        &self,
7474        f: impl FnOnce(&mut crate::txn::Transaction) -> Result<T>,
7475    ) -> Result<(T, Vec<RowId>)> {
7476        if self.principal.read().is_some() {
7477            self.refresh_principal()?;
7478        }
7479        let mut transaction = self.begin_as(self.principal.read().clone());
7480        match f(&mut transaction) {
7481            Ok(output) => {
7482                let (_, row_ids) = transaction.commit_with_row_ids()?;
7483                Ok((output, row_ids))
7484            }
7485            Err(error) => {
7486                transaction.rollback();
7487                Err(error)
7488            }
7489        }
7490    }
7491
7492    /// Run `f` in a transaction with an external-trigger bridge; commit on
7493    /// `Ok`, rollback on `Err`.
7494    pub fn transaction_with_external_trigger_bridge<'a, T>(
7495        &'a self,
7496        bridge: &'a dyn ExternalTriggerBridge,
7497        f: impl FnOnce(&mut crate::txn::Transaction) -> Result<T>,
7498    ) -> Result<T> {
7499        let mut tx = self.begin_with_external_trigger_bridge(bridge);
7500        match f(&mut tx) {
7501            Ok(out) => {
7502                tx.commit()?;
7503                Ok(out)
7504            }
7505            Err(e) => {
7506                tx.rollback();
7507                Err(e)
7508            }
7509        }
7510    }
7511
7512    pub fn transaction_with_external_trigger_bridge_as<'a, T>(
7513        &'a self,
7514        bridge: &'a dyn ExternalTriggerBridge,
7515        principal: Option<crate::auth::Principal>,
7516        f: impl FnOnce(&mut crate::txn::Transaction) -> Result<T>,
7517    ) -> Result<T> {
7518        let mut tx = self.begin_with_external_trigger_bridge_as(bridge, principal);
7519        match f(&mut tx) {
7520            Ok(output) => {
7521                tx.commit()?;
7522                Ok(output)
7523            }
7524            Err(error) => {
7525                tx.rollback();
7526                Err(error)
7527            }
7528        }
7529    }
7530
7531    /// Register a txn in `ActiveTxns` (spec §9.2, review fix #12). Called from
7532    /// `Transaction::new` so registration happens **before** any read.
7533    pub(crate) fn register_active(&self, epoch: Epoch) -> crate::txn::ActiveTxnGuard<'_> {
7534        self.active_txns.register(epoch)
7535    }
7536
7537    fn fill_auto_increment_for_staging(
7538        &self,
7539        staging: &mut [(u64, crate::txn::Staged)],
7540        control: Option<&crate::ExecutionControl>,
7541    ) -> Result<()> {
7542        let mut puts_by_table: HashMap<u64, Vec<usize>> = HashMap::new();
7543        for (index, (table_id, staged)) in staging.iter().enumerate() {
7544            commit_prepare_checkpoint(control, index)?;
7545            if matches!(staged, crate::txn::Staged::Put(_)) {
7546                puts_by_table.entry(*table_id).or_default().push(index);
7547            }
7548        }
7549
7550        let tables = self.tables.read();
7551        for (table_index, (table_id, indexes)) in puts_by_table.into_iter().enumerate() {
7552            commit_prepare_checkpoint(control, table_index)?;
7553            if let Some(handle) = tables.get(&table_id) {
7554                #[cfg(test)]
7555                AUTO_INCREMENT_TABLE_LOCKS.with(|count| count.set(count.get() + 1));
7556                let mut t = handle.lock();
7557                for (fill_index, index) in indexes.into_iter().enumerate() {
7558                    commit_prepare_checkpoint(control, fill_index)?;
7559                    if let crate::txn::Staged::Put(cells) = &mut staging[index].1 {
7560                        t.fill_auto_inc(cells)?;
7561                    }
7562                }
7563            }
7564        }
7565        Ok(())
7566    }
7567
7568    fn expand_table_triggers(
7569        &self,
7570        staging: &mut Vec<(u64, crate::txn::Staged)>,
7571        read_epoch: Epoch,
7572        external_trigger_bridge: Option<&dyn ExternalTriggerBridge>,
7573        external_states: &mut Vec<(String, Vec<u8>)>,
7574        control: Option<&crate::ExecutionControl>,
7575    ) -> Result<()> {
7576        commit_prepare_checkpoint(control, 0)?;
7577        let mut external_writes = Vec::new();
7578        let config = self.trigger_config();
7579        if config.recursive_triggers {
7580            let chunk = std::mem::take(staging);
7581            let stacks = vec![Vec::new(); chunk.len()];
7582            *staging = self.expand_trigger_chunk(
7583                chunk,
7584                stacks,
7585                read_epoch,
7586                0,
7587                config.max_depth,
7588                &mut external_writes,
7589                &config,
7590                control,
7591            )?;
7592            self.apply_external_trigger_writes(
7593                external_writes,
7594                external_trigger_bridge,
7595                external_states,
7596                staging,
7597                control,
7598            )?;
7599            return Ok(());
7600        }
7601
7602        let mut expansion =
7603            self.expand_table_triggers_once(staging, read_epoch, None, &config, control)?;
7604        if !expansion.before.is_empty() {
7605            let mut final_staging = expansion.before;
7606            final_staging.extend(filter_ignored_staging(
7607                std::mem::take(staging),
7608                &expansion.ignored_indices,
7609            ));
7610            *staging = final_staging;
7611        } else if !expansion.ignored_indices.is_empty() {
7612            *staging = filter_ignored_staging(std::mem::take(staging), &expansion.ignored_indices);
7613        }
7614        staging.append(&mut expansion.after);
7615        external_writes.append(&mut expansion.before_external);
7616        external_writes.append(&mut expansion.after_external);
7617        self.apply_external_trigger_writes(
7618            external_writes,
7619            external_trigger_bridge,
7620            external_states,
7621            staging,
7622            control,
7623        )?;
7624        Ok(())
7625    }
7626
7627    #[allow(clippy::too_many_arguments)]
7628    fn expand_trigger_chunk(
7629        &self,
7630        mut chunk: Vec<(u64, crate::txn::Staged)>,
7631        stacks: Vec<Vec<String>>,
7632        read_epoch: Epoch,
7633        depth: u32,
7634        max_depth: u32,
7635        external_writes: &mut Vec<ExternalTriggerWrite>,
7636        config: &TriggerConfig,
7637        control: Option<&crate::ExecutionControl>,
7638    ) -> Result<Vec<(u64, crate::txn::Staged)>> {
7639        if chunk.is_empty() {
7640            return Ok(Vec::new());
7641        }
7642        commit_prepare_checkpoint(control, 0)?;
7643        self.fill_auto_increment_for_staging(&mut chunk, control)?;
7644        let expansion = self.expand_table_triggers_once(
7645            &mut chunk,
7646            read_epoch,
7647            Some(&stacks),
7648            config,
7649            control,
7650        )?;
7651        if depth >= max_depth && (!expansion.before.is_empty() || !expansion.after.is_empty()) {
7652            let stack = expansion
7653                .before_stacks
7654                .first()
7655                .or_else(|| expansion.after_stacks.first())
7656                .cloned()
7657                .unwrap_or_default();
7658            return Err(MongrelError::TriggerValidation(format!(
7659                "trigger recursion exceeded max depth {max_depth}; trigger stack: {}",
7660                Self::format_trigger_stack(&stack)
7661            )));
7662        }
7663
7664        let mut out = Vec::new();
7665        external_writes.extend(expansion.before_external);
7666        out.extend(self.expand_trigger_chunk(
7667            expansion.before,
7668            expansion.before_stacks,
7669            read_epoch,
7670            depth + 1,
7671            max_depth,
7672            external_writes,
7673            config,
7674            control,
7675        )?);
7676        out.extend(filter_ignored_staging(chunk, &expansion.ignored_indices));
7677        external_writes.extend(expansion.after_external);
7678        out.extend(self.expand_trigger_chunk(
7679            expansion.after,
7680            expansion.after_stacks,
7681            read_epoch,
7682            depth + 1,
7683            max_depth,
7684            external_writes,
7685            config,
7686            control,
7687        )?);
7688        Ok(out)
7689    }
7690
7691    fn apply_external_trigger_writes(
7692        &self,
7693        writes: Vec<ExternalTriggerWrite>,
7694        bridge: Option<&dyn ExternalTriggerBridge>,
7695        external_states: &mut Vec<(String, Vec<u8>)>,
7696        staging: &mut Vec<(u64, crate::txn::Staged)>,
7697        control: Option<&crate::ExecutionControl>,
7698    ) -> Result<()> {
7699        if writes.is_empty() {
7700            return Ok(());
7701        }
7702        let bridge = bridge.ok_or_else(|| {
7703            MongrelError::TriggerValidation(
7704                "trigger program wrote an external table, but this transaction has no external trigger bridge".into(),
7705            )
7706        })?;
7707        for (write_index, write) in writes.into_iter().enumerate() {
7708            commit_prepare_checkpoint(control, write_index)?;
7709            let table = write.table().to_string();
7710            let entry = self.external_table(&table).ok_or_else(|| {
7711                MongrelError::NotFound(format!("external table {table:?} not found"))
7712            })?;
7713            let base_state = current_external_state_bytes(&self.root, external_states, &table)?;
7714            let result = bridge.apply_trigger_external_write(&entry, base_state, write)?;
7715            external_states.push((table, result.state));
7716            for (base_index, base_write) in result.base_writes.into_iter().enumerate() {
7717                commit_prepare_checkpoint(control, base_index)?;
7718                match base_write {
7719                    ExternalTriggerBaseWrite::Put { table, cells } => {
7720                        let table_id = self.table_id(&table)?;
7721                        staging.push((table_id, crate::txn::Staged::Put(cells)));
7722                    }
7723                    ExternalTriggerBaseWrite::Delete { table, row_id } => {
7724                        let table_id = self.table_id(&table)?;
7725                        staging.push((table_id, crate::txn::Staged::Delete(row_id)));
7726                    }
7727                }
7728            }
7729        }
7730        dedup_external_states_in_place(external_states);
7731        Ok(())
7732    }
7733
7734    fn expand_table_triggers_once(
7735        &self,
7736        staging: &mut Vec<(u64, crate::txn::Staged)>,
7737        read_epoch: Epoch,
7738        trigger_stacks: Option<&[Vec<String>]>,
7739        config: &TriggerConfig,
7740        control: Option<&crate::ExecutionControl>,
7741    ) -> Result<TriggerExpansion> {
7742        commit_prepare_checkpoint(control, 0)?;
7743        let triggers: Vec<StoredTrigger> = self
7744            .catalog
7745            .read()
7746            .triggers
7747            .iter()
7748            .filter(|entry| {
7749                entry.trigger.enabled
7750                    && matches!(
7751                        entry.trigger.timing,
7752                        TriggerTiming::Before | TriggerTiming::After
7753                    )
7754                    && matches!(entry.trigger.target, TriggerTarget::Table(_))
7755            })
7756            .map(|entry| entry.trigger.clone())
7757            .collect();
7758        if triggers.is_empty() || staging.is_empty() {
7759            return Ok(TriggerExpansion::default());
7760        }
7761
7762        let before_triggers = triggers
7763            .iter()
7764            .filter(|trigger| trigger.timing == TriggerTiming::Before)
7765            .cloned()
7766            .collect::<Vec<_>>();
7767        let after_triggers = triggers
7768            .iter()
7769            .filter(|trigger| trigger.timing == TriggerTiming::After)
7770            .cloned()
7771            .collect::<Vec<_>>();
7772
7773        let mut before_added = Vec::new();
7774        let mut before_stacks = Vec::new();
7775        let mut before_external = Vec::new();
7776        let mut ignored_indices = std::collections::BTreeSet::new();
7777        if !before_triggers.is_empty() {
7778            let before_events =
7779                self.trigger_events_for_staging(staging, read_epoch, trigger_stacks, control)?;
7780            let mut out = TriggerProgramOutput {
7781                added: &mut before_added,
7782                added_stacks: &mut before_stacks,
7783                added_external: &mut before_external,
7784                ignored_indices: &mut ignored_indices,
7785            };
7786            self.execute_triggers_for_events(
7787                &before_triggers,
7788                &before_events,
7789                Some(staging),
7790                &mut out,
7791                config,
7792                read_epoch,
7793                control,
7794            )?;
7795        }
7796
7797        let after_events = if after_triggers.is_empty() {
7798            Vec::new()
7799        } else {
7800            self.trigger_events_for_staging(staging, read_epoch, trigger_stacks, control)?
7801                .into_iter()
7802                .filter(|event| {
7803                    !event
7804                        .op_indices
7805                        .iter()
7806                        .any(|idx| ignored_indices.contains(idx))
7807                })
7808                .collect()
7809        };
7810
7811        let mut after_added = Vec::new();
7812        let mut after_stacks = Vec::new();
7813        let mut after_external = Vec::new();
7814        let mut out = TriggerProgramOutput {
7815            added: &mut after_added,
7816            added_stacks: &mut after_stacks,
7817            added_external: &mut after_external,
7818            ignored_indices: &mut ignored_indices,
7819        };
7820        self.execute_triggers_for_events(
7821            &after_triggers,
7822            &after_events,
7823            None,
7824            &mut out,
7825            config,
7826            read_epoch,
7827            control,
7828        )?;
7829        Ok(TriggerExpansion {
7830            before: before_added,
7831            before_stacks,
7832            before_external,
7833            after: after_added,
7834            after_stacks,
7835            after_external,
7836            ignored_indices,
7837        })
7838    }
7839
7840    #[allow(clippy::too_many_arguments)]
7841    fn execute_triggers_for_events(
7842        &self,
7843        triggers: &[StoredTrigger],
7844        events: &[WriteEvent],
7845        mut staging: Option<&mut Vec<(u64, crate::txn::Staged)>>,
7846        out: &mut TriggerProgramOutput<'_>,
7847        config: &TriggerConfig,
7848        read_epoch: Epoch,
7849        control: Option<&crate::ExecutionControl>,
7850    ) -> Result<()> {
7851        let mut checkpoint_index = 0_usize;
7852        for event in events {
7853            for trigger in triggers {
7854                commit_prepare_checkpoint(control, checkpoint_index)?;
7855                checkpoint_index += 1;
7856                if event
7857                    .op_indices
7858                    .iter()
7859                    .any(|idx| out.ignored_indices.contains(idx))
7860                {
7861                    break;
7862                }
7863                let matches = {
7864                    let cat = self.catalog.read();
7865                    trigger_matches_event(trigger, event, &cat)?
7866                };
7867                if !matches {
7868                    continue;
7869                }
7870                if let Some(when) = &trigger.when {
7871                    if !eval_trigger_expr(when, event)? {
7872                        continue;
7873                    }
7874                }
7875                let trigger_stack = Self::trigger_stack_with(&event.trigger_stack, &trigger.name);
7876                if event.trigger_stack.iter().any(|name| name == &trigger.name) {
7877                    return Err(MongrelError::TriggerValidation(format!(
7878                        "trigger recursion cycle detected; trigger stack: {}",
7879                        Self::format_trigger_stack(&trigger_stack)
7880                    )));
7881                }
7882                let outcome = match staging.as_mut() {
7883                    Some(staging) => self.execute_trigger_program(
7884                        trigger,
7885                        event,
7886                        Some(&mut **staging),
7887                        out,
7888                        &trigger_stack,
7889                        config,
7890                        read_epoch,
7891                        control,
7892                    )?,
7893                    None => self.execute_trigger_program(
7894                        trigger,
7895                        event,
7896                        None,
7897                        out,
7898                        &trigger_stack,
7899                        config,
7900                        read_epoch,
7901                        control,
7902                    )?,
7903                };
7904                if outcome == TriggerProgramOutcome::Ignore {
7905                    out.ignored_indices.extend(event.op_indices.iter().copied());
7906                    break;
7907                }
7908            }
7909        }
7910        Ok(())
7911    }
7912
7913    fn trigger_events_for_staging(
7914        &self,
7915        staging: &[(u64, crate::txn::Staged)],
7916        read_epoch: Epoch,
7917        trigger_stacks: Option<&[Vec<String>]>,
7918        control: Option<&crate::ExecutionControl>,
7919    ) -> Result<Vec<WriteEvent>> {
7920        use crate::txn::Staged;
7921        use std::collections::{HashMap, VecDeque};
7922
7923        let snapshot = Snapshot::at(read_epoch);
7924        let cat = self.catalog.read();
7925        let mut table_names = HashMap::new();
7926        let mut table_schemas = HashMap::new();
7927        for entry in cat
7928            .tables
7929            .iter()
7930            .filter(|entry| matches!(entry.state, TableState::Live))
7931        {
7932            table_names.insert(entry.table_id, entry.name.clone());
7933            table_schemas.insert(entry.table_id, entry.schema.clone());
7934        }
7935        drop(cat);
7936
7937        let mut old_rows: HashMap<usize, TriggerRowImage> = HashMap::new();
7938        let mut delete_by_key: HashMap<(u64, Vec<u8>), VecDeque<usize>> = HashMap::new();
7939        let mut put_by_key: HashMap<(u64, Vec<u8>), VecDeque<usize>> = HashMap::new();
7940
7941        for (idx, (table_id, staged)) in staging.iter().enumerate() {
7942            commit_prepare_checkpoint(control, idx)?;
7943            let Some(schema) = table_schemas.get(table_id) else {
7944                continue;
7945            };
7946            let Some(pk) = schema.primary_key() else {
7947                continue;
7948            };
7949            match staged {
7950                Staged::Delete(row_id) => {
7951                    let handle = self.table_by_id(*table_id)?;
7952                    let Some(row) = handle.lock().get(*row_id, snapshot) else {
7953                        continue;
7954                    };
7955                    let Some(pk_value) = row.columns.get(&pk.id) else {
7956                        continue;
7957                    };
7958                    old_rows.insert(idx, TriggerRowImage::from_row(row.clone()));
7959                    delete_by_key
7960                        .entry((*table_id, pk_value.encode_key()))
7961                        .or_default()
7962                        .push_back(idx);
7963                }
7964                Staged::Put(cells) => {
7965                    if let Some((_, value)) = cells.iter().find(|(id, _)| *id == pk.id) {
7966                        put_by_key
7967                            .entry((*table_id, value.encode_key()))
7968                            .or_default()
7969                            .push_back(idx);
7970                    }
7971                }
7972                Staged::Update { row_id, .. } => {
7973                    let handle = self.table_by_id(*table_id)?;
7974                    let row = handle.lock().get(*row_id, snapshot);
7975                    if let Some(row) = row {
7976                        old_rows.insert(idx, TriggerRowImage::from_row(row));
7977                    }
7978                }
7979                Staged::Truncate => {}
7980            }
7981        }
7982
7983        let mut paired_delete = std::collections::HashSet::new();
7984        let mut paired_put = std::collections::HashSet::new();
7985        let mut events = Vec::new();
7986
7987        for (pair_index, (key, deletes)) in delete_by_key.iter_mut().enumerate() {
7988            commit_prepare_checkpoint(control, pair_index)?;
7989            let Some(puts) = put_by_key.get_mut(key) else {
7990                continue;
7991            };
7992            while let (Some(delete_idx), Some(put_idx)) = (deletes.pop_front(), puts.pop_front()) {
7993                paired_delete.insert(delete_idx);
7994                paired_put.insert(put_idx);
7995                let (table_id, _) = &staging[put_idx];
7996                let Some(table_name) = table_names.get(table_id).cloned() else {
7997                    continue;
7998                };
7999                let old = old_rows.get(&delete_idx).cloned();
8000                let new = match &staging[put_idx].1 {
8001                    Staged::Put(cells) => Some(TriggerRowImage::from_cells(cells)),
8002                    _ => None,
8003                };
8004                let changed_columns = changed_columns(old.as_ref(), new.as_ref());
8005                events.push(WriteEvent {
8006                    table: table_name,
8007                    kind: TriggerEvent::Update,
8008                    old,
8009                    new,
8010                    changed_columns,
8011                    op_indices: vec![delete_idx, put_idx],
8012                    put_idx: Some(put_idx),
8013                    trigger_stack: Self::trigger_stack_for_indices(
8014                        trigger_stacks,
8015                        &[delete_idx, put_idx],
8016                    ),
8017                });
8018            }
8019        }
8020
8021        for (idx, (table_id, staged)) in staging.iter().enumerate() {
8022            commit_prepare_checkpoint(control, idx)?;
8023            let Some(table_name) = table_names.get(table_id).cloned() else {
8024                continue;
8025            };
8026            match staged {
8027                Staged::Put(cells) if !paired_put.contains(&idx) => {
8028                    let new = Some(TriggerRowImage::from_cells(cells));
8029                    let changed_columns = cells.iter().map(|(id, _)| *id).collect();
8030                    events.push(WriteEvent {
8031                        table: table_name,
8032                        kind: TriggerEvent::Insert,
8033                        old: None,
8034                        new,
8035                        changed_columns,
8036                        op_indices: vec![idx],
8037                        put_idx: Some(idx),
8038                        trigger_stack: Self::trigger_stack_for_indices(trigger_stacks, &[idx]),
8039                    });
8040                }
8041                Staged::Delete(row_id) if !paired_delete.contains(&idx) => {
8042                    let old = match old_rows.get(&idx).cloned() {
8043                        Some(old) => Some(old),
8044                        None => {
8045                            let handle = self.table_by_id(*table_id)?;
8046                            let row = handle.lock().get(*row_id, snapshot);
8047                            row.map(TriggerRowImage::from_row)
8048                        }
8049                    };
8050                    let Some(old) = old else {
8051                        continue;
8052                    };
8053                    let changed_columns = old.columns.keys().copied().collect();
8054                    events.push(WriteEvent {
8055                        table: table_name,
8056                        kind: TriggerEvent::Delete,
8057                        old: Some(old),
8058                        new: None,
8059                        changed_columns,
8060                        op_indices: vec![idx],
8061                        put_idx: None,
8062                        trigger_stack: Self::trigger_stack_for_indices(trigger_stacks, &[idx]),
8063                    });
8064                }
8065                Staged::Update { new_row: cells, .. } => {
8066                    let old = old_rows.get(&idx).cloned();
8067                    let new = Some(TriggerRowImage::from_cells(cells));
8068                    let changed_columns = changed_columns(old.as_ref(), new.as_ref());
8069                    events.push(WriteEvent {
8070                        table: table_name,
8071                        kind: TriggerEvent::Update,
8072                        old,
8073                        new,
8074                        changed_columns,
8075                        op_indices: vec![idx],
8076                        put_idx: Some(idx),
8077                        trigger_stack: Self::trigger_stack_for_indices(trigger_stacks, &[idx]),
8078                    });
8079                }
8080                Staged::Truncate => {}
8081                _ => {}
8082            }
8083        }
8084
8085        Ok(events)
8086    }
8087
8088    #[allow(clippy::too_many_arguments)]
8089    fn execute_trigger_program(
8090        &self,
8091        trigger: &StoredTrigger,
8092        event: &WriteEvent,
8093        staging: Option<&mut Vec<(u64, crate::txn::Staged)>>,
8094        out: &mut TriggerProgramOutput<'_>,
8095        trigger_stack: &[String],
8096        config: &TriggerConfig,
8097        read_epoch: Epoch,
8098        control: Option<&crate::ExecutionControl>,
8099    ) -> Result<TriggerProgramOutcome> {
8100        let mut event = event.clone();
8101        let mut select_results: HashMap<String, Vec<TriggerRowImage>> = HashMap::new();
8102        self.execute_trigger_steps(
8103            trigger,
8104            &trigger.program.steps,
8105            &mut event,
8106            staging,
8107            out,
8108            trigger_stack,
8109            config,
8110            &mut select_results,
8111            0,
8112            None,
8113            read_epoch,
8114            control,
8115        )
8116    }
8117
8118    #[allow(clippy::too_many_arguments)]
8119    fn execute_trigger_steps(
8120        &self,
8121        trigger: &StoredTrigger,
8122        steps: &[TriggerStep],
8123        event: &mut WriteEvent,
8124        mut staging: Option<&mut Vec<(u64, crate::txn::Staged)>>,
8125        out: &mut TriggerProgramOutput<'_>,
8126        trigger_stack: &[String],
8127        config: &TriggerConfig,
8128        select_results: &mut HashMap<String, Vec<TriggerRowImage>>,
8129        depth: u32,
8130        selected: Option<&TriggerRowImage>,
8131        read_epoch: Epoch,
8132        control: Option<&crate::ExecutionControl>,
8133    ) -> Result<TriggerProgramOutcome> {
8134        let _ = depth;
8135        for (step_index, step) in steps.iter().enumerate() {
8136            commit_prepare_checkpoint(control, step_index)?;
8137            match step {
8138                TriggerStep::SetNew { cells } => {
8139                    if trigger.timing != TriggerTiming::Before {
8140                        return Err(MongrelError::InvalidArgument(
8141                            "SetNew trigger step is only valid in BEFORE triggers".into(),
8142                        ));
8143                    }
8144                    let put_idx = event.put_idx.ok_or_else(|| {
8145                        MongrelError::InvalidArgument(
8146                            "SetNew trigger step requires INSERT or UPDATE NEW row".into(),
8147                        )
8148                    })?;
8149                    let staging = staging.as_deref_mut().ok_or_else(|| {
8150                        MongrelError::InvalidArgument(
8151                            "SetNew trigger step requires mutable trigger staging".into(),
8152                        )
8153                    })?;
8154                    let mut update_changed_columns = None;
8155                    let row_cells = match staging.get_mut(put_idx).map(|(_, op)| op) {
8156                        Some(crate::txn::Staged::Put(cells)) => cells,
8157                        Some(crate::txn::Staged::Update {
8158                            new_row,
8159                            changed_columns,
8160                            ..
8161                        }) => {
8162                            update_changed_columns = Some(changed_columns);
8163                            new_row
8164                        }
8165                        _ => {
8166                            return Err(MongrelError::InvalidArgument(
8167                                "SetNew trigger step target row is not mutable".into(),
8168                            ))
8169                        }
8170                    };
8171                    for (column_id, value) in eval_trigger_cells(cells, event, selected)? {
8172                        row_cells.retain(|(id, _)| *id != column_id);
8173                        row_cells.push((column_id, value.clone()));
8174                        if let Some(changed_columns) = &mut update_changed_columns {
8175                            changed_columns.push(column_id);
8176                        }
8177                        if let Some(new) = &mut event.new {
8178                            new.columns.insert(column_id, value);
8179                        }
8180                    }
8181                    row_cells.sort_by_key(|(id, _)| *id);
8182                    if let Some(changed_columns) = update_changed_columns {
8183                        changed_columns.sort_unstable();
8184                        changed_columns.dedup();
8185                    }
8186                }
8187                TriggerStep::Insert { table, cells } => {
8188                    let cells = eval_trigger_cells(cells, event, selected)?;
8189                    if let Ok(table_id) = self.table_id(table) {
8190                        out.added.push((table_id, crate::txn::Staged::Put(cells)));
8191                        out.added_stacks.push(trigger_stack.to_vec());
8192                    } else if self.external_table(table).is_some() {
8193                        out.added_external.push(ExternalTriggerWrite::Insert {
8194                            table: table.clone(),
8195                            cells,
8196                        });
8197                    } else {
8198                        return Err(MongrelError::NotFound(format!(
8199                            "trigger {:?} insert target {table:?} not found",
8200                            trigger.name
8201                        )));
8202                    }
8203                }
8204                TriggerStep::UpdateByPk { table, pk, cells } => {
8205                    let pk = eval_trigger_value(pk, event, selected)?;
8206                    let cells = eval_trigger_cells(cells, event, selected)?;
8207                    if self.external_table(table).is_some() {
8208                        out.added_external.push(ExternalTriggerWrite::UpdateByPk {
8209                            table: table.clone(),
8210                            pk,
8211                            cells,
8212                        });
8213                    } else {
8214                        let row_id = self
8215                            .table(table)?
8216                            .lock()
8217                            .lookup_pk(&pk.encode_key())
8218                            .ok_or_else(|| {
8219                                MongrelError::NotFound(format!(
8220                                    "trigger {:?} update target not found",
8221                                    trigger.name
8222                                ))
8223                            })?;
8224                        let handle = self.table(table)?;
8225                        let snapshot = Snapshot::at(self.epoch.visible());
8226                        let old = handle.lock().get(row_id, snapshot).ok_or_else(|| {
8227                            MongrelError::NotFound(format!(
8228                                "trigger {:?} update target not visible",
8229                                trigger.name
8230                            ))
8231                        })?;
8232                        let mut changed_columns = cells
8233                            .iter()
8234                            .map(|(column_id, _)| *column_id)
8235                            .collect::<Vec<_>>();
8236                        changed_columns.sort_unstable();
8237                        changed_columns.dedup();
8238                        let mut merged = old.columns;
8239                        for (column_id, value) in cells {
8240                            merged.insert(column_id, value);
8241                        }
8242                        out.added.push((
8243                            self.table_id(table)?,
8244                            crate::txn::Staged::Update {
8245                                row_id,
8246                                new_row: merged.into_iter().collect(),
8247                                changed_columns,
8248                            },
8249                        ));
8250                        out.added_stacks.push(trigger_stack.to_vec());
8251                    }
8252                }
8253                TriggerStep::DeleteByPk { table, pk } => {
8254                    let pk = eval_trigger_value(pk, event, selected)?;
8255                    if self.external_table(table).is_some() {
8256                        out.added_external.push(ExternalTriggerWrite::DeleteByPk {
8257                            table: table.clone(),
8258                            pk,
8259                        });
8260                    } else {
8261                        let row_id = self
8262                            .table(table)?
8263                            .lock()
8264                            .lookup_pk(&pk.encode_key())
8265                            .ok_or_else(|| {
8266                                MongrelError::NotFound(format!(
8267                                    "trigger {:?} delete target not found",
8268                                    trigger.name
8269                                ))
8270                            })?;
8271                        out.added
8272                            .push((self.table_id(table)?, crate::txn::Staged::Delete(row_id)));
8273                        out.added_stacks.push(trigger_stack.to_vec());
8274                    }
8275                }
8276                TriggerStep::Select {
8277                    id,
8278                    table,
8279                    conditions,
8280                } => {
8281                    let schema = self.table(table)?.lock().schema().clone();
8282                    let snapshot = Snapshot::at(read_epoch);
8283                    let handle = self.table(table)?;
8284                    let rows = match control {
8285                        Some(control) => {
8286                            handle.lock().visible_rows_controlled(snapshot, control)?
8287                        }
8288                        None => handle.lock().visible_rows(snapshot)?,
8289                    };
8290                    let mut matched = Vec::new();
8291                    for (row_index, row) in rows.into_iter().enumerate() {
8292                        commit_prepare_checkpoint(control, row_index)?;
8293                        let image = TriggerRowImage::from_row(row);
8294                        let passes = conditions
8295                            .iter()
8296                            .map(|cond| eval_trigger_condition(cond, event, &image, &schema))
8297                            .collect::<Result<Vec<_>>>()?
8298                            .into_iter()
8299                            .all(|b| b);
8300                        if passes {
8301                            matched.push(image);
8302                        }
8303                    }
8304                    if let Some(pk) = schema.primary_key() {
8305                        matched.sort_by(|a, b| {
8306                            let av = a.columns.get(&pk.id).unwrap_or(&Value::Null);
8307                            let bv = b.columns.get(&pk.id).unwrap_or(&Value::Null);
8308                            value_order(av, bv).unwrap_or(std::cmp::Ordering::Equal)
8309                        });
8310                    }
8311                    select_results.insert(id.clone(), matched);
8312                }
8313                TriggerStep::Foreach { id, steps } => {
8314                    let rows = select_results.get(id).ok_or_else(|| {
8315                        MongrelError::InvalidArgument(format!(
8316                            "trigger {:?} foreach references unknown select id {id:?}",
8317                            trigger.name
8318                        ))
8319                    })?;
8320                    if rows.len() > config.max_loop_iterations as usize {
8321                        return Err(MongrelError::InvalidArgument(format!(
8322                            "trigger {:?} foreach exceeded max_loop_iterations ({})",
8323                            trigger.name, config.max_loop_iterations
8324                        )));
8325                    }
8326                    for (row_index, row) in rows.clone().into_iter().enumerate() {
8327                        commit_prepare_checkpoint(control, row_index)?;
8328                        let result = self.execute_trigger_steps(
8329                            trigger,
8330                            steps,
8331                            event,
8332                            staging.as_deref_mut(),
8333                            out,
8334                            trigger_stack,
8335                            config,
8336                            select_results,
8337                            depth + 1,
8338                            Some(&row),
8339                            read_epoch,
8340                            control,
8341                        )?;
8342                        if result == TriggerProgramOutcome::Ignore {
8343                            return Ok(TriggerProgramOutcome::Ignore);
8344                        }
8345                    }
8346                }
8347                TriggerStep::DeleteWhere { table, conditions } => {
8348                    let schema = self.table(table)?.lock().schema().clone();
8349                    let snapshot = Snapshot::at(read_epoch);
8350                    let handle = self.table(table)?;
8351                    let rows = match control {
8352                        Some(control) => {
8353                            handle.lock().visible_rows_controlled(snapshot, control)?
8354                        }
8355                        None => handle.lock().visible_rows(snapshot)?,
8356                    };
8357                    let table_id = self.table_id(table)?;
8358                    let mut to_delete = Vec::new();
8359                    for (row_index, row) in rows.into_iter().enumerate() {
8360                        commit_prepare_checkpoint(control, row_index)?;
8361                        let image = TriggerRowImage::from_row(row.clone());
8362                        let passes = conditions
8363                            .iter()
8364                            .map(|cond| eval_trigger_condition(cond, event, &image, &schema))
8365                            .collect::<Result<Vec<_>>>()?
8366                            .into_iter()
8367                            .all(|b| b);
8368                        if passes {
8369                            to_delete.push((table_id, row.row_id));
8370                        }
8371                    }
8372                    for (row_index, (table_id, row_id)) in to_delete.into_iter().enumerate() {
8373                        commit_prepare_checkpoint(control, row_index)?;
8374                        out.added
8375                            .push((table_id, crate::txn::Staged::Delete(row_id)));
8376                        out.added_stacks.push(trigger_stack.to_vec());
8377                    }
8378                }
8379                TriggerStep::UpdateWhere {
8380                    table,
8381                    conditions,
8382                    cells,
8383                } => {
8384                    let schema = self.table(table)?.lock().schema().clone();
8385                    let snapshot = Snapshot::at(read_epoch);
8386                    let handle = self.table(table)?;
8387                    let rows = match control {
8388                        Some(control) => {
8389                            handle.lock().visible_rows_controlled(snapshot, control)?
8390                        }
8391                        None => handle.lock().visible_rows(snapshot)?,
8392                    };
8393                    let table_id = self.table_id(table)?;
8394                    let mut changed_columns =
8395                        cells.iter().map(|cell| cell.column_id).collect::<Vec<_>>();
8396                    changed_columns.sort_unstable();
8397                    changed_columns.dedup();
8398                    let mut to_update = Vec::new();
8399                    for (row_index, row) in rows.into_iter().enumerate() {
8400                        commit_prepare_checkpoint(control, row_index)?;
8401                        let image = TriggerRowImage::from_row(row.clone());
8402                        let passes = conditions
8403                            .iter()
8404                            .map(|cond| eval_trigger_condition(cond, event, &image, &schema))
8405                            .collect::<Result<Vec<_>>>()?
8406                            .into_iter()
8407                            .all(|b| b);
8408                        if passes {
8409                            let new_cells = cells
8410                                .iter()
8411                                .map(|cell| {
8412                                    Ok((
8413                                        cell.column_id,
8414                                        eval_trigger_value(&cell.value, event, Some(&image))?,
8415                                    ))
8416                                })
8417                                .collect::<Result<Vec<_>>>()?;
8418                            let mut merged = row.columns.clone();
8419                            for (column_id, value) in new_cells {
8420                                merged.insert(column_id, value);
8421                            }
8422                            to_update.push((table_id, row.row_id, merged));
8423                        }
8424                    }
8425                    for (row_index, (table_id, row_id, merged)) in to_update.into_iter().enumerate()
8426                    {
8427                        commit_prepare_checkpoint(control, row_index)?;
8428                        out.added.push((
8429                            table_id,
8430                            crate::txn::Staged::Update {
8431                                row_id,
8432                                new_row: merged.into_iter().collect(),
8433                                changed_columns: changed_columns.clone(),
8434                            },
8435                        ));
8436                        out.added_stacks.push(trigger_stack.to_vec());
8437                    }
8438                }
8439                TriggerStep::Raise { action, message } => match action {
8440                    TriggerRaiseAction::Ignore => return Ok(TriggerProgramOutcome::Ignore),
8441                    TriggerRaiseAction::Abort
8442                    | TriggerRaiseAction::Fail
8443                    | TriggerRaiseAction::Rollback => {
8444                        let message = eval_trigger_value(message, event, selected)?;
8445                        return Err(MongrelError::TriggerValidation(format!(
8446                            "trigger {:?} raised: {}; trigger stack: {}",
8447                            trigger.name,
8448                            trigger_message(message),
8449                            Self::format_trigger_stack(trigger_stack)
8450                        )));
8451                    }
8452                },
8453            }
8454        }
8455        Ok(TriggerProgramOutcome::Continue)
8456    }
8457
8458    fn trigger_stack_for_indices(stacks: Option<&[Vec<String>]>, indices: &[usize]) -> Vec<String> {
8459        let Some(stacks) = stacks else {
8460            return Vec::new();
8461        };
8462        let mut out = Vec::new();
8463        for idx in indices {
8464            let Some(stack) = stacks.get(*idx) else {
8465                continue;
8466            };
8467            for name in stack {
8468                if !out.iter().any(|existing| existing == name) {
8469                    out.push(name.clone());
8470                }
8471            }
8472        }
8473        out
8474    }
8475
8476    fn trigger_stack_with(stack: &[String], trigger_name: &str) -> Vec<String> {
8477        let mut out = stack.to_vec();
8478        out.push(trigger_name.to_string());
8479        out
8480    }
8481
8482    fn format_trigger_stack(stack: &[String]) -> String {
8483        if stack.is_empty() {
8484            "<root>".into()
8485        } else {
8486            stack.join(" -> ")
8487        }
8488    }
8489
8490    /// Authoritatively validate every declared constraint on the staged write
8491    /// set under the transaction's read snapshot, AND expand ON DELETE CASCADE /
8492    /// SET NULL actions into explicit child ops. Called from
8493    /// [`Self::commit_transaction`] outside the WAL mutex. Returns the first
8494    /// violation as an `Err`, aborting the commit atomically. This is the
8495    /// server-side authority point: concurrent remote writers that each pass
8496    /// their own client-side checks still cannot both commit a violating batch.
8497    ///
8498    /// Scope: CHECK (full, three-valued), UNIQUE beyond the PK (existence scan +
8499    /// intra-transaction dedup; concurrent-txn races are additionally caught by
8500    /// `WriteKey::Unique`), and FK insert-side parent existence + ON DELETE
8501    /// {RESTRICT, CASCADE, SET NULL}. CASCADE appends child deletes (transitive
8502    /// fixpoint); SET NULL appends child updates (FK columns nulled). Truncate is
8503    /// RESTRICT-only (cascade-truncate is unsupported).
8504    fn validate_constraints(
8505        &self,
8506        staging: &mut Vec<(u64, crate::txn::Staged)>,
8507        read_epoch: Epoch,
8508        control: Option<&crate::ExecutionControl>,
8509    ) -> Result<()> {
8510        use crate::constraint::{encode_composite_key, validate_checks, FkAction};
8511        use crate::memtable::Row;
8512        use crate::txn::Staged;
8513        use std::collections::HashSet;
8514
8515        commit_prepare_checkpoint(control, 0)?;
8516        let snapshot = Snapshot::at(read_epoch);
8517        let cat = self.catalog.read();
8518
8519        // Collect live (id, name, constraints-bearing?) for staged tables.
8520        let live: Vec<(u64, &str, &crate::schema::Schema)> = cat
8521            .tables
8522            .iter()
8523            .filter(|entry| matches!(entry.state, TableState::Live | TableState::Building { .. }))
8524            .map(|e| (e.table_id, e.name.as_str(), &e.schema))
8525            .collect();
8526
8527        // Fast path: bail if no live table declares any constraints at all.
8528        let any_constraints = live.iter().any(|(_, _, s)| !s.constraints.is_empty());
8529        if !any_constraints {
8530            return Ok(());
8531        }
8532
8533        // Lazily-loaded visible rows per table, shared across checks.
8534        let mut rows_cache: HashMap<u64, Vec<Row>> = HashMap::new();
8535        let mut load_rows = |table_id: u64| -> Result<Vec<Row>> {
8536            if let Some(r) = rows_cache.get(&table_id) {
8537                return Ok(r.clone());
8538            }
8539            let handle = self.table_by_id(table_id)?;
8540            let rows = match control {
8541                Some(control) => handle.lock().visible_rows_controlled(snapshot, control)?,
8542                None => handle.lock().visible_rows(snapshot)?,
8543            };
8544            rows_cache.insert(table_id, rows.clone());
8545            Ok(rows)
8546        };
8547
8548        // ── Phase A1: expand ON UPDATE CASCADE / SET NULL while updates still
8549        // carry an explicit old RowId + full new image. This makes action choice
8550        // reliable even when the referenced key itself changes; a delete+put
8551        // heuristic cannot distinguish that from unrelated operations.
8552        let mut processed_updates = HashSet::new();
8553        type PendingUpdate = (usize, u64, crate::rowid::RowId, Vec<(u16, Value)>);
8554        let mut update_pass = 0_usize;
8555        loop {
8556            commit_prepare_checkpoint(control, update_pass)?;
8557            update_pass += 1;
8558            let updates: Vec<PendingUpdate> = staging
8559                .iter()
8560                .enumerate()
8561                .filter_map(|(index, (table_id, op))| match op {
8562                    Staged::Update {
8563                        row_id,
8564                        new_row: cells,
8565                        ..
8566                    } if !processed_updates.contains(&index) => {
8567                        Some((index, *table_id, *row_id, cells.clone()))
8568                    }
8569                    _ => None,
8570                })
8571                .collect();
8572            if updates.is_empty() {
8573                break;
8574            }
8575            let mut new_ops = Vec::new();
8576            for (update_index, (index, table_id, row_id, new_cells)) in
8577                updates.into_iter().enumerate()
8578            {
8579                commit_prepare_checkpoint(control, update_index)?;
8580                processed_updates.insert(index);
8581                let Some(tname) = live
8582                    .iter()
8583                    .find(|(id, _, _)| *id == table_id)
8584                    .map(|(_, name, _)| *name)
8585                else {
8586                    continue;
8587                };
8588                let Some(old_row) = self.table_by_id(table_id)?.lock().get(row_id, snapshot) else {
8589                    continue;
8590                };
8591                let new_map: HashMap<u16, Value> = new_cells.iter().cloned().collect();
8592                for (child_id, _child_name, child_schema) in &live {
8593                    for fk in &child_schema.constraints.foreign_keys {
8594                        if fk.ref_table != tname {
8595                            continue;
8596                        }
8597                        let Some(old_key) = encode_composite_key(&fk.ref_columns, &old_row.columns)
8598                        else {
8599                            continue;
8600                        };
8601                        if encode_composite_key(&fk.ref_columns, &new_map).as_deref()
8602                            == Some(old_key.as_slice())
8603                        {
8604                            continue;
8605                        }
8606                        if fk.on_update == FkAction::Restrict {
8607                            continue;
8608                        }
8609                        let child_rows = load_rows(*child_id)?;
8610                        for (child_index, child) in child_rows.into_iter().enumerate() {
8611                            commit_prepare_checkpoint(control, child_index)?;
8612                            if encode_composite_key(&fk.columns, &child.columns).as_deref()
8613                                != Some(old_key.as_slice())
8614                            {
8615                                continue;
8616                            }
8617                            if staging.iter().any(|(id, op)| {
8618                                *id == *child_id
8619                                    && matches!(op, Staged::Delete(id) if *id == child.row_id)
8620                            }) {
8621                                continue;
8622                            }
8623                            let mut cells: Vec<(u16, Value)> = child
8624                                .columns
8625                                .iter()
8626                                .map(|(column_id, value)| (*column_id, value.clone()))
8627                                .collect();
8628                            for (child_column, parent_column) in
8629                                fk.columns.iter().zip(&fk.ref_columns)
8630                            {
8631                                cells.retain(|(column_id, _)| column_id != child_column);
8632                                let value = match fk.on_update {
8633                                    FkAction::Cascade => {
8634                                        new_map.get(parent_column).cloned().unwrap_or(Value::Null)
8635                                    }
8636                                    FkAction::SetNull => Value::Null,
8637                                    FkAction::Restrict => {
8638                                        return Err(MongrelError::Other(
8639                                            "restricted foreign-key update reached cascade preparation"
8640                                                .into(),
8641                                        ));
8642                                    }
8643                                };
8644                                cells.push((*child_column, value));
8645                            }
8646                            cells.sort_by_key(|(column_id, _)| *column_id);
8647                            if let Some(existing_index) = staging.iter().position(|(id, op)| {
8648                                *id == *child_id
8649                                    && matches!(op, Staged::Update { row_id, .. } if *row_id == child.row_id)
8650                            }) {
8651                                if let Staged::Update {
8652                                    new_row: existing,
8653                                    changed_columns,
8654                                    ..
8655                                } = &mut staging[existing_index].1 {
8656                                    changed_columns.extend(fk.columns.iter().copied());
8657                                    changed_columns.sort_unstable();
8658                                    changed_columns.dedup();
8659                                    if *existing != cells {
8660                                        *existing = cells;
8661                                        processed_updates.remove(&existing_index);
8662                                    }
8663                                }
8664                            } else {
8665                                new_ops.push((
8666                                    *child_id,
8667                                    Staged::Update {
8668                                        row_id: child.row_id,
8669                                        new_row: cells,
8670                                        changed_columns: fk.columns.clone(),
8671                                    },
8672                                ));
8673                            }
8674                        }
8675                    }
8676                }
8677            }
8678            staging.extend(new_ops);
8679        }
8680
8681        // ── Phase A2: expand ON DELETE CASCADE / SET NULL into explicit child
8682        // ops (transitive fixpoint). RESTRICT is not expanded here — it is
8683        // enforced as a violation in Phase B. `cascaded` records every delete
8684        // we have already expanded so a self-referential CASCADE FK cannot loop.
8685        let mut cascaded: HashSet<(u64, u64)> = HashSet::new();
8686        let mut cascade_pass = 0_usize;
8687        loop {
8688            commit_prepare_checkpoint(control, cascade_pass)?;
8689            cascade_pass += 1;
8690            let mut new_ops: Vec<(u64, Staged)> = Vec::new();
8691            let deletes: Vec<(u64, crate::rowid::RowId)> = staging
8692                .iter()
8693                .filter_map(|(t, op)| match op {
8694                    Staged::Delete(rid) => Some((*t, *rid)),
8695                    _ => None,
8696                })
8697                .collect();
8698            for (delete_index, (table_id, rid)) in deletes.into_iter().enumerate() {
8699                commit_prepare_checkpoint(control, delete_index)?;
8700                if !cascaded.insert((table_id, rid.0)) {
8701                    continue;
8702                }
8703                let Some(tname) = live
8704                    .iter()
8705                    .find(|(t, _, _)| *t == table_id)
8706                    .map(|(_, n, _)| *n)
8707                else {
8708                    continue;
8709                };
8710                let parent_handle = self.table_by_id(table_id)?;
8711                let Some(parent_row) = parent_handle.lock().get(rid, snapshot) else {
8712                    continue;
8713                };
8714                for (child_id, _child_name, child_schema) in &live {
8715                    for fk in &child_schema.constraints.foreign_keys {
8716                        if fk.ref_table != tname {
8717                            continue;
8718                        }
8719                        let Some(parent_key) =
8720                            encode_composite_key(&fk.ref_columns, &parent_row.columns)
8721                        else {
8722                            continue;
8723                        };
8724                        // Suppress ON DELETE cascade/set-null when this "delete"
8725                        // is actually half of an UPDATE encoded as Delete(old)+
8726                        // Put(new): if a staged Put in the SAME table still
8727                        // provides the referenced parent key, the parent still
8728                        // exists (its non-key columns changed) and the children
8729                        // must be left alone. A genuine delete, or an update
8730                        // that CHANGES the referenced key, has no preserving Put
8731                        // → cascade fires as before.
8732                        let key_preserved = staging.iter().any(|(t, op)| {
8733                            if *t != table_id {
8734                                return false;
8735                            }
8736                            let Staged::Put(cells) = op else {
8737                                return false;
8738                            };
8739                            let map: HashMap<u16, crate::memtable::Value> =
8740                                cells.iter().cloned().collect();
8741                            encode_composite_key(&fk.ref_columns, &map).as_deref()
8742                                == Some(parent_key.as_slice())
8743                        });
8744                        if key_preserved {
8745                            continue;
8746                        }
8747                        match fk.on_delete {
8748                            FkAction::Restrict => continue,
8749                            FkAction::Cascade => {
8750                                let child_rows = load_rows(*child_id)?;
8751                                for (child_index, cr) in child_rows.iter().enumerate() {
8752                                    commit_prepare_checkpoint(control, child_index)?;
8753                                    if !cascaded.contains(&(*child_id, cr.row_id.0))
8754                                        && encode_composite_key(&fk.columns, &cr.columns).as_deref()
8755                                            == Some(parent_key.as_slice())
8756                                    {
8757                                        new_ops.push((*child_id, Staged::Delete(cr.row_id)));
8758                                    }
8759                                }
8760                            }
8761                            FkAction::SetNull => {
8762                                let child_rows = load_rows(*child_id)?;
8763                                for (child_index, cr) in child_rows.iter().enumerate() {
8764                                    commit_prepare_checkpoint(control, child_index)?;
8765                                    if !cascaded.contains(&(*child_id, cr.row_id.0))
8766                                        && encode_composite_key(&fk.columns, &cr.columns).as_deref()
8767                                            == Some(parent_key.as_slice())
8768                                    {
8769                                        // Re-emit the child row with the FK
8770                                        // columns set to NULL (delete + put).
8771                                        let mut cells: Vec<(u16, crate::memtable::Value)> = cr
8772                                            .columns
8773                                            .iter()
8774                                            .map(|(k, v)| (*k, v.clone()))
8775                                            .collect();
8776                                        for cid in &fk.columns {
8777                                            cells.retain(|(k, _)| k != cid);
8778                                            cells.push((*cid, crate::memtable::Value::Null));
8779                                        }
8780                                        new_ops.push((
8781                                            *child_id,
8782                                            Staged::Update {
8783                                                row_id: cr.row_id,
8784                                                new_row: cells,
8785                                                changed_columns: fk.columns.clone(),
8786                                            },
8787                                        ));
8788                                    }
8789                                }
8790                            }
8791                        }
8792                    }
8793                }
8794            }
8795            if new_ops.is_empty() {
8796                break;
8797            }
8798            staging.extend(new_ops);
8799        }
8800
8801        // Rows staged for deletion in THIS transaction (now including cascaded
8802        // deletes). Used to exclude the old version of an updated row from
8803        // unique-existence scans.
8804        let staged_deletes: HashSet<(u64, u64)> = staging
8805            .iter()
8806            .filter_map(|(t, op)| match op {
8807                Staged::Delete(rid) | Staged::Update { row_id: rid, .. } => Some((*t, rid.0)),
8808                _ => None,
8809            })
8810            .collect();
8811
8812        // Intra-transaction unique-key dedup: (table_id, uc_id, key).
8813        let mut seen_unique: HashSet<(u64, u16, Vec<u8>)> = HashSet::new();
8814
8815        // ── Phase B: validate the fully-expanded staging set.
8816        for (operation_index, (table_id, op)) in staging.iter().enumerate() {
8817            commit_prepare_checkpoint(control, operation_index)?;
8818            let Some((_, tname, schema)) = live.iter().find(|(t, _, _)| t == table_id).copied()
8819            else {
8820                continue;
8821            };
8822            let cells_map: HashMap<u16, crate::memtable::Value>;
8823            match op {
8824                Staged::Put(cells) | Staged::Update { new_row: cells, .. } => {
8825                    cells_map = cells.iter().cloned().collect();
8826
8827                    // CHECK constraints.
8828                    if !schema.constraints.checks.is_empty() {
8829                        validate_checks(&schema.constraints.checks, &cells_map)?;
8830                    }
8831
8832                    // UNIQUE (non-PK) constraints.
8833                    for uc in &schema.constraints.uniques {
8834                        let Some(key) = encode_composite_key(&uc.columns, &cells_map) else {
8835                            continue; // NULL in a constrained column → skip (SQL).
8836                        };
8837                        let marker = (*table_id, uc.id, key.clone());
8838                        if !seen_unique.insert(marker) {
8839                            return Err(MongrelError::Conflict(format!(
8840                                "UNIQUE constraint '{}' on table '{tname}' violated within batch",
8841                                uc.name
8842                            )));
8843                        }
8844                        let rows = load_rows(*table_id)?;
8845                        for (row_index, r) in rows.iter().enumerate() {
8846                            commit_prepare_checkpoint(control, row_index)?;
8847                            // Skip rows this same transaction is deleting (the
8848                            // old version of an updated/cascade-deleted row).
8849                            if staged_deletes.contains(&(*table_id, r.row_id.0)) {
8850                                continue;
8851                            }
8852                            if let Some(theirs) = encode_composite_key(&uc.columns, &r.columns) {
8853                                if theirs == key {
8854                                    return Err(MongrelError::Conflict(format!(
8855                                        "UNIQUE constraint '{}' on table '{tname}' violated",
8856                                        uc.name
8857                                    )));
8858                                }
8859                            }
8860                        }
8861                    }
8862
8863                    // FK insert-side: parent must exist.
8864                    for fk in &schema.constraints.foreign_keys {
8865                        let Some(child_key) = encode_composite_key(&fk.columns, &cells_map) else {
8866                            continue; // NULL FK component → not checked (SQL).
8867                        };
8868                        let Some(parent_id) = cat
8869                            .tables
8870                            .iter()
8871                            .find(|t| t.name == fk.ref_table)
8872                            .map(|t| t.table_id)
8873                        else {
8874                            return Err(MongrelError::InvalidArgument(format!(
8875                                "FOREIGN KEY '{}' references unknown table '{}'",
8876                                fk.name, fk.ref_table
8877                            )));
8878                        };
8879                        let parent_rows = load_rows(parent_id)?;
8880                        let mut found = false;
8881                        for (row_index, r) in parent_rows.iter().enumerate() {
8882                            commit_prepare_checkpoint(control, row_index)?;
8883                            if staged_deletes.contains(&(parent_id, r.row_id.0)) {
8884                                continue;
8885                            }
8886                            if let Some(pkey) = encode_composite_key(&fk.ref_columns, &r.columns) {
8887                                if pkey == child_key {
8888                                    found = true;
8889                                    break;
8890                                }
8891                            }
8892                        }
8893                        // Final-write-set FK validation: a parent inserted in
8894                        // THIS transaction also satisfies the FK. This enables
8895                        // atomic parent+child batches and cyclical/mutual FK
8896                        // inserts within a single transaction — the child sees
8897                        // the staged parent put even though it is not committed
8898                        // yet.
8899                        if !found {
8900                            for (staged_index, (st_table, st_op)) in staging.iter().enumerate() {
8901                                commit_prepare_checkpoint(control, staged_index)?;
8902                                if *st_table != parent_id {
8903                                    continue;
8904                                }
8905                                if let Staged::Put(pcells)
8906                                | Staged::Update {
8907                                    new_row: pcells, ..
8908                                } = st_op
8909                                {
8910                                    let pmap: HashMap<u16, crate::memtable::Value> =
8911                                        pcells.iter().cloned().collect();
8912                                    if let Some(pkey) = encode_composite_key(&fk.ref_columns, &pmap)
8913                                    {
8914                                        if pkey == child_key {
8915                                            found = true;
8916                                            break;
8917                                        }
8918                                    }
8919                                }
8920                            }
8921                        }
8922                        if !found {
8923                            return Err(MongrelError::Conflict(format!(
8924                                "FOREIGN KEY '{}' on table '{tname}' has no matching parent in '{}'",
8925                                fk.name, fk.ref_table
8926                            )));
8927                        }
8928                    }
8929
8930                    // Parent-side ON UPDATE RESTRICT. CASCADE/SET NULL were
8931                    // expanded in Phase A; here the final child write set is
8932                    // known, so a child explicitly moved/deleted by this same
8933                    // transaction does not cause a false violation.
8934                    if let Staged::Update { row_id, .. } = op {
8935                        let parent_handle = self.table_by_id(*table_id)?;
8936                        let Some(old_parent) = parent_handle.lock().get(*row_id, snapshot) else {
8937                            continue;
8938                        };
8939                        for (child_id, child_name, child_schema) in &live {
8940                            for fk in &child_schema.constraints.foreign_keys {
8941                                if fk.ref_table != tname || fk.on_update != FkAction::Restrict {
8942                                    continue;
8943                                }
8944                                let Some(old_key) =
8945                                    encode_composite_key(&fk.ref_columns, &old_parent.columns)
8946                                else {
8947                                    continue;
8948                                };
8949                                if encode_composite_key(&fk.ref_columns, &cells_map).as_deref()
8950                                    == Some(old_key.as_slice())
8951                                {
8952                                    continue;
8953                                }
8954                                for (child_index, child) in
8955                                    load_rows(*child_id)?.into_iter().enumerate()
8956                                {
8957                                    commit_prepare_checkpoint(control, child_index)?;
8958                                    if encode_composite_key(&fk.columns, &child.columns).as_deref()
8959                                        != Some(old_key.as_slice())
8960                                    {
8961                                        continue;
8962                                    }
8963                                    let replacement = staging.iter().find_map(|(id, op)| {
8964                                        if *id != *child_id {
8965                                            return None;
8966                                        }
8967                                        match op {
8968                                            Staged::Delete(id) if *id == child.row_id => Some(None),
8969                                            Staged::Update {
8970                                                row_id,
8971                                                new_row: cells,
8972                                                ..
8973                                            } if *row_id == child.row_id => {
8974                                                let map: HashMap<u16, Value> =
8975                                                    cells.iter().cloned().collect();
8976                                                Some(encode_composite_key(&fk.columns, &map))
8977                                            }
8978                                            _ => None,
8979                                        }
8980                                    });
8981                                    if replacement.is_some_and(|key| {
8982                                        key.as_deref() != Some(old_key.as_slice())
8983                                    }) {
8984                                        continue;
8985                                    }
8986                                    return Err(MongrelError::Conflict(format!(
8987                                        "FOREIGN KEY '{}' on table '{child_name}' restricts update (parent key referenced)",
8988                                        fk.name
8989                                    )));
8990                                }
8991                            }
8992                        }
8993                    }
8994                }
8995                Staged::Delete(rid) => {
8996                    // FK ON DELETE RESTRICT: a child row (whose FK action is
8997                    // RESTRICT) referencing this parent blocks the delete.
8998                    // CASCADE/SET NULL children were expanded in Phase A.
8999                    let parent_handle = self.table_by_id(*table_id)?;
9000                    let Some(parent_row) = parent_handle.lock().get(*rid, snapshot) else {
9001                        continue;
9002                    };
9003                    for (child_id, child_name, child_schema) in &live {
9004                        for fk in &child_schema.constraints.foreign_keys {
9005                            if fk.ref_table != tname || fk.on_delete != FkAction::Restrict {
9006                                continue;
9007                            }
9008                            let Some(parent_key) =
9009                                encode_composite_key(&fk.ref_columns, &parent_row.columns)
9010                            else {
9011                                continue;
9012                            };
9013                            let child_rows = load_rows(*child_id)?;
9014                            for (row_index, r) in child_rows.iter().enumerate() {
9015                                commit_prepare_checkpoint(control, row_index)?;
9016                                // A child already being deleted by this txn
9017                                // (cascade/inline) is not a restrict violation.
9018                                if staged_deletes.contains(&(*child_id, r.row_id.0)) {
9019                                    continue;
9020                                }
9021                                if let Some(ck) = encode_composite_key(&fk.columns, &r.columns) {
9022                                    if ck == parent_key {
9023                                        return Err(MongrelError::Conflict(format!(
9024                                            "FOREIGN KEY '{}' on table '{child_name}' restricts delete (parent referenced)",
9025                                            fk.name
9026                                        )));
9027                                    }
9028                                }
9029                            }
9030                        }
9031                    }
9032                }
9033                Staged::Truncate => {
9034                    // Truncate is RESTRICT-only: reject if any child references
9035                    // this table (any FK action), since cascade-truncate is
9036                    // unsupported.
9037                    for (child_id, child_name, child_schema) in &live {
9038                        for fk in &child_schema.constraints.foreign_keys {
9039                            if fk.ref_table != tname {
9040                                continue;
9041                            }
9042                            let child_rows = load_rows(*child_id)?;
9043                            if child_rows
9044                                .iter()
9045                                .any(|r| encode_composite_key(&fk.columns, &r.columns).is_some())
9046                            {
9047                                return Err(MongrelError::Conflict(format!(
9048                                    "FOREIGN KEY '{}' on table '{child_name}' restricts truncate of '{tname}'",
9049                                    fk.name
9050                                )));
9051                            }
9052                        }
9053                    }
9054                }
9055            }
9056        }
9057        Ok(())
9058    }
9059
9060    fn validate_write_permissions(
9061        &self,
9062        staging: &[(u64, crate::txn::Staged)],
9063        principal: Option<&crate::auth::Principal>,
9064        control: Option<&crate::ExecutionControl>,
9065    ) -> Result<()> {
9066        commit_prepare_checkpoint(control, 0)?;
9067        if principal.is_none() && !self.auth_state.require_auth() {
9068            return Ok(());
9069        }
9070        let principal = principal.ok_or(MongrelError::AuthRequired)?;
9071        let needs = summarize_write_permissions(staging);
9072        let catalog = self.catalog.read();
9073
9074        if needs.values().any(|need| need.truncate) {
9075            self.require_for(Some(principal), &crate::auth::Permission::Admin)?;
9076        }
9077        for (need_index, (table_id, need)) in needs.into_iter().enumerate() {
9078            commit_prepare_checkpoint(control, need_index)?;
9079            let entry = catalog
9080                .tables
9081                .iter()
9082                .find(|entry| {
9083                    entry.table_id == table_id
9084                        && matches!(entry.state, TableState::Live | TableState::Building { .. })
9085                })
9086                .ok_or_else(|| {
9087                    MongrelError::NotFound(format!(
9088                        "live table {table_id} not found during write validation"
9089                    ))
9090                })?;
9091            if matches!(entry.state, TableState::Building { .. }) {
9092                self.require_for(Some(principal), &crate::auth::Permission::Ddl)?;
9093                continue;
9094            }
9095            if need.insert {
9096                Self::require_columns_for_principal(
9097                    &entry.name,
9098                    &entry.schema,
9099                    crate::auth::ColumnOperation::Insert,
9100                    &need.insert_columns,
9101                    principal,
9102                )?;
9103            }
9104            if need.update {
9105                Self::require_columns_for_principal(
9106                    &entry.name,
9107                    &entry.schema,
9108                    crate::auth::ColumnOperation::Update,
9109                    &need.update_columns,
9110                    principal,
9111                )?;
9112            }
9113            if need.delete {
9114                self.require_for(
9115                    Some(principal),
9116                    &crate::auth::Permission::Delete {
9117                        table: entry.name.clone(),
9118                    },
9119                )?;
9120            }
9121        }
9122        Ok(())
9123    }
9124
9125    fn validate_security_writes(
9126        &self,
9127        staging: &[(u64, crate::txn::Staged)],
9128        read_epoch: Epoch,
9129        explicit_principal: Option<&crate::auth::Principal>,
9130        control: Option<&crate::ExecutionControl>,
9131    ) -> Result<()> {
9132        commit_prepare_checkpoint(control, 0)?;
9133        use crate::security::PolicyCommand;
9134        use crate::txn::Staged;
9135
9136        let catalog = self.catalog.read();
9137        if catalog.security.rls_tables.is_empty() {
9138            return Ok(());
9139        }
9140        let security = catalog.security.clone();
9141        let table_names = catalog
9142            .tables
9143            .iter()
9144            .filter(|entry| matches!(entry.state, TableState::Live))
9145            .map(|entry| (entry.table_id, entry.name.clone()))
9146            .collect::<HashMap<_, _>>();
9147        drop(catalog);
9148        if !staging.iter().any(|(table_id, _)| {
9149            table_names
9150                .get(table_id)
9151                .is_some_and(|table| security.rls_enabled(table))
9152        }) {
9153            return Ok(());
9154        }
9155        let principal = explicit_principal.ok_or(MongrelError::AuthRequired)?;
9156
9157        for (operation_index, (table_id, operation)) in staging.iter().enumerate() {
9158            commit_prepare_checkpoint(control, operation_index)?;
9159            let Some(table) = table_names.get(table_id) else {
9160                continue;
9161            };
9162            if !security.rls_enabled(table) || principal.is_admin {
9163                continue;
9164            }
9165            let denied = |command| MongrelError::PermissionDenied {
9166                required: match command {
9167                    PolicyCommand::Insert => crate::auth::Permission::Insert {
9168                        table: table.clone(),
9169                    },
9170                    PolicyCommand::Update => crate::auth::Permission::Update {
9171                        table: table.clone(),
9172                    },
9173                    PolicyCommand::Delete | PolicyCommand::All | PolicyCommand::Select => {
9174                        crate::auth::Permission::Delete {
9175                            table: table.clone(),
9176                        }
9177                    }
9178                },
9179                principal: principal.username.clone(),
9180            };
9181            match operation {
9182                Staged::Put(cells) => {
9183                    let mut row = crate::memtable::Row::new(RowId(0), Epoch(read_epoch.0));
9184                    row.columns.extend(cells.iter().cloned());
9185                    if !security.row_allowed(table, PolicyCommand::Insert, &row, principal, true) {
9186                        return Err(denied(PolicyCommand::Insert));
9187                    }
9188                }
9189                Staged::Update {
9190                    row_id,
9191                    new_row: cells,
9192                    ..
9193                } => {
9194                    let old = self
9195                        .table_by_id(*table_id)?
9196                        .lock()
9197                        .get(*row_id, Snapshot::at(read_epoch))
9198                        .ok_or_else(|| {
9199                            MongrelError::NotFound(format!("row {} not found", row_id.0))
9200                        })?;
9201                    if !security.row_allowed(table, PolicyCommand::Update, &old, principal, false) {
9202                        return Err(denied(PolicyCommand::Update));
9203                    }
9204                    let mut new = crate::memtable::Row::new(*row_id, Epoch(read_epoch.0));
9205                    new.columns.extend(cells.iter().cloned());
9206                    if !security.row_allowed(table, PolicyCommand::Update, &new, principal, true) {
9207                        return Err(denied(PolicyCommand::Update));
9208                    }
9209                }
9210                Staged::Delete(row_id) => {
9211                    let old = self
9212                        .table_by_id(*table_id)?
9213                        .lock()
9214                        .get(*row_id, Snapshot::at(read_epoch))
9215                        .ok_or_else(|| {
9216                            MongrelError::NotFound(format!("row {} not found", row_id.0))
9217                        })?;
9218                    if !security.row_allowed(table, PolicyCommand::Delete, &old, principal, false) {
9219                        return Err(denied(PolicyCommand::Delete));
9220                    }
9221                }
9222                Staged::Truncate => return Err(denied(PolicyCommand::Delete)),
9223            }
9224        }
9225        Ok(())
9226    }
9227
9228    /// Seal a transaction (spec §9.3):
9229    /// 1. Prepare — derive write keys, allocate row ids (brief table locks).
9230    /// 2. Sequencer — validate-first under the WAL mutex; abort on conflict
9231    ///    with no epoch consumed; assign epoch, append data records + TxnCommit,
9232    ///    group-sync, record conflict keys.
9233    /// 3. Publish — apply to tables, advance visible in-order.
9234    #[allow(clippy::too_many_arguments)]
9235    pub(crate) fn commit_transaction_with_external_states(
9236        &self,
9237        txn_id: u64,
9238        read_epoch: Epoch,
9239        staging: Vec<(u64, crate::txn::Staged)>,
9240        external_states: Vec<(String, Vec<u8>)>,
9241        materialized_view_updates: Vec<crate::catalog::MaterializedViewEntry>,
9242        security_principal: Option<crate::auth::Principal>,
9243        principal_catalog_bound: bool,
9244        external_trigger_bridge: Option<&dyn ExternalTriggerBridge>,
9245    ) -> Result<(Epoch, Vec<RowId>)> {
9246        self.commit_transaction_with_external_states_inner(
9247            txn_id,
9248            read_epoch,
9249            staging,
9250            external_states,
9251            materialized_view_updates,
9252            security_principal,
9253            principal_catalog_bound,
9254            external_trigger_bridge,
9255            None,
9256            None,
9257        )
9258    }
9259
9260    #[allow(clippy::too_many_arguments)]
9261    pub(crate) fn commit_transaction_with_external_states_controlled(
9262        &self,
9263        txn_id: u64,
9264        read_epoch: Epoch,
9265        staging: Vec<(u64, crate::txn::Staged)>,
9266        external_states: Vec<(String, Vec<u8>)>,
9267        materialized_view_updates: Vec<crate::catalog::MaterializedViewEntry>,
9268        security_principal: Option<crate::auth::Principal>,
9269        principal_catalog_bound: bool,
9270        external_trigger_bridge: Option<&dyn ExternalTriggerBridge>,
9271        control: &crate::ExecutionControl,
9272        before_commit: &mut dyn FnMut() -> Result<()>,
9273    ) -> Result<(Epoch, Vec<RowId>)> {
9274        self.commit_transaction_with_external_states_inner(
9275            txn_id,
9276            read_epoch,
9277            staging,
9278            external_states,
9279            materialized_view_updates,
9280            security_principal,
9281            principal_catalog_bound,
9282            external_trigger_bridge,
9283            Some(control),
9284            Some(before_commit),
9285        )
9286    }
9287
9288    #[allow(clippy::too_many_arguments)]
9289    fn commit_transaction_with_external_states_inner(
9290        &self,
9291        txn_id: u64,
9292        read_epoch: Epoch,
9293        mut staging: Vec<(u64, crate::txn::Staged)>,
9294        external_states: Vec<(String, Vec<u8>)>,
9295        materialized_view_updates: Vec<crate::catalog::MaterializedViewEntry>,
9296        mut security_principal: Option<crate::auth::Principal>,
9297        principal_catalog_bound: bool,
9298        external_trigger_bridge: Option<&dyn ExternalTriggerBridge>,
9299        control: Option<&crate::ExecutionControl>,
9300        mut before_commit: Option<&mut dyn FnMut() -> Result<()>>,
9301    ) -> Result<(Epoch, Vec<RowId>)> {
9302        use crate::memtable::Row;
9303        use crate::txn::{Staged, StagedOp, WriteKey};
9304        use crate::wal::Op;
9305        use std::collections::hash_map::DefaultHasher;
9306        use std::hash::{Hash, Hasher};
9307        use std::sync::atomic::Ordering;
9308
9309        if txn_id == crate::wal::SYSTEM_TXN_ID {
9310            return Err(MongrelError::Full(
9311                "per-open transaction id namespace exhausted; reopen the database".into(),
9312            ));
9313        }
9314        if self.read_only {
9315            return Err(MongrelError::ReadOnlyReplica);
9316        }
9317        commit_prepare_checkpoint(control, 0)?;
9318        let observed_security_version = self.security_coordinator.version.load(Ordering::Acquire);
9319        self.refresh_security_catalog_if_stale(observed_security_version)?;
9320        let trigger_binding = trigger_catalog_binding(&self.catalog.read());
9321        if self.auth_state.require_auth() && security_principal.is_none() {
9322            return Err(MongrelError::AuthRequired);
9323        }
9324        {
9325            let catalog = self.catalog.read();
9326            if catalog.require_auth
9327                || principal_catalog_bound
9328                || security_principal
9329                    .as_ref()
9330                    .is_some_and(|principal| principal.user_id != 0)
9331            {
9332                let principal = security_principal
9333                    .as_ref()
9334                    .ok_or(MongrelError::AuthRequired)?;
9335                security_principal =
9336                    Self::resolve_bound_principal_from_catalog(&catalog, principal);
9337                if security_principal.is_none() {
9338                    return Err(MongrelError::AuthRequired);
9339                }
9340            }
9341        }
9342        let _replication_guard = self.replication_barrier.read();
9343        if self.poisoned.load(Ordering::Relaxed) {
9344            return Err(MongrelError::Other(
9345                "database poisoned by fsync error".into(),
9346            ));
9347        }
9348        let mut external_states = dedup_external_states(external_states);
9349        if !external_states.is_empty() {
9350            let cat = self.catalog.read();
9351            for (name, _) in &external_states {
9352                if !cat.external_tables.iter().any(|entry| entry.name == *name) {
9353                    return Err(MongrelError::NotFound(format!(
9354                        "external table {name:?} not found"
9355                    )));
9356                }
9357            }
9358        }
9359        let prepared_materialized_views = {
9360            let mut deduplicated = HashMap::new();
9361            for (definition_index, definition) in materialized_view_updates.into_iter().enumerate()
9362            {
9363                commit_prepare_checkpoint(control, definition_index)?;
9364                if definition.name.is_empty() || definition.query.trim().is_empty() {
9365                    return Err(MongrelError::InvalidArgument(
9366                        "materialized view name and query must not be empty".into(),
9367                    ));
9368                }
9369                deduplicated.insert(definition.name.clone(), definition);
9370            }
9371            let catalog = self.catalog.read();
9372            let mut prepared = Vec::with_capacity(deduplicated.len());
9373            for (definition_index, definition) in deduplicated.into_values().enumerate() {
9374                commit_prepare_checkpoint(control, definition_index)?;
9375                let table_id = catalog
9376                    .live(&definition.name)
9377                    .ok_or_else(|| {
9378                        MongrelError::NotFound(format!(
9379                            "materialized view table {:?} not found",
9380                            definition.name
9381                        ))
9382                    })?
9383                    .table_id;
9384                prepared.push((table_id, definition));
9385            }
9386            prepared.sort_by(|left, right| left.1.name.cmp(&right.1.name));
9387            prepared
9388        };
9389
9390        // ── 1. Prepare: fill generated values, expand triggers, validate, then
9391        // derive write keys from the final atomic write set.
9392        self.fill_auto_increment_for_staging(&mut staging, control)?;
9393        self.expand_table_triggers(
9394            &mut staging,
9395            read_epoch,
9396            external_trigger_bridge,
9397            &mut external_states,
9398            control,
9399        )?;
9400        self.fill_auto_increment_for_staging(&mut staging, control)?;
9401        external_states = dedup_external_states(external_states);
9402        let expected_external_generations = {
9403            let catalog = self.catalog.read();
9404            let mut generations = HashMap::with_capacity(external_states.len());
9405            for (name, _) in &external_states {
9406                let entry = catalog
9407                    .external_tables
9408                    .iter()
9409                    .find(|entry| entry.name == *name)
9410                    .ok_or_else(|| {
9411                        MongrelError::NotFound(format!("external table {name:?} not found"))
9412                    })?;
9413                generations.insert(name.clone(), entry.created_epoch);
9414            }
9415            generations
9416        };
9417
9418        // Validate declarative constraints (unique / FK / check) under the read
9419        // snapshot, outside the WAL mutex. Trigger-produced writes are included
9420        // here, so the batch either satisfies every declared constraint or is
9421        // rejected atomically.
9422        self.validate_constraints(&mut staging, read_epoch, control)?;
9423        self.validate_write_permissions(&staging, security_principal.as_ref(), control)?;
9424        self.validate_security_writes(&staging, read_epoch, security_principal.as_ref(), control)?;
9425        let mut normalized = Vec::with_capacity(staging.len() * 2);
9426        for (staged_index, (table_id, op)) in staging.into_iter().enumerate() {
9427            commit_prepare_checkpoint(control, staged_index)?;
9428            match op {
9429                crate::txn::Staged::Update {
9430                    row_id,
9431                    new_row: cells,
9432                    ..
9433                } => {
9434                    normalized.push((table_id, crate::txn::Staged::Delete(row_id)));
9435                    normalized.push((table_id, crate::txn::Staged::Put(cells)));
9436                }
9437                op => normalized.push((table_id, op)),
9438            }
9439        }
9440        staging = normalized;
9441        let has_changes = !staging.is_empty()
9442            || !external_states.is_empty()
9443            || !prepared_materialized_views.is_empty();
9444        let truncated_tables: HashSet<u64> = staging
9445            .iter()
9446            .filter_map(|(table_id, op)| matches!(op, Staged::Truncate).then_some(*table_id))
9447            .collect();
9448
9449        let write_keys = {
9450            let cat = self.catalog.read();
9451            let mut keys: Vec<WriteKey> = Vec::new();
9452            for (staged_index, (table_id, staged)) in staging.iter().enumerate() {
9453                commit_prepare_checkpoint(control, staged_index)?;
9454                match staged {
9455                    Staged::Put(cells) => {
9456                        if let Some(entry) = cat.tables.iter().find(|t| t.table_id == *table_id) {
9457                            for col in &entry.schema.columns {
9458                                if col.flags.contains(crate::schema::ColumnFlags::PRIMARY_KEY) {
9459                                    if let Some((_, val)) =
9460                                        cells.iter().find(|(id, _)| *id == col.id)
9461                                    {
9462                                        let mut h = DefaultHasher::new();
9463                                        val.encode_key().hash(&mut h);
9464                                        keys.push(WriteKey::Unique {
9465                                            table_id: *table_id,
9466                                            index_id: 0,
9467                                            key_hash: h.finish(),
9468                                        });
9469                                    }
9470                                }
9471                            }
9472                            // Declared non-PK unique constraints register a
9473                            // `WriteKey::Unique` (namespace-separated from the
9474                            // PK's index_id==0 by setting the high bit) so two
9475                            // concurrent transactions inserting the same key
9476                            // cannot both commit. Rows with any NULL constrained
9477                            // column are skipped (SQL semantics).
9478                            for uc in &entry.schema.constraints.uniques {
9479                                if let Some(key_bytes) = crate::constraint::encode_composite_key(
9480                                    &uc.columns,
9481                                    &cells.iter().cloned().collect(),
9482                                ) {
9483                                    let mut h = DefaultHasher::new();
9484                                    key_bytes.hash(&mut h);
9485                                    keys.push(WriteKey::Unique {
9486                                        table_id: *table_id,
9487                                        index_id: uc.id | 0x8000,
9488                                        key_hash: h.finish(),
9489                                    });
9490                                }
9491                            }
9492                        }
9493                    }
9494                    Staged::Delete(rid) => keys.push(WriteKey::Row {
9495                        table_id: *table_id,
9496                        row_id: rid.0,
9497                    }),
9498                    Staged::Truncate => keys.push(WriteKey::Table {
9499                        table_id: *table_id,
9500                    }),
9501                    Staged::Update { .. } => {
9502                        return Err(MongrelError::Other(
9503                            "transaction contains an unnormalized update during preparation".into(),
9504                        ));
9505                    }
9506                }
9507            }
9508            for (external_index, (name, _)) in external_states.iter().enumerate() {
9509                commit_prepare_checkpoint(control, external_index)?;
9510                let mut h = DefaultHasher::new();
9511                name.hash(&mut h);
9512                keys.push(WriteKey::Unique {
9513                    table_id: EXTERNAL_TABLE_ID,
9514                    index_id: 0,
9515                    key_hash: h.finish(),
9516                });
9517            }
9518            keys
9519        };
9520
9521        // Opportunistic pruning.
9522        let min_active = self.active_txns.min_read_epoch();
9523        if min_active < u64::MAX {
9524            self.conflicts.prune_below(Epoch(min_active));
9525        }
9526
9527        // ── 1a. Pre-validate the full write-set OUTSIDE the sequencer (spec
9528        // §8.5, review fix #17). Snapshot the conflict-index version so the
9529        // sequencer only re-checks if new commits arrived in the interim.
9530        if self.conflicts.conflicts(&write_keys, read_epoch) {
9531            return Err(MongrelError::Conflict(
9532                "write-write conflict (pre-validate, first-committer-wins)".into(),
9533            ));
9534        }
9535        let pre_validate_version = self.conflicts.version();
9536
9537        // ── 1b. Spill: if a table's staged puts exceed the threshold, write a
9538        // uniform-epoch pending run (spec §8.5). Rows in the run are NOT
9539        // streamed as Put records; they are linked at publish time.
9540        let mut spilled: Vec<SpilledRun> = Vec::new();
9541        let mut spilled_tables: std::collections::HashSet<u64> = std::collections::HashSet::new();
9542        // Protect this txn's `_txn/<id>/` dir from a concurrent `gc()` for as long
9543        // as the spill runs are live (registered on first spill, dropped at the
9544        // end of this function on commit/abort/error).
9545        let mut spill_guard: Option<crate::retention::SpillGuard> = None;
9546        {
9547            let mut table_bytes: HashMap<u64, u64> = HashMap::new();
9548            let mut put_indexes: HashMap<u64, Vec<usize>> = HashMap::new();
9549            for (staged_index, (table_id, staged)) in staging.iter().enumerate() {
9550                commit_prepare_checkpoint(control, staged_index)?;
9551                if let Staged::Put(cells) = staged {
9552                    let bytes = cells.iter().fold(32_u64, |bytes, (_, value)| {
9553                        bytes.saturating_add(value.estimated_bytes())
9554                    });
9555                    let table_bytes = table_bytes.entry(*table_id).or_default();
9556                    *table_bytes = table_bytes.saturating_add(bytes);
9557                    put_indexes.entry(*table_id).or_default().push(staged_index);
9558                }
9559            }
9560            let tables = self.tables.read();
9561            for (table_index, (&table_id, &bytes)) in table_bytes.iter().enumerate() {
9562                commit_prepare_checkpoint(control, table_index)?;
9563                if bytes
9564                    <= self
9565                        .spill_threshold
9566                        .load(std::sync::atomic::Ordering::Relaxed)
9567                {
9568                    continue;
9569                }
9570                let Some(handle) = tables.get(&table_id) else {
9571                    continue;
9572                };
9573                spill_guard.get_or_insert_with(|| self.active_spills.register(txn_id));
9574                let mut t = handle.lock();
9575                let tdir = t.table_dir().to_path_buf();
9576                let txn_dir = tdir.join("_txn").join(txn_id.to_string());
9577                std::fs::create_dir_all(&txn_dir)?;
9578                let run_id = t.alloc_run_id()? as u128;
9579                let pending_path = txn_dir.join(format!("r-{run_id}.sr"));
9580                let final_path = t.run_path(run_id as u64);
9581
9582                let mut rows: Vec<Row> = Vec::new();
9583                for (put_index, staged_index) in put_indexes[&table_id].iter().enumerate() {
9584                    commit_prepare_checkpoint(control, put_index)?;
9585                    let Staged::Put(cells) = &mut staging[*staged_index].1 else {
9586                        return Err(MongrelError::Other(
9587                            "transaction put index no longer references a put".into(),
9588                        ));
9589                    };
9590                    t.validate_cells_not_null(cells)?;
9591                    let row_id = t.alloc_row_id()?;
9592                    let mut row = Row::new(row_id, Epoch(0));
9593                    row.columns.extend(std::mem::take(cells));
9594                    rows.push(row);
9595                }
9596                let schema = t.schema_ref().clone();
9597                let kek = t.kek_ref().cloned();
9598                let specs = t.indexable_column_specs();
9599                drop(t);
9600
9601                let mut writer = crate::sorted_run::RunWriter::new(&schema, run_id, Epoch(0), 0)
9602                    .uniform_epoch(true);
9603                if let Some(ref kek) = kek {
9604                    writer = writer.with_encryption(kek.as_ref(), specs);
9605                }
9606                commit_prepare_checkpoint(control, 0)?;
9607                let header = writer.write(&pending_path, &rows)?;
9608                commit_prepare_checkpoint(control, 0)?;
9609                let row_count = header.row_count;
9610                let min_rid = rows.first().map(|r| r.row_id.0).unwrap_or(0);
9611                let max_rid = rows.last().map(|r| r.row_id.0).unwrap_or(0);
9612
9613                spilled.push(SpilledRun {
9614                    table_id,
9615                    run_id,
9616                    pending_path,
9617                    final_path,
9618                    rows,
9619                    row_count,
9620                    min_rid,
9621                    max_rid,
9622                    content_hash: header.content_hash,
9623                });
9624                spilled_tables.insert(table_id);
9625            }
9626        }
9627
9628        // Test seam: let a test race `gc()` against this in-flight spill.
9629        if spill_guard.is_some() {
9630            if let Some(hook) = self.spill_hook.lock().as_ref() {
9631                hook();
9632            }
9633        }
9634
9635        // ── 1c. Pre-build non-spilled put rows OUTSIDE the WAL critical section.
9636        // Allocating row ids + building the rows here (lock order: table handle →
9637        // nothing) means the sequencer never locks a table handle while holding
9638        // the shared-WAL mutex. That matters because `Table::commit`/`flush` lock
9639        // the table handle THEN the shared WAL; if the sequencer did the reverse
9640        // (WAL then handle) the two paths would deadlock (review fix: B1).
9641        // Aligned 1:1 with `staging`; `None` for deletes and spilled puts.
9642        // Row ids are allocated here, before the sequencer's delta conflict
9643        // re-check, so a losing txn leaks the ids it reserved — harmless, the
9644        // u64 row-id space is monotonic and gaps are expected (spills do the same).
9645        let mut prebuilt: Vec<Option<Row>> = std::iter::repeat_with(|| None)
9646            .take(staging.len())
9647            .collect();
9648        let mut delete_images: Vec<Option<Row>> = std::iter::repeat_with(|| None)
9649            .take(staging.len())
9650            .collect();
9651        {
9652            let mut indexes_by_table: HashMap<u64, Vec<usize>> = HashMap::new();
9653            for (index, (table_id, staged)) in staging.iter().enumerate() {
9654                commit_prepare_checkpoint(control, index)?;
9655                if matches!(staged, Staged::Delete(_))
9656                    || matches!(staged, Staged::Put(_) if !spilled_tables.contains(table_id))
9657                {
9658                    indexes_by_table.entry(*table_id).or_default().push(index);
9659                }
9660            }
9661            let tables = self.tables.read();
9662            for (table_index, (table_id, indexes)) in indexes_by_table.into_iter().enumerate() {
9663                commit_prepare_checkpoint(control, table_index)?;
9664                let handle = tables.get(&table_id).ok_or_else(|| {
9665                    MongrelError::NotFound(format!("table {table_id} not mounted"))
9666                })?;
9667                #[cfg(test)]
9668                PREBUILD_TABLE_LOCKS.with(|count| count.set(count.get() + 1));
9669                let mut t = handle.lock();
9670                for (prepare_index, index) in indexes.into_iter().enumerate() {
9671                    commit_prepare_checkpoint(control, prepare_index)?;
9672                    match &staging[index].1 {
9673                        Staged::Put(cells) if !spilled_tables.contains(&table_id) => {
9674                            t.validate_cells_not_null(cells)?;
9675                            let mut row = Row::new(t.alloc_row_id()?, Epoch(0));
9676                            for (column, value) in cells {
9677                                row.columns.insert(*column, value.clone());
9678                            }
9679                            prebuilt[index] = Some(row);
9680                        }
9681                        Staged::Delete(row_id) => {
9682                            delete_images[index] = t.get(*row_id, Snapshot::at(read_epoch));
9683                        }
9684                        Staged::Put(_) | Staged::Truncate => {}
9685                        Staged::Update { .. } => {
9686                            return Err(MongrelError::Other(
9687                                "transaction contains an unnormalized update during row preparation"
9688                                    .into(),
9689                            ));
9690                        }
9691                    }
9692                }
9693            }
9694        }
9695
9696        // Finish every fallible index read before the commit marker can become
9697        // durable. Post-durable row/run metadata application is then entirely
9698        // in-memory and cannot stop halfway through a multi-table publish.
9699        let prepared_table_handles = {
9700            let table_ids: HashSet<u64> = staging.iter().map(|(table_id, _)| *table_id).collect();
9701            let put_table_ids: HashSet<u64> = staging
9702                .iter()
9703                .filter_map(|(table_id, staged)| {
9704                    matches!(staged, Staged::Put(_)).then_some(*table_id)
9705                })
9706                .collect();
9707            let tables = self.tables.read();
9708            let mut handles = HashMap::with_capacity(table_ids.len());
9709            for (table_index, table_id) in table_ids.into_iter().enumerate() {
9710                commit_prepare_checkpoint(control, table_index)?;
9711                let handle = tables.get(&table_id).ok_or_else(|| {
9712                    MongrelError::NotFound(format!("table {table_id} not mounted"))
9713                })?;
9714                if put_table_ids.contains(&table_id) {
9715                    match control {
9716                        Some(control) => {
9717                            handle.lock().prepare_durable_publish_controlled(control)?
9718                        }
9719                        None => handle.lock().prepare_durable_publish()?,
9720                    }
9721                }
9722                handles.insert(table_id, handle.clone());
9723            }
9724            handles
9725        };
9726
9727        // Link large-transaction spill files before WAL durability. The guard
9728        // restores their pending names on every error before WAL append begins;
9729        // publication only attaches already-present files in memory.
9730        let mut prepared_run_links = PreparedRunLinks::prepare(&spilled)?;
9731
9732        let mut spilled_row_ids: HashMap<u64, VecDeque<RowId>> = spilled
9733            .iter()
9734            .map(|run| {
9735                (
9736                    run.table_id,
9737                    run.rows.iter().map(|row| row.row_id).collect(),
9738                )
9739            })
9740            .collect();
9741        let committed_row_ids = staging
9742            .iter()
9743            .enumerate()
9744            .filter_map(|(index, (table_id, staged))| {
9745                if !matches!(staged, Staged::Put(_)) {
9746                    return None;
9747                }
9748                prebuilt[index].as_ref().map(|row| row.row_id).or_else(|| {
9749                    spilled_row_ids
9750                        .get_mut(table_id)
9751                        .and_then(VecDeque::pop_front)
9752                })
9753            })
9754            .collect();
9755
9756        let mut prepared_external = Vec::with_capacity(external_states.len());
9757        for (external_index, (name, state)) in external_states.iter().enumerate() {
9758            commit_prepare_checkpoint(control, external_index)?;
9759            let pending = prepare_external_state_file(&self.root, name, state, txn_id)?;
9760            prepared_external.push((name.clone(), state.clone(), pending));
9761        }
9762
9763        // ── 2. Sequencer: validate-first → assign → append → sync → record ──
9764        let added_runs: Vec<crate::wal::AddedRun> = spilled
9765            .iter()
9766            .map(|s| crate::wal::AddedRun {
9767                table_id: s.table_id,
9768                run_id: s.run_id,
9769                row_count: s.row_count,
9770                level: 0,
9771                min_row_id: s.min_rid,
9772                max_row_id: s.max_rid,
9773                content_hash: s.content_hash,
9774            })
9775            .collect();
9776        if let Some(hook) = self.catalog_commit_hook.lock().as_ref() {
9777            hook();
9778        }
9779        // Lock order: security gate -> commit lock -> shared WAL -> table locks.
9780        // Security mutations cannot overtake an authorized commit before its
9781        // commit marker is durable.
9782        let security_guard = self.security_coordinator.gate.read();
9783        if self.security_coordinator.version.load(Ordering::Acquire) != observed_security_version {
9784            return Err(MongrelError::Conflict(
9785                "security policy changed during write".into(),
9786            ));
9787        }
9788        if spill_guard.is_some() {
9789            if let Some(hook) = self.security_commit_hook.lock().as_ref() {
9790                hook();
9791            }
9792        }
9793        let commit_guard = self.commit_lock.lock();
9794        let catalog_generation_result = (|| {
9795            {
9796                let catalog = self.catalog.read();
9797                for table_id in prepared_table_handles.keys() {
9798                    let is_current = catalog.tables.iter().any(|entry| {
9799                        entry.table_id == *table_id
9800                            && matches!(entry.state, TableState::Live | TableState::Building { .. })
9801                    });
9802                    if !is_current {
9803                        return Err(MongrelError::Conflict(format!(
9804                            "table {table_id} changed during transaction preparation"
9805                        )));
9806                    }
9807                }
9808                for (name, created_epoch) in &expected_external_generations {
9809                    let current = catalog
9810                        .external_tables
9811                        .iter()
9812                        .find(|entry| entry.name == *name)
9813                        .map(|entry| entry.created_epoch);
9814                    if current != Some(*created_epoch) {
9815                        return Err(MongrelError::Conflict(format!(
9816                            "external table {name:?} changed during transaction preparation"
9817                        )));
9818                    }
9819                }
9820                for (table_id, definition) in &prepared_materialized_views {
9821                    let current = catalog.live(&definition.name).map(|entry| entry.table_id);
9822                    if current != Some(*table_id) {
9823                        return Err(MongrelError::Conflict(format!(
9824                            "materialized view {:?} changed during transaction preparation",
9825                            definition.name
9826                        )));
9827                    }
9828                }
9829                if trigger_catalog_binding(&catalog) != trigger_binding {
9830                    return Err(MongrelError::Conflict(
9831                        "trigger or referenced table generation changed during transaction preparation"
9832                            .into(),
9833                    ));
9834                }
9835            }
9836            let tables = self.tables.read();
9837            for (table_id, prepared) in &prepared_table_handles {
9838                if !tables
9839                    .get(table_id)
9840                    .is_some_and(|current| current.ptr_eq(prepared))
9841                {
9842                    return Err(MongrelError::Conflict(format!(
9843                        "table {table_id} mount changed during transaction preparation"
9844                    )));
9845                }
9846            }
9847            Ok(())
9848        })();
9849        if let Err(error) = catalog_generation_result {
9850            drop(commit_guard);
9851            for (_, _, pending) in &prepared_external {
9852                let _ = std::fs::remove_file(pending);
9853            }
9854            return Err(error);
9855        }
9856        // The commit lock keeps the next epoch stable while logical spill
9857        // records are serialized. Build them before taking the shared WAL
9858        // lock, and cap their aggregate memory/WAL footprint.
9859        let new_epoch = self.epoch.assigned().next();
9860        let mut spilled_wal_bytes = 0;
9861        let mut spilled_wal_records = Vec::<(u64, Op)>::new();
9862        let spill_prepare = (|| {
9863            for run in &mut spilled {
9864                for row in &mut run.rows {
9865                    row.committed_epoch = new_epoch;
9866                }
9867                for rows in encode_spilled_row_chunks(
9868                    &run.rows,
9869                    &mut spilled_wal_bytes,
9870                    SPILLED_WAL_TOTAL_MAX_BYTES,
9871                    control,
9872                )? {
9873                    spilled_wal_records.push((
9874                        run.table_id,
9875                        Op::SpilledRows {
9876                            table_id: run.table_id,
9877                            rows,
9878                        },
9879                    ));
9880                }
9881            }
9882            Result::<()>::Ok(())
9883        })();
9884        if let Err(error) = spill_prepare {
9885            for (_, _, pending) in &prepared_external {
9886                let _ = std::fs::remove_file(pending);
9887            }
9888            return Err(error);
9889        }
9890        let (new_epoch, mut _epoch_guard, applies, committed_materialized_views, commit_seq) = {
9891            let mut wal = self.shared_wal.lock();
9892
9893            // Re-check only if the conflict index advanced since pre-validation
9894            // (bounded delta — spec §8.5, review fix #17). If the version is
9895            // unchanged, the pre-check result is still valid and the sequencer
9896            // does O(1) work regardless of write-set size.
9897            if self.conflicts.version() != pre_validate_version
9898                && self.conflicts.conflicts(&write_keys, read_epoch)
9899            {
9900                // Abort: this txn assigned no epoch yet. The prepared-run guard
9901                // restores final run names to their pending paths on return.
9902                drop(wal);
9903                for (_, _, pending) in &prepared_external {
9904                    let _ = std::fs::remove_file(pending);
9905                }
9906                return Err(MongrelError::Conflict(
9907                    "write-write conflict (sequencer delta re-check)".into(),
9908                ));
9909            }
9910
9911            if let Some(control) = control {
9912                if let Err(error) = control.checkpoint() {
9913                    drop(wal);
9914                    for (_, _, pending) in &prepared_external {
9915                        let _ = std::fs::remove_file(pending);
9916                    }
9917                    return Err(error);
9918                }
9919            }
9920            let mut applies = Vec::<TableApplyBatch>::new();
9921            let mut apply_indexes = HashMap::<u64, usize>::new();
9922            let mut committed_materialized_views = Vec::new();
9923            let mut wal_records = spilled_wal_records;
9924
9925            let mut index = 0;
9926            while index < staging.len() {
9927                let table_id = staging[index].0;
9928                let handle = prepared_table_handles
9929                    .get(&table_id)
9930                    .cloned()
9931                    .ok_or_else(|| {
9932                        MongrelError::NotFound(format!("table {table_id} not prepared"))
9933                    })?;
9934                let batch_index = *apply_indexes.entry(table_id).or_insert_with(|| {
9935                    let index = applies.len();
9936                    applies.push(TableApplyBatch {
9937                        table_id,
9938                        handle,
9939                        ops: Vec::new(),
9940                    });
9941                    index
9942                });
9943
9944                // Skip puts for tables that were spilled — their data is in a
9945                // pending run, not in streamed Put records.
9946                if spilled_tables.contains(&table_id) && matches!(&staging[index].1, Staged::Put(_))
9947                {
9948                    index += 1;
9949                    continue;
9950                }
9951
9952                match &staging[index].1 {
9953                    Staged::Put(_) => {
9954                        let mut rows = Vec::new();
9955                        while index < staging.len()
9956                            && staging[index].0 == table_id
9957                            && matches!(&staging[index].1, Staged::Put(_))
9958                        {
9959                            let mut row = prebuilt[index].take().ok_or_else(|| {
9960                                MongrelError::Other(
9961                                    "transaction prepare lost a prebuilt put row".into(),
9962                                )
9963                            })?;
9964                            row.committed_epoch = new_epoch;
9965                            rows.push(row);
9966                            index += 1;
9967                        }
9968                        let payload = bincode::serialize(&rows)
9969                            .map_err(|e| MongrelError::Other(format!("row serialize: {e}")))?;
9970                        wal_records.push((
9971                            table_id,
9972                            Op::Put {
9973                                table_id,
9974                                rows: payload,
9975                            },
9976                        ));
9977                        applies[batch_index].ops.push(StagedOp::Put(rows));
9978                    }
9979                    Staged::Delete(_) => {
9980                        let mut row_ids = Vec::new();
9981                        while index < staging.len()
9982                            && staging[index].0 == table_id
9983                            && matches!(&staging[index].1, Staged::Delete(_))
9984                        {
9985                            let Staged::Delete(row_id) = &staging[index].1 else {
9986                                return Err(MongrelError::Other(
9987                                    "transaction delete batch changed during WAL preparation"
9988                                        .into(),
9989                                ));
9990                            };
9991                            if let Some(before) = &delete_images[index] {
9992                                wal_records.push((
9993                                    table_id,
9994                                    Op::BeforeImage {
9995                                        table_id,
9996                                        row_id: *row_id,
9997                                        row: bincode::serialize(before).map_err(|error| {
9998                                            MongrelError::Other(format!(
9999                                                "before-image serialize: {error}"
10000                                            ))
10001                                        })?,
10002                                    },
10003                                ));
10004                            }
10005                            row_ids.push(*row_id);
10006                            index += 1;
10007                        }
10008                        wal_records.push((
10009                            table_id,
10010                            Op::Delete {
10011                                table_id,
10012                                row_ids: row_ids.clone(),
10013                            },
10014                        ));
10015                        applies[batch_index].ops.push(StagedOp::Delete(row_ids));
10016                    }
10017                    Staged::Truncate => {
10018                        wal_records.push((table_id, Op::TruncateTable { table_id }));
10019                        applies[batch_index].ops.push(StagedOp::Truncate);
10020                        index += 1;
10021                    }
10022                    Staged::Update { .. } => {
10023                        return Err(MongrelError::Other(
10024                            "transaction contains an unnormalized update at the sequencer".into(),
10025                        ));
10026                    }
10027                }
10028            }
10029
10030            for (name, state, _) in &prepared_external {
10031                wal_records.push((
10032                    EXTERNAL_TABLE_ID,
10033                    Op::ExternalTableState {
10034                        name: name.clone(),
10035                        state: state.clone(),
10036                    },
10037                ));
10038            }
10039
10040            for (table_id, definition) in &prepared_materialized_views {
10041                let mut definition = definition.clone();
10042                definition.last_refresh_epoch = new_epoch.0;
10043                wal_records.push((
10044                    *table_id,
10045                    Op::Ddl(crate::wal::DdlOp::SetMaterializedView {
10046                        name: definition.name.clone(),
10047                        definition_json: crate::wal::DdlOp::encode_materialized_view(&definition)?,
10048                    }),
10049                ));
10050                committed_materialized_views.push(definition);
10051            }
10052            if !committed_materialized_views.is_empty() {
10053                let mut next_catalog = self.catalog.read().clone();
10054                for definition in &committed_materialized_views {
10055                    if let Some(existing) = next_catalog
10056                        .materialized_views
10057                        .iter_mut()
10058                        .find(|existing| existing.name == definition.name)
10059                    {
10060                        *existing = definition.clone();
10061                    } else {
10062                        next_catalog.materialized_views.push(definition.clone());
10063                    }
10064                }
10065                next_catalog.db_epoch = next_catalog.db_epoch.max(new_epoch.0);
10066                wal_records.push((
10067                    WAL_TABLE_ID,
10068                    Op::Ddl(crate::wal::DdlOp::CatalogSnapshot {
10069                        catalog_json: crate::wal::DdlOp::encode_catalog(&next_catalog)?,
10070                    }),
10071                ));
10072            }
10073
10074            if let Some(control) = control {
10075                if let Err(error) = control.checkpoint() {
10076                    drop(wal);
10077                    for (_, _, pending) in &prepared_external {
10078                        let _ = std::fs::remove_file(pending);
10079                    }
10080                    return Err(error);
10081                }
10082            }
10083            if let Some(before_commit) = before_commit.as_mut() {
10084                if let Err(error) = before_commit() {
10085                    drop(wal);
10086                    for (_, _, pending) in &prepared_external {
10087                        let _ = std::fs::remove_file(pending);
10088                    }
10089                    return Err(error);
10090                }
10091            }
10092
10093            let assigned_epoch = self.epoch.bump_assigned();
10094            let _epoch_guard = EpochGuard::new(self.epoch.as_ref(), assigned_epoch);
10095            if assigned_epoch != new_epoch {
10096                for (_, _, pending) in &prepared_external {
10097                    let _ = std::fs::remove_file(pending);
10098                }
10099                return Err(MongrelError::Conflict(
10100                    "commit epoch changed while sequencer lock was held".into(),
10101                ));
10102            }
10103
10104            // From this point the outcome can become ambiguous. Keep prepared
10105            // spill files at the final names referenced by a possibly durable
10106            // commit marker; orphan cleanup is safe when the append did fail.
10107            prepared_run_links.disarm();
10108
10109            let append: Result<u64> = (|| {
10110                for (table_id, op) in wal_records {
10111                    wal.append(txn_id, table_id, op)?;
10112                }
10113                wal.append_commit(txn_id, new_epoch, &added_runs)
10114            })();
10115            let commit_seq =
10116                append.map_err(|error| self.commit_outcome_unknown(new_epoch, error))?;
10117
10118            // Record the conflict + assign the epoch under the WAL lock so commit
10119            // order == WAL append order, but DO NOT fsync here (P3.2): the fsync
10120            // moves out of this critical section to the group-commit coordinator
10121            // so concurrent committers share a single leader fsync.
10122            self.conflicts.record(&write_keys, new_epoch);
10123            (
10124                new_epoch,
10125                _epoch_guard,
10126                applies,
10127                committed_materialized_views,
10128                commit_seq,
10129            )
10130        };
10131        drop(commit_guard);
10132
10133        // ── 2b. Durability: one leader fsync serves this whole batch (P3.2). ──
10134        self.await_durable_commit(commit_seq, new_epoch)?;
10135        drop(security_guard);
10136
10137        // ── 3. Publish: apply non-spilled ops + link spilled runs ──
10138        let publish_result: Result<()> = {
10139            let mut first_error = None;
10140            let mut spilled_by_table: HashMap<u64, Vec<&SpilledRun>> = HashMap::new();
10141            for run in &spilled {
10142                spilled_by_table.entry(run.table_id).or_default().push(run);
10143            }
10144            let mut modified_tables = Vec::with_capacity(applies.len());
10145            // Apply every table completely before any fallible manifest write.
10146            // The visible epoch remains unchanged until all tables are coherent.
10147            for batch in applies {
10148                #[cfg(test)]
10149                PUBLISH_TABLE_LOCKS.with(|count| count.set(count.get() + 1));
10150                let mut t = batch.handle.lock();
10151                for op in batch.ops {
10152                    match op {
10153                        StagedOp::Put(rows) => t.apply_put_rows_prepared(rows),
10154                        StagedOp::Delete(row_ids) => {
10155                            for row_id in row_ids {
10156                                t.apply_delete(row_id, new_epoch);
10157                            }
10158                        }
10159                        StagedOp::Truncate => t.apply_truncate(new_epoch),
10160                    }
10161                }
10162                if let Some(runs) = spilled_by_table.remove(&batch.table_id) {
10163                    for run in runs {
10164                        t.link_run(crate::manifest::RunRef {
10165                            run_id: run.run_id,
10166                            level: 0,
10167                            epoch_created: new_epoch.0,
10168                            row_count: run.row_count,
10169                        });
10170                        t.apply_run_metadata_prepared(&run.rows)?;
10171                        if truncated_tables.contains(&batch.table_id) {
10172                            // TRUNCATE + spilled puts fully describe this table at
10173                            // the commit epoch. Endorse the epoch so clean-reopen
10174                            // recovery does not replay the truncate over the
10175                            // already-linked replacement run.
10176                            t.set_flushed_epoch(new_epoch);
10177                        }
10178                    }
10179                }
10180                t.invalidate_pending_cache();
10181                drop(t);
10182                modified_tables.push(batch.handle);
10183            }
10184
10185            // Checkpoint only after every live table carries the durable state.
10186            // Continue after one checkpoint failure so runtime publication stays
10187            // all-or-nothing; WAL recovery repairs failed files on reopen.
10188            for handle in modified_tables {
10189                #[cfg(test)]
10190                COMMIT_MANIFEST_WRITES.with(|count| count.set(count.get() + 1));
10191                if let Err(error) = handle.lock().persist_manifest(new_epoch) {
10192                    first_error.get_or_insert(error);
10193                }
10194            }
10195            for (name, _, pending) in &prepared_external {
10196                if let Err(error) = publish_external_state_file(&self.root, name, pending) {
10197                    first_error.get_or_insert(error);
10198                }
10199            }
10200            if !committed_materialized_views.is_empty() {
10201                let mut next_catalog = self.catalog.read().clone();
10202                for definition in committed_materialized_views {
10203                    if let Some(existing) = next_catalog
10204                        .materialized_views
10205                        .iter_mut()
10206                        .find(|existing| existing.name == definition.name)
10207                    {
10208                        *existing = definition;
10209                    } else {
10210                        next_catalog.materialized_views.push(definition);
10211                    }
10212                }
10213                next_catalog.db_epoch = next_catalog.db_epoch.max(new_epoch.0);
10214                if let Err(error) = self.checkpoint_catalog_after_durable(next_catalog) {
10215                    first_error.get_or_insert(error);
10216                }
10217            }
10218            match first_error {
10219                Some(error) => Err(error),
10220                None => Ok(()),
10221            }
10222        };
10223
10224        if has_changes {
10225            let _ = self.change_wake.send(());
10226        }
10227        self.finish_durable_publish(new_epoch, &mut _epoch_guard, publish_result)?;
10228        Ok((new_epoch, committed_row_ids))
10229    }
10230
10231    /// Register a read snapshot at the current visible epoch and return it with
10232    /// a guard that retains it for GC until dropped.
10233    pub fn snapshot(&self) -> (Snapshot, SnapshotGuard<'_>) {
10234        let e = self.epoch.visible();
10235        let g = self.snapshots.register(e);
10236        (Snapshot::at(e), g)
10237    }
10238
10239    /// Owned (clonable-handle) variant of [`Self::snapshot`] for cross-thread
10240    /// retention.
10241    pub fn snapshot_owned(&self) -> (Snapshot, OwnedSnapshotGuard) {
10242        let e = self.epoch.visible();
10243        let g = self.snapshots.register_owned(e);
10244        (Snapshot::at(e), g)
10245    }
10246
10247    /// Configure a rolling history window measured in prior commit epochs.
10248    /// The first enable starts at the current epoch because earlier versions
10249    /// may already have been compacted. Increasing the window likewise cannot
10250    /// recreate history that fell outside the previous guarantee.
10251    pub fn set_history_retention_epochs(&self, epochs: u64) -> Result<()> {
10252        let _guard = self.ddl_lock.lock();
10253        let current = self.epoch.visible();
10254        let (old_epochs, old_start) = self.snapshots.history_config();
10255        let earliest_already_guaranteed = if old_epochs == 0 {
10256            current
10257        } else {
10258            Epoch(old_start.0.max(current.0.saturating_sub(old_epochs)))
10259        };
10260        let start = if epochs == 0 {
10261            current
10262        } else {
10263            earliest_already_guaranteed
10264        };
10265        let published = std::cell::Cell::new(false);
10266        let result = write_history_retention(&self.root, epochs, start, || {
10267            self.snapshots.configure_history(epochs, start);
10268            published.set(true);
10269        });
10270        match result {
10271            Err(error) if published.get() => Err(MongrelError::CommitOutcomeUnknown {
10272                epoch: current.0,
10273                message: format!("history-retention publication was not durable: {error}"),
10274            }),
10275            result => result,
10276        }
10277    }
10278
10279    pub fn history_retention_epochs(&self) -> u64 {
10280        self.snapshots.history_config().0
10281    }
10282
10283    pub fn earliest_retained_epoch(&self) -> Epoch {
10284        let current = self.epoch.visible();
10285        self.snapshots.history_floor(current).unwrap_or(current)
10286    }
10287
10288    /// Pin a guaranteed historical epoch for the lifetime of the returned
10289    /// guard. Rejects future epochs and epochs outside the configured window.
10290    pub fn snapshot_at_owned(&self, epoch: Epoch) -> Result<(Snapshot, OwnedSnapshotGuard)> {
10291        let current = self.epoch.visible();
10292        if epoch > current {
10293            return Err(MongrelError::InvalidArgument(format!(
10294                "epoch {} is in the future; current epoch is {}",
10295                epoch.0, current.0
10296            )));
10297        }
10298        let earliest = self.earliest_retained_epoch();
10299        if epoch < earliest {
10300            return Err(MongrelError::InvalidArgument(format!(
10301                "epoch {} is no longer retained; earliest available epoch is {}",
10302                epoch.0, earliest.0
10303            )));
10304        }
10305        let guard = self.snapshots.register_owned(epoch);
10306        Ok((Snapshot::at(epoch), guard))
10307    }
10308
10309    /// Names of all live tables.
10310    pub fn table_names(&self) -> Vec<String> {
10311        self.catalog
10312            .read()
10313            .tables
10314            .iter()
10315            .filter(|t| matches!(t.state, TableState::Live))
10316            .map(|t| t.name.clone())
10317            .collect()
10318    }
10319
10320    /// Best-effort flush-on-close (§4.4): force-flush every mounted table
10321    /// that has pending writes to a `.sr` sorted run, so WAL segments can be
10322    /// reaped on the next open. Call this as the last action before a
10323    /// short-lived process (CLI, one-shot script) exits. The daemon does not
10324    /// need this — its background auto-compactor handles run management.
10325    pub fn close(&self) -> Result<()> {
10326        for name in self.table_names() {
10327            if let Ok(handle) = self.table(&name) {
10328                if let Err(e) = handle.lock().close() {
10329                    eprintln!("[close] flush failed for {name}: {e}");
10330                }
10331            }
10332        }
10333        Ok(())
10334    }
10335
10336    /// Compact every mounted table: merge all sorted runs into one clean run
10337    /// so query cost stays flat (single-run fast path) instead of growing
10338    /// with run count. Tables with < 2 runs are skipped unless TTL has expired
10339    /// rows to reclaim. Each table
10340    /// is locked individually for its own compaction; snapshot retention is
10341    /// honored by `Table::compact`. Returns `(tables_compacted, tables_skipped)`.
10342    pub fn compact(&self) -> Result<(usize, usize)> {
10343        self.require(&crate::auth::Permission::Ddl)?;
10344        let mut compacted = 0;
10345        let mut skipped = 0;
10346        for name in self.table_names() {
10347            let Ok(handle) = self.table(&name) else {
10348                continue;
10349            };
10350            {
10351                let mut t = handle.lock();
10352                let before = t.run_count();
10353                if before < 2 && !t.should_compact() {
10354                    skipped += 1;
10355                    continue;
10356                }
10357                match t.compact() {
10358                    Ok(()) => {
10359                        let after = t.run_count();
10360                        compacted += 1;
10361                        eprintln!("[compact] {name}: {before} -> {after} runs");
10362                    }
10363                    Err(e) => {
10364                        eprintln!("[compact] {name}: compaction failed: {e}");
10365                        skipped += 1;
10366                    }
10367                }
10368            }
10369        }
10370        Ok((compacted, skipped))
10371    }
10372
10373    /// Compact a single table by name. Returns `Ok(true)` if it was
10374    /// compacted, `Ok(false)` if skipped (< 2 runs).
10375    pub fn compact_table(&self, name: &str) -> Result<bool> {
10376        self.require(&crate::auth::Permission::Ddl)?;
10377        let handle = self.table(name)?;
10378        let mut t = handle.lock();
10379        let before = t.run_count();
10380        if before < 2 {
10381            return Ok(false);
10382        }
10383        t.compact()?;
10384        Ok(t.run_count() < before)
10385    }
10386
10387    /// Look up a live table by name.
10388    pub fn table(&self, name: &str) -> Result<TableHandle> {
10389        let cat = self.catalog.read();
10390        let entry = cat
10391            .live(name)
10392            .ok_or_else(|| MongrelError::NotFound(format!("table {name:?} not found")))?;
10393        let id = entry.table_id;
10394        drop(cat);
10395        self.tables
10396            .read()
10397            .get(&id)
10398            .cloned()
10399            .ok_or_else(|| MongrelError::NotFound(format!("table {name:?} not mounted")))
10400    }
10401
10402    /// Whether any mounted table has wall-clock TTL retention. SQL sessions
10403    /// use this to avoid epoch-keyed result caches that can outlive a cutoff.
10404    pub fn has_ttl_tables(&self) -> bool {
10405        self.tables
10406            .read()
10407            .values()
10408            .any(|table| table.lock().ttl().is_some())
10409    }
10410
10411    /// Resolve a live table id → mounted handle (used by the constraint
10412    /// validation pass and other id-qualified internal paths).
10413    pub(crate) fn table_by_id(&self, id: u64) -> Result<TableHandle> {
10414        self.tables
10415            .read()
10416            .get(&id)
10417            .cloned()
10418            .ok_or_else(|| MongrelError::NotFound(format!("table id {id} not mounted")))
10419    }
10420
10421    /// Create a new table. The DDL is first logged to the shared WAL
10422    /// (`Op::Ddl(CreateTable)` + `TxnCommit`) and group-synced so it is durable
10423    /// BEFORE the in-memory catalog and table map are mutated; the catalog
10424    /// checkpoint is rewritten afterwards (spec §15, review fix #16). A reopen
10425    /// that sees a stale catalog still recovers the table by replaying the Ddl.
10426    pub fn create_table(&self, name: &str, schema: Schema) -> Result<u64> {
10427        if name.starts_with(CTAS_BUILD_TABLE_PREFIX) {
10428            return Err(MongrelError::InvalidArgument(format!(
10429                "table names beginning with {CTAS_BUILD_TABLE_PREFIX:?} are reserved"
10430            )));
10431        }
10432        self.create_table_with_state(name, schema, TableState::Live)
10433    }
10434
10435    /// Create a durable but non-queryable CTAS build table.
10436    #[doc(hidden)]
10437    pub fn create_building_table(
10438        &self,
10439        build_name: &str,
10440        intended_name: &str,
10441        query_id: &str,
10442        schema: Schema,
10443    ) -> Result<u64> {
10444        if !build_name.starts_with(CTAS_BUILD_TABLE_PREFIX)
10445            || intended_name.is_empty()
10446            || intended_name.starts_with(CTAS_BUILD_TABLE_PREFIX)
10447            || query_id.is_empty()
10448        {
10449            return Err(MongrelError::InvalidArgument(
10450                "invalid CTAS building-table identity".into(),
10451            ));
10452        }
10453        self.create_table_with_state(
10454            build_name,
10455            schema,
10456            TableState::Building {
10457                intended_name: intended_name.to_string(),
10458                query_id: query_id.to_string(),
10459                created_at_unix_nanos: current_unix_nanos(),
10460                replaces_table_id: None,
10461            },
10462        )
10463    }
10464
10465    /// Create a hidden schema-rebuild table while the intended target remains
10466    /// live. Publication later validates that the same target is still live.
10467    #[doc(hidden)]
10468    pub fn create_rebuilding_table(
10469        &self,
10470        build_name: &str,
10471        intended_name: &str,
10472        query_id: &str,
10473        schema: Schema,
10474    ) -> Result<u64> {
10475        if !build_name.starts_with(CTAS_BUILD_TABLE_PREFIX)
10476            || intended_name.is_empty()
10477            || intended_name.starts_with(CTAS_BUILD_TABLE_PREFIX)
10478            || query_id.is_empty()
10479        {
10480            return Err(MongrelError::InvalidArgument(
10481                "invalid rebuilding-table identity".into(),
10482            ));
10483        }
10484        let replaces_table_id = self
10485            .catalog
10486            .read()
10487            .live(intended_name)
10488            .ok_or_else(|| MongrelError::NotFound(format!("table {intended_name:?} not found")))?
10489            .table_id;
10490        self.create_table_with_state(
10491            build_name,
10492            schema,
10493            TableState::Building {
10494                intended_name: intended_name.to_string(),
10495                query_id: query_id.to_string(),
10496                created_at_unix_nanos: current_unix_nanos(),
10497                replaces_table_id: Some(replaces_table_id),
10498            },
10499        )
10500    }
10501
10502    fn create_table_with_state(
10503        &self,
10504        name: &str,
10505        schema: Schema,
10506        state: TableState,
10507    ) -> Result<u64> {
10508        use crate::wal::DdlOp;
10509        use std::sync::atomic::Ordering;
10510
10511        self.require(&crate::auth::Permission::Ddl)?;
10512        if self.poisoned.load(Ordering::Relaxed) {
10513            return Err(MongrelError::Other(
10514                "database poisoned by fsync error".into(),
10515            ));
10516        }
10517
10518        let _g = self.ddl_lock.lock();
10519        let _security_write = self.security_write()?;
10520        self.require(&crate::auth::Permission::Ddl)?;
10521        {
10522            let cat = self.catalog.read();
10523            match &state {
10524                TableState::Live => {
10525                    if cat.live(name).is_some() || cat.building_for(name).is_some() {
10526                        return Err(MongrelError::InvalidArgument(format!(
10527                            "table {name:?} already exists or is being built"
10528                        )));
10529                    }
10530                }
10531                TableState::Building {
10532                    intended_name,
10533                    replaces_table_id,
10534                    ..
10535                } => {
10536                    let target_matches = match replaces_table_id {
10537                        Some(table_id) => cat
10538                            .live(intended_name)
10539                            .is_some_and(|entry| entry.table_id == *table_id),
10540                        None => cat.live(intended_name).is_none(),
10541                    };
10542                    if !target_matches || cat.building_for(intended_name).is_some() {
10543                        return Err(MongrelError::InvalidArgument(format!(
10544                            "table {intended_name:?} changed or is already being built"
10545                        )));
10546                    }
10547                    if cat.building(name).is_some() {
10548                        return Err(MongrelError::InvalidArgument(format!(
10549                            "building table {name:?} already exists"
10550                        )));
10551                    }
10552                }
10553                TableState::Dropped { .. } => {
10554                    return Err(MongrelError::InvalidArgument(
10555                        "cannot create a dropped table".into(),
10556                    ));
10557                }
10558            }
10559        }
10560
10561        // Allocate id + epoch + txn id under the commit lock so the DDL commit
10562        // is serialized with data commits (in-order publish).
10563        let commit_lock = Arc::clone(&self.commit_lock);
10564        let _c = commit_lock.lock();
10565        let table_id = {
10566            let mut cat = self.catalog.write();
10567            let id = cat.next_table_id;
10568            cat.next_table_id = id
10569                .checked_add(1)
10570                .ok_or_else(|| MongrelError::InvalidArgument("table id space exhausted".into()))?;
10571            Result::<u64>::Ok(id)
10572        }?;
10573        let epoch = self.epoch.bump_assigned();
10574        let mut _epoch_guard = EpochGuard::new(self.epoch.as_ref(), epoch);
10575        let txn_id = self.alloc_txn_id()?;
10576
10577        // Stamp the schema_id with the unique table_id so every table in the
10578        // database has a distinct schema_id (caller-provided values are
10579        // ignored to prevent collisions).
10580        let mut schema = schema;
10581        schema.schema_id = table_id;
10582        // Defense in depth: reject an invalid schema BEFORE any durable
10583        // side-effect. `Table::create_in` re-validates, but by then the DDL has
10584        // already been appended to the shared WAL; a failing create_in would
10585        // leave a dangling entry that `recover_ddl_from_wal` replays without
10586        // re-validating, corrupting the catalog on reopen. Validating here
10587        // keeps the WAL free of schemas that can never be opened.
10588        schema.validate_auto_increment()?;
10589        schema.validate_defaults()?;
10590        schema.validate_ai()?;
10591        for index in &schema.indexes {
10592            index.validate_options()?;
10593        }
10594        for constraint in &schema.constraints.checks {
10595            constraint.expr.validate()?;
10596        }
10597
10598        // Build the complete mounted table before its DDL can become durable.
10599        // Any failure removes the unpublished directory and abandons the epoch.
10600        let table_relative = Path::new(TABLES_DIR).join(table_id.to_string());
10601        let canonical_tdir = self.root.join(&table_relative);
10602        let table_root = Arc::new(
10603            self.durable_root
10604                .create_directory_all_pinned(&table_relative)?,
10605        );
10606        let tdir = table_root.io_path()?;
10607        let mut pending_table_dir = PendingTableDir::new(canonical_tdir);
10608        let ctx = SharedCtx {
10609            root_guard: Some(table_root),
10610            epoch: Arc::clone(&self.epoch),
10611            page_cache: Arc::clone(&self.page_cache),
10612            decoded_cache: Arc::clone(&self.decoded_cache),
10613            snapshots: Arc::clone(&self.snapshots),
10614            kek: self.kek.clone(),
10615            commit_lock: Arc::clone(&self.commit_lock),
10616            shared: Some(crate::engine::SharedWalCtx {
10617                wal: Arc::clone(&self.shared_wal),
10618                group: Arc::clone(&self.group),
10619                poisoned: Arc::clone(&self.poisoned),
10620                txn_ids: Arc::clone(&self.next_txn_id),
10621                change_wake: self.change_wake.clone(),
10622            }),
10623            table_name: Some(name.to_string()),
10624            auth: self.table_auth_checker(),
10625            read_only: self.read_only,
10626        };
10627        let table = Table::create_in(&tdir, schema.clone(), table_id, ctx)?;
10628
10629        // 1. Log the DDL + commit marker to the shared WAL, then make it durable
10630        //    via the group-commit coordinator (no fsync under the WAL lock — P3.2).
10631        let schema_json = DdlOp::encode_schema(&schema)?;
10632        let ddl = match &state {
10633            TableState::Live => DdlOp::CreateTable {
10634                table_id,
10635                name: name.to_string(),
10636                schema_json,
10637            },
10638            TableState::Building {
10639                intended_name,
10640                query_id,
10641                created_at_unix_nanos,
10642                replaces_table_id,
10643            } => match replaces_table_id {
10644                Some(replaces_table_id) => DdlOp::CreateRebuildingTable {
10645                    table_id,
10646                    build_name: name.to_string(),
10647                    intended_name: intended_name.clone(),
10648                    query_id: query_id.clone(),
10649                    created_at_unix_nanos: *created_at_unix_nanos,
10650                    replaces_table_id: *replaces_table_id,
10651                    schema_json,
10652                },
10653                None => DdlOp::CreateBuildingTable {
10654                    table_id,
10655                    build_name: name.to_string(),
10656                    intended_name: intended_name.clone(),
10657                    query_id: query_id.clone(),
10658                    created_at_unix_nanos: *created_at_unix_nanos,
10659                    schema_json,
10660                },
10661            },
10662            TableState::Dropped { .. } => {
10663                return Err(MongrelError::InvalidArgument(
10664                    "cannot create a table in dropped state".into(),
10665                ));
10666            }
10667        };
10668        let mut next_catalog = self.catalog.read().clone();
10669        next_catalog.tables.push(CatalogEntry {
10670            table_id,
10671            name: name.to_string(),
10672            schema: schema.clone(),
10673            state: state.clone(),
10674            created_epoch: epoch.0,
10675        });
10676        next_catalog.db_epoch = next_catalog.db_epoch.max(epoch.0);
10677        let commit_seq = {
10678            let mut wal = self.shared_wal.lock();
10679            let append: Result<u64> = (|| {
10680                wal.append(txn_id, table_id, crate::wal::Op::Ddl(ddl))?;
10681                append_catalog_snapshot(&mut wal, txn_id, &next_catalog)?;
10682                wal.append_commit(txn_id, epoch, &[])
10683            })();
10684            append.map_err(|error| self.commit_outcome_unknown(epoch, error))?
10685        };
10686        self.await_durable_commit(commit_seq, epoch)?;
10687        pending_table_dir.disarm();
10688
10689        // Publish the mounted table and catalog in memory even if the catalog
10690        // checkpoint fails after the WAL commit.
10691        self.tables
10692            .write()
10693            .insert(table_id, TableHandle::new(table));
10694        let checkpoint = self.checkpoint_catalog_after_durable(next_catalog);
10695        self.finish_durable_publish(epoch, &mut _epoch_guard, checkpoint)?;
10696        Ok(table_id)
10697    }
10698
10699    /// Logically drop a table, logging the DDL through the shared WAL first.
10700    pub fn drop_table(&self, name: &str) -> Result<()> {
10701        self.drop_table_with_epoch(name).map(|_| ())
10702    }
10703
10704    /// Logically drop a table and return the exact publication epoch.
10705    pub fn drop_table_with_epoch(&self, name: &str) -> Result<Epoch> {
10706        self.drop_table_with_state(name, false, None)
10707    }
10708
10709    pub fn drop_table_with_epoch_controlled<F>(
10710        &self,
10711        name: &str,
10712        mut before_commit: F,
10713    ) -> Result<Epoch>
10714    where
10715        F: FnMut() -> Result<()>,
10716    {
10717        self.drop_table_with_state(name, false, Some(&mut before_commit))
10718    }
10719
10720    /// Discard an unpublished CTAS build.
10721    #[doc(hidden)]
10722    pub fn discard_building_table(&self, name: &str) -> Result<()> {
10723        if !name.starts_with(CTAS_BUILD_TABLE_PREFIX) {
10724            return Err(MongrelError::InvalidArgument(
10725                "not a CTAS building table".into(),
10726            ));
10727        }
10728        self.drop_table_with_state(name, true, None).map(|_| ())
10729    }
10730
10731    fn drop_table_with_state(
10732        &self,
10733        name: &str,
10734        building: bool,
10735        before_commit: Option<&mut dyn FnMut() -> Result<()>>,
10736    ) -> Result<Epoch> {
10737        use crate::wal::DdlOp;
10738        use std::sync::atomic::Ordering;
10739
10740        self.require(&crate::auth::Permission::Ddl)?;
10741        if self.poisoned.load(Ordering::Relaxed) {
10742            return Err(MongrelError::Other(
10743                "database poisoned by fsync error".into(),
10744            ));
10745        }
10746
10747        let _g = self.ddl_lock.lock();
10748        let _security_write = self.security_write()?;
10749        self.require(&crate::auth::Permission::Ddl)?;
10750        let table_id = {
10751            let cat = self.catalog.read();
10752            if building {
10753                cat.building(name)
10754            } else {
10755                cat.live(name)
10756            }
10757            .ok_or_else(|| MongrelError::NotFound(format!("table {name:?} not found")))?
10758            .table_id
10759        };
10760
10761        let commit_lock = Arc::clone(&self.commit_lock);
10762        let _c = commit_lock.lock();
10763        let epoch = self.epoch.bump_assigned();
10764        let mut _epoch_guard = EpochGuard::new(self.epoch.as_ref(), epoch);
10765        let txn_id = self.alloc_txn_id()?;
10766        let mut next_catalog = self.catalog.read().clone();
10767        let entry = next_catalog
10768            .tables
10769            .iter_mut()
10770            .find(|t| t.table_id == table_id)
10771            .ok_or_else(|| MongrelError::NotFound(format!("table {name:?} not found")))?;
10772        entry.state = TableState::Dropped { at_epoch: epoch.0 };
10773        next_catalog.triggers.retain(|trigger| {
10774            !matches!(
10775                &trigger.trigger.target,
10776                TriggerTarget::Table(target) if target == name
10777            )
10778        });
10779        next_catalog
10780            .materialized_views
10781            .retain(|definition| definition.name != name);
10782        next_catalog
10783            .security
10784            .rls_tables
10785            .retain(|table| table != name);
10786        next_catalog
10787            .security
10788            .policies
10789            .retain(|policy| policy.table != name);
10790        next_catalog
10791            .security
10792            .masks
10793            .retain(|mask| mask.table != name);
10794        for role in &mut next_catalog.roles {
10795            role.permissions
10796                .retain(|permission| permission_table(permission) != Some(name));
10797        }
10798        advance_security_version(&mut next_catalog)?;
10799        next_catalog.db_epoch = next_catalog.db_epoch.max(epoch.0);
10800        let commit_seq = {
10801            let mut wal = self.shared_wal.lock();
10802            if let Some(before_commit) = before_commit {
10803                before_commit()?;
10804            }
10805            let append: Result<u64> = (|| {
10806                wal.append(
10807                    txn_id,
10808                    table_id,
10809                    crate::wal::Op::Ddl(DdlOp::DropTable { table_id }),
10810                )?;
10811                append_catalog_snapshot(&mut wal, txn_id, &next_catalog)?;
10812                wal.append_commit(txn_id, epoch, &[])
10813            })();
10814            append.map_err(|error| self.commit_outcome_unknown(epoch, error))?
10815        };
10816        self.await_durable_commit(commit_seq, epoch)?;
10817
10818        let checkpoint = self.checkpoint_catalog_after_durable(next_catalog);
10819        self.tables.write().remove(&table_id);
10820        self.finish_durable_publish(epoch, &mut _epoch_guard, checkpoint)?;
10821        Ok(epoch)
10822    }
10823
10824    /// Rename a live table. `name` must exist and `new_name` must not collide
10825    /// with any live table; both checks run under `ddl_lock` so they are atomic
10826    /// with the rename and with concurrent `create_table` existence checks (no
10827    /// TOCTOU window). A no-op rename (`name == new_name`) succeeds without
10828    /// side-effects. The rename is logged to the shared WAL as
10829    /// `DdlOp::RenameTable` and recovered on reopen; the `table_id`, schema,
10830    /// and on-disk layout are unchanged (the table is keyed by `table_id`, so
10831    /// the in-memory object does not move — only the catalog name changes).
10832    pub fn rename_table(&self, name: &str, new_name: &str) -> Result<()> {
10833        self.rename_table_with_epoch(name, new_name).map(|_| ())
10834    }
10835
10836    /// Rename a table and return its exact publication epoch.
10837    pub fn rename_table_with_epoch(&self, name: &str, new_name: &str) -> Result<Epoch> {
10838        self.rename_table_with_epoch_inner(name, new_name, None)
10839    }
10840
10841    pub fn rename_table_with_epoch_controlled<F>(
10842        &self,
10843        name: &str,
10844        new_name: &str,
10845        mut before_commit: F,
10846    ) -> Result<Epoch>
10847    where
10848        F: FnMut() -> Result<()>,
10849    {
10850        self.rename_table_with_epoch_inner(name, new_name, Some(&mut before_commit))
10851    }
10852
10853    fn rename_table_with_epoch_inner(
10854        &self,
10855        name: &str,
10856        new_name: &str,
10857        before_commit: Option<&mut dyn FnMut() -> Result<()>>,
10858    ) -> Result<Epoch> {
10859        if name.starts_with(CTAS_BUILD_TABLE_PREFIX)
10860            || new_name.starts_with(CTAS_BUILD_TABLE_PREFIX)
10861        {
10862            return Err(MongrelError::InvalidArgument(
10863                "the CTAS building-table namespace is reserved".into(),
10864            ));
10865        }
10866        self.rename_table_with_state(name, new_name, false, None, before_commit)
10867    }
10868
10869    /// Atomically publish a hidden CTAS build under its intended live name.
10870    #[doc(hidden)]
10871    pub fn publish_building_table(&self, build_name: &str, new_name: &str) -> Result<Epoch> {
10872        self.publish_building_table_inner(build_name, new_name, None)
10873    }
10874
10875    #[doc(hidden)]
10876    pub fn publish_building_table_controlled<F>(
10877        &self,
10878        build_name: &str,
10879        new_name: &str,
10880        mut before_commit: F,
10881    ) -> Result<Epoch>
10882    where
10883        F: FnMut() -> Result<()>,
10884    {
10885        self.publish_building_table_inner(build_name, new_name, Some(&mut before_commit))
10886    }
10887
10888    fn publish_building_table_inner(
10889        &self,
10890        build_name: &str,
10891        new_name: &str,
10892        before_commit: Option<&mut dyn FnMut() -> Result<()>>,
10893    ) -> Result<Epoch> {
10894        if !build_name.starts_with(CTAS_BUILD_TABLE_PREFIX)
10895            || new_name.starts_with(CTAS_BUILD_TABLE_PREFIX)
10896        {
10897            return Err(MongrelError::InvalidArgument(
10898                "invalid CTAS publish identity".into(),
10899            ));
10900        }
10901        self.rename_table_with_state(build_name, new_name, true, None, before_commit)
10902    }
10903
10904    /// Atomically publish a hidden build and its materialized-view definition.
10905    #[doc(hidden)]
10906    pub fn publish_materialized_building_table(
10907        &self,
10908        build_name: &str,
10909        new_name: &str,
10910        definition: crate::catalog::MaterializedViewEntry,
10911    ) -> Result<Epoch> {
10912        self.publish_materialized_building_table_inner(build_name, new_name, definition, None)
10913    }
10914
10915    #[doc(hidden)]
10916    pub fn publish_materialized_building_table_controlled<F>(
10917        &self,
10918        build_name: &str,
10919        new_name: &str,
10920        definition: crate::catalog::MaterializedViewEntry,
10921        mut before_commit: F,
10922    ) -> Result<Epoch>
10923    where
10924        F: FnMut() -> Result<()>,
10925    {
10926        self.publish_materialized_building_table_inner(
10927            build_name,
10928            new_name,
10929            definition,
10930            Some(&mut before_commit),
10931        )
10932    }
10933
10934    fn publish_materialized_building_table_inner(
10935        &self,
10936        build_name: &str,
10937        new_name: &str,
10938        definition: crate::catalog::MaterializedViewEntry,
10939        before_commit: Option<&mut dyn FnMut() -> Result<()>>,
10940    ) -> Result<Epoch> {
10941        if definition.name != new_name || definition.query.trim().is_empty() {
10942            return Err(MongrelError::InvalidArgument(
10943                "invalid materialized-view publication".into(),
10944            ));
10945        }
10946        self.rename_table_with_state(build_name, new_name, true, Some(definition), before_commit)
10947    }
10948
10949    /// Atomically replace a still-live table with its completed hidden rebuild.
10950    #[doc(hidden)]
10951    pub fn publish_rebuilding_table(&self, build_name: &str, new_name: &str) -> Result<Epoch> {
10952        self.publish_rebuilding_table_inner(build_name, new_name, None, None)
10953    }
10954
10955    #[doc(hidden)]
10956    pub fn publish_rebuilding_table_controlled<F>(
10957        &self,
10958        build_name: &str,
10959        new_name: &str,
10960        mut before_commit: F,
10961    ) -> Result<Epoch>
10962    where
10963        F: FnMut() -> Result<()>,
10964    {
10965        self.publish_rebuilding_table_inner(build_name, new_name, None, Some(&mut before_commit))
10966    }
10967
10968    /// Atomically replace a live materialized-view table and its definition.
10969    #[doc(hidden)]
10970    pub fn publish_materialized_rebuilding_table_controlled<F>(
10971        &self,
10972        build_name: &str,
10973        new_name: &str,
10974        definition: crate::catalog::MaterializedViewEntry,
10975        mut before_commit: F,
10976    ) -> Result<Epoch>
10977    where
10978        F: FnMut() -> Result<()>,
10979    {
10980        self.publish_rebuilding_table_inner(
10981            build_name,
10982            new_name,
10983            Some(definition),
10984            Some(&mut before_commit),
10985        )
10986    }
10987
10988    fn publish_rebuilding_table_inner(
10989        &self,
10990        build_name: &str,
10991        new_name: &str,
10992        mut materialized_view: Option<crate::catalog::MaterializedViewEntry>,
10993        before_commit: Option<&mut dyn FnMut() -> Result<()>>,
10994    ) -> Result<Epoch> {
10995        use crate::wal::DdlOp;
10996
10997        if !build_name.starts_with(CTAS_BUILD_TABLE_PREFIX)
10998            || new_name.is_empty()
10999            || new_name.starts_with(CTAS_BUILD_TABLE_PREFIX)
11000        {
11001            return Err(MongrelError::InvalidArgument(
11002                "invalid rebuilding-table publish identity".into(),
11003            ));
11004        }
11005        if materialized_view.as_ref().is_some_and(|definition| {
11006            definition.name != new_name || definition.query.trim().is_empty()
11007        }) {
11008            return Err(MongrelError::InvalidArgument(
11009                "invalid materialized-view replacement".into(),
11010            ));
11011        }
11012        self.require(&crate::auth::Permission::Ddl)?;
11013        if self.poisoned.load(Ordering::Relaxed) {
11014            return Err(MongrelError::Other(
11015                "database poisoned by fsync error".into(),
11016            ));
11017        }
11018
11019        let _ddl = self.ddl_lock.lock();
11020        let _security_write = self.security_write()?;
11021        let (table_id, replaced_table_id) = {
11022            let catalog = self.catalog.read();
11023            let build = catalog.building(build_name).ok_or_else(|| {
11024                MongrelError::NotFound(format!("building table {build_name:?} not found"))
11025            })?;
11026            let replaced_table_id = match &build.state {
11027                TableState::Building {
11028                    intended_name,
11029                    replaces_table_id: Some(replaced_table_id),
11030                    ..
11031                } if intended_name == new_name => *replaced_table_id,
11032                _ => {
11033                    return Err(MongrelError::InvalidArgument(format!(
11034                        "building table {build_name:?} is not a replacement for {new_name:?}"
11035                    )))
11036                }
11037            };
11038            if catalog
11039                .live(new_name)
11040                .is_none_or(|entry| entry.table_id != replaced_table_id)
11041            {
11042                return Err(MongrelError::Conflict(format!(
11043                    "table {new_name:?} changed while its replacement was built"
11044                )));
11045            }
11046            (build.table_id, replaced_table_id)
11047        };
11048
11049        let _commit = self.commit_lock.lock();
11050        let epoch = self.epoch.assigned().next();
11051        let txn_id = self.alloc_txn_id()?;
11052        let mut next_catalog = self.catalog.read().clone();
11053        apply_rebuilding_publish(
11054            &mut next_catalog,
11055            table_id,
11056            replaced_table_id,
11057            new_name,
11058            epoch.0,
11059        )?;
11060        if let Some(definition) = materialized_view.as_mut() {
11061            definition.last_refresh_epoch = epoch.0;
11062        }
11063        let materialized_view_json = materialized_view
11064            .as_ref()
11065            .map(DdlOp::encode_materialized_view)
11066            .transpose()?;
11067        if let Some(definition) = materialized_view {
11068            if let Some(existing) = next_catalog
11069                .materialized_views
11070                .iter_mut()
11071                .find(|existing| existing.name == definition.name)
11072            {
11073                *existing = definition;
11074            } else {
11075                next_catalog.materialized_views.push(definition);
11076            }
11077        }
11078        next_catalog.db_epoch = next_catalog.db_epoch.max(epoch.0);
11079        if let Some(before_commit) = before_commit {
11080            before_commit()?;
11081        }
11082        let assigned_epoch = self.epoch.bump_assigned();
11083        let mut epoch_guard = EpochGuard::new(self.epoch.as_ref(), assigned_epoch);
11084        if assigned_epoch != epoch {
11085            return Err(MongrelError::Conflict(
11086                "commit epoch changed while sequencer lock was held".into(),
11087            ));
11088        }
11089        let commit_seq = {
11090            let mut wal = self.shared_wal.lock();
11091            let append: Result<u64> = (|| {
11092                wal.append(
11093                    txn_id,
11094                    table_id,
11095                    crate::wal::Op::Ddl(DdlOp::ReplaceBuildingTable {
11096                        table_id,
11097                        replaced_table_id,
11098                        new_name: new_name.to_string(),
11099                    }),
11100                )?;
11101                if let Some(definition_json) = materialized_view_json {
11102                    wal.append(
11103                        txn_id,
11104                        table_id,
11105                        crate::wal::Op::Ddl(DdlOp::SetMaterializedView {
11106                            name: new_name.to_string(),
11107                            definition_json,
11108                        }),
11109                    )?;
11110                }
11111                append_catalog_snapshot(&mut wal, txn_id, &next_catalog)?;
11112                wal.append_commit(txn_id, epoch, &[])
11113            })();
11114            append.map_err(|error| self.commit_outcome_unknown(epoch, error))?
11115        };
11116        self.await_durable_commit(commit_seq, epoch)?;
11117
11118        let checkpoint = self.checkpoint_catalog_after_durable(next_catalog);
11119        self.tables.write().remove(&replaced_table_id);
11120        if let Some(table) = self.tables.read().get(&table_id) {
11121            table.lock().set_catalog_name(new_name.to_string());
11122        }
11123        self.finish_durable_publish(epoch, &mut epoch_guard, checkpoint)?;
11124        Ok(epoch)
11125    }
11126
11127    fn rename_table_with_state(
11128        &self,
11129        name: &str,
11130        new_name: &str,
11131        building: bool,
11132        mut materialized_view: Option<crate::catalog::MaterializedViewEntry>,
11133        before_commit: Option<&mut dyn FnMut() -> Result<()>>,
11134    ) -> Result<Epoch> {
11135        use crate::wal::DdlOp;
11136        use std::sync::atomic::Ordering;
11137
11138        self.require(&crate::auth::Permission::Ddl)?;
11139        if self.poisoned.load(Ordering::Relaxed) {
11140            return Err(MongrelError::Other(
11141                "database poisoned by fsync error".into(),
11142            ));
11143        }
11144
11145        // A no-op rename short-circuits before any locking, so it can never
11146        // trip the "target already exists" check (the source *is* that name).
11147        if name == new_name {
11148            return Ok(self.visible_epoch());
11149        }
11150        if new_name.is_empty() {
11151            return Err(MongrelError::InvalidArgument(
11152                "rename_table: new name must not be empty".into(),
11153            ));
11154        }
11155
11156        let _g = self.ddl_lock.lock();
11157        let _security_write = self.security_write()?;
11158        self.require(&crate::auth::Permission::Ddl)?;
11159        let table_id = {
11160            let cat = self.catalog.read();
11161            let src = if building {
11162                cat.building(name)
11163            } else {
11164                cat.live(name)
11165            }
11166            .ok_or_else(|| MongrelError::NotFound(format!("table {name:?} not found")))?;
11167            if building
11168                && !matches!(
11169                    &src.state,
11170                    TableState::Building { intended_name, .. } if intended_name == new_name
11171                )
11172            {
11173                return Err(MongrelError::InvalidArgument(format!(
11174                    "building table {name:?} is not reserved for {new_name:?}"
11175                )));
11176            }
11177            // Target must be free. Checked under ddl_lock, which every other
11178            // DDL (create/rename/drop) also holds, so a concurrent operation
11179            // cannot claim `new_name` between this check and the catalog write.
11180            if cat.live(new_name).is_some() || (!building && cat.building_for(new_name).is_some()) {
11181                return Err(MongrelError::InvalidArgument(format!(
11182                    "rename_table: a table named {new_name:?} already exists"
11183                )));
11184            }
11185            src.table_id
11186        };
11187
11188        let commit_lock = Arc::clone(&self.commit_lock);
11189        let _c = commit_lock.lock();
11190        let epoch = self.epoch.bump_assigned();
11191        let mut _epoch_guard = EpochGuard::new(self.epoch.as_ref(), epoch);
11192        let txn_id = self.alloc_txn_id()?;
11193        if let Some(definition) = materialized_view.as_mut() {
11194            definition.last_refresh_epoch = epoch.0;
11195        }
11196        let materialized_view_json = materialized_view
11197            .as_ref()
11198            .map(DdlOp::encode_materialized_view)
11199            .transpose()?;
11200        let mut next_catalog = self.catalog.read().clone();
11201        let entry = next_catalog
11202            .tables
11203            .iter_mut()
11204            .find(|t| t.table_id == table_id)
11205            .ok_or_else(|| MongrelError::NotFound(format!("table {name:?} not found")))?;
11206        entry.name = new_name.to_string();
11207        if building {
11208            entry.state = TableState::Live;
11209        }
11210        for trigger in &mut next_catalog.triggers {
11211            if matches!(
11212                &trigger.trigger.target,
11213                TriggerTarget::Table(target) if target == name
11214            ) {
11215                trigger.trigger = trigger.trigger.retarget_table(new_name, epoch.0)?;
11216            }
11217        }
11218        if let Some(definition) = next_catalog
11219            .materialized_views
11220            .iter_mut()
11221            .find(|definition| definition.name == name)
11222        {
11223            definition.name = new_name.to_string();
11224        }
11225        if let Some(definition) = materialized_view.take() {
11226            next_catalog.materialized_views.push(definition);
11227        }
11228        next_catalog.db_epoch = next_catalog.db_epoch.max(epoch.0);
11229        for table in &mut next_catalog.security.rls_tables {
11230            if table == name {
11231                *table = new_name.to_string();
11232            }
11233        }
11234        for policy in &mut next_catalog.security.policies {
11235            if policy.table == name {
11236                policy.table = new_name.to_string();
11237            }
11238        }
11239        for mask in &mut next_catalog.security.masks {
11240            if mask.table == name {
11241                mask.table = new_name.to_string();
11242            }
11243        }
11244        for role in &mut next_catalog.roles {
11245            for permission in &mut role.permissions {
11246                rename_permission_table(permission, name, new_name);
11247            }
11248        }
11249        advance_security_version(&mut next_catalog)?;
11250        let ddl = if building {
11251            DdlOp::PublishBuildingTable {
11252                table_id,
11253                new_name: new_name.to_string(),
11254            }
11255        } else {
11256            DdlOp::RenameTable {
11257                table_id,
11258                new_name: new_name.to_string(),
11259            }
11260        };
11261        let commit_seq = {
11262            let mut wal = self.shared_wal.lock();
11263            if let Some(before_commit) = before_commit {
11264                before_commit()?;
11265            }
11266            let append: Result<u64> = (|| {
11267                wal.append(txn_id, table_id, crate::wal::Op::Ddl(ddl))?;
11268                if let Some(definition_json) = materialized_view_json {
11269                    wal.append(
11270                        txn_id,
11271                        table_id,
11272                        crate::wal::Op::Ddl(DdlOp::SetMaterializedView {
11273                            name: new_name.to_string(),
11274                            definition_json,
11275                        }),
11276                    )?;
11277                }
11278                append_catalog_snapshot(&mut wal, txn_id, &next_catalog)?;
11279                wal.append_commit(txn_id, epoch, &[])
11280            })();
11281            append.map_err(|error| self.commit_outcome_unknown(epoch, error))?
11282        };
11283        self.await_durable_commit(commit_seq, epoch)?;
11284
11285        let checkpoint = self.checkpoint_catalog_after_durable(next_catalog);
11286        // The in-memory table object is keyed by table_id, not name, so it does
11287        // not move and live TableHandles remain valid.
11288        if let Some(table) = self.tables.read().get(&table_id) {
11289            table.lock().set_catalog_name(new_name.to_string());
11290        }
11291        self.finish_durable_publish(epoch, &mut _epoch_guard, checkpoint)?;
11292        Ok(epoch)
11293    }
11294
11295    pub fn alter_column(
11296        &self,
11297        table_name: &str,
11298        column_name: &str,
11299        change: AlterColumn,
11300    ) -> Result<ColumnDef> {
11301        self.alter_column_with_epoch(table_name, column_name, change)
11302            .map(|(column, _)| column)
11303    }
11304
11305    pub fn alter_column_with_epoch(
11306        &self,
11307        table_name: &str,
11308        column_name: &str,
11309        change: AlterColumn,
11310    ) -> Result<(ColumnDef, Option<Epoch>)> {
11311        self.alter_column_with_epoch_inner(table_name, column_name, change, None, None, None)
11312    }
11313
11314    /// Cooperatively prepare an ALTER and fence each durable commit separately.
11315    /// `after_commit(Some(epoch))` follows an exact durable outcome;
11316    /// `after_commit(None)` follows an uncertain WAL attempt. It is called once
11317    /// for every successful `before_commit` callback.
11318    pub fn alter_column_with_epoch_controlled<B, A>(
11319        &self,
11320        table_name: &str,
11321        column_name: &str,
11322        change: AlterColumn,
11323        control: &crate::ExecutionControl,
11324        mut before_commit: B,
11325        mut after_commit: A,
11326    ) -> Result<(ColumnDef, Option<Epoch>)>
11327    where
11328        B: FnMut() -> Result<()>,
11329        A: FnMut(Option<Epoch>) -> Result<()>,
11330    {
11331        self.alter_column_with_epoch_inner(
11332            table_name,
11333            column_name,
11334            change,
11335            Some(control),
11336            Some(&mut before_commit),
11337            Some(&mut after_commit),
11338        )
11339    }
11340
11341    #[allow(clippy::too_many_arguments)]
11342    fn alter_column_with_epoch_inner(
11343        &self,
11344        table_name: &str,
11345        column_name: &str,
11346        change: AlterColumn,
11347        control: Option<&crate::ExecutionControl>,
11348        mut before_commit: Option<&mut dyn FnMut() -> Result<()>>,
11349        mut after_commit: Option<&mut dyn FnMut(Option<Epoch>) -> Result<()>>,
11350    ) -> Result<(ColumnDef, Option<Epoch>)> {
11351        use crate::wal::DdlOp;
11352        use std::sync::atomic::Ordering;
11353
11354        self.require(&crate::auth::Permission::Ddl)?;
11355        commit_prepare_checkpoint(control, 0)?;
11356        if self.poisoned.load(Ordering::Relaxed) {
11357            return Err(MongrelError::Other(
11358                "database poisoned by fsync error".into(),
11359            ));
11360        }
11361
11362        let _g = self.ddl_lock.lock();
11363        let table_id = {
11364            let cat = self.catalog.read();
11365            cat.live(table_name)
11366                .ok_or_else(|| MongrelError::NotFound(format!("table {table_name:?} not found")))?
11367                .table_id
11368        };
11369        let handle =
11370            self.tables.read().get(&table_id).cloned().ok_or_else(|| {
11371                MongrelError::NotFound(format!("table {table_name:?} not mounted"))
11372            })?;
11373
11374        // Legitimate online-ALTER slice: when nullable -> NOT NULL has a
11375        // declared default, backfill existing NULL/absent cells as one durable
11376        // transaction before logging the metadata change. A crash between the
11377        // two commits leaves a harmless nullable-but-filled column; retry is
11378        // idempotent because only remaining NULLs are touched.
11379        let backfill = {
11380            let table = handle.lock();
11381            let old = table
11382                .schema()
11383                .column(column_name)
11384                .cloned()
11385                .ok_or_else(|| MongrelError::Schema(format!("unknown column {column_name}")))?;
11386            let next_flags = change.flags.unwrap_or(old.flags);
11387            if old.flags.contains(crate::schema::ColumnFlags::NULLABLE)
11388                && !next_flags.contains(crate::schema::ColumnFlags::NULLABLE)
11389                && old.default_value.is_some()
11390            {
11391                let snapshot = Snapshot::at(self.epoch.visible());
11392                let mut updates = Vec::new();
11393                let rows = match control {
11394                    Some(control) => table.visible_rows_controlled(snapshot, control)?,
11395                    None => table.visible_rows(snapshot)?,
11396                };
11397                for (row_index, row) in rows.into_iter().enumerate() {
11398                    commit_prepare_checkpoint(control, row_index)?;
11399                    if row
11400                        .columns
11401                        .get(&old.id)
11402                        .is_some_and(|value| !matches!(value, Value::Null))
11403                    {
11404                        continue;
11405                    }
11406                    let mut cells: Vec<(u16, Value)> = row.columns.into_iter().collect();
11407                    table.apply_defaults(&mut cells)?;
11408                    updates.push((
11409                        table_id,
11410                        crate::txn::Staged::Update {
11411                            row_id: row.row_id,
11412                            new_row: cells,
11413                            changed_columns: vec![old.id],
11414                        },
11415                    ));
11416                }
11417                updates
11418            } else {
11419                Vec::new()
11420            }
11421        };
11422        let durable_epoch = std::cell::Cell::new(None);
11423        let backfill_epoch = if backfill.is_empty() {
11424            None
11425        } else {
11426            let (principal, catalog_bound) = self.transaction_principal_snapshot();
11427            let txn_id = self.alloc_txn_id()?;
11428            let mut entered_fence = false;
11429            let commit_result = match (control, before_commit.as_deref_mut()) {
11430                (Some(control), Some(before_commit)) => self
11431                    .commit_transaction_with_external_states_controlled(
11432                        txn_id,
11433                        self.epoch.visible(),
11434                        backfill,
11435                        Vec::new(),
11436                        Vec::new(),
11437                        principal.clone(),
11438                        catalog_bound,
11439                        None,
11440                        control,
11441                        &mut || {
11442                            before_commit()?;
11443                            entered_fence = true;
11444                            Ok(())
11445                        },
11446                    )
11447                    .map(|(epoch, _)| epoch),
11448                _ => self
11449                    .commit_transaction_with_external_states(
11450                        txn_id,
11451                        self.epoch.visible(),
11452                        backfill,
11453                        Vec::new(),
11454                        Vec::new(),
11455                        principal,
11456                        catalog_bound,
11457                        None,
11458                    )
11459                    .map(|(epoch, _)| epoch),
11460            };
11461            let commit_result = if entered_fence {
11462                finish_controlled_commit_attempt(commit_result, &mut after_commit)
11463            } else {
11464                commit_result
11465            };
11466            match &commit_result {
11467                Ok(epoch) => durable_epoch.set(Some(*epoch)),
11468                Err(MongrelError::DurableCommit { epoch, .. }) => {
11469                    durable_epoch.set(Some(Epoch(*epoch)));
11470                }
11471                Err(_) => {}
11472            }
11473            Some(commit_result?)
11474        };
11475        let result: Result<(ColumnDef, Option<Epoch>)> = (|| {
11476            let _security_write = self.security_write()?;
11477            self.require(&crate::auth::Permission::Ddl)?;
11478            if self
11479                .catalog
11480                .read()
11481                .live(table_name)
11482                .is_none_or(|entry| entry.table_id != table_id)
11483            {
11484                return Err(MongrelError::Conflict(format!(
11485                    "table {table_name:?} changed during ALTER"
11486                )));
11487            }
11488            let mut table = handle.lock();
11489            let (column, prepared_schema) = table.prepare_alter_column(column_name, &change)?;
11490            let renamed_column = (column.name != column_name).then(|| column.name.clone());
11491            let Some(prepared_schema) = prepared_schema else {
11492                return Ok((column, backfill_epoch));
11493            };
11494
11495            let commit_lock = Arc::clone(&self.commit_lock);
11496            let _c = commit_lock.lock();
11497            let epoch = self.epoch.bump_assigned();
11498            let mut _epoch_guard = EpochGuard::new(self.epoch.as_ref(), epoch);
11499            let txn_id = self.alloc_txn_id()?;
11500            let column_json = DdlOp::encode_column(&column)?;
11501            let mut next_catalog = self.catalog.read().clone();
11502            let catalog_entry_index = next_catalog
11503                .tables
11504                .iter()
11505                .position(|entry| entry.table_id == table_id)
11506                .ok_or_else(|| MongrelError::NotFound(format!("table {table_name:?} not found")))?;
11507            if let Some(new_column_name) = &renamed_column {
11508                for (trigger_index, trigger) in next_catalog.triggers.iter_mut().enumerate() {
11509                    commit_prepare_checkpoint(control, trigger_index)?;
11510                    if matches!(
11511                        &trigger.trigger.target,
11512                        TriggerTarget::Table(target) if target == table_name
11513                    ) {
11514                        trigger.trigger = trigger.trigger.renamed_update_column(
11515                            column_name,
11516                            new_column_name.clone(),
11517                            epoch.0,
11518                        )?;
11519                    }
11520                }
11521                for (role_index, role) in next_catalog.roles.iter_mut().enumerate() {
11522                    commit_prepare_checkpoint(control, role_index)?;
11523                    for (permission_index, permission) in role.permissions.iter_mut().enumerate() {
11524                        commit_prepare_checkpoint(control, permission_index)?;
11525                        rename_permission_column(
11526                            permission,
11527                            table_name,
11528                            column_name,
11529                            new_column_name,
11530                        );
11531                    }
11532                }
11533                advance_security_version(&mut next_catalog)?;
11534            }
11535            next_catalog.tables[catalog_entry_index].schema = prepared_schema.clone();
11536            next_catalog.db_epoch = next_catalog.db_epoch.max(epoch.0);
11537            commit_prepare_checkpoint(control, 0)?;
11538            let mut entered_fence = false;
11539            if let Some(before_commit) = before_commit.as_deref_mut() {
11540                before_commit()?;
11541                entered_fence = true;
11542            }
11543            let commit_result: Result<Epoch> = (|| {
11544                let commit_seq = {
11545                    let mut wal = self.shared_wal.lock();
11546                    let append: Result<u64> = (|| {
11547                        wal.append(
11548                            txn_id,
11549                            table_id,
11550                            crate::wal::Op::Ddl(DdlOp::AlterTable {
11551                                table_id,
11552                                column_json,
11553                            }),
11554                        )?;
11555                        append_catalog_snapshot(&mut wal, txn_id, &next_catalog)?;
11556                        wal.append_commit(txn_id, epoch, &[])
11557                    })();
11558                    append.map_err(|error| self.commit_outcome_unknown(epoch, error))?
11559                };
11560                self.await_durable_commit(commit_seq, epoch)?;
11561                durable_epoch.set(Some(epoch));
11562
11563                table.apply_altered_schema_prepared(prepared_schema);
11564                let schema = table.schema().clone();
11565                let table_checkpoint = table.checkpoint_altered_schema();
11566                drop(table);
11567                next_catalog.tables[catalog_entry_index].schema = schema;
11568                next_catalog.db_epoch = next_catalog.db_epoch.max(epoch.0);
11569                let catalog_result =
11570                    catalog::write_atomic(&self.root, &next_catalog, self.meta_dek.as_ref());
11571                let security_version = next_catalog.security_version;
11572                *self.catalog.write() = next_catalog;
11573                if renamed_column.is_some() {
11574                    self.security_coordinator
11575                        .version
11576                        .store(security_version, Ordering::Release);
11577                }
11578                self.epoch.publish_in_order(epoch);
11579                _epoch_guard.disarm();
11580                if let Err(error) = table_checkpoint.and(catalog_result) {
11581                    self.poisoned.store(true, Ordering::Relaxed);
11582                    return Err(MongrelError::DurableCommit {
11583                        epoch: epoch.0,
11584                        message: error.to_string(),
11585                    });
11586                }
11587                Ok(epoch)
11588            })();
11589            let commit_result = if entered_fence {
11590                finish_controlled_commit_attempt(commit_result, &mut after_commit)
11591            } else {
11592                commit_result
11593            };
11594            let epoch = commit_result?;
11595            Ok((column, Some(epoch)))
11596        })();
11597        result.map_err(|error| match (durable_epoch.get(), error) {
11598            (_, error @ MongrelError::DurableCommit { .. }) => error,
11599            (Some(epoch), error) => MongrelError::DurableCommit {
11600                epoch: epoch.0,
11601                message: error.to_string(),
11602            },
11603            (None, error) => error,
11604        })
11605    }
11606
11607    /// Set a timestamp-column TTL policy and WAL-log it for crash recovery and
11608    /// replication. Duration is in nanoseconds.
11609    pub fn set_table_ttl(
11610        &self,
11611        table_name: &str,
11612        column_name: &str,
11613        duration_nanos: u64,
11614    ) -> Result<crate::manifest::TtlPolicy> {
11615        let policy = self.replace_table_ttl(table_name, Some((column_name, duration_nanos)))?;
11616        policy.ok_or_else(|| MongrelError::Other("set TTL produced no policy".into()))
11617    }
11618
11619    /// Set TTL metadata on a hidden build before it is published.
11620    #[doc(hidden)]
11621    pub fn set_building_table_ttl(
11622        &self,
11623        table_name: &str,
11624        column_name: &str,
11625        duration_nanos: u64,
11626    ) -> Result<crate::manifest::TtlPolicy> {
11627        let policy = self.replace_table_ttl_with_state(
11628            table_name,
11629            Some((column_name, duration_nanos)),
11630            true,
11631        )?;
11632        policy
11633            .ok_or_else(|| MongrelError::Other("set building-table TTL produced no policy".into()))
11634    }
11635
11636    pub fn clear_table_ttl(&self, table_name: &str) -> Result<()> {
11637        self.replace_table_ttl(table_name, None)?;
11638        Ok(())
11639    }
11640
11641    fn replace_table_ttl(
11642        &self,
11643        table_name: &str,
11644        requested: Option<(&str, u64)>,
11645    ) -> Result<Option<crate::manifest::TtlPolicy>> {
11646        self.replace_table_ttl_with_state(table_name, requested, false)
11647    }
11648
11649    fn replace_table_ttl_with_state(
11650        &self,
11651        table_name: &str,
11652        requested: Option<(&str, u64)>,
11653        building: bool,
11654    ) -> Result<Option<crate::manifest::TtlPolicy>> {
11655        use crate::wal::DdlOp;
11656        use std::sync::atomic::Ordering;
11657
11658        self.require(&crate::auth::Permission::Ddl)?;
11659        if self.poisoned.load(Ordering::Relaxed) {
11660            return Err(MongrelError::Other(
11661                "database poisoned by fsync error".into(),
11662            ));
11663        }
11664
11665        let _g = self.ddl_lock.lock();
11666        let _security_write = self.security_write()?;
11667        self.require(&crate::auth::Permission::Ddl)?;
11668        let table_id = {
11669            let cat = self.catalog.read();
11670            if building {
11671                cat.building(table_name)
11672            } else {
11673                cat.live(table_name)
11674            }
11675            .ok_or_else(|| MongrelError::NotFound(format!("table {table_name:?} not found")))?
11676            .table_id
11677        };
11678        let handle =
11679            self.tables.read().get(&table_id).cloned().ok_or_else(|| {
11680                MongrelError::NotFound(format!("table {table_name:?} not mounted"))
11681            })?;
11682        let mut table = handle.lock();
11683        let policy = match requested {
11684            Some((column, duration)) => Some(table.prepare_ttl_policy(column, duration)?),
11685            None => None,
11686        };
11687        if table.ttl() == policy {
11688            return Ok(policy);
11689        }
11690
11691        let commit_lock = Arc::clone(&self.commit_lock);
11692        let _c = commit_lock.lock();
11693        let epoch = self.epoch.bump_assigned();
11694        let mut _epoch_guard = EpochGuard::new(self.epoch.as_ref(), epoch);
11695        let txn_id = self.alloc_txn_id()?;
11696        let policy_json = DdlOp::encode_ttl(policy)?;
11697        let mut next_catalog = self.catalog.read().clone();
11698        next_catalog.db_epoch = next_catalog.db_epoch.max(epoch.0);
11699        let commit_seq = {
11700            let mut wal = self.shared_wal.lock();
11701            let append: Result<u64> = (|| {
11702                wal.append(
11703                    txn_id,
11704                    table_id,
11705                    crate::wal::Op::Ddl(DdlOp::SetTtl {
11706                        table_id,
11707                        policy_json,
11708                    }),
11709                )?;
11710                append_catalog_snapshot(&mut wal, txn_id, &next_catalog)?;
11711                wal.append_commit(txn_id, epoch, &[])
11712            })();
11713            append.map_err(|error| self.commit_outcome_unknown(epoch, error))?
11714        };
11715        self.await_durable_commit(commit_seq, epoch)?;
11716
11717        let mut publish_error = table.apply_ttl_policy_at(policy, epoch).err();
11718        drop(table);
11719        if let Err(error) = self.checkpoint_catalog_after_durable(next_catalog) {
11720            publish_error.get_or_insert(error);
11721        }
11722        self.finish_durable_publish(epoch, &mut _epoch_guard, publish_error.map_or(Ok(()), Err))?;
11723        Ok(policy)
11724    }
11725
11726    /// Retention-gated garbage collection (spec §6.4, §7.4, §16). Deletes:
11727    /// - Dropped-table subdirs whose `at_epoch < min_active_snapshot`.
11728    /// - Stale `_txn/` dirs (aborted/crashed large-txn pending runs).
11729    ///
11730    /// Returns the number of items reclaimed.
11731    pub fn gc(&self) -> Result<usize> {
11732        let control = crate::ExecutionControl::new(None);
11733        self.gc_controlled(&control, || true)
11734    }
11735
11736    /// Discover reclaimable state cooperatively, then cross one publication
11737    /// boundary immediately before the first irreversible deletion.
11738    #[doc(hidden)]
11739    pub fn gc_controlled<F>(
11740        &self,
11741        control: &crate::ExecutionControl,
11742        before_publish: F,
11743    ) -> Result<usize>
11744    where
11745        F: FnOnce() -> bool,
11746    {
11747        self.gc_controlled_with_receipt(control, before_publish)
11748            .map(|(reclaimed, _)| reclaimed)
11749    }
11750
11751    /// Discover reclaimable state from one exact catalog/epoch snapshot, then
11752    /// return that snapshot if an irreversible deletion was attempted.
11753    #[doc(hidden)]
11754    pub fn gc_controlled_with_receipt<F>(
11755        &self,
11756        control: &crate::ExecutionControl,
11757        before_publish: F,
11758    ) -> Result<(usize, Option<MaintenanceReceipt>)>
11759    where
11760        F: FnOnce() -> bool,
11761    {
11762        enum Candidate {
11763            Directory(PathBuf),
11764            File(PathBuf),
11765        }
11766
11767        self.require(&crate::auth::Permission::Ddl)?;
11768        let _ddl = self.ddl_lock.lock();
11769        self.require(&crate::auth::Permission::Ddl)?;
11770        control.checkpoint()?;
11771        let maintenance_epoch = self.epoch.visible();
11772        let min_active = self.snapshots.min_active(maintenance_epoch).0;
11773        let mut candidates = Vec::new();
11774
11775        // Reclaim dropped-table dirs where no pinned snapshot still needs them.
11776        let cat = self.catalog.read();
11777        for (entry_index, entry) in cat.tables.iter().enumerate() {
11778            if entry_index % 256 == 0 {
11779                control.checkpoint()?;
11780            }
11781            if let TableState::Dropped { at_epoch } = entry.state {
11782                if at_epoch <= min_active {
11783                    let tdir = self.root.join(TABLES_DIR).join(entry.table_id.to_string());
11784                    if tdir.exists() {
11785                        candidates.push(Candidate::Directory(tdir));
11786                    }
11787                }
11788            }
11789        }
11790        drop(cat);
11791
11792        // Sweep stale _txn/<id>/ dirs on remaining live tables — but NEVER an
11793        // in-flight spill's dir (deleting it would lose the pending run and fail
11794        // the commit, review fix #14). Each `_txn/` subdir is named by its txn id;
11795        // skip any id still registered in `active_spills`.
11796        let cat = self.catalog.read();
11797        for (entry_index, entry) in cat.tables.iter().enumerate() {
11798            if entry_index % 256 == 0 {
11799                control.checkpoint()?;
11800            }
11801            if !matches!(entry.state, TableState::Live) {
11802                continue;
11803            }
11804            let txn_dir = self
11805                .root
11806                .join(TABLES_DIR)
11807                .join(entry.table_id.to_string())
11808                .join("_txn");
11809            if !txn_dir.exists() {
11810                continue;
11811            }
11812            for (sub_index, sub) in std::fs::read_dir(&txn_dir)?.enumerate() {
11813                if sub_index % 256 == 0 {
11814                    control.checkpoint()?;
11815                }
11816                let sub = sub?;
11817                let name = sub.file_name();
11818                let Some(name) = name.to_str() else { continue };
11819                // A non-numeric entry can't belong to a live txn — sweep it.
11820                let is_active = name
11821                    .parse::<u64>()
11822                    .map(|id| self.active_spills.is_active(id))
11823                    .unwrap_or(false);
11824                if is_active {
11825                    continue;
11826                }
11827                candidates.push(Candidate::Directory(sub.path()));
11828            }
11829        }
11830        drop(cat);
11831
11832        let external_names = {
11833            let cat = self.catalog.read();
11834            cat.external_tables
11835                .iter()
11836                .map(|entry| entry.name.clone())
11837                .collect::<std::collections::HashSet<_>>()
11838        };
11839        let vtab_dir = self.root.join(VTAB_DIR);
11840        if vtab_dir.exists() {
11841            for (entry_index, entry) in std::fs::read_dir(&vtab_dir)?.enumerate() {
11842                if entry_index % 256 == 0 {
11843                    control.checkpoint()?;
11844                }
11845                let entry = entry?;
11846                let name = entry.file_name();
11847                let Some(name) = name.to_str() else { continue };
11848                if external_names.contains(name) {
11849                    continue;
11850                }
11851                let path = entry.path();
11852                if path.is_dir() {
11853                    candidates.push(Candidate::Directory(path));
11854                } else {
11855                    candidates.push(Candidate::File(path));
11856                }
11857            }
11858        }
11859
11860        // Reap compaction-superseded runs whose retire epoch no pinned snapshot
11861        // can still need (spec §6.4). Each table deletes its own retired files
11862        // gated on `min_active` and persists its manifest.
11863        let tables = self
11864            .tables
11865            .read()
11866            .iter()
11867            .map(|(table_id, handle)| (*table_id, handle.clone()))
11868            .collect::<Vec<_>>();
11869        let mut retiring = Vec::new();
11870        for (table_index, (table_id, handle)) in tables.iter().enumerate() {
11871            if table_index % 256 == 0 {
11872                control.checkpoint()?;
11873            }
11874            let backup_pinned: HashSet<u128> = self
11875                .backup_pins
11876                .lock()
11877                .keys()
11878                .filter_map(|(pinned_table, run_id)| {
11879                    (*pinned_table == *table_id).then_some(*run_id)
11880                })
11881                .collect();
11882            if handle
11883                .lock()
11884                .has_reapable_retiring(Epoch(min_active), &backup_pinned)
11885            {
11886                retiring.push((handle.clone(), backup_pinned));
11887            }
11888        }
11889
11890        // WAL-segment GC (spec §6.4/§16). `SharedWal::open` mints a fresh active
11891        // segment on every reopen without truncating the prior ones, so rotated
11892        // segments accumulate. Once every live table's committed data is durable
11893        // in runs (no in-memory rows) and no in-flight spill is open, all rotated
11894        // (non-active) segments are redundant for recovery and safe to delete —
11895        // an in-flight txn only ever appends to the active segment, which is
11896        // never deleted.
11897        let all_durable = self.active_spills.is_idle()
11898            && tables.iter().all(|(_, handle)| {
11899                let g = handle.lock();
11900                g.memtable_len() == 0 && g.mutable_run_len() == 0
11901            });
11902        let retain = self
11903            .replication_wal_retention_segments
11904            .load(std::sync::atomic::Ordering::Relaxed);
11905        let reap_wal = all_durable
11906            && self
11907                .shared_wal
11908                .lock()
11909                .has_gc_segments_retain_recent(retain)?;
11910
11911        if candidates.is_empty() && retiring.is_empty() && !reap_wal {
11912            return Ok((0, None));
11913        }
11914        control.checkpoint()?;
11915        if !before_publish() {
11916            return Err(MongrelError::Cancelled);
11917        }
11918
11919        let mut reclaimed = 0;
11920        for candidate in candidates {
11921            match candidate {
11922                Candidate::Directory(path) => std::fs::remove_dir_all(path)?,
11923                Candidate::File(path) => std::fs::remove_file(path)?,
11924            }
11925            reclaimed += 1;
11926        }
11927        for (handle, backup_pinned) in retiring {
11928            reclaimed += handle
11929                .lock()
11930                .reap_retiring(Epoch(min_active), &backup_pinned)?;
11931        }
11932        if reap_wal {
11933            reclaimed += self
11934                .shared_wal
11935                .lock()
11936                .gc_segments_retain_recent(u64::MAX, retain)?;
11937        }
11938
11939        Ok((
11940            reclaimed,
11941            Some(MaintenanceReceipt {
11942                epoch: maintenance_epoch,
11943            }),
11944        ))
11945    }
11946
11947    /// Produce a deterministic-stable byte image of the database directory.
11948    ///
11949    /// After `checkpoint()`:
11950    ///   - All pending writes are flushed to sorted runs (no memtable data).
11951    ///   - Each table is compacted to a single sorted run (no run fragmentation).
11952    ///   - All non-active WAL segments are deleted (data is durable in runs).
11953    ///   - The active WAL segment is rotated to a fresh empty segment.
11954    ///   - Dropped-table directories are removed.
11955    ///   - All manifests + catalog are persisted.
11956    ///
11957    /// The resulting directory is byte-stable: `git add` captures a snapshot
11958    /// that `git checkout` restores deterministically. No stale WAL tail bytes,
11959    /// no unbounded segment growth, no mutable-run spill files.
11960    ///
11961    /// This is the engine primitive behind `mongreldb snapshot <dir>` (CLI).
11962    /// It does NOT clear the exclusive lock — the caller still owns the
11963    /// database handle.
11964    pub fn checkpoint(&self) -> Result<()> {
11965        self.checkpoint_controlled(|| Ok(()))
11966    }
11967
11968    /// Strict checkpoint with a deterministic test hook after every table is
11969    /// flushed/compacted but before WAL replacement.
11970    #[doc(hidden)]
11971    pub fn checkpoint_controlled<F>(&self, before_wal_reset: F) -> Result<()>
11972    where
11973        F: FnOnce() -> Result<()>,
11974    {
11975        self.require(&crate::auth::Permission::Ddl)?;
11976        // Block cross-table commits and DDL for the full operation. Locking all
11977        // mounted handles also excludes direct `Table` commits, which do not
11978        // enter the database replication barrier.
11979        let _replication = self.replication_barrier.write();
11980        let _ddl = self.ddl_lock.lock();
11981        let _security = self.security_coordinator.gate.read();
11982        self.require(&crate::auth::Permission::Ddl)?;
11983
11984        let mut handles = self
11985            .tables
11986            .read()
11987            .iter()
11988            .map(|(table_id, handle)| (*table_id, handle.clone()))
11989            .collect::<Vec<_>>();
11990        handles.sort_by_key(|(table_id, _)| *table_id);
11991        let mut tables = handles
11992            .iter()
11993            .map(|(table_id, handle)| (*table_id, handle.lock()))
11994            .collect::<Vec<_>>();
11995
11996        // Strict flush. Any error leaves the old WAL recovery source intact.
11997        for (_, table) in &mut tables {
11998            if table.has_pending_writes() || table.memtable_len() > 0 || table.mutable_run_len() > 0
11999            {
12000                table.force_flush()?;
12001            }
12002        }
12003
12004        // Strict compaction. Checkpoint never reports a stable image after a
12005        // skipped failure.
12006        for (_, table) in &mut tables {
12007            if table.run_count() >= 2 || table.should_compact() {
12008                table.compact()?;
12009            }
12010        }
12011
12012        before_wal_reset()?;
12013
12014        // Reap table-local retired runs while every table remains quiesced.
12015        let maintenance_epoch = self.epoch.visible();
12016        let min_active = self.snapshots.min_active(maintenance_epoch);
12017        for (table_id, table) in &mut tables {
12018            let backup_pinned: HashSet<u128> = self
12019                .backup_pins
12020                .lock()
12021                .keys()
12022                .filter_map(|(pinned_table, run_id)| {
12023                    (*pinned_table == *table_id).then_some(*run_id)
12024                })
12025                .collect();
12026            table.reap_retiring(min_active, &backup_pinned)?;
12027        }
12028
12029        // Publish a fresh synced active WAL, then durably reap every older
12030        // segment. This point is reached only after every strict flush succeeds.
12031        self.shared_wal.lock().reset_after_checkpoint()?;
12032
12033        // Remove catalog-unreachable directories and stale transaction state.
12034        let catalog_snapshot = self.catalog.read().clone();
12035        for entry in &catalog_snapshot.tables {
12036            if matches!(entry.state, TableState::Dropped { at_epoch } if at_epoch <= min_active.0) {
12037                crate::durable_file::remove_directory_all(
12038                    &self.root.join(TABLES_DIR).join(entry.table_id.to_string()),
12039                )?;
12040            }
12041            if !matches!(entry.state, TableState::Live) {
12042                continue;
12043            }
12044            let transaction_dir = self
12045                .root
12046                .join(TABLES_DIR)
12047                .join(entry.table_id.to_string())
12048                .join("_txn");
12049            if transaction_dir.is_dir() {
12050                for child in std::fs::read_dir(&transaction_dir)? {
12051                    let child = child?;
12052                    let active = child
12053                        .file_name()
12054                        .to_str()
12055                        .and_then(|name| name.parse::<u64>().ok())
12056                        .is_some_and(|txn_id| self.active_spills.is_active(txn_id));
12057                    if !active {
12058                        crate::durable_file::remove_directory_all(&child.path())?;
12059                    }
12060                }
12061            }
12062        }
12063        let external_names = catalog_snapshot
12064            .external_tables
12065            .iter()
12066            .map(|entry| entry.name.as_str())
12067            .collect::<HashSet<_>>();
12068        let external_root = self.root.join(VTAB_DIR);
12069        if external_root.is_dir() {
12070            for entry in std::fs::read_dir(&external_root)? {
12071                let entry = entry?;
12072                let name = entry.file_name();
12073                if name
12074                    .to_str()
12075                    .is_some_and(|name| external_names.contains(name))
12076                {
12077                    continue;
12078                }
12079                if entry.file_type()?.is_dir() {
12080                    crate::durable_file::remove_directory_all(&entry.path())?;
12081                } else {
12082                    std::fs::remove_file(entry.path())?;
12083                    crate::durable_file::sync_directory(&external_root)?;
12084                }
12085            }
12086        }
12087
12088        // Final authoritative metadata checkpoint while all writers remain
12089        // excluded.
12090        catalog::write_atomic(&self.root, &catalog_snapshot, self.meta_dek.as_ref())?;
12091        let visible = self.epoch.visible();
12092        for (_, table) in &tables {
12093            table.persist_manifest(visible)?;
12094        }
12095
12096        Ok(())
12097    }
12098    fn alloc_txn_id(&self) -> Result<u64> {
12099        crate::txn::allocate_txn_id(&self.next_txn_id)
12100    }
12101
12102    /// Set the per-table spill threshold (bytes). When a transaction's staged
12103    /// bytes for a single table exceed this, the rows are written as a
12104    /// uniform-epoch pending run instead of streamed Put records (spec §8.5).
12105    pub fn set_spill_threshold(&self, bytes: u64) {
12106        self.spill_threshold
12107            .store(bytes, std::sync::atomic::Ordering::Relaxed);
12108    }
12109
12110    /// Test-only: install a hook invoked after a transaction writes its spill
12111    /// runs but before the sequencer, so a test can race `gc()` against an
12112    /// in-flight spill. Not part of the stable API.
12113    #[doc(hidden)]
12114    pub fn __set_spill_hook(&self, f: impl Fn() + Send + Sync + 'static) {
12115        *self.spill_hook.lock() = Some(Box::new(f));
12116    }
12117
12118    /// Test-only: install a hook invoked while a spilled commit holds the
12119    /// security read gate and before it appends to the WAL.
12120    #[doc(hidden)]
12121    pub fn __set_security_commit_hook(&self, f: impl Fn() + Send + Sync + 'static) {
12122        *self.security_commit_hook.lock() = Some(Box::new(f));
12123    }
12124
12125    /// Test-only: install a hook after transaction preparation and before the
12126    /// commit sequencer validates catalog generations.
12127    #[doc(hidden)]
12128    pub fn __set_catalog_commit_hook(&self, f: impl Fn() + Send + Sync + 'static) {
12129        *self.catalog_commit_hook.lock() = Some(Box::new(f));
12130    }
12131
12132    /// Test-only: pause an online backup after its consistent boundary is
12133    /// captured but before the pinned immutable runs are copied.
12134    #[doc(hidden)]
12135    pub fn __set_backup_hook(&self, f: impl Fn() + Send + Sync + 'static) {
12136        *self.backup_hook.lock() = Some(Box::new(f));
12137    }
12138
12139    /// Test-only: pause WAL extraction before its final principal recheck.
12140    #[doc(hidden)]
12141    pub fn __set_replication_hook(&self, f: impl Fn() + Send + Sync + 'static) {
12142        *self.replication_hook.lock() = Some(Box::new(f));
12143    }
12144
12145    /// Number of WAL fsyncs issued so far (test/diagnostic). With group commit
12146    /// this stays well below the number of committed transactions when commits
12147    /// are concurrent (one leader fsync covers a whole batch — spec §9.3).
12148    #[doc(hidden)]
12149    pub fn __wal_group_sync_count(&self) -> u64 {
12150        self.shared_wal.lock().group_sync_count()
12151    }
12152
12153    /// Force the poisoned state (test-only) to verify the §9.3e fail-fast
12154    /// contract that an fsync error would trigger in production.
12155    #[doc(hidden)]
12156    pub fn __poison(&self) {
12157        self.poisoned
12158            .store(true, std::sync::atomic::Ordering::Relaxed);
12159    }
12160
12161    /// Verify multi-table integrity (spec §16). For every live table this:
12162    /// authenticates the manifest; opens each `RunRef`'s file through
12163    /// [`RunReader`](crate::sorted_run::RunReader), which verifies the run footer
12164    /// checksum and — for encrypted DBs — the keyed run-metadata MAC; checks each
12165    /// run's physical row count against its `RunRef`; flags `RunRef`s whose file
12166    /// is missing (dangling) and `.sr` files on disk that no `RunRef` references
12167    /// (orphan); and verifies `flushed_epoch <= current_epoch`. Returns the list
12168    /// of issues found (empty = healthy). Orphans are `warning`-severity; all
12169    /// other findings are `error`-severity (so [`Self::doctor`] quarantines them).
12170    ///
12171    /// Cost: O(total run bytes) — the footer checksum is verified over each run's
12172    /// full body, so this is an integrity tool, not a hot path.
12173    pub fn check(&self) -> Vec<CheckIssue> {
12174        match self.check_inner(None) {
12175            Ok(issues) => issues,
12176            Err(error) => vec![CheckIssue {
12177                table_id: WAL_TABLE_ID,
12178                table_name: "shared WAL".into(),
12179                severity: "error".into(),
12180                description: error.to_string(),
12181            }],
12182        }
12183    }
12184
12185    /// Integrity check with cooperative cancellation between tables and runs.
12186    #[doc(hidden)]
12187    pub fn check_controlled(&self, control: &crate::ExecutionControl) -> Result<Vec<CheckIssue>> {
12188        self.check_inner(Some(control))
12189    }
12190
12191    fn check_inner(&self, control: Option<&crate::ExecutionControl>) -> Result<Vec<CheckIssue>> {
12192        let mut issues = Vec::new();
12193        let cat = self.catalog.read();
12194        let manifest_meta_dek = crate::encryption::meta_dek_for(self.kek.as_deref());
12195        for (table_index, entry) in cat.tables.iter().enumerate() {
12196            if table_index % 256 == 0 {
12197                if let Some(control) = control {
12198                    control.checkpoint()?;
12199                }
12200            }
12201            if !matches!(entry.state, TableState::Live) {
12202                continue;
12203            }
12204            let tdir = self.root.join(TABLES_DIR).join(entry.table_id.to_string());
12205            let mut err = |sev: &str, desc: String| {
12206                issues.push(CheckIssue {
12207                    table_id: entry.table_id,
12208                    table_name: entry.name.clone(),
12209                    severity: sev.into(),
12210                    description: desc,
12211                });
12212            };
12213            let m = match crate::manifest::read(&tdir, manifest_meta_dek.as_ref()) {
12214                Ok(m) => m,
12215                Err(e) => {
12216                    err("error", format!("manifest read failed: {e}"));
12217                    continue;
12218                }
12219            };
12220            if m.flushed_epoch > m.current_epoch {
12221                err(
12222                    "error",
12223                    format!(
12224                        "flushed_epoch {} exceeds current_epoch {} (impossible)",
12225                        m.flushed_epoch, m.current_epoch
12226                    ),
12227                );
12228            }
12229
12230            let runs_dir = tdir.join(crate::engine::RUNS_DIR);
12231            let mut referenced: std::collections::HashSet<u128> = std::collections::HashSet::new();
12232            for (run_index, rr) in m.runs.iter().enumerate() {
12233                if run_index % 256 == 0 {
12234                    if let Some(control) = control {
12235                        control.checkpoint()?;
12236                    }
12237                }
12238                referenced.insert(rr.run_id);
12239                let run_path = runs_dir.join(format!("r-{}.sr", rr.run_id));
12240                if !run_path.exists() {
12241                    err("error", format!("missing run file: r-{}.sr", rr.run_id));
12242                    continue;
12243                }
12244                match crate::sorted_run::RunReader::open(
12245                    &run_path,
12246                    entry.schema.clone(),
12247                    self.kek.clone(),
12248                ) {
12249                    Ok(reader) => {
12250                        if reader.row_count() as u64 != rr.row_count {
12251                            err(
12252                                "error",
12253                                format!(
12254                                    "run r-{} row count mismatch: manifest {} vs run {}",
12255                                    rr.run_id,
12256                                    rr.row_count,
12257                                    reader.row_count()
12258                                ),
12259                            );
12260                        }
12261                    }
12262                    Err(e) => {
12263                        err(
12264                            "error",
12265                            format!("run r-{} integrity check failed: {e}", rr.run_id),
12266                        );
12267                    }
12268                }
12269            }
12270
12271            // Compaction-superseded runs awaiting retention-gated deletion are
12272            // tracked in `retiring`; their files are expected on disk, so they
12273            // are not orphans.
12274            for r in &m.retiring {
12275                referenced.insert(r.run_id);
12276            }
12277
12278            // Orphan `.sr` files present on disk but absent from the manifest.
12279            if let Ok(rd) = std::fs::read_dir(&runs_dir) {
12280                for (entry_index, ent) in rd.flatten().enumerate() {
12281                    if entry_index % 256 == 0 {
12282                        if let Some(control) = control {
12283                            control.checkpoint()?;
12284                        }
12285                    }
12286                    let p = ent.path();
12287                    if p.extension().and_then(|s| s.to_str()) != Some("sr") {
12288                        continue;
12289                    }
12290                    let run_id = p
12291                        .file_stem()
12292                        .and_then(|s| s.to_str())
12293                        .and_then(|s| s.strip_prefix("r-"))
12294                        .and_then(|s| s.parse::<u128>().ok());
12295                    if let Some(id) = run_id {
12296                        if !referenced.contains(&id) {
12297                            err(
12298                                "warning",
12299                                format!("orphan run file r-{id}.sr not referenced by the manifest"),
12300                            );
12301                        }
12302                    }
12303                }
12304            }
12305        }
12306
12307        let external_names = cat
12308            .external_tables
12309            .iter()
12310            .map(|entry| entry.name.clone())
12311            .collect::<std::collections::HashSet<_>>();
12312        let vtab_dir = self.root.join(VTAB_DIR);
12313        if let Ok(entries) = std::fs::read_dir(&vtab_dir) {
12314            for (entry_index, entry) in entries.flatten().enumerate() {
12315                if entry_index % 256 == 0 {
12316                    if let Some(control) = control {
12317                        control.checkpoint()?;
12318                    }
12319                }
12320                let name = entry.file_name();
12321                let Some(name) = name.to_str() else { continue };
12322                if !external_names.contains(name) {
12323                    issues.push(CheckIssue {
12324                        table_id: EXTERNAL_TABLE_ID,
12325                        table_name: name.to_string(),
12326                        severity: "warning".into(),
12327                        description: format!(
12328                            "orphan external table state entry {:?} not referenced by the catalog",
12329                            entry.path()
12330                        ),
12331                    });
12332                }
12333            }
12334        }
12335
12336        // WAL retention / integrity invariant (spec §16): every on-disk WAL
12337        // segment must open (header magic + version, and the frame cipher must
12338        // be derivable for an encrypted WAL). A segment that won't open is
12339        // corrupt or truncated and would break crash recovery. `table_id` is
12340        // the reserved `WAL_TABLE_ID` sentinel (u64::MAX) so [`Self::doctor`]
12341        // never confuses a WAL issue with a real table.
12342        if let Some(control) = control {
12343            control.checkpoint()?;
12344        }
12345        for (seg, msg) in self.shared_wal.lock().verify_segments() {
12346            issues.push(CheckIssue {
12347                table_id: WAL_TABLE_ID,
12348                table_name: "<wal>".into(),
12349                severity: "error".into(),
12350                description: format!("WAL segment seg-{seg:06}.wal failed integrity check: {msg}"),
12351            });
12352        }
12353        Ok(issues)
12354    }
12355
12356    /// Quarantine unreadable tables (spec §16). Moves corrupt table dirs to
12357    /// `_quarantine/<table_id>/`, marks them dropped in the catalog, and
12358    /// unmounts them from the live table map so the DB still opens.
12359    pub fn doctor(&self) -> Result<Vec<u64>> {
12360        let control = crate::ExecutionControl::new(None);
12361        self.doctor_controlled(&control, || true)
12362    }
12363
12364    /// Check cancellably, then fence immediately before the first quarantine
12365    /// mutation. Returning `false` from `before_publish` leaves the database
12366    /// untouched.
12367    #[doc(hidden)]
12368    pub fn doctor_controlled<F>(
12369        &self,
12370        control: &crate::ExecutionControl,
12371        before_publish: F,
12372    ) -> Result<Vec<u64>>
12373    where
12374        F: FnOnce() -> bool,
12375    {
12376        self.doctor_controlled_with_receipt(control, before_publish)
12377            .map(|(quarantined, _)| quarantined)
12378    }
12379
12380    /// Check cancellably and return the exact catalog epoch used for a
12381    /// quarantine publication. No receipt is returned when nothing changes.
12382    #[doc(hidden)]
12383    pub fn doctor_controlled_with_receipt<F>(
12384        &self,
12385        control: &crate::ExecutionControl,
12386        before_publish: F,
12387    ) -> Result<(Vec<u64>, Option<MaintenanceReceipt>)>
12388    where
12389        F: FnOnce() -> bool,
12390    {
12391        // Hold the DDL lock for the whole operation to prevent concurrent
12392        // create_table/drop_table from racing the catalog/dir mutation.
12393        let _ddl = self.ddl_lock.lock();
12394        let _security_write = self.security_write()?;
12395        let issues = self.check_inner(Some(control))?;
12396        // A corrupt WAL segment is reported as an error but is NOT a table
12397        // problem — quarantining an innocent table cannot fix it (and the first
12398        // real table is id 0, so the WAL sentinel WAL_TABLE_ID = u64::MAX keeps
12399        // them disjoint). The admin must address WAL corruption manually.
12400        let bad_tables: std::collections::HashSet<u64> = issues
12401            .iter()
12402            .filter(|i| {
12403                i.severity == "error"
12404                    && i.table_id != WAL_TABLE_ID
12405                    && i.table_id != EXTERNAL_TABLE_ID
12406            })
12407            .map(|i| i.table_id)
12408            .collect();
12409        if bad_tables.is_empty() {
12410            return Ok((Vec::new(), None));
12411        }
12412        let _commit = self.commit_lock.lock();
12413        control.checkpoint()?;
12414        if !before_publish() {
12415            return Err(MongrelError::Cancelled);
12416        }
12417        let maintenance_epoch = self.epoch.bump_assigned();
12418        let mut epoch_guard = EpochGuard::new(self.epoch.as_ref(), maintenance_epoch);
12419
12420        let qdir = self.root.join("_quarantine");
12421        crate::durable_file::create_directory(&qdir)?;
12422        let mut bad_tables = bad_tables.into_iter().collect::<Vec<_>>();
12423        bad_tables.sort_unstable();
12424
12425        // Quiesce every mounted target before catalog publication. Existing
12426        // handle clones are marked unavailable in the publication callback so
12427        // they cannot append to the shared WAL after their catalog entry drops.
12428        let mut handles = self
12429            .tables
12430            .read()
12431            .iter()
12432            .filter(|(table_id, _)| bad_tables.binary_search(table_id).is_ok())
12433            .map(|(table_id, handle)| (*table_id, handle.clone()))
12434            .collect::<Vec<_>>();
12435        handles.sort_by_key(|(table_id, _)| *table_id);
12436        let mut table_guards = handles
12437            .iter()
12438            .map(|(table_id, handle)| (*table_id, handle.lock()))
12439            .collect::<Vec<_>>();
12440
12441        let mut next_catalog = self.catalog.read().clone();
12442        for table_id in &bad_tables {
12443            if let Some(entry) = next_catalog
12444                .tables
12445                .iter_mut()
12446                .find(|entry| entry.table_id == *table_id)
12447            {
12448                entry.state = TableState::Dropped {
12449                    at_epoch: maintenance_epoch.0,
12450                };
12451            }
12452        }
12453        next_catalog.db_epoch = next_catalog.db_epoch.max(maintenance_epoch.0);
12454
12455        let txn_id = self.alloc_txn_id()?;
12456        let commit_seq = {
12457            let mut wal = self.shared_wal.lock();
12458            let append: Result<u64> = (|| {
12459                for table_id in &bad_tables {
12460                    wal.append(
12461                        txn_id,
12462                        *table_id,
12463                        crate::wal::Op::Ddl(crate::wal::DdlOp::DropTable {
12464                            table_id: *table_id,
12465                        }),
12466                    )?;
12467                }
12468                append_catalog_snapshot(&mut wal, txn_id, &next_catalog)?;
12469                wal.append_commit(txn_id, maintenance_epoch, &[])
12470            })();
12471            append.map_err(|error| self.commit_outcome_unknown(maintenance_epoch, error))?
12472        };
12473        self.await_durable_commit(commit_seq, maintenance_epoch)?;
12474        for (_, table) in &mut table_guards {
12475            table.mark_unavailable_after_quarantine();
12476        }
12477        {
12478            let mut live_tables = self.tables.write();
12479            for table_id in &bad_tables {
12480                live_tables.remove(table_id);
12481            }
12482        }
12483        let checkpoint = self.checkpoint_catalog_after_durable(next_catalog);
12484        self.finish_durable_publish(maintenance_epoch, &mut epoch_guard, checkpoint)?;
12485
12486        // The catalog drop is durable. Directory placement is secondary but
12487        // still uses a write-through rename. A failure reports the known
12488        // catalog outcome and leaves a harmless orphan under `tables/`.
12489        for table_id in &bad_tables {
12490            let source = self.root.join(TABLES_DIR).join(table_id.to_string());
12491            if source.exists() {
12492                let destination = qdir.join(table_id.to_string());
12493                if let Err(error) = crate::durable_file::rename(&source, &destination) {
12494                    return Err(MongrelError::DurableCommit {
12495                        epoch: maintenance_epoch.0,
12496                        message: format!(
12497                            "DOCTOR dropped table {table_id} but quarantine move failed: {error}"
12498                        ),
12499                    });
12500                }
12501            }
12502        }
12503        Ok((
12504            bad_tables,
12505            Some(MaintenanceReceipt {
12506                epoch: maintenance_epoch,
12507            }),
12508        ))
12509    }
12510
12511    /// The DB-wide KEK (if encrypted).
12512    #[allow(dead_code)]
12513    pub(crate) fn kek(&self) -> Option<&Arc<crate::encryption::Kek>> {
12514        self.kek.as_ref()
12515    }
12516
12517    /// Shared epoch authority (used by the transaction layer in P2).
12518    #[allow(dead_code)]
12519    pub(crate) fn epoch_authority(&self) -> &Arc<EpochAuthority> {
12520        &self.epoch
12521    }
12522
12523    /// Shared snapshot registry (used by GC in P3.6).
12524    #[allow(dead_code)]
12525    pub(crate) fn snapshots(&self) -> &Arc<SnapshotRegistry> {
12526        &self.snapshots
12527    }
12528}
12529
12530fn external_state_dir(root: &Path, name: &str) -> PathBuf {
12531    root.join(VTAB_DIR).join(name)
12532}
12533
12534fn append_catalog_snapshot(
12535    wal: &mut crate::wal::SharedWal,
12536    txn_id: u64,
12537    catalog: &Catalog,
12538) -> Result<()> {
12539    let catalog_json = crate::wal::DdlOp::encode_catalog(catalog)?;
12540    wal.append(
12541        txn_id,
12542        WAL_TABLE_ID,
12543        crate::wal::Op::Ddl(crate::wal::DdlOp::CatalogSnapshot { catalog_json }),
12544    )?;
12545    Ok(())
12546}
12547
12548fn filter_ignored_staging(
12549    staging: Vec<(u64, crate::txn::Staged)>,
12550    ignored_indices: &std::collections::BTreeSet<usize>,
12551) -> Vec<(u64, crate::txn::Staged)> {
12552    if ignored_indices.is_empty() {
12553        return staging;
12554    }
12555    staging
12556        .into_iter()
12557        .enumerate()
12558        .filter_map(|(idx, staged)| (!ignored_indices.contains(&idx)).then_some(staged))
12559        .collect()
12560}
12561
12562fn external_state_file(root: &Path, name: &str) -> PathBuf {
12563    external_state_dir(root, name).join("state.json")
12564}
12565
12566fn read_external_state_file(root: &Path, name: &str) -> Result<Vec<u8>> {
12567    let path = external_state_file(root, name);
12568    match std::fs::read(path) {
12569        Ok(bytes) => Ok(bytes),
12570        Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(Vec::new()),
12571        Err(e) => Err(e.into()),
12572    }
12573}
12574
12575fn current_external_state_bytes(
12576    root: &Path,
12577    external_states: &[(String, Vec<u8>)],
12578    name: &str,
12579) -> Result<Vec<u8>> {
12580    for (table, state) in external_states.iter().rev() {
12581        if table == name {
12582            return Ok(state.clone());
12583        }
12584    }
12585    read_external_state_file(root, name)
12586}
12587
12588fn dedup_external_states(external_states: Vec<(String, Vec<u8>)>) -> Vec<(String, Vec<u8>)> {
12589    let mut out = external_states;
12590    dedup_external_states_in_place(&mut out);
12591    out
12592}
12593
12594fn dedup_external_states_in_place(external_states: &mut Vec<(String, Vec<u8>)>) {
12595    let mut seen = std::collections::HashSet::new();
12596    let mut out = Vec::with_capacity(external_states.len());
12597    for (name, state) in std::mem::take(external_states).into_iter().rev() {
12598        if seen.insert(name.clone()) {
12599            out.push((name, state));
12600        }
12601    }
12602    out.reverse();
12603    *external_states = out;
12604}
12605
12606fn prepare_external_state_file(
12607    root: &Path,
12608    name: &str,
12609    state: &[u8],
12610    txn_id: u64,
12611) -> Result<PathBuf> {
12612    crate::durable_file::create_directory(&root.join(VTAB_DIR))?;
12613    let dir = external_state_dir(root, name);
12614    crate::durable_file::create_directory(&dir)?;
12615    let pending = dir.join(format!("state.json.{txn_id}.tmp"));
12616    {
12617        let mut file = std::fs::OpenOptions::new()
12618            .create_new(true)
12619            .write(true)
12620            .open(&pending)?;
12621        file.write_all(state)?;
12622        file.sync_all()?;
12623    }
12624    Ok(pending)
12625}
12626
12627fn publish_external_state_file(root: &Path, name: &str, pending: &Path) -> Result<()> {
12628    let path = external_state_file(root, name);
12629    crate::durable_file::replace(pending, &path)?;
12630    Ok(())
12631}
12632
12633fn write_external_state_file(
12634    durable: &crate::durable_file::DurableRoot,
12635    name: &str,
12636    state: &[u8],
12637) -> Result<()> {
12638    let directory = Path::new(VTAB_DIR).join(name);
12639    durable.create_directory_all(&directory)?;
12640    durable.write_atomic(directory.join("state.json"), state)?;
12641    Ok(())
12642}
12643
12644fn validate_recovered_data_table(
12645    catalog: &Catalog,
12646    tables: &HashMap<u64, TableHandle>,
12647    table_id: u64,
12648    commit_epoch: u64,
12649    offset: u64,
12650) -> Result<bool> {
12651    let entry = catalog
12652        .tables
12653        .iter()
12654        .find(|entry| entry.table_id == table_id)
12655        .ok_or_else(|| MongrelError::CorruptWal {
12656            offset,
12657            reason: format!("committed record references unknown table {table_id}"),
12658        })?;
12659    if commit_epoch < entry.created_epoch {
12660        return Err(MongrelError::CorruptWal {
12661            offset,
12662            reason: format!(
12663                "table {table_id} record epoch {commit_epoch} precedes creation epoch {}",
12664                entry.created_epoch
12665            ),
12666        });
12667    }
12668    match entry.state {
12669        TableState::Dropped { at_epoch } => {
12670            // Abandoned hidden builds are marked dropped at the last durable
12671            // boundary during open, so their final build commit may equal the
12672            // cleanup epoch. Ordinary table drops consume a new epoch and must
12673            // remain strictly later than every data commit.
12674            let abandoned_build_boundary =
12675                entry.name.starts_with(CTAS_BUILD_TABLE_PREFIX) && commit_epoch == at_epoch;
12676            if commit_epoch >= at_epoch && !abandoned_build_boundary {
12677                Err(MongrelError::CorruptWal {
12678                    offset,
12679                    reason: format!(
12680                        "table {table_id} record epoch {commit_epoch} is not before drop epoch {at_epoch}"
12681                    ),
12682                })
12683            } else {
12684                Ok(false)
12685            }
12686        }
12687        TableState::Live | TableState::Building { .. } => {
12688            if tables.contains_key(&table_id) {
12689                Ok(true)
12690            } else {
12691                Err(MongrelError::CorruptWal {
12692                    offset,
12693                    reason: format!("live table {table_id} has no mounted recovery handle"),
12694                })
12695            }
12696        }
12697    }
12698}
12699
12700type RecoveryTableStage = (
12701    Vec<crate::memtable::Row>,
12702    Vec<(crate::rowid::RowId, Epoch)>,
12703    Option<Epoch>,
12704    Epoch,
12705);
12706
12707#[derive(Clone)]
12708struct RecoveryValidationTable {
12709    schema: Schema,
12710    flushed_epoch: u64,
12711}
12712
12713fn validate_shared_wal_recovery_plan(
12714    durable_root: &crate::durable_file::DurableRoot,
12715    catalog: &Catalog,
12716    recovered_table_ids: &HashSet<u64>,
12717    reconciled_table_ids: &HashSet<u64>,
12718    meta_dek: Option<&[u8; META_DEK_LEN]>,
12719    kek: Option<Arc<crate::encryption::Kek>>,
12720    records: &[crate::wal::Record],
12721) -> Result<()> {
12722    use crate::wal::{DdlOp, Op};
12723
12724    let mut tables = HashMap::<u64, RecoveryValidationTable>::new();
12725    for entry in &catalog.tables {
12726        if !matches!(entry.state, TableState::Live) {
12727            continue;
12728        }
12729        let relative_dir = Path::new(TABLES_DIR).join(entry.table_id.to_string());
12730        let manifest = match crate::manifest::read_durable(durable_root, &relative_dir, meta_dek) {
12731            Ok(manifest) => Some(manifest),
12732            Err(MongrelError::Io(error)) if error.kind() == std::io::ErrorKind::NotFound => None,
12733            Err(error) => return Err(error),
12734        };
12735        let flushed_epoch = if let Some(manifest) = manifest {
12736            if manifest.table_id != entry.table_id {
12737                return Err(MongrelError::Conflict(format!(
12738                    "catalog table {} storage identity mismatch",
12739                    entry.table_id
12740                )));
12741            }
12742            if (manifest.schema_id != entry.schema.schema_id
12743                && !reconciled_table_ids.contains(&entry.table_id))
12744                || manifest.flushed_epoch > manifest.current_epoch
12745                || manifest.global_idx_epoch > manifest.current_epoch
12746                || manifest.next_row_id == u64::MAX
12747                || manifest.auto_inc_next < 0
12748                || manifest.auto_inc_next == i64::MAX
12749                || (entry.schema.auto_increment_column().is_none() && manifest.auto_inc_next != 0)
12750            {
12751                return Err(MongrelError::InvalidArgument(format!(
12752                    "table {} manifest counters or schema identity are invalid",
12753                    entry.table_id
12754                )));
12755            }
12756            #[cfg(feature = "encryption")]
12757            let idx_dek = kek.as_ref().map(|key| key.derive_idx_key());
12758            #[cfg(not(feature = "encryption"))]
12759            let idx_dek: Option<zeroize::Zeroizing<[u8; 32]>> = None;
12760            crate::global_idx::read_durable_for(
12761                durable_root,
12762                &relative_dir,
12763                entry.table_id,
12764                &entry.schema,
12765                idx_dek.as_deref(),
12766            )?;
12767            let mut run_ids = HashSet::new();
12768            let mut maximum_row_id = None::<u64>;
12769            for run in &manifest.runs {
12770                if run.run_id >= u64::MAX as u128
12771                    || run.epoch_created > manifest.current_epoch
12772                    || !run_ids.insert(run.run_id)
12773                {
12774                    return Err(MongrelError::InvalidArgument(format!(
12775                        "table {} manifest contains an invalid or duplicate run id",
12776                        entry.table_id
12777                    )));
12778                }
12779                let relative = relative_dir
12780                    .join(crate::engine::RUNS_DIR)
12781                    .join(format!("r-{}.sr", run.run_id as u64));
12782                let file = durable_root.open_regular(&relative)?;
12783                let mut reader = crate::sorted_run::RunReader::open_file(
12784                    file,
12785                    entry.schema.clone(),
12786                    kek.clone(),
12787                )?;
12788                let header = reader.header();
12789                if header.run_id != run.run_id
12790                    || header.level != run.level
12791                    || header.row_count != run.row_count
12792                    || !header.is_uniform_epoch() && header.epoch_created != run.epoch_created
12793                    || header.is_uniform_epoch() && header.epoch_created != 0
12794                    || header.schema_id > entry.schema.schema_id
12795                {
12796                    return Err(MongrelError::InvalidArgument(format!(
12797                        "table {} run {} differs from its manifest: header=(id {}, level {}, rows {}, epoch {}, schema {}), manifest=(id {}, level {}, rows {}, epoch {}, schema <= {})",
12798                        entry.table_id,
12799                        run.run_id,
12800                        header.run_id,
12801                        header.level,
12802                        header.row_count,
12803                        header.epoch_created,
12804                        header.schema_id,
12805                        run.run_id,
12806                        run.level,
12807                        run.row_count,
12808                        run.epoch_created,
12809                        entry.schema.schema_id,
12810                    )));
12811                }
12812                if header.row_count != 0 {
12813                    maximum_row_id = Some(
12814                        maximum_row_id
12815                            .map_or(header.max_row_id, |value| value.max(header.max_row_id)),
12816                    );
12817                }
12818                reader.validate_all_pages()?;
12819            }
12820            if maximum_row_id.is_some_and(|maximum| manifest.next_row_id <= maximum) {
12821                return Err(MongrelError::InvalidArgument(format!(
12822                    "table {} next_row_id does not advance beyond persisted rows",
12823                    entry.table_id
12824                )));
12825            }
12826            for run in &manifest.retiring {
12827                if run.run_id >= u64::MAX as u128
12828                    || run.retire_epoch > manifest.current_epoch
12829                    || !run_ids.insert(run.run_id)
12830                {
12831                    return Err(MongrelError::InvalidArgument(format!(
12832                        "table {} manifest contains an invalid or aliased retired run",
12833                        entry.table_id
12834                    )));
12835                }
12836            }
12837            manifest.flushed_epoch
12838        } else {
12839            if !recovered_table_ids.contains(&entry.table_id) {
12840                return Err(MongrelError::NotFound(format!(
12841                    "live table {} manifest is missing",
12842                    entry.table_id
12843                )));
12844            }
12845            0
12846        };
12847        tables.insert(
12848            entry.table_id,
12849            RecoveryValidationTable {
12850                schema: entry.schema.clone(),
12851                flushed_epoch,
12852            },
12853        );
12854    }
12855
12856    let committed = records
12857        .iter()
12858        .filter_map(|record| match record.op {
12859            Op::TxnCommit { epoch, .. } => Some((record.txn_id, epoch)),
12860            _ => None,
12861        })
12862        .collect::<HashMap<_, _>>();
12863    let mut run_ids = HashSet::new();
12864    let mut recovered_row_ids = HashMap::<u64, HashSet<u64>>::new();
12865    for record in records {
12866        let Some(&commit_epoch) = committed.get(&record.txn_id) else {
12867            continue;
12868        };
12869        match &record.op {
12870            Op::Put { table_id, rows } => {
12871                let table = validate_recovery_data_table_plan(
12872                    catalog,
12873                    &tables,
12874                    *table_id,
12875                    commit_epoch,
12876                    record.seq.0,
12877                )?;
12878                let decoded: Vec<crate::memtable::Row> =
12879                    bincode::deserialize(rows).map_err(|error| MongrelError::CorruptWal {
12880                        offset: record.seq.0,
12881                        reason: format!(
12882                            "committed Put payload for transaction {} could not be decoded: {error}",
12883                            record.txn_id
12884                        ),
12885                    })?;
12886                if let Some(table) = table {
12887                    for row in &decoded {
12888                        if !recovered_row_ids
12889                            .entry(*table_id)
12890                            .or_default()
12891                            .insert(row.row_id.0)
12892                        {
12893                            return Err(MongrelError::CorruptWal {
12894                                offset: record.seq.0,
12895                                reason: format!(
12896                                    "committed WAL repeats recovered row id {} for table {table_id}",
12897                                    row.row_id.0
12898                                ),
12899                            });
12900                        }
12901                        validate_recovered_row(&table.schema, row)?;
12902                    }
12903                }
12904            }
12905            Op::Delete { table_id, .. } | Op::TruncateTable { table_id } => {
12906                validate_recovery_data_table_plan(
12907                    catalog,
12908                    &tables,
12909                    *table_id,
12910                    commit_epoch,
12911                    record.seq.0,
12912                )?;
12913            }
12914            Op::ExternalTableState { name, .. } => validate_recovered_external_name(name)?,
12915            Op::Ddl(DdlOp::ResetExternalTableState {
12916                name,
12917                generation_epoch,
12918            }) => {
12919                if *generation_epoch != commit_epoch {
12920                    return Err(MongrelError::CorruptWal {
12921                        offset: record.seq.0,
12922                        reason: format!(
12923                            "external state reset epoch {generation_epoch} does not match WAL commit epoch {commit_epoch}"
12924                        ),
12925                    });
12926                }
12927                validate_recovered_external_name(name)?;
12928            }
12929            Op::TxnCommit { added_runs, .. } => {
12930                for added in added_runs {
12931                    let Some(table) = validate_recovery_data_table_plan(
12932                        catalog,
12933                        &tables,
12934                        added.table_id,
12935                        commit_epoch,
12936                        record.seq.0,
12937                    )?
12938                    else {
12939                        continue;
12940                    };
12941                    if added.run_id >= u64::MAX as u128
12942                        || !run_ids.insert((added.table_id, added.run_id))
12943                    {
12944                        return Err(MongrelError::CorruptWal {
12945                            offset: record.seq.0,
12946                            reason: format!(
12947                                "duplicate or invalid recovered run {} for table {}",
12948                                added.run_id, added.table_id
12949                            ),
12950                        });
12951                    }
12952                    if commit_epoch <= table.flushed_epoch {
12953                        continue;
12954                    }
12955                    validate_planned_spilled_run(
12956                        durable_root,
12957                        record.txn_id,
12958                        commit_epoch,
12959                        added,
12960                        &table.schema,
12961                        kek.clone(),
12962                    )?;
12963                }
12964            }
12965            _ => {}
12966        }
12967    }
12968    Ok(())
12969}
12970
12971fn validate_recovery_data_table_plan<'a>(
12972    catalog: &Catalog,
12973    tables: &'a HashMap<u64, RecoveryValidationTable>,
12974    table_id: u64,
12975    commit_epoch: u64,
12976    offset: u64,
12977) -> Result<Option<&'a RecoveryValidationTable>> {
12978    let entry = catalog
12979        .tables
12980        .iter()
12981        .find(|entry| entry.table_id == table_id)
12982        .ok_or_else(|| MongrelError::CorruptWal {
12983            offset,
12984            reason: format!("committed record references unknown table {table_id}"),
12985        })?;
12986    if commit_epoch < entry.created_epoch {
12987        return Err(MongrelError::CorruptWal {
12988            offset,
12989            reason: format!(
12990                "table {table_id} record epoch {commit_epoch} precedes creation epoch {}",
12991                entry.created_epoch
12992            ),
12993        });
12994    }
12995    match entry.state {
12996        TableState::Dropped { at_epoch } => {
12997            let abandoned =
12998                entry.name.starts_with(CTAS_BUILD_TABLE_PREFIX) && commit_epoch == at_epoch;
12999            if commit_epoch >= at_epoch && !abandoned {
13000                return Err(MongrelError::CorruptWal {
13001                    offset,
13002                    reason: format!(
13003                        "table {table_id} record epoch {commit_epoch} is not before drop epoch {at_epoch}"
13004                    ),
13005                });
13006            }
13007            Ok(None)
13008        }
13009        TableState::Live => {
13010            tables
13011                .get(&table_id)
13012                .map(Some)
13013                .ok_or_else(|| MongrelError::CorruptWal {
13014                    offset,
13015                    reason: format!("live table {table_id} has no recovery plan"),
13016                })
13017        }
13018        TableState::Building { .. } => Err(MongrelError::CorruptWal {
13019            offset,
13020            reason: format!("building table {table_id} was not normalized before recovery"),
13021        }),
13022    }
13023}
13024
13025fn validate_planned_spilled_run(
13026    root: &crate::durable_file::DurableRoot,
13027    txn_id: u64,
13028    commit_epoch: u64,
13029    added: &crate::wal::AddedRun,
13030    schema: &Schema,
13031    kek: Option<Arc<crate::encryption::Kek>>,
13032) -> Result<()> {
13033    let table = Path::new(TABLES_DIR).join(added.table_id.to_string());
13034    let destination = table
13035        .join(crate::engine::RUNS_DIR)
13036        .join(format!("r-{}.sr", added.run_id as u64));
13037    let pending = table
13038        .join("_txn")
13039        .join(txn_id.to_string())
13040        .join(format!("r-{}.sr", added.run_id as u64));
13041    let file = match root.open_regular(&destination) {
13042        Ok(file) => file,
13043        Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
13044            root.open_regular(&pending).map_err(|pending_error| {
13045                if pending_error.kind() == std::io::ErrorKind::NotFound {
13046                    MongrelError::CorruptWal {
13047                        offset: commit_epoch,
13048                        reason: format!(
13049                            "committed spilled run {} for transaction {txn_id} is missing",
13050                            added.run_id
13051                        ),
13052                    }
13053                } else {
13054                    pending_error.into()
13055                }
13056            })?
13057        }
13058        Err(error) => return Err(error.into()),
13059    };
13060    let mut reader = crate::sorted_run::RunReader::open_file(file, schema.clone(), kek)?;
13061    let header = reader.header();
13062    if header.run_id != added.run_id
13063        || header.content_hash != added.content_hash
13064        || header.row_count != added.row_count
13065        || header.level != added.level
13066        || header.min_row_id != added.min_row_id
13067        || header.max_row_id != added.max_row_id
13068        || header.schema_id != schema.schema_id
13069        || !header.is_uniform_epoch()
13070        || header.epoch_created != 0
13071    {
13072        return Err(MongrelError::CorruptWal {
13073            offset: commit_epoch,
13074            reason: format!(
13075                "committed spilled run {} metadata differs from WAL",
13076                added.run_id
13077            ),
13078        });
13079    }
13080    reader.validate_all_pages()?;
13081    Ok(())
13082}
13083
13084/// Two-pass, `flushed_epoch`-gated recovery of the shared WAL (spec §15).
13085///
13086/// Pass 1 scans every `TxnCommit` marker and records `txn_id → commit_epoch`
13087/// (the per-txn outcome; aborted / in-flight / torn-tail txns are absent). Pass
13088/// 2 applies each committed data record (Put/Delete) to its table at the commit
13089/// epoch, skipping records whose `commit_epoch <= table.flushed_epoch` (already
13090/// durable in a sorted run). Finally the shared epoch authority is raised to the
13091/// max committed epoch so the next commit continues monotonically.
13092fn recover_shared_wal(
13093    durable_root: &crate::durable_file::DurableRoot,
13094    tables: &HashMap<u64, TableHandle>,
13095    catalog: &Catalog,
13096    epoch: &EpochAuthority,
13097    records: &[crate::wal::Record],
13098) -> Result<()> {
13099    use crate::memtable::Row;
13100    use crate::wal::{DdlOp, Op};
13101
13102    // Pass 1: committed-txn outcomes + collect spilled-run info.
13103    let mut committed: HashMap<u64, u64> = HashMap::new();
13104    let mut spilled_to_link: Vec<(
13105        u64, /*txn_id*/
13106        u64, /*epoch*/
13107        Vec<crate::wal::AddedRun>,
13108    )> = Vec::new();
13109    for r in records {
13110        if let Op::TxnCommit {
13111            epoch: ce,
13112            ref added_runs,
13113        } = r.op
13114        {
13115            committed.insert(r.txn_id, ce);
13116            if !added_runs.is_empty() {
13117                spilled_to_link.push((r.txn_id, ce, added_runs.clone()));
13118            }
13119        }
13120    }
13121    for record in records {
13122        let Some(&commit_epoch) = committed.get(&record.txn_id) else {
13123            continue;
13124        };
13125        match &record.op {
13126            Op::Put { table_id, .. }
13127            | Op::Delete { table_id, .. }
13128            | Op::TruncateTable { table_id } => {
13129                validate_recovered_data_table(
13130                    catalog,
13131                    tables,
13132                    *table_id,
13133                    commit_epoch,
13134                    record.seq.0,
13135                )?;
13136            }
13137            Op::TxnCommit { added_runs, .. } => {
13138                for run in added_runs {
13139                    validate_recovered_data_table(
13140                        catalog,
13141                        tables,
13142                        run.table_id,
13143                        commit_epoch,
13144                        record.seq.0,
13145                    )?;
13146                }
13147            }
13148            _ => {}
13149        }
13150    }
13151    let truncated_transactions: HashSet<(u64, u64)> = records
13152        .iter()
13153        .filter_map(|record| {
13154            committed.get(&record.txn_id)?;
13155            match record.op {
13156                Op::TruncateTable { table_id } => Some((record.txn_id, table_id)),
13157                _ => None,
13158            }
13159        })
13160        .collect();
13161
13162    // Pass 2: stage data per table, gated by flushed_epoch.
13163    enum ExternalRecoveryAction {
13164        Write { name: String, state: Vec<u8> },
13165        Reset { name: String },
13166    }
13167    let mut stage: HashMap<u64, RecoveryTableStage> = HashMap::new();
13168    let mut external_actions = Vec::new();
13169    let mut max_epoch = epoch.visible().0;
13170    for r in records.iter().cloned() {
13171        let Some(&ce) = committed.get(&r.txn_id) else {
13172            continue; // aborted / in-flight — discard
13173        };
13174        let commit_epoch = Epoch(ce);
13175        max_epoch = max_epoch.max(ce);
13176        match r.op {
13177            Op::Put { table_id, rows } => {
13178                // Skip if this table already flushed past the commit epoch.
13179                let skip = tables
13180                    .get(&table_id)
13181                    .map(|h| h.lock().flushed_epoch() >= ce)
13182                    .unwrap_or(true);
13183                if skip {
13184                    continue;
13185                }
13186                let rows: Vec<Row> = bincode::deserialize(&rows).map_err(|error| {
13187                    MongrelError::CorruptWal {
13188                        offset: r.seq.0,
13189                        reason: format!(
13190                            "committed Put payload for transaction {} could not be decoded: {error}",
13191                            r.txn_id
13192                        ),
13193                    }
13194                })?;
13195                // Re-stamp each row at the txn commit epoch (rows are pre-stamped
13196                // at pending_epoch which equals the commit epoch, but be robust).
13197                let rows: Vec<Row> = rows
13198                    .into_iter()
13199                    .map(|mut row| {
13200                        row.committed_epoch = commit_epoch;
13201                        row
13202                    })
13203                    .collect();
13204                let entry = stage
13205                    .entry(table_id)
13206                    .or_insert_with(|| (Vec::new(), Vec::new(), None, commit_epoch));
13207                entry.0.extend(rows);
13208                entry.3 = commit_epoch;
13209            }
13210            Op::Delete { table_id, row_ids } => {
13211                let skip = tables
13212                    .get(&table_id)
13213                    .map(|h| h.lock().flushed_epoch() >= ce)
13214                    .unwrap_or(true);
13215                if skip {
13216                    continue;
13217                }
13218                let dels = row_ids.into_iter().map(|rid| (rid, commit_epoch));
13219                let entry = stage
13220                    .entry(table_id)
13221                    .or_insert_with(|| (Vec::new(), Vec::new(), None, commit_epoch));
13222                entry.1.extend(dels);
13223                entry.3 = commit_epoch;
13224            }
13225            Op::TruncateTable { table_id } => {
13226                let skip = tables
13227                    .get(&table_id)
13228                    .map(|h| h.lock().flushed_epoch() >= ce)
13229                    .unwrap_or(true);
13230                if skip {
13231                    continue;
13232                }
13233                stage.insert(
13234                    table_id,
13235                    (Vec::new(), Vec::new(), Some(commit_epoch), commit_epoch),
13236                );
13237            }
13238            Op::ExternalTableState { name, state } => {
13239                let current_generation = catalog
13240                    .external_tables
13241                    .iter()
13242                    .find(|entry| entry.name == name)
13243                    .map(|entry| entry.created_epoch);
13244                if current_generation.is_some_and(|created_epoch| ce >= created_epoch) {
13245                    validate_recovered_external_name(&name)?;
13246                    external_actions.push(ExternalRecoveryAction::Write { name, state });
13247                }
13248            }
13249            Op::Ddl(DdlOp::ResetExternalTableState {
13250                name,
13251                generation_epoch,
13252            }) => {
13253                if generation_epoch != ce {
13254                    return Err(MongrelError::CorruptWal {
13255                        offset: r.seq.0,
13256                        reason: format!(
13257                        "external state reset epoch {generation_epoch} does not match WAL commit epoch {ce}"
13258                    ),
13259                    });
13260                }
13261                validate_recovered_external_name(&name)?;
13262                external_actions.push(ExternalRecoveryAction::Reset { name });
13263            }
13264            Op::Flush { .. }
13265            | Op::TxnCommit { .. }
13266            | Op::TxnAbort
13267            | Op::Ddl(_)
13268            | Op::BeforeImage { .. }
13269            | Op::CommitTimestamp { .. }
13270            | Op::SpilledRows { .. } => {}
13271        }
13272    }
13273    for (_, commit_epoch, added_runs) in &mut spilled_to_link {
13274        added_runs.retain(|added| {
13275            tables
13276                .get(&added.table_id)
13277                .is_some_and(|table| table.lock().flushed_epoch() < *commit_epoch)
13278        });
13279    }
13280    spilled_to_link.retain(|(_, _, added_runs)| !added_runs.is_empty());
13281    validate_recovery_table_stages(tables, &stage)?;
13282    validate_recovery_spilled_runs(durable_root, tables, &spilled_to_link)?;
13283
13284    // All WAL payloads, catalog generations, table stages, and immutable run
13285    // identities have now been validated. Only this application phase mutates
13286    // the database tree.
13287    for action in external_actions {
13288        match action {
13289            ExternalRecoveryAction::Write { name, state } => {
13290                write_external_state_file(durable_root, &name, &state)?;
13291            }
13292            ExternalRecoveryAction::Reset { name } => {
13293                durable_root.create_directory_all(VTAB_DIR)?;
13294                durable_root.remove_directory_all(Path::new(VTAB_DIR).join(name))?;
13295            }
13296        }
13297    }
13298    for (table_id, (rows, deletes, truncate_epoch, table_epoch)) in stage {
13299        let Some(handle) = tables.get(&table_id) else {
13300            continue;
13301        };
13302        let mut t = handle.lock();
13303        if let Some(epoch) = truncate_epoch {
13304            t.apply_truncate(epoch);
13305        }
13306        t.recover_apply(rows, deletes)?;
13307        // The WAL can be newer than the copied/persisted manifest after a
13308        // crash or replication apply. Rebuild O(1) count metadata from the
13309        // recovered state before endorsing the commit epoch in the manifest.
13310        let rows = t.visible_rows(Snapshot::at(Epoch(u64::MAX)))?;
13311        t.live_count = rows.len() as u64;
13312        // Recovery can replay older row commits while a newer spilled run is
13313        // already linked by the copied manifest. Never move that manifest's
13314        // epoch behind its existing run references.
13315        t.persist_manifest(table_epoch.max(epoch.visible()))?;
13316    }
13317
13318    // Pass 3: link spilled runs from committed txns (spec §8.5). A crash
13319    // between TxnCommit sync and the publish phase leaves the run in
13320    // `_txn/<txn_id>/`. Move it to `_runs/` and add the RunRef.
13321    for (txn_id, ce, added_runs) in &spilled_to_link {
13322        for ar in added_runs {
13323            let Some(handle) = tables.get(&ar.table_id) else {
13324                continue;
13325            };
13326            let mut t = handle.lock();
13327            let table_dir = Path::new(TABLES_DIR).join(ar.table_id.to_string());
13328            let destination = table_dir
13329                .join(crate::engine::RUNS_DIR)
13330                .join(format!("r-{}.sr", ar.run_id));
13331            match durable_root.open_regular(&destination) {
13332                Ok(_) => {}
13333                Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
13334                    let pending = table_dir
13335                        .join("_txn")
13336                        .join(txn_id.to_string())
13337                        .join(format!("r-{}.sr", ar.run_id));
13338                    durable_root.rename_file_new(&pending, &destination)?;
13339                }
13340                Err(error) => return Err(error.into()),
13341            }
13342            // Only link a run whose file is actually present, and never re-link
13343            // one the publish phase already persisted into the manifest (which is
13344            // the common clean-reopen case, since the `TxnCommit` lives in the WAL
13345            // until segment GC). `recover_spilled_run` is idempotent + reconciles
13346            // `live_count`/indexes only when the run is genuinely new.
13347            let linked = t.recover_spilled_run(crate::manifest::RunRef {
13348                run_id: ar.run_id,
13349                level: ar.level,
13350                epoch_created: *ce,
13351                row_count: ar.row_count,
13352            });
13353            let replaced = truncated_transactions.contains(&(*txn_id, ar.table_id));
13354            if replaced {
13355                t.set_flushed_epoch(Epoch(*ce));
13356            }
13357            if linked || replaced {
13358                t.persist_manifest(Epoch(*ce).max(epoch.visible()))?;
13359            }
13360        }
13361    }
13362
13363    epoch.advance_recovered(Epoch(max_epoch));
13364    Ok(())
13365}
13366
13367fn reconcile_recovered_table_metadata(
13368    tables: &HashMap<u64, TableHandle>,
13369    epoch: Epoch,
13370) -> Result<()> {
13371    let mut table_ids = tables.keys().copied().collect::<Vec<_>>();
13372    table_ids.sort_unstable();
13373    let mut plans = Vec::with_capacity(table_ids.len());
13374    for table_id in &table_ids {
13375        let handle = tables.get(table_id).ok_or_else(|| {
13376            MongrelError::Other(format!("mounted table {table_id} vanished during recovery"))
13377        })?;
13378        plans.push((*table_id, handle.lock().plan_recovered_metadata()?));
13379    }
13380    // Every table's data and metadata have been decoded successfully. Publish
13381    // repairs only after the complete database-wide plan is known valid.
13382    for (table_id, plan) in plans {
13383        let handle = tables.get(&table_id).ok_or_else(|| {
13384            MongrelError::Other(format!("mounted table {table_id} vanished during recovery"))
13385        })?;
13386        handle.lock().apply_recovered_metadata(plan, epoch)?;
13387    }
13388    Ok(())
13389}
13390
13391fn validate_recovered_external_name(name: &str) -> Result<()> {
13392    if name.is_empty()
13393        || !name.chars().all(|character| {
13394            character.is_ascii_alphanumeric() || character == '_' || character == '-'
13395        })
13396    {
13397        return Err(MongrelError::CorruptWal {
13398            offset: 0,
13399            reason: format!("unsafe recovered external-table name {name:?}"),
13400        });
13401    }
13402    Ok(())
13403}
13404
13405fn validate_recovery_table_stages(
13406    tables: &HashMap<u64, TableHandle>,
13407    stages: &HashMap<u64, RecoveryTableStage>,
13408) -> Result<()> {
13409    for (table_id, (rows, _, _, _)) in stages {
13410        let handle = tables
13411            .get(table_id)
13412            .ok_or_else(|| MongrelError::CorruptWal {
13413                offset: *table_id,
13414                reason: format!("recovery stage references unmounted table {table_id}"),
13415            })?;
13416        let table = handle.lock();
13417        // Force all existing immutable runs through their integrity/decode path
13418        // before any other table manifest can be changed.
13419        table.visible_rows(Snapshot::at(Epoch(u64::MAX)))?;
13420        for row in rows {
13421            validate_recovered_row(table.schema(), row)?;
13422        }
13423    }
13424    Ok(())
13425}
13426
13427fn validate_recovered_row(schema: &Schema, row: &crate::memtable::Row) -> Result<()> {
13428    if row.deleted || row.row_id.0 == u64::MAX {
13429        return Err(MongrelError::CorruptWal {
13430            offset: row.row_id.0,
13431            reason: "committed Put payload contains a tombstone or exhausted row id".into(),
13432        });
13433    }
13434    let cells = row
13435        .columns
13436        .iter()
13437        .map(|(column, value)| (*column, value.clone()))
13438        .collect::<Vec<_>>();
13439    schema
13440        .validate_persisted_values(&cells)
13441        .map_err(|error| MongrelError::CorruptWal {
13442            offset: row.row_id.0,
13443            reason: format!("recovered row violates table schema: {error}"),
13444        })?;
13445    if schema.auto_increment_column().is_some_and(|column| {
13446        matches!(row.columns.get(&column.id), Some(Value::Int64(value)) if *value == i64::MAX)
13447    }) {
13448        return Err(MongrelError::CorruptWal {
13449            offset: row.row_id.0,
13450            reason: "recovered AUTO_INCREMENT value exhausts i64".into(),
13451        });
13452    }
13453    Ok(())
13454}
13455
13456fn validate_recovery_spilled_runs(
13457    root: &crate::durable_file::DurableRoot,
13458    tables: &HashMap<u64, TableHandle>,
13459    spilled: &[(u64, u64, Vec<crate::wal::AddedRun>)],
13460) -> Result<()> {
13461    let mut identities = HashSet::new();
13462    for (txn_id, commit_epoch, added_runs) in spilled {
13463        for added in added_runs {
13464            if added.run_id >= u64::MAX as u128 {
13465                return Err(MongrelError::CorruptWal {
13466                    offset: *commit_epoch,
13467                    reason: format!(
13468                        "recovered run id {} exceeds the on-disk namespace",
13469                        added.run_id
13470                    ),
13471                });
13472            }
13473            let Some(handle) = tables.get(&added.table_id) else {
13474                continue;
13475            };
13476            if !identities.insert((added.table_id, added.run_id)) {
13477                return Err(MongrelError::CorruptWal {
13478                    offset: *commit_epoch,
13479                    reason: format!(
13480                        "duplicate recovered run {} for table {}",
13481                        added.run_id, added.table_id
13482                    ),
13483                });
13484            }
13485            let table = handle.lock();
13486            validate_planned_spilled_run(
13487                root,
13488                *txn_id,
13489                *commit_epoch,
13490                added,
13491                table.schema(),
13492                table.kek(),
13493            )?;
13494        }
13495    }
13496    Ok(())
13497}
13498
13499fn validate_condition_columns(condition: &ProcedureCondition, schema: &Schema) -> Result<()> {
13500    match condition {
13501        ProcedureCondition::Pk { .. } => {
13502            if schema.primary_key().is_none() {
13503                return Err(MongrelError::InvalidArgument(
13504                    "procedure condition Pk references a table without a primary key".into(),
13505                ));
13506            }
13507        }
13508        ProcedureCondition::BitmapEq { column_id, .. }
13509        | ProcedureCondition::BitmapIn { column_id, .. }
13510        | ProcedureCondition::Range { column_id, .. }
13511        | ProcedureCondition::RangeF64 { column_id, .. }
13512        | ProcedureCondition::IsNull { column_id }
13513        | ProcedureCondition::IsNotNull { column_id }
13514        | ProcedureCondition::FmContains { column_id, .. } => {
13515            validate_column_id(*column_id, schema)?;
13516        }
13517    }
13518    Ok(())
13519}
13520
13521fn bind_procedure_args(
13522    procedure: &StoredProcedure,
13523    mut args: HashMap<String, crate::Value>,
13524) -> Result<HashMap<String, crate::Value>> {
13525    let mut out = HashMap::new();
13526    for param in &procedure.params {
13527        let value = match args.remove(&param.name) {
13528            Some(value) => value,
13529            None => param.default.clone().ok_or_else(|| {
13530                MongrelError::InvalidArgument(format!(
13531                    "missing required procedure parameter {:?}",
13532                    param.name
13533                ))
13534            })?,
13535        };
13536        if !param.nullable && matches!(value, crate::Value::Null) {
13537            return Err(MongrelError::InvalidArgument(format!(
13538                "procedure parameter {:?} must not be NULL",
13539                param.name
13540            )));
13541        }
13542        if !matches!(value, crate::Value::Null) && !value_matches_type(&value, param.ty.clone()) {
13543            return Err(MongrelError::InvalidArgument(format!(
13544                "procedure parameter {:?} has wrong type",
13545                param.name
13546            )));
13547        }
13548        out.insert(param.name.clone(), value);
13549    }
13550    if let Some(extra) = args.keys().next() {
13551        return Err(MongrelError::InvalidArgument(format!(
13552            "unknown procedure parameter {extra:?}"
13553        )));
13554    }
13555    Ok(out)
13556}
13557
13558fn value_matches_type(value: &crate::Value, ty: crate::TypeId) -> bool {
13559    matches!(
13560        (value, ty),
13561        (crate::Value::Bool(_), crate::TypeId::Bool)
13562            | (crate::Value::Int64(_), crate::TypeId::Int8)
13563            | (crate::Value::Int64(_), crate::TypeId::Int16)
13564            | (crate::Value::Int64(_), crate::TypeId::Int32)
13565            | (crate::Value::Int64(_), crate::TypeId::Int64)
13566            | (crate::Value::Int64(_), crate::TypeId::UInt8)
13567            | (crate::Value::Int64(_), crate::TypeId::UInt16)
13568            | (crate::Value::Int64(_), crate::TypeId::UInt32)
13569            | (crate::Value::Int64(_), crate::TypeId::UInt64)
13570            | (crate::Value::Int64(_), crate::TypeId::TimestampNanos)
13571            | (crate::Value::Int64(_), crate::TypeId::Date32)
13572            | (crate::Value::Float64(_), crate::TypeId::Float32)
13573            | (crate::Value::Float64(_), crate::TypeId::Float64)
13574            | (crate::Value::Bytes(_), crate::TypeId::Bytes)
13575            | (crate::Value::Embedding(_), crate::TypeId::Embedding { .. })
13576    )
13577}
13578
13579fn eval_cells(
13580    cells: &[crate::procedure::ProcedureCell],
13581    args: &HashMap<String, crate::Value>,
13582    outputs: &HashMap<String, ProcedureCallOutput>,
13583) -> Result<Vec<(u16, crate::Value)>> {
13584    cells
13585        .iter()
13586        .map(|cell| Ok((cell.column_id, eval_value(&cell.value, args, outputs)?)))
13587        .collect()
13588}
13589
13590fn eval_condition(
13591    condition: &ProcedureCondition,
13592    args: &HashMap<String, crate::Value>,
13593    outputs: &HashMap<String, ProcedureCallOutput>,
13594) -> Result<crate::Condition> {
13595    Ok(match condition {
13596        ProcedureCondition::Pk { value } => {
13597            crate::Condition::Pk(eval_value(value, args, outputs)?.encode_key())
13598        }
13599        ProcedureCondition::BitmapEq { column_id, value } => crate::Condition::BitmapEq {
13600            column_id: *column_id,
13601            value: eval_value(value, args, outputs)?.encode_key(),
13602        },
13603        ProcedureCondition::BitmapIn { column_id, values } => crate::Condition::BitmapIn {
13604            column_id: *column_id,
13605            values: values
13606                .iter()
13607                .map(|value| Ok(eval_value(value, args, outputs)?.encode_key()))
13608                .collect::<Result<Vec<_>>>()?,
13609        },
13610        ProcedureCondition::Range { column_id, lo, hi } => crate::Condition::Range {
13611            column_id: *column_id,
13612            lo: expect_i64(eval_value(lo, args, outputs)?)?,
13613            hi: expect_i64(eval_value(hi, args, outputs)?)?,
13614        },
13615        ProcedureCondition::RangeF64 {
13616            column_id,
13617            lo,
13618            lo_inclusive,
13619            hi,
13620            hi_inclusive,
13621        } => crate::Condition::RangeF64 {
13622            column_id: *column_id,
13623            lo: expect_f64(eval_value(lo, args, outputs)?)?,
13624            lo_inclusive: *lo_inclusive,
13625            hi: expect_f64(eval_value(hi, args, outputs)?)?,
13626            hi_inclusive: *hi_inclusive,
13627        },
13628        ProcedureCondition::IsNull { column_id } => crate::Condition::IsNull {
13629            column_id: *column_id,
13630        },
13631        ProcedureCondition::IsNotNull { column_id } => crate::Condition::IsNotNull {
13632            column_id: *column_id,
13633        },
13634        ProcedureCondition::FmContains { column_id, pattern } => crate::Condition::FmContains {
13635            column_id: *column_id,
13636            pattern: expect_bytes(eval_value(pattern, args, outputs)?)?,
13637        },
13638    })
13639}
13640
13641fn eval_value(
13642    value: &ProcedureValue,
13643    args: &HashMap<String, crate::Value>,
13644    outputs: &HashMap<String, ProcedureCallOutput>,
13645) -> Result<crate::Value> {
13646    match value {
13647        ProcedureValue::Literal(value) => Ok(value.clone()),
13648        ProcedureValue::Param(name) => args.get(name).cloned().ok_or_else(|| {
13649            MongrelError::InvalidArgument(format!("unknown procedure parameter {name:?}"))
13650        }),
13651        ProcedureValue::StepScalar(id) => match outputs.get(id) {
13652            Some(ProcedureCallOutput::Scalar(value)) => Ok(value.clone()),
13653            _ => Err(MongrelError::InvalidArgument(format!(
13654                "procedure step {id:?} did not return a scalar"
13655            ))),
13656        },
13657        ProcedureValue::StepRows(_) | ProcedureValue::StepRow(_) => {
13658            Err(MongrelError::InvalidArgument(
13659                "row-valued procedure reference cannot be used as a scalar".into(),
13660            ))
13661        }
13662        ProcedureValue::Object(_) | ProcedureValue::Array(_) => Err(MongrelError::InvalidArgument(
13663            "structured procedure value cannot be used as a scalar cell".into(),
13664        )),
13665    }
13666}
13667
13668fn eval_return_output(
13669    value: &ProcedureValue,
13670    args: &HashMap<String, crate::Value>,
13671    outputs: &HashMap<String, ProcedureCallOutput>,
13672) -> Result<ProcedureCallOutput> {
13673    match value {
13674        ProcedureValue::Literal(value) => Ok(ProcedureCallOutput::Scalar(value.clone())),
13675        ProcedureValue::Param(name) => Ok(ProcedureCallOutput::Scalar(
13676            args.get(name).cloned().ok_or_else(|| {
13677                MongrelError::InvalidArgument(format!("unknown procedure parameter {name:?}"))
13678            })?,
13679        )),
13680        ProcedureValue::StepRows(id)
13681        | ProcedureValue::StepRow(id)
13682        | ProcedureValue::StepScalar(id) => outputs.get(id).cloned().ok_or_else(|| {
13683            MongrelError::InvalidArgument(format!("unknown procedure step output {id:?}"))
13684        }),
13685        ProcedureValue::Object(fields) => {
13686            let mut out = Vec::with_capacity(fields.len());
13687            for (name, value) in fields {
13688                out.push((name.clone(), eval_return_output(value, args, outputs)?));
13689            }
13690            Ok(ProcedureCallOutput::Object(out))
13691        }
13692        ProcedureValue::Array(values) => {
13693            let mut out = Vec::with_capacity(values.len());
13694            for value in values {
13695                out.push(eval_return_output(value, args, outputs)?);
13696            }
13697            Ok(ProcedureCallOutput::Array(out))
13698        }
13699    }
13700}
13701
13702fn expect_i64(value: crate::Value) -> Result<i64> {
13703    match value {
13704        crate::Value::Int64(value) => Ok(value),
13705        _ => Err(MongrelError::InvalidArgument(
13706            "procedure value must be Int64".into(),
13707        )),
13708    }
13709}
13710
13711fn expect_f64(value: crate::Value) -> Result<f64> {
13712    match value {
13713        crate::Value::Float64(value) => Ok(value),
13714        _ => Err(MongrelError::InvalidArgument(
13715            "procedure value must be Float64".into(),
13716        )),
13717    }
13718}
13719
13720fn expect_bytes(value: crate::Value) -> Result<Vec<u8>> {
13721    match value {
13722        crate::Value::Bytes(value) => Ok(value),
13723        _ => Err(MongrelError::InvalidArgument(
13724            "procedure value must be Bytes".into(),
13725        )),
13726    }
13727}
13728
13729fn validate_column_id(column_id: u16, schema: &Schema) -> Result<()> {
13730    if schema.columns.iter().any(|c| c.id == column_id) {
13731        Ok(())
13732    } else {
13733        Err(MongrelError::InvalidArgument(format!(
13734            "unknown column id {column_id}"
13735        )))
13736    }
13737}
13738
13739fn trigger_matches_event(
13740    trigger: &StoredTrigger,
13741    event: &WriteEvent,
13742    cat: &Catalog,
13743) -> Result<bool> {
13744    if trigger.event != event.kind {
13745        return Ok(false);
13746    }
13747    let TriggerTarget::Table(target) = &trigger.target else {
13748        return Ok(false);
13749    };
13750    if target != &event.table {
13751        return Ok(false);
13752    }
13753    if trigger.event == TriggerEvent::Update && !trigger.update_of.is_empty() {
13754        let schema = &cat
13755            .live(target)
13756            .ok_or_else(|| {
13757                MongrelError::InvalidArgument(format!(
13758                    "trigger {:?} references unknown table {target:?}",
13759                    trigger.name
13760                ))
13761            })?
13762            .schema;
13763        let mut watched = Vec::with_capacity(trigger.update_of.len());
13764        for name in &trigger.update_of {
13765            let col = schema.column(name).ok_or_else(|| {
13766                MongrelError::InvalidArgument(format!(
13767                    "trigger {:?} references unknown UPDATE OF column {name:?}",
13768                    trigger.name
13769                ))
13770            })?;
13771            watched.push(col.id);
13772        }
13773        if !event
13774            .changed_columns
13775            .iter()
13776            .any(|column_id| watched.contains(column_id))
13777        {
13778            return Ok(false);
13779        }
13780    }
13781    Ok(true)
13782}
13783
13784fn changed_columns(old: Option<&TriggerRowImage>, new: Option<&TriggerRowImage>) -> Vec<u16> {
13785    let mut ids = std::collections::BTreeSet::new();
13786    if let Some(old) = old {
13787        ids.extend(old.columns.keys().copied());
13788    }
13789    if let Some(new) = new {
13790        ids.extend(new.columns.keys().copied());
13791    }
13792    ids.into_iter()
13793        .filter(|id| {
13794            old.and_then(|row| row.columns.get(id)) != new.and_then(|row| row.columns.get(id))
13795        })
13796        .collect()
13797}
13798
13799fn eval_trigger_cells(
13800    cells: &[crate::trigger::TriggerCell],
13801    event: &WriteEvent,
13802    selected: Option<&TriggerRowImage>,
13803) -> Result<Vec<(u16, Value)>> {
13804    cells
13805        .iter()
13806        .map(|cell| {
13807            Ok((
13808                cell.column_id,
13809                eval_trigger_value(&cell.value, event, selected)?,
13810            ))
13811        })
13812        .collect()
13813}
13814
13815fn eval_trigger_expr(expr: &TriggerExpr, event: &WriteEvent) -> Result<bool> {
13816    match expr {
13817        TriggerExpr::Value(value) => match eval_trigger_value(value, event, None)? {
13818            Value::Bool(value) => Ok(value),
13819            Value::Null => Ok(false),
13820            other => Err(MongrelError::InvalidArgument(format!(
13821                "trigger WHEN value must be boolean, got {other:?}"
13822            ))),
13823        },
13824        TriggerExpr::Eq { left, right } => Ok(values_equal(
13825            &eval_trigger_value(left, event, None)?,
13826            &eval_trigger_value(right, event, None)?,
13827        )),
13828        TriggerExpr::NotEq { left, right } => Ok(!values_equal(
13829            &eval_trigger_value(left, event, None)?,
13830            &eval_trigger_value(right, event, None)?,
13831        )),
13832        TriggerExpr::Lt { left, right } => match value_order(
13833            &eval_trigger_value(left, event, None)?,
13834            &eval_trigger_value(right, event, None)?,
13835        ) {
13836            Some(ordering) => Ok(ordering == std::cmp::Ordering::Less),
13837            None => Ok(false),
13838        },
13839        TriggerExpr::Lte { left, right } => match value_order(
13840            &eval_trigger_value(left, event, None)?,
13841            &eval_trigger_value(right, event, None)?,
13842        ) {
13843            Some(ordering) => Ok(ordering != std::cmp::Ordering::Greater),
13844            None => Ok(false),
13845        },
13846        TriggerExpr::Gt { left, right } => match value_order(
13847            &eval_trigger_value(left, event, None)?,
13848            &eval_trigger_value(right, event, None)?,
13849        ) {
13850            Some(ordering) => Ok(ordering == std::cmp::Ordering::Greater),
13851            None => Ok(false),
13852        },
13853        TriggerExpr::Gte { left, right } => match value_order(
13854            &eval_trigger_value(left, event, None)?,
13855            &eval_trigger_value(right, event, None)?,
13856        ) {
13857            Some(ordering) => Ok(ordering != std::cmp::Ordering::Less),
13858            None => Ok(false),
13859        },
13860        TriggerExpr::IsNull(value) => Ok(matches!(
13861            eval_trigger_value(value, event, None)?,
13862            Value::Null
13863        )),
13864        TriggerExpr::IsNotNull(value) => Ok(!matches!(
13865            eval_trigger_value(value, event, None)?,
13866            Value::Null
13867        )),
13868        TriggerExpr::And { left, right } => {
13869            if !eval_trigger_expr(left, event)? {
13870                Ok(false)
13871            } else {
13872                Ok(eval_trigger_expr(right, event)?)
13873            }
13874        }
13875        TriggerExpr::Or { left, right } => {
13876            if eval_trigger_expr(left, event)? {
13877                Ok(true)
13878            } else {
13879                Ok(eval_trigger_expr(right, event)?)
13880            }
13881        }
13882        TriggerExpr::Not(expr) => Ok(!eval_trigger_expr(expr, event)?),
13883    }
13884}
13885
13886fn eval_trigger_condition(
13887    condition: &TriggerCondition,
13888    event: &WriteEvent,
13889    selected: &TriggerRowImage,
13890    schema: &Schema,
13891) -> Result<bool> {
13892    match condition {
13893        TriggerCondition::Pk { value } => {
13894            let pk = schema.primary_key().ok_or_else(|| {
13895                MongrelError::InvalidArgument(
13896                    "trigger condition Pk references a table without a primary key".into(),
13897                )
13898            })?;
13899            let lhs = eval_trigger_value(value, event, Some(selected))?;
13900            Ok(values_equal(
13901                &lhs,
13902                selected.columns.get(&pk.id).unwrap_or(&Value::Null),
13903            ))
13904        }
13905        TriggerCondition::Eq { column_id, value } => Ok(values_equal(
13906            selected.columns.get(column_id).unwrap_or(&Value::Null),
13907            &eval_trigger_value(value, event, Some(selected))?,
13908        )),
13909        TriggerCondition::NotEq { column_id, value } => Ok(!values_equal(
13910            selected.columns.get(column_id).unwrap_or(&Value::Null),
13911            &eval_trigger_value(value, event, Some(selected))?,
13912        )),
13913        TriggerCondition::Lt { column_id, value } => match value_order(
13914            selected.columns.get(column_id).unwrap_or(&Value::Null),
13915            &eval_trigger_value(value, event, Some(selected))?,
13916        ) {
13917            Some(ordering) => Ok(ordering == std::cmp::Ordering::Less),
13918            None => Ok(false),
13919        },
13920        TriggerCondition::Lte { column_id, value } => match value_order(
13921            selected.columns.get(column_id).unwrap_or(&Value::Null),
13922            &eval_trigger_value(value, event, Some(selected))?,
13923        ) {
13924            Some(ordering) => Ok(ordering != std::cmp::Ordering::Greater),
13925            None => Ok(false),
13926        },
13927        TriggerCondition::Gt { column_id, value } => match value_order(
13928            selected.columns.get(column_id).unwrap_or(&Value::Null),
13929            &eval_trigger_value(value, event, Some(selected))?,
13930        ) {
13931            Some(ordering) => Ok(ordering == std::cmp::Ordering::Greater),
13932            None => Ok(false),
13933        },
13934        TriggerCondition::Gte { column_id, value } => match value_order(
13935            selected.columns.get(column_id).unwrap_or(&Value::Null),
13936            &eval_trigger_value(value, event, Some(selected))?,
13937        ) {
13938            Some(ordering) => Ok(ordering != std::cmp::Ordering::Less),
13939            None => Ok(false),
13940        },
13941        TriggerCondition::IsNull { column_id } => Ok(matches!(
13942            selected.columns.get(column_id),
13943            None | Some(Value::Null)
13944        )),
13945        TriggerCondition::IsNotNull { column_id } => Ok(!matches!(
13946            selected.columns.get(column_id),
13947            None | Some(Value::Null)
13948        )),
13949        TriggerCondition::And { left, right } => {
13950            if !eval_trigger_condition(left, event, selected, schema)? {
13951                Ok(false)
13952            } else {
13953                Ok(eval_trigger_condition(right, event, selected, schema)?)
13954            }
13955        }
13956        TriggerCondition::Or { left, right } => {
13957            if eval_trigger_condition(left, event, selected, schema)? {
13958                Ok(true)
13959            } else {
13960                Ok(eval_trigger_condition(right, event, selected, schema)?)
13961            }
13962        }
13963        TriggerCondition::Not(condition) => {
13964            Ok(!eval_trigger_condition(condition, event, selected, schema)?)
13965        }
13966    }
13967}
13968
13969fn eval_trigger_value(
13970    value: &TriggerValue,
13971    event: &WriteEvent,
13972    selected: Option<&TriggerRowImage>,
13973) -> Result<Value> {
13974    match value {
13975        TriggerValue::Literal(value) => Ok(value.clone()),
13976        TriggerValue::NewColumn(column_id) => event
13977            .new
13978            .as_ref()
13979            .and_then(|row| row.columns.get(column_id))
13980            .cloned()
13981            .ok_or_else(|| MongrelError::InvalidArgument("NEW column is not available".into())),
13982        TriggerValue::OldColumn(column_id) => event
13983            .old
13984            .as_ref()
13985            .and_then(|row| row.columns.get(column_id))
13986            .cloned()
13987            .ok_or_else(|| MongrelError::InvalidArgument("OLD column is not available".into())),
13988        TriggerValue::SelectedColumn(column_id) => selected
13989            .and_then(|row| row.columns.get(column_id))
13990            .cloned()
13991            .ok_or_else(|| {
13992                MongrelError::InvalidArgument("SELECTED column is not available".into())
13993            }),
13994    }
13995}
13996
13997fn values_equal(left: &Value, right: &Value) -> bool {
13998    match (left, right) {
13999        (Value::Null, Value::Null) => true,
14000        (Value::Bool(a), Value::Bool(b)) => a == b,
14001        (Value::Int64(a), Value::Int64(b)) => a == b,
14002        (Value::Float64(a), Value::Float64(b)) => a.to_bits() == b.to_bits(),
14003        (Value::Bytes(a), Value::Bytes(b)) => a == b,
14004        (Value::Embedding(a), Value::Embedding(b)) => {
14005            a.len() == b.len()
14006                && a.iter()
14007                    .zip(b.iter())
14008                    .all(|(a, b)| a.to_bits() == b.to_bits())
14009        }
14010        _ => false,
14011    }
14012}
14013
14014fn value_order(left: &Value, right: &Value) -> Option<std::cmp::Ordering> {
14015    match (left, right) {
14016        (Value::Null, _) | (_, Value::Null) => None,
14017        (Value::Bool(a), Value::Bool(b)) => Some(a.cmp(b)),
14018        (Value::Int64(a), Value::Int64(b)) => Some(a.cmp(b)),
14019        // Cross-type Int64/Float64 comparison coerces the integer to f64.
14020        // This matches the spec but can lose precision for i64 values above 2^53.
14021        (Value::Int64(a), Value::Float64(b)) => {
14022            let af = *a as f64;
14023            Some(af.total_cmp(b))
14024        }
14025        // Cross-type Int64/Float64 comparison coerces the integer to f64.
14026        // This matches the spec but can lose precision for i64 values above 2^53.
14027        (Value::Float64(a), Value::Int64(b)) => {
14028            let bf = *b as f64;
14029            Some(a.total_cmp(&bf))
14030        }
14031        (Value::Float64(a), Value::Float64(b)) => Some(a.total_cmp(b)),
14032        (Value::Bytes(a), Value::Bytes(b)) => Some(a.cmp(b)),
14033        (Value::Embedding(_), Value::Embedding(_)) => None,
14034        _ => None,
14035    }
14036}
14037
14038fn trigger_message(value: Value) -> String {
14039    match value {
14040        Value::Null => "NULL".into(),
14041        Value::Bool(value) => value.to_string(),
14042        Value::Int64(value) => value.to_string(),
14043        Value::Float64(value) => value.to_string(),
14044        Value::Bytes(value) => String::from_utf8_lossy(&value).into_owned(),
14045        Value::Embedding(value) => format!("{value:?}"),
14046        Value::Decimal(value) => value.to_string(),
14047        Value::Interval {
14048            months,
14049            days,
14050            nanos,
14051        } => format!("{months}m {days}d {nanos}ns"),
14052        Value::Uuid(b) => b.iter().map(|x| format!("{x:02x}")).collect(),
14053        Value::Json(b) => String::from_utf8_lossy(&b).into_owned(),
14054    }
14055}
14056
14057fn validate_trigger_step<'a>(
14058    step: &TriggerStep,
14059    cat: &'a Catalog,
14060    target_schema: &Schema,
14061    event: TriggerEvent,
14062    select_schemas: &mut HashMap<String, &'a Schema>,
14063) -> Result<()> {
14064    match step {
14065        TriggerStep::SetNew { cells } => {
14066            if event == TriggerEvent::Delete {
14067                return Err(MongrelError::InvalidArgument(
14068                    "SetNew trigger step is not valid for DELETE triggers".into(),
14069                ));
14070            }
14071            for cell in cells {
14072                validate_column_id(cell.column_id, target_schema)?;
14073                validate_trigger_value(&cell.value, target_schema, event)?;
14074            }
14075        }
14076        TriggerStep::Insert { table, cells } => {
14077            let schema = trigger_write_schema(cat, table, "insert")?;
14078            for cell in cells {
14079                validate_column_id(cell.column_id, schema)?;
14080                validate_trigger_value(&cell.value, target_schema, event)?;
14081            }
14082        }
14083        TriggerStep::UpdateByPk { table, pk, cells } => {
14084            let schema = trigger_write_schema(cat, table, "update")?;
14085            if schema.primary_key().is_none() {
14086                return Err(MongrelError::InvalidArgument(format!(
14087                    "trigger update_by_pk references table {table:?} without a primary key"
14088                )));
14089            }
14090            validate_trigger_value(pk, target_schema, event)?;
14091            for cell in cells {
14092                validate_column_id(cell.column_id, schema)?;
14093                validate_trigger_value(&cell.value, target_schema, event)?;
14094            }
14095        }
14096        TriggerStep::DeleteByPk { table, pk } => {
14097            let schema = trigger_write_schema(cat, table, "delete")?;
14098            if schema.primary_key().is_none() {
14099                return Err(MongrelError::InvalidArgument(format!(
14100                    "trigger delete_by_pk references table {table:?} without a primary key"
14101                )));
14102            }
14103            validate_trigger_value(pk, target_schema, event)?;
14104        }
14105        TriggerStep::Select {
14106            id,
14107            table,
14108            conditions,
14109        } => {
14110            let schema = trigger_read_schema(cat, table)?;
14111            for condition in conditions {
14112                validate_trigger_condition(condition, schema, target_schema, event)?;
14113            }
14114            if select_schemas.contains_key(id) {
14115                return Err(MongrelError::InvalidArgument(format!(
14116                    "duplicate select id {id:?} in trigger program"
14117                )));
14118            }
14119            select_schemas.insert(id.clone(), schema);
14120        }
14121        TriggerStep::Foreach { id, steps } => {
14122            if !select_schemas.contains_key(id) {
14123                return Err(MongrelError::InvalidArgument(format!(
14124                    "foreach references unknown select id {id:?}"
14125                )));
14126            }
14127            let mut inner_select_schemas = select_schemas.clone();
14128            for step in steps {
14129                validate_trigger_step(step, cat, target_schema, event, &mut inner_select_schemas)?;
14130            }
14131        }
14132        TriggerStep::DeleteWhere { table, conditions } => {
14133            let schema = trigger_write_schema(cat, table, "delete")?;
14134            for condition in conditions {
14135                validate_trigger_condition(condition, schema, target_schema, event)?;
14136            }
14137        }
14138        TriggerStep::UpdateWhere {
14139            table,
14140            conditions,
14141            cells,
14142        } => {
14143            let schema = trigger_write_schema(cat, table, "update")?;
14144            for condition in conditions {
14145                validate_trigger_condition(condition, schema, target_schema, event)?;
14146            }
14147            for cell in cells {
14148                validate_column_id(cell.column_id, schema)?;
14149                validate_trigger_value(&cell.value, target_schema, event)?;
14150            }
14151        }
14152        TriggerStep::Raise { message, .. } => {
14153            validate_trigger_value(message, target_schema, event)?
14154        }
14155    }
14156    Ok(())
14157}
14158
14159fn trigger_validation_error(error: MongrelError) -> MongrelError {
14160    match error {
14161        MongrelError::TriggerValidation(_) => error,
14162        MongrelError::InvalidArgument(message)
14163        | MongrelError::Conflict(message)
14164        | MongrelError::NotFound(message) => MongrelError::TriggerValidation(message),
14165        error => error,
14166    }
14167}
14168
14169fn trigger_write_schema<'a>(cat: &'a Catalog, table: &str, op: &str) -> Result<&'a Schema> {
14170    if let Some(entry) = cat.live(table) {
14171        return Ok(&entry.schema);
14172    }
14173    if let Some(entry) = cat.external_tables.iter().find(|entry| entry.name == table) {
14174        let allowed = match op {
14175            "insert" => entry.capabilities.writable || entry.capabilities.insert_only,
14176            "update" | "delete" => entry.capabilities.writable,
14177            _ => false,
14178        };
14179        if !allowed {
14180            return Err(MongrelError::InvalidArgument(format!(
14181                "trigger {op} references external table {table:?}, but module {:?} is not writable for that operation",
14182                entry.module
14183            )));
14184        }
14185        if !entry.capabilities.transaction_safe {
14186            return Err(MongrelError::InvalidArgument(format!(
14187                "trigger {op} references external table {table:?}, but module {:?} is not transaction-safe",
14188                entry.module
14189            )));
14190        }
14191        return Ok(&entry.declared_schema);
14192    }
14193    Err(MongrelError::InvalidArgument(format!(
14194        "trigger references unknown table {table:?}"
14195    )))
14196}
14197
14198fn trigger_read_schema<'a>(cat: &'a Catalog, table: &str) -> Result<&'a Schema> {
14199    if let Some(entry) = cat.live(table) {
14200        return Ok(&entry.schema);
14201    }
14202    if let Some(entry) = cat.external_tables.iter().find(|entry| entry.name == table) {
14203        if entry.capabilities.trigger_safe {
14204            return Ok(&entry.declared_schema);
14205        }
14206        return Err(MongrelError::InvalidArgument(format!(
14207            "trigger reads external table {table:?}, but module {:?} is not trigger-safe",
14208            entry.module
14209        )));
14210    }
14211    Err(MongrelError::InvalidArgument(format!(
14212        "trigger references unknown table {table:?}"
14213    )))
14214}
14215
14216fn validate_trigger_condition(
14217    condition: &TriggerCondition,
14218    schema: &Schema,
14219    target_schema: &Schema,
14220    event: TriggerEvent,
14221) -> Result<()> {
14222    match condition {
14223        TriggerCondition::Pk { value } => {
14224            if schema.primary_key().is_none() {
14225                return Err(MongrelError::InvalidArgument(
14226                    "trigger condition Pk references a table without a primary key".into(),
14227                ));
14228            }
14229            validate_trigger_value(value, target_schema, event)
14230        }
14231        TriggerCondition::Eq { column_id, value }
14232        | TriggerCondition::NotEq { column_id, value }
14233        | TriggerCondition::Lt { column_id, value }
14234        | TriggerCondition::Lte { column_id, value }
14235        | TriggerCondition::Gt { column_id, value }
14236        | TriggerCondition::Gte { column_id, value } => {
14237            validate_column_id(*column_id, schema)?;
14238            validate_trigger_value(value, target_schema, event)
14239        }
14240        TriggerCondition::IsNull { column_id } | TriggerCondition::IsNotNull { column_id } => {
14241            validate_column_id(*column_id, schema)
14242        }
14243        TriggerCondition::And { left, right } | TriggerCondition::Or { left, right } => {
14244            validate_trigger_condition(left, schema, target_schema, event)?;
14245            validate_trigger_condition(right, schema, target_schema, event)
14246        }
14247        TriggerCondition::Not(condition) => {
14248            validate_trigger_condition(condition, schema, target_schema, event)
14249        }
14250    }
14251}
14252
14253fn validate_trigger_expr(expr: &TriggerExpr, schema: &Schema, event: TriggerEvent) -> Result<()> {
14254    match expr {
14255        TriggerExpr::Value(value) | TriggerExpr::IsNull(value) | TriggerExpr::IsNotNull(value) => {
14256            validate_trigger_value(value, schema, event)
14257        }
14258        TriggerExpr::Eq { left, right }
14259        | TriggerExpr::NotEq { left, right }
14260        | TriggerExpr::Lt { left, right }
14261        | TriggerExpr::Lte { left, right }
14262        | TriggerExpr::Gt { left, right }
14263        | TriggerExpr::Gte { left, right } => {
14264            validate_trigger_value(left, schema, event)?;
14265            validate_trigger_value(right, schema, event)
14266        }
14267        TriggerExpr::And { left, right } | TriggerExpr::Or { left, right } => {
14268            validate_trigger_expr(left, schema, event)?;
14269            validate_trigger_expr(right, schema, event)
14270        }
14271        TriggerExpr::Not(expr) => validate_trigger_expr(expr, schema, event),
14272    }
14273}
14274
14275fn validate_trigger_value(
14276    value: &TriggerValue,
14277    schema: &Schema,
14278    event: TriggerEvent,
14279) -> Result<()> {
14280    match value {
14281        TriggerValue::Literal(_) => Ok(()),
14282        TriggerValue::NewColumn(id) => {
14283            if event == TriggerEvent::Delete {
14284                return Err(MongrelError::InvalidArgument(
14285                    "DELETE triggers cannot reference NEW".into(),
14286                ));
14287            }
14288            validate_column_id(*id, schema)
14289        }
14290        TriggerValue::OldColumn(id) => {
14291            if event == TriggerEvent::Insert {
14292                return Err(MongrelError::InvalidArgument(
14293                    "INSERT triggers cannot reference OLD".into(),
14294                ));
14295            }
14296            validate_column_id(*id, schema)
14297        }
14298        // SELECTED column references are only meaningful inside a foreach loop.
14299        // Strict loop-scope validation is deferred to runtime; the executor raises
14300        // an error if a selected row is not available.
14301        TriggerValue::SelectedColumn(_) => Ok(()),
14302    }
14303}
14304
14305/// Replay committed `Op::Ddl` records from the shared WAL into the catalog
14306/// (spec §15, review fix #16). A crash between WAL group-sync and the catalog
14307/// checkpoint leaves DDL durable in the WAL but absent from the on-disk
14308/// catalog. This pass closes that window by reconstructing missing entries
14309/// (and marking committed drops) before tables are mounted.
14310fn recover_ddl_from_wal(
14311    root: &Path,
14312    durable_root: Option<&crate::durable_file::DurableRoot>,
14313    target_catalog: &mut Catalog,
14314    meta_dek: Option<&[u8; META_DEK_LEN]>,
14315    wal_dek: Option<&zeroize::Zeroizing<[u8; 32]>>,
14316    apply: bool,
14317    table_roots: Option<&HashMap<u64, Arc<crate::durable_file::DurableRoot>>>,
14318) -> Result<()> {
14319    use crate::wal::SharedWal;
14320    let records = match durable_root {
14321        Some(root) => SharedWal::replay_durable_with_dek(root, wal_dek)?,
14322        None => SharedWal::replay_with_dek(root, wal_dek)?,
14323    };
14324    recover_ddl_from_records(
14325        root,
14326        durable_root,
14327        target_catalog,
14328        meta_dek,
14329        apply,
14330        table_roots,
14331        &records,
14332    )
14333}
14334
14335fn recover_ddl_from_records(
14336    root: &Path,
14337    durable_root: Option<&crate::durable_file::DurableRoot>,
14338    target_catalog: &mut Catalog,
14339    meta_dek: Option<&[u8; META_DEK_LEN]>,
14340    apply: bool,
14341    table_roots: Option<&HashMap<u64, Arc<crate::durable_file::DurableRoot>>>,
14342    records: &[crate::wal::Record],
14343) -> Result<()> {
14344    use crate::wal::{DdlOp, Op};
14345
14346    let original_catalog = target_catalog.clone();
14347    let mut recovered_catalog = original_catalog.clone();
14348    let cat = &mut recovered_catalog;
14349    let mut created_table_ids = HashSet::<u64>::new();
14350    let mut ttl_updates = HashMap::<u64, (Option<crate::manifest::TtlPolicy>, u64)>::new();
14351
14352    let mut committed: HashMap<u64, u64> = HashMap::new();
14353    for r in records {
14354        if let Op::TxnCommit { epoch: ce, .. } = r.op {
14355            committed.insert(r.txn_id, ce);
14356        }
14357    }
14358    let catalog_snapshot_txns = records
14359        .iter()
14360        .filter_map(|record| {
14361            (committed.contains_key(&record.txn_id)
14362                && matches!(&record.op, Op::Ddl(DdlOp::CatalogSnapshot { .. })))
14363            .then_some(record.txn_id)
14364        })
14365        .collect::<HashSet<_>>();
14366
14367    let mut changed = false;
14368    let mut applied_catalog_epoch = cat.db_epoch;
14369    let max_committed_epoch = committed.values().copied().max().unwrap_or(cat.db_epoch);
14370    for r in records.iter().cloned() {
14371        let Some(&ce) = committed.get(&r.txn_id) else {
14372            continue;
14373        };
14374        let txn_id = r.txn_id;
14375        match r.op {
14376            Op::Ddl(DdlOp::CreateTable {
14377                table_id,
14378                ref name,
14379                ref schema_json,
14380            }) => {
14381                if cat.tables.iter().any(|t| t.table_id == table_id) {
14382                    continue;
14383                }
14384                let schema = DdlOp::decode_schema(schema_json)?;
14385                validate_recovered_schema(&schema)?;
14386                created_table_ids.insert(table_id);
14387                cat.tables.push(CatalogEntry {
14388                    table_id,
14389                    name: name.clone(),
14390                    schema,
14391                    state: TableState::Live,
14392                    created_epoch: ce,
14393                });
14394                cat.next_table_id =
14395                    cat.next_table_id
14396                        .max(table_id.checked_add(1).ok_or_else(|| {
14397                            MongrelError::Full("table id namespace exhausted".into())
14398                        })?);
14399                changed = true;
14400            }
14401            Op::Ddl(DdlOp::CreateBuildingTable {
14402                table_id,
14403                ref build_name,
14404                ref intended_name,
14405                ref query_id,
14406                created_at_unix_nanos,
14407                ref schema_json,
14408            }) => {
14409                if cat.tables.iter().any(|table| table.table_id == table_id) {
14410                    continue;
14411                }
14412                let schema = DdlOp::decode_schema(schema_json)?;
14413                validate_recovered_schema(&schema)?;
14414                created_table_ids.insert(table_id);
14415                cat.tables.push(CatalogEntry {
14416                    table_id,
14417                    name: build_name.clone(),
14418                    schema,
14419                    state: TableState::Building {
14420                        intended_name: intended_name.clone(),
14421                        query_id: query_id.clone(),
14422                        created_at_unix_nanos,
14423                        replaces_table_id: None,
14424                    },
14425                    created_epoch: ce,
14426                });
14427                cat.next_table_id =
14428                    cat.next_table_id
14429                        .max(table_id.checked_add(1).ok_or_else(|| {
14430                            MongrelError::Full("table id namespace exhausted".into())
14431                        })?);
14432                changed = true;
14433            }
14434            Op::Ddl(DdlOp::CreateRebuildingTable {
14435                table_id,
14436                ref build_name,
14437                ref intended_name,
14438                ref query_id,
14439                created_at_unix_nanos,
14440                replaces_table_id,
14441                ref schema_json,
14442            }) => {
14443                if cat.tables.iter().any(|table| table.table_id == table_id) {
14444                    continue;
14445                }
14446                let schema = DdlOp::decode_schema(schema_json)?;
14447                validate_recovered_schema(&schema)?;
14448                created_table_ids.insert(table_id);
14449                cat.tables.push(CatalogEntry {
14450                    table_id,
14451                    name: build_name.clone(),
14452                    schema,
14453                    state: TableState::Building {
14454                        intended_name: intended_name.clone(),
14455                        query_id: query_id.clone(),
14456                        created_at_unix_nanos,
14457                        replaces_table_id: Some(replaces_table_id),
14458                    },
14459                    created_epoch: ce,
14460                });
14461                cat.next_table_id =
14462                    cat.next_table_id
14463                        .max(table_id.checked_add(1).ok_or_else(|| {
14464                            MongrelError::Full("table id namespace exhausted".into())
14465                        })?);
14466                changed = true;
14467            }
14468            Op::Ddl(DdlOp::DropTable { table_id }) => {
14469                let mut dropped_name = None;
14470                if let Some(entry) = cat.tables.iter_mut().find(|t| t.table_id == table_id) {
14471                    if matches!(entry.state, TableState::Live | TableState::Building { .. }) {
14472                        dropped_name = Some(entry.name.clone());
14473                        entry.state = TableState::Dropped { at_epoch: ce };
14474                        changed = true;
14475                    }
14476                }
14477                if let Some(name) = dropped_name {
14478                    let before = cat.materialized_views.len();
14479                    cat.materialized_views
14480                        .retain(|definition| definition.name != name);
14481                    changed |= before != cat.materialized_views.len();
14482                    cat.security.rls_tables.retain(|table| table != &name);
14483                    cat.security.policies.retain(|policy| policy.table != name);
14484                    cat.security.masks.retain(|mask| mask.table != name);
14485                    for role in &mut cat.roles {
14486                        role.permissions
14487                            .retain(|permission| permission_table(permission) != Some(&name));
14488                    }
14489                    if !catalog_snapshot_txns.contains(&txn_id) {
14490                        advance_security_version(cat)?;
14491                    }
14492                }
14493            }
14494            Op::Ddl(DdlOp::PublishBuildingTable {
14495                table_id,
14496                ref new_name,
14497            }) => {
14498                if let Some(entry) = cat
14499                    .tables
14500                    .iter_mut()
14501                    .find(|table| table.table_id == table_id)
14502                {
14503                    if entry.name != *new_name || !matches!(entry.state, TableState::Live) {
14504                        entry.name = new_name.clone();
14505                        entry.state = TableState::Live;
14506                        changed = true;
14507                    }
14508                }
14509            }
14510            Op::Ddl(DdlOp::ReplaceBuildingTable {
14511                table_id,
14512                replaced_table_id,
14513                ref new_name,
14514            }) => {
14515                changed |=
14516                    apply_rebuilding_publish(cat, table_id, replaced_table_id, new_name, ce)?;
14517            }
14518            Op::Ddl(DdlOp::RenameTable {
14519                table_id,
14520                ref new_name,
14521            }) => {
14522                let mut old_name = None;
14523                if let Some(entry) = cat.tables.iter_mut().find(|t| t.table_id == table_id) {
14524                    if entry.name != *new_name {
14525                        old_name = Some(entry.name.clone());
14526                        entry.name = new_name.clone();
14527                        changed = true;
14528                    }
14529                }
14530                if let Some(old_name) = old_name {
14531                    if let Some(definition) = cat
14532                        .materialized_views
14533                        .iter_mut()
14534                        .find(|definition| definition.name == old_name)
14535                    {
14536                        definition.name = new_name.clone();
14537                    }
14538                    for table in &mut cat.security.rls_tables {
14539                        if *table == old_name {
14540                            *table = new_name.clone();
14541                        }
14542                    }
14543                    for policy in &mut cat.security.policies {
14544                        if policy.table == old_name {
14545                            policy.table = new_name.clone();
14546                        }
14547                    }
14548                    for mask in &mut cat.security.masks {
14549                        if mask.table == old_name {
14550                            mask.table = new_name.clone();
14551                        }
14552                    }
14553                    for role in &mut cat.roles {
14554                        for permission in &mut role.permissions {
14555                            rename_permission_table(permission, &old_name, new_name);
14556                        }
14557                    }
14558                    if !catalog_snapshot_txns.contains(&txn_id) {
14559                        advance_security_version(cat)?;
14560                    }
14561                }
14562                // If the entry is absent, its CreateTable was already
14563                // checkpointed carrying the post-rename name, so there is
14564                // nothing to apply — a no-op, not an error.
14565            }
14566            Op::Ddl(DdlOp::AlterTable {
14567                table_id,
14568                ref column_json,
14569            }) => {
14570                let column = DdlOp::decode_column(column_json)?;
14571                let mut renamed = None;
14572                if let Some(entry) = cat.tables.iter_mut().find(|t| t.table_id == table_id) {
14573                    renamed = entry
14574                        .schema
14575                        .columns
14576                        .iter()
14577                        .find(|existing| existing.id == column.id && existing.name != column.name)
14578                        .map(|existing| {
14579                            (
14580                                entry.name.clone(),
14581                                existing.name.clone(),
14582                                column.name.clone(),
14583                            )
14584                        });
14585                    if apply_recovered_column_def(&mut entry.schema, column)? {
14586                        validate_recovered_schema(&entry.schema)?;
14587                        changed = true;
14588                    }
14589                }
14590                if let Some((table, old_name, new_name)) = renamed {
14591                    for role in &mut cat.roles {
14592                        for permission in &mut role.permissions {
14593                            rename_permission_column(permission, &table, &old_name, &new_name);
14594                        }
14595                    }
14596                    if !catalog_snapshot_txns.contains(&txn_id) {
14597                        advance_security_version(cat)?;
14598                    }
14599                }
14600            }
14601            Op::Ddl(DdlOp::SetTtl {
14602                table_id,
14603                ref policy_json,
14604            }) => {
14605                let policy = DdlOp::decode_ttl(policy_json)?;
14606                let entry = cat
14607                    .tables
14608                    .iter()
14609                    .find(|entry| entry.table_id == table_id)
14610                    .ok_or_else(|| {
14611                        MongrelError::Schema(format!(
14612                            "recovered TTL references unknown table id {table_id}"
14613                        ))
14614                    })?;
14615                if let Some(policy) = policy {
14616                    let valid = entry
14617                        .schema
14618                        .columns
14619                        .iter()
14620                        .find(|column| column.id == policy.column_id)
14621                        .is_some_and(|column| {
14622                            column.ty == TypeId::TimestampNanos
14623                                && policy.duration_nanos > 0
14624                                && policy.duration_nanos <= i64::MAX as u64
14625                        });
14626                    if !valid {
14627                        return Err(MongrelError::Schema(format!(
14628                            "invalid recovered TTL policy for table id {table_id}"
14629                        )));
14630                    }
14631                }
14632                ttl_updates.insert(table_id, (policy, ce));
14633            }
14634            Op::Ddl(DdlOp::SetMaterializedView {
14635                ref name,
14636                ref definition_json,
14637            }) => {
14638                let definition = DdlOp::decode_materialized_view(definition_json)?;
14639                if definition.name != *name {
14640                    return Err(MongrelError::Schema(format!(
14641                        "materialized view WAL name mismatch: {name:?}"
14642                    )));
14643                }
14644                if cat.live(name).is_some() {
14645                    if let Some(existing) = cat
14646                        .materialized_views
14647                        .iter_mut()
14648                        .find(|existing| existing.name == *name)
14649                    {
14650                        if *existing != definition {
14651                            *existing = definition;
14652                            changed = true;
14653                        }
14654                    } else {
14655                        cat.materialized_views.push(definition);
14656                        changed = true;
14657                    }
14658                }
14659            }
14660            Op::Ddl(DdlOp::SetSecurityCatalog { ref security_json }) => {
14661                let security = DdlOp::decode_security(security_json)?;
14662                validate_security_catalog(cat, &security)?;
14663                if cat.security != security {
14664                    cat.security = security;
14665                    if !catalog_snapshot_txns.contains(&txn_id) {
14666                        advance_security_version(cat)?;
14667                    }
14668                    changed = true;
14669                }
14670            }
14671            Op::Ddl(DdlOp::SetSqlPragma { ref key, value }) => {
14672                let target = match key.as_str() {
14673                    "user_version" => &mut cat.user_version,
14674                    "application_id" => &mut cat.application_id,
14675                    _ => {
14676                        return Err(MongrelError::InvalidArgument(format!(
14677                            "unsupported recovered SQL pragma {key:?}"
14678                        )))
14679                    }
14680                };
14681                if *target != Some(value) {
14682                    *target = Some(value);
14683                    cat.db_epoch = cat.db_epoch.max(ce);
14684                    changed = true;
14685                }
14686            }
14687            Op::Ddl(DdlOp::CatalogSnapshot { ref catalog_json }) => {
14688                if ce <= applied_catalog_epoch {
14689                    continue;
14690                }
14691                let snapshot = DdlOp::decode_catalog(catalog_json)?;
14692                if snapshot.db_epoch != ce {
14693                    return Err(MongrelError::Schema(format!(
14694                        "catalog snapshot epoch {} does not match WAL commit epoch {ce}",
14695                        snapshot.db_epoch
14696                    )));
14697                }
14698                validate_recovered_catalog(&snapshot)?;
14699                validate_catalog_transition(cat, &snapshot)?;
14700                *cat = snapshot;
14701                applied_catalog_epoch = ce;
14702                changed = true;
14703            }
14704            _ => {}
14705        }
14706    }
14707
14708    if cat.db_epoch < max_committed_epoch {
14709        cat.db_epoch = max_committed_epoch;
14710        changed = true;
14711    }
14712    changed |= repair_catalog_allocator_counters(cat)?;
14713
14714    validate_recovered_catalog(cat)?;
14715    let storage_reconciliation = validate_recovered_storage_plan(
14716        root,
14717        durable_root,
14718        cat,
14719        &created_table_ids,
14720        &ttl_updates,
14721        meta_dek,
14722    )?;
14723
14724    let needs_storage_apply = !storage_reconciliation.is_empty() || !ttl_updates.is_empty();
14725    if apply && (changed || needs_storage_apply) {
14726        for table_id in storage_reconciliation {
14727            let entry = cat
14728                .tables
14729                .iter()
14730                .find(|entry| entry.table_id == table_id)
14731                .ok_or_else(|| MongrelError::CorruptWal {
14732                    offset: table_id,
14733                    reason: "recovery storage plan lost its catalog table".into(),
14734                })?;
14735            ensure_recovered_table_storage(
14736                table_roots
14737                    .and_then(|roots| roots.get(&table_id))
14738                    .map(Arc::as_ref),
14739                durable_root,
14740                &root.join(TABLES_DIR).join(table_id.to_string()),
14741                table_id,
14742                &entry.schema,
14743                meta_dek,
14744            )?;
14745        }
14746        for (table_id, (policy, ttl_epoch)) in ttl_updates {
14747            let Some(entry) = cat.tables.iter().find(|entry| {
14748                entry.table_id == table_id
14749                    && matches!(entry.state, TableState::Live | TableState::Building { .. })
14750            }) else {
14751                continue;
14752            };
14753            let table_root = if let Some(root) = table_roots.and_then(|roots| roots.get(&table_id))
14754            {
14755                root.try_clone()?
14756            } else if let Some(root) = durable_root {
14757                root.open_directory(Path::new(TABLES_DIR).join(table_id.to_string()))?
14758            } else {
14759                crate::durable_file::DurableRoot::open(
14760                    root.join(TABLES_DIR).join(table_id.to_string()),
14761                )?
14762            };
14763            let table_dir = table_root.io_path()?;
14764            let mut manifest = crate::manifest::read_durable(&table_root, "", meta_dek)?;
14765            if manifest.ttl != policy || manifest.current_epoch < ttl_epoch {
14766                manifest.ttl = policy;
14767                manifest.current_epoch = manifest.current_epoch.max(ttl_epoch);
14768                manifest.schema_id = entry.schema.schema_id;
14769                crate::manifest::write_atomic(&table_dir, &mut manifest, meta_dek)?;
14770            }
14771        }
14772        if changed {
14773            match durable_root {
14774                Some(root) => catalog::write_durable(root, cat, meta_dek)?,
14775                None => catalog::write_atomic(root, cat, meta_dek)?,
14776            }
14777        }
14778    }
14779    *target_catalog = recovered_catalog;
14780    Ok(())
14781}
14782
14783fn ensure_recovered_table_storage(
14784    pinned_table: Option<&crate::durable_file::DurableRoot>,
14785    durable_root: Option<&crate::durable_file::DurableRoot>,
14786    fallback_table_dir: &Path,
14787    table_id: u64,
14788    schema: &Schema,
14789    meta_dek: Option<&[u8; META_DEK_LEN]>,
14790) -> Result<()> {
14791    let table_root = if let Some(root) = pinned_table {
14792        root.try_clone()?
14793    } else if let Some(root) = durable_root {
14794        let relative = Path::new(TABLES_DIR).join(table_id.to_string());
14795        match root.open_directory(&relative) {
14796            Ok(table) => table,
14797            Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
14798                root.create_directory_all_pinned(relative)?
14799            }
14800            Err(error) => return Err(error.into()),
14801        }
14802    } else {
14803        crate::durable_file::create_directory_all(fallback_table_dir)?;
14804        crate::durable_file::DurableRoot::open(fallback_table_dir)?
14805    };
14806    let table_dir = table_root.io_path()?;
14807    let mut existing_manifest = match crate::manifest::read_durable(&table_root, "", meta_dek) {
14808        Ok(manifest) => {
14809            if manifest.table_id != table_id {
14810                return Err(MongrelError::Conflict(format!(
14811                    "recovered table directory id mismatch: expected {table_id}, found {}",
14812                    manifest.table_id
14813                )));
14814            }
14815            Some(manifest)
14816        }
14817        Err(MongrelError::Io(error)) if error.kind() == std::io::ErrorKind::NotFound => None,
14818        Err(error) => return Err(error),
14819    };
14820
14821    table_root.create_directory_all(crate::engine::WAL_DIR)?;
14822    table_root.create_directory_all(crate::engine::RUNS_DIR)?;
14823    crate::engine::write_schema(&table_dir, schema)?;
14824
14825    if let Some(mut manifest) = existing_manifest.take() {
14826        if manifest.schema_id != schema.schema_id {
14827            manifest.schema_id = schema.schema_id;
14828            crate::manifest::write_atomic(&table_dir, &mut manifest, meta_dek)?;
14829        }
14830    } else {
14831        // The DB-wide meta DEK is also the per-table manifest meta DEK.
14832        let mut manifest = crate::manifest::Manifest::new(table_id, schema.schema_id);
14833        crate::manifest::write_atomic(&table_dir, &mut manifest, meta_dek)?;
14834    }
14835    Ok(())
14836}
14837
14838fn validate_recovered_schema(schema: &Schema) -> Result<()> {
14839    schema.validate_auto_increment()?;
14840    schema.validate_defaults()?;
14841    schema.validate_ai()?;
14842    let mut column_ids = HashSet::new();
14843    let mut column_names = HashSet::new();
14844    for column in &schema.columns {
14845        if !column_ids.insert(column.id) || !column_names.insert(column.name.as_str()) {
14846            return Err(MongrelError::Schema(
14847                "recovered schema contains duplicate columns".into(),
14848            ));
14849        }
14850        match &column.ty {
14851            TypeId::Decimal128 { precision, scale }
14852                if *precision == 0 || *precision > 38 || scale.unsigned_abs() > *precision =>
14853            {
14854                return Err(MongrelError::Schema(format!(
14855                    "column {:?} has invalid decimal precision or scale",
14856                    column.name
14857                )));
14858            }
14859            TypeId::Enum { variants }
14860                if variants.is_empty()
14861                    || variants.iter().any(String::is_empty)
14862                    || variants.iter().collect::<HashSet<_>>().len() != variants.len() =>
14863            {
14864                return Err(MongrelError::Schema(format!(
14865                    "column {:?} has invalid enum variants",
14866                    column.name
14867                )));
14868            }
14869            _ => {}
14870        }
14871    }
14872    let mut index_names = HashSet::new();
14873    for index in &schema.indexes {
14874        index.validate_options()?;
14875        if index.name.is_empty()
14876            || !index_names.insert(index.name.as_str())
14877            || schema
14878                .columns
14879                .iter()
14880                .all(|column| column.id != index.column_id)
14881        {
14882            return Err(MongrelError::Schema(format!(
14883                "recovered index {:?} references missing column {}",
14884                index.name, index.column_id
14885            )));
14886        }
14887    }
14888    let mut colocated = HashSet::new();
14889    for group in &schema.colocation {
14890        if group.is_empty()
14891            || group.iter().any(|id| !column_ids.contains(id))
14892            || group.iter().any(|id| !colocated.insert(*id))
14893        {
14894            return Err(MongrelError::Schema(
14895                "recovered schema contains invalid column co-location groups".into(),
14896            ));
14897        }
14898    }
14899
14900    let mut constraint_ids = HashSet::new();
14901    let mut constraint_names = HashSet::<String>::new();
14902    let mut validate_constraint_identity = |id: u16, name: &str| -> Result<()> {
14903        if name.is_empty()
14904            || !constraint_ids.insert(id)
14905            || !constraint_names.insert(name.to_owned())
14906        {
14907            return Err(MongrelError::Schema(
14908                "recovered schema contains duplicate or empty constraint identities".into(),
14909            ));
14910        }
14911        Ok(())
14912    };
14913    for unique in &schema.constraints.uniques {
14914        validate_constraint_identity(unique.id, &unique.name)?;
14915        if unique.columns.is_empty()
14916            || unique.columns.iter().any(|id| !column_ids.contains(id))
14917            || unique.columns.iter().collect::<HashSet<_>>().len() != unique.columns.len()
14918        {
14919            return Err(MongrelError::Schema(format!(
14920                "unique constraint {:?} has invalid columns",
14921                unique.name
14922            )));
14923        }
14924    }
14925    for foreign_key in &schema.constraints.foreign_keys {
14926        validate_constraint_identity(foreign_key.id, &foreign_key.name)?;
14927        if foreign_key.ref_table.is_empty()
14928            || foreign_key.columns.is_empty()
14929            || foreign_key.columns.len() != foreign_key.ref_columns.len()
14930            || foreign_key
14931                .columns
14932                .iter()
14933                .any(|id| !column_ids.contains(id))
14934            || foreign_key.columns.iter().collect::<HashSet<_>>().len() != foreign_key.columns.len()
14935            || foreign_key.ref_columns.iter().collect::<HashSet<_>>().len()
14936                != foreign_key.ref_columns.len()
14937        {
14938            return Err(MongrelError::Schema(format!(
14939                "foreign key {:?} has invalid columns",
14940                foreign_key.name
14941            )));
14942        }
14943        if (matches!(foreign_key.on_delete, crate::constraint::FkAction::SetNull)
14944            || matches!(foreign_key.on_update, crate::constraint::FkAction::SetNull))
14945            && foreign_key.columns.iter().any(|id| {
14946                schema
14947                    .columns
14948                    .iter()
14949                    .find(|column| column.id == *id)
14950                    .is_none_or(|column| {
14951                        !column.flags.contains(crate::schema::ColumnFlags::NULLABLE)
14952                    })
14953            })
14954        {
14955            return Err(MongrelError::Schema(format!(
14956                "foreign key {:?} uses SET NULL on a non-nullable column",
14957                foreign_key.name
14958            )));
14959        }
14960    }
14961    for check in &schema.constraints.checks {
14962        validate_constraint_identity(check.id, &check.name)?;
14963        check.expr.validate()?;
14964        validate_check_columns(&check.expr, &column_ids)?;
14965    }
14966    Ok(())
14967}
14968
14969fn validate_check_columns(
14970    expression: &crate::constraint::CheckExpr,
14971    column_ids: &HashSet<u16>,
14972) -> Result<()> {
14973    use crate::constraint::CheckExpr;
14974    match expression {
14975        CheckExpr::Col(id) | CheckExpr::IsNull(id) | CheckExpr::IsNotNull(id) => {
14976            if column_ids.contains(id) {
14977                Ok(())
14978            } else {
14979                Err(MongrelError::Schema(format!(
14980                    "check constraint references unknown column {id}"
14981                )))
14982            }
14983        }
14984        CheckExpr::Regex { col, .. } => {
14985            if column_ids.contains(col) {
14986                Ok(())
14987            } else {
14988                Err(MongrelError::Schema(format!(
14989                    "check constraint references unknown column {col}"
14990                )))
14991            }
14992        }
14993        CheckExpr::Add(left, right)
14994        | CheckExpr::Sub(left, right)
14995        | CheckExpr::Mul(left, right)
14996        | CheckExpr::Div(left, right)
14997        | CheckExpr::Mod(left, right)
14998        | CheckExpr::Eq(left, right)
14999        | CheckExpr::Ne(left, right)
15000        | CheckExpr::Lt(left, right)
15001        | CheckExpr::Le(left, right)
15002        | CheckExpr::Gt(left, right)
15003        | CheckExpr::Ge(left, right)
15004        | CheckExpr::And(left, right)
15005        | CheckExpr::Or(left, right) => {
15006            validate_check_columns(left, column_ids)?;
15007            validate_check_columns(right, column_ids)
15008        }
15009        CheckExpr::Not(inner) => validate_check_columns(inner, column_ids),
15010        CheckExpr::True | CheckExpr::Lit(_) => Ok(()),
15011    }
15012}
15013
15014fn validate_catalog_transition(current: &Catalog, next: &Catalog) -> Result<()> {
15015    for (name, prior, candidate) in [
15016        ("db_epoch", current.db_epoch, next.db_epoch),
15017        ("next_table_id", current.next_table_id, next.next_table_id),
15018        (
15019            "next_segment_no",
15020            current.next_segment_no,
15021            next.next_segment_no,
15022        ),
15023        ("next_user_id", current.next_user_id, next.next_user_id),
15024        (
15025            "security_version",
15026            current.security_version,
15027            next.security_version,
15028        ),
15029    ] {
15030        if candidate < prior {
15031            return Err(MongrelError::Schema(format!(
15032                "catalog snapshot rolls back {name} from {prior} to {candidate}"
15033            )));
15034        }
15035    }
15036    for prior in &current.tables {
15037        let Some(candidate) = next
15038            .tables
15039            .iter()
15040            .find(|entry| entry.table_id == prior.table_id)
15041        else {
15042            return Err(MongrelError::Schema(format!(
15043                "catalog snapshot removes table identity {}",
15044                prior.table_id
15045            )));
15046        };
15047        if candidate.created_epoch != prior.created_epoch
15048            || candidate.schema.schema_id < prior.schema.schema_id
15049            || matches!(prior.state, TableState::Dropped { .. })
15050                && !matches!(candidate.state, TableState::Dropped { .. })
15051        {
15052            return Err(MongrelError::Schema(format!(
15053                "catalog snapshot rolls back table identity {}",
15054                prior.table_id
15055            )));
15056        }
15057    }
15058    for prior in &current.users {
15059        if let Some(candidate) = next.users.iter().find(|user| user.id == prior.id) {
15060            if candidate.username != prior.username
15061                || candidate.created_epoch != prior.created_epoch
15062            {
15063                return Err(MongrelError::Schema(format!(
15064                    "catalog snapshot reuses user identity {}",
15065                    prior.id
15066                )));
15067            }
15068        }
15069    }
15070    Ok(())
15071}
15072
15073fn validate_recovered_catalog(catalog: &Catalog) -> Result<()> {
15074    let mut table_ids = HashSet::new();
15075    let mut active_names = HashSet::new();
15076    let mut max_table_id = None::<u64>;
15077    for entry in &catalog.tables {
15078        if !table_ids.insert(entry.table_id) {
15079            return Err(MongrelError::Schema(format!(
15080                "catalog contains duplicate table id {}",
15081                entry.table_id
15082            )));
15083        }
15084        max_table_id = Some(max_table_id.map_or(entry.table_id, |value| value.max(entry.table_id)));
15085        if entry.name.is_empty() || entry.created_epoch > catalog.db_epoch {
15086            return Err(MongrelError::Schema(format!(
15087                "catalog table {} has invalid name or creation epoch",
15088                entry.table_id
15089            )));
15090        }
15091        validate_recovered_schema(&entry.schema)?;
15092        match &entry.state {
15093            TableState::Live => {
15094                if !active_names.insert(entry.name.as_str()) {
15095                    return Err(MongrelError::Schema(format!(
15096                        "catalog contains duplicate active table name {:?}",
15097                        entry.name
15098                    )));
15099                }
15100            }
15101            TableState::Dropped { at_epoch } => {
15102                if *at_epoch < entry.created_epoch || *at_epoch > catalog.db_epoch {
15103                    return Err(MongrelError::Schema(format!(
15104                        "catalog table {} has invalid drop epoch {at_epoch}",
15105                        entry.table_id
15106                    )));
15107                }
15108            }
15109            TableState::Building {
15110                intended_name,
15111                query_id,
15112                replaces_table_id,
15113                ..
15114            } => {
15115                if intended_name.is_empty() || query_id.is_empty() {
15116                    return Err(MongrelError::Schema(format!(
15117                        "building table {} has empty identity fields",
15118                        entry.table_id
15119                    )));
15120                }
15121                if !active_names.insert(entry.name.as_str()) {
15122                    return Err(MongrelError::Schema(format!(
15123                        "catalog contains duplicate active/building table name {:?}",
15124                        entry.name
15125                    )));
15126                }
15127                if replaces_table_id.is_some_and(|id| id == entry.table_id) {
15128                    return Err(MongrelError::Schema(
15129                        "building table cannot replace itself".into(),
15130                    ));
15131                }
15132            }
15133        }
15134    }
15135    if let Some(maximum) = max_table_id {
15136        let required = maximum
15137            .checked_add(1)
15138            .ok_or_else(|| MongrelError::Full("table id namespace exhausted".into()))?;
15139        if catalog.next_table_id < required {
15140            return Err(MongrelError::Schema(format!(
15141                "catalog next_table_id {} precedes required {required}",
15142                catalog.next_table_id
15143            )));
15144        }
15145    }
15146    for entry in &catalog.tables {
15147        if let TableState::Building {
15148            replaces_table_id: Some(replaced),
15149            ..
15150        } = entry.state
15151        {
15152            if !table_ids.contains(&replaced) {
15153                return Err(MongrelError::Schema(format!(
15154                    "building table {} replaces unknown table {replaced}",
15155                    entry.table_id
15156                )));
15157            }
15158        }
15159    }
15160    for entry in &catalog.tables {
15161        if matches!(entry.state, TableState::Live | TableState::Building { .. }) {
15162            validate_foreign_key_targets(catalog, &entry.schema)?;
15163        }
15164    }
15165
15166    let mut external_names = HashSet::new();
15167    for entry in &catalog.external_tables {
15168        entry.validate()?;
15169        validate_recovered_schema(&entry.declared_schema)?;
15170        if !entry.declared_schema.constraints.is_empty() {
15171            return Err(MongrelError::Schema(format!(
15172                "external table {:?} cannot carry engine-enforced constraints",
15173                entry.name
15174            )));
15175        }
15176        if entry.created_epoch > catalog.db_epoch
15177            || !external_names.insert(entry.name.as_str())
15178            || active_names.contains(entry.name.as_str())
15179        {
15180            return Err(MongrelError::Schema(format!(
15181                "invalid or duplicate external table {:?}",
15182                entry.name
15183            )));
15184        }
15185    }
15186
15187    let mut procedure_names = HashSet::new();
15188    for entry in &catalog.procedures {
15189        entry.procedure.validate()?;
15190        if entry.procedure.created_epoch > entry.procedure.updated_epoch
15191            || entry.procedure.updated_epoch > catalog.db_epoch
15192            || !procedure_names.insert(entry.procedure.name.as_str())
15193        {
15194            return Err(MongrelError::Schema(format!(
15195                "invalid or duplicate procedure {:?}",
15196                entry.procedure.name
15197            )));
15198        }
15199        validate_recovered_procedure_references(catalog, &entry.procedure)?;
15200    }
15201
15202    let mut trigger_names = HashSet::new();
15203    for entry in &catalog.triggers {
15204        entry.trigger.validate()?;
15205        if entry.trigger.created_epoch > entry.trigger.updated_epoch
15206            || entry.trigger.updated_epoch > catalog.db_epoch
15207            || !trigger_names.insert(entry.trigger.name.as_str())
15208        {
15209            return Err(MongrelError::Schema(format!(
15210                "invalid or duplicate trigger {:?}",
15211                entry.trigger.name
15212            )));
15213        }
15214        validate_recovered_trigger_references(catalog, &entry.trigger)?;
15215    }
15216
15217    let mut views = HashSet::new();
15218    for view in &catalog.materialized_views {
15219        let target = catalog.live(&view.name).ok_or_else(|| {
15220            MongrelError::Schema(format!(
15221                "materialized view {:?} has no live table",
15222                view.name
15223            ))
15224        })?;
15225        if view.name.is_empty()
15226            || view.query.trim().is_empty()
15227            || view.last_refresh_epoch > catalog.db_epoch
15228            || !views.insert(view.name.as_str())
15229        {
15230            return Err(MongrelError::Schema(format!(
15231                "materialized view {:?} has no unique live table",
15232                view.name
15233            )));
15234        }
15235        if let Some(incremental) = &view.incremental {
15236            let source = catalog.live(&incremental.source_table).ok_or_else(|| {
15237                MongrelError::Schema(format!(
15238                    "materialized view {:?} references missing source {:?}",
15239                    view.name, incremental.source_table
15240                ))
15241            })?;
15242            if source.table_id != incremental.source_table_id
15243                || source
15244                    .schema
15245                    .columns
15246                    .iter()
15247                    .all(|column| column.id != incremental.group_column)
15248            {
15249                return Err(MongrelError::Schema(format!(
15250                    "materialized view {:?} has invalid incremental source",
15251                    view.name
15252                )));
15253            }
15254            let target_ids = target
15255                .schema
15256                .columns
15257                .iter()
15258                .map(|column| column.id)
15259                .collect::<HashSet<_>>();
15260            let mut output_ids = HashSet::new();
15261            let count_outputs = incremental
15262                .outputs
15263                .iter()
15264                .filter(|output| {
15265                    matches!(output.kind, crate::catalog::IncrementalAggregateKind::Count)
15266                })
15267                .count();
15268            if incremental.checkpoint_event_id.is_empty()
15269                || !target_ids.contains(&incremental.group_output_column)
15270                || !target_ids.contains(&incremental.count_output_column)
15271                || incremental.outputs.is_empty()
15272                || count_outputs != 1
15273                || incremental.outputs.iter().any(|output| {
15274                    !target_ids.contains(&output.output_column)
15275                        || output.output_column == incremental.group_output_column
15276                        || !output_ids.insert(output.output_column)
15277                        || matches!(output.kind, crate::catalog::IncrementalAggregateKind::Count)
15278                            && output.output_column != incremental.count_output_column
15279                        || match output.kind {
15280                            crate::catalog::IncrementalAggregateKind::Sum { source_column } => {
15281                                source
15282                                    .schema
15283                                    .columns
15284                                    .iter()
15285                                    .all(|column| column.id != source_column)
15286                            }
15287                            crate::catalog::IncrementalAggregateKind::Count => false,
15288                        }
15289                })
15290            {
15291                return Err(MongrelError::Schema(format!(
15292                    "materialized view {:?} has invalid incremental outputs",
15293                    view.name
15294                )));
15295            }
15296        }
15297    }
15298
15299    validate_security_catalog(catalog, &catalog.security)?;
15300    validate_recovered_auth_catalog(catalog)?;
15301    Ok(())
15302}
15303
15304fn repair_catalog_allocator_counters(catalog: &mut Catalog) -> Result<bool> {
15305    let mut changed = false;
15306    if let Some(maximum) = catalog.tables.iter().map(|entry| entry.table_id).max() {
15307        let required = maximum
15308            .checked_add(1)
15309            .ok_or_else(|| MongrelError::Full("table id namespace exhausted".into()))?;
15310        if catalog.next_table_id < required {
15311            catalog.next_table_id = required;
15312            changed = true;
15313        }
15314    }
15315    if let Some(maximum) = catalog.users.iter().map(|user| user.id).max() {
15316        let required = maximum
15317            .checked_add(1)
15318            .ok_or_else(|| MongrelError::Full("user id namespace exhausted".into()))?;
15319        if catalog.next_user_id < required {
15320            catalog.next_user_id = required;
15321            changed = true;
15322        }
15323    }
15324    Ok(changed)
15325}
15326
15327fn validate_foreign_key_targets(catalog: &Catalog, schema: &Schema) -> Result<()> {
15328    for foreign_key in &schema.constraints.foreign_keys {
15329        let parent = catalog.live(&foreign_key.ref_table).ok_or_else(|| {
15330            MongrelError::Schema(format!(
15331                "foreign key {:?} references unknown live table {:?}",
15332                foreign_key.name, foreign_key.ref_table
15333            ))
15334        })?;
15335        let referenced_unique = parent
15336            .schema
15337            .constraints
15338            .uniques
15339            .iter()
15340            .any(|unique| unique.columns == foreign_key.ref_columns)
15341            || foreign_key.ref_columns.len() == 1
15342                && parent
15343                    .schema
15344                    .primary_key()
15345                    .is_some_and(|column| column.id == foreign_key.ref_columns[0]);
15346        if !referenced_unique {
15347            return Err(MongrelError::Schema(format!(
15348                "foreign key {:?} does not reference a unique key",
15349                foreign_key.name
15350            )));
15351        }
15352        for (local_id, parent_id) in foreign_key.columns.iter().zip(&foreign_key.ref_columns) {
15353            let local = schema.columns.iter().find(|column| column.id == *local_id);
15354            let referenced = parent
15355                .schema
15356                .columns
15357                .iter()
15358                .find(|column| column.id == *parent_id);
15359            if local
15360                .zip(referenced)
15361                .is_none_or(|(local, referenced)| local.ty != referenced.ty)
15362            {
15363                return Err(MongrelError::Schema(format!(
15364                    "foreign key {:?} has missing or incompatible columns",
15365                    foreign_key.name
15366                )));
15367            }
15368        }
15369    }
15370    Ok(())
15371}
15372
15373fn validate_recovered_procedure_references(
15374    catalog: &Catalog,
15375    procedure: &StoredProcedure,
15376) -> Result<()> {
15377    for step in &procedure.body.steps {
15378        let Some(table_name) = step.table() else {
15379            continue;
15380        };
15381        let schema = &catalog
15382            .live(table_name)
15383            .ok_or_else(|| {
15384                MongrelError::Schema(format!(
15385                    "procedure {:?} references unknown table {table_name:?}",
15386                    procedure.name
15387                ))
15388            })?
15389            .schema;
15390        match step {
15391            ProcedureStep::NativeQuery {
15392                conditions,
15393                projection,
15394                ..
15395            } => {
15396                for condition in conditions {
15397                    validate_condition_columns(condition, schema)?;
15398                }
15399                for id in projection.iter().flatten() {
15400                    validate_column_id(*id, schema)?;
15401                }
15402            }
15403            ProcedureStep::Put { cells, .. } => {
15404                for cell in cells {
15405                    validate_column_id(cell.column_id, schema)?;
15406                }
15407            }
15408            ProcedureStep::Upsert {
15409                cells,
15410                update_cells,
15411                ..
15412            } => {
15413                for cell in cells.iter().chain(update_cells.iter().flatten()) {
15414                    validate_column_id(cell.column_id, schema)?;
15415                }
15416            }
15417            ProcedureStep::DeleteByPk { .. } if schema.primary_key().is_none() => {
15418                return Err(MongrelError::Schema(format!(
15419                    "procedure {:?} deletes by primary key on table without one",
15420                    procedure.name
15421                )));
15422            }
15423            ProcedureStep::DeleteByPk { .. }
15424            | ProcedureStep::DeleteRows { .. }
15425            | ProcedureStep::SqlQuery { .. } => {}
15426        }
15427    }
15428    Ok(())
15429}
15430
15431fn validate_recovered_trigger_references(catalog: &Catalog, trigger: &StoredTrigger) -> Result<()> {
15432    let target_schema = match &trigger.target {
15433        TriggerTarget::Table(name) => catalog
15434            .live(name)
15435            .ok_or_else(|| {
15436                MongrelError::Schema(format!(
15437                    "trigger {:?} references unknown table {name:?}",
15438                    trigger.name
15439                ))
15440            })?
15441            .schema
15442            .clone(),
15443        TriggerTarget::View(_) => Schema {
15444            columns: trigger.target_columns.clone(),
15445            ..Schema::default()
15446        },
15447    };
15448    for column in &trigger.update_of {
15449        if target_schema.column(column).is_none() {
15450            return Err(MongrelError::Schema(format!(
15451                "trigger {:?} references unknown UPDATE OF column {column:?}",
15452                trigger.name
15453            )));
15454        }
15455    }
15456    if let Some(expr) = &trigger.when {
15457        validate_trigger_expr(expr, &target_schema, trigger.event)?;
15458    }
15459    let mut selects = HashMap::new();
15460    for step in &trigger.program.steps {
15461        if matches!(step, TriggerStep::SetNew { .. }) && trigger.timing != TriggerTiming::Before {
15462            return Err(MongrelError::Schema(
15463                "SetNew is only valid in BEFORE triggers".into(),
15464            ));
15465        }
15466        validate_trigger_step(step, catalog, &target_schema, trigger.event, &mut selects)?;
15467    }
15468    Ok(())
15469}
15470
15471fn validate_recovered_auth_catalog(catalog: &Catalog) -> Result<()> {
15472    let mut role_names = HashSet::new();
15473    for role in &catalog.roles {
15474        if role.name.is_empty()
15475            || role.created_epoch > catalog.db_epoch
15476            || !role_names.insert(role.name.as_str())
15477        {
15478            return Err(MongrelError::Schema(format!(
15479                "invalid or duplicate role {:?}",
15480                role.name
15481            )));
15482        }
15483        for permission in &role.permissions {
15484            if let Some(table) = permission_table(permission) {
15485                let schema = catalog
15486                    .live(table)
15487                    .map(|entry| &entry.schema)
15488                    .or_else(|| {
15489                        catalog
15490                            .external_tables
15491                            .iter()
15492                            .find(|entry| entry.name == table)
15493                            .map(|entry| &entry.declared_schema)
15494                    })
15495                    .ok_or_else(|| {
15496                        MongrelError::Schema(format!(
15497                            "role {:?} references unknown table {table:?}",
15498                            role.name
15499                        ))
15500                    })?;
15501                let columns = match permission {
15502                    crate::auth::Permission::SelectColumns { columns, .. }
15503                    | crate::auth::Permission::InsertColumns { columns, .. }
15504                    | crate::auth::Permission::UpdateColumns { columns, .. } => Some(columns),
15505                    _ => None,
15506                };
15507                if columns.is_some_and(|columns| {
15508                    columns.is_empty()
15509                        || columns.iter().any(|column| schema.column(column).is_none())
15510                }) {
15511                    return Err(MongrelError::Schema(format!(
15512                        "role {:?} contains invalid column permissions",
15513                        role.name
15514                    )));
15515                }
15516            }
15517        }
15518    }
15519    let mut user_ids = HashSet::new();
15520    let mut usernames = HashSet::new();
15521    let mut maximum_user_id = 0;
15522    for user in &catalog.users {
15523        maximum_user_id = maximum_user_id.max(user.id);
15524        if user.id == 0
15525            || user.username.is_empty()
15526            || user.password_hash.is_empty()
15527            || user.created_epoch > catalog.db_epoch
15528            || !user_ids.insert(user.id)
15529            || !usernames.insert(user.username.as_str())
15530            || user
15531                .roles
15532                .iter()
15533                .any(|role| !role_names.contains(role.as_str()))
15534        {
15535            return Err(MongrelError::Schema(format!(
15536                "invalid or duplicate user {:?}",
15537                user.username
15538            )));
15539        }
15540    }
15541    if !catalog.users.is_empty() && catalog.next_user_id <= maximum_user_id {
15542        return Err(MongrelError::Schema(
15543            "catalog next_user_id does not advance beyond existing user ids".into(),
15544        ));
15545    }
15546    if catalog.require_auth && !catalog.users.iter().any(|user| user.is_admin) {
15547        return Err(MongrelError::Schema(
15548            "authenticated catalog has no administrator".into(),
15549        ));
15550    }
15551    Ok(())
15552}
15553
15554fn validate_recovered_storage_plan(
15555    root: &Path,
15556    durable_root: Option<&crate::durable_file::DurableRoot>,
15557    catalog: &Catalog,
15558    created_table_ids: &HashSet<u64>,
15559    ttl_updates: &HashMap<u64, (Option<crate::manifest::TtlPolicy>, u64)>,
15560    meta_dek: Option<&[u8; META_DEK_LEN]>,
15561) -> Result<Vec<u64>> {
15562    const MAX_SCHEMA_BYTES: u64 = 16 * 1024 * 1024;
15563    let mut reconcile = Vec::new();
15564    for entry in &catalog.tables {
15565        if !matches!(entry.state, TableState::Live | TableState::Building { .. }) {
15566            continue;
15567        }
15568        let relative_dir = Path::new(TABLES_DIR).join(entry.table_id.to_string());
15569        let table_dir = root.join(TABLES_DIR).join(entry.table_id.to_string());
15570        let table_exists = match durable_root {
15571            Some(root) => match root.open_directory(&relative_dir) {
15572                Ok(_) => true,
15573                Err(error) if error.kind() == std::io::ErrorKind::NotFound => false,
15574                Err(error) => return Err(error.into()),
15575            },
15576            None => table_dir.is_dir(),
15577        };
15578        if !table_exists {
15579            if created_table_ids.contains(&entry.table_id) {
15580                reconcile.push(entry.table_id);
15581                continue;
15582            }
15583            return Err(MongrelError::NotFound(format!(
15584                "catalog table {} storage is missing",
15585                entry.table_id
15586            )));
15587        }
15588        let manifest_result = match durable_root {
15589            Some(root) => crate::manifest::read_durable(root, &relative_dir, meta_dek),
15590            None => crate::manifest::read(&table_dir, meta_dek),
15591        };
15592        let manifest = match manifest_result {
15593            Ok(manifest) => manifest,
15594            Err(MongrelError::Io(error))
15595                if created_table_ids.contains(&entry.table_id)
15596                    && error.kind() == std::io::ErrorKind::NotFound =>
15597            {
15598                reconcile.push(entry.table_id);
15599                continue;
15600            }
15601            Err(error) => return Err(error),
15602        };
15603        if manifest.table_id != entry.table_id {
15604            return Err(MongrelError::Conflict(format!(
15605                "catalog table {} storage identity mismatch",
15606                entry.table_id
15607            )));
15608        }
15609        let schema_result = match durable_root {
15610            Some(root) => root
15611                .open_regular(relative_dir.join(crate::engine::SCHEMA_FILENAME))
15612                .map_err(MongrelError::from),
15613            None => crate::durable_file::open_regular_nofollow(
15614                &table_dir.join(crate::engine::SCHEMA_FILENAME),
15615            ),
15616        };
15617        let file = match schema_result {
15618            Ok(file) => file,
15619            Err(MongrelError::Io(error))
15620                if created_table_ids.contains(&entry.table_id)
15621                    && error.kind() == std::io::ErrorKind::NotFound =>
15622            {
15623                reconcile.push(entry.table_id);
15624                continue;
15625            }
15626            Err(error) => return Err(error),
15627        };
15628        let length = file.metadata()?.len();
15629        if length > MAX_SCHEMA_BYTES {
15630            return Err(MongrelError::ResourceLimitExceeded {
15631                resource: "recovered schema bytes",
15632                requested: usize::try_from(length).unwrap_or(usize::MAX),
15633                limit: MAX_SCHEMA_BYTES as usize,
15634            });
15635        }
15636        let disk_schema: Schema = serde_json::from_reader(file.take(MAX_SCHEMA_BYTES + 1))
15637            .map_err(|error| MongrelError::Schema(format!("decode recovered schema: {error}")))?;
15638        if manifest.schema_id != entry.schema.schema_id
15639            || crate::wal::DdlOp::encode_schema(&disk_schema)?
15640                != crate::wal::DdlOp::encode_schema(&entry.schema)?
15641        {
15642            reconcile.push(entry.table_id);
15643        }
15644    }
15645    for table_id in ttl_updates.keys() {
15646        if !catalog.tables.iter().any(|entry| {
15647            entry.table_id == *table_id
15648                && matches!(entry.state, TableState::Live | TableState::Building { .. })
15649        }) {
15650            continue;
15651        }
15652        let relative_dir = Path::new(TABLES_DIR).join(table_id.to_string());
15653        let table_exists = match durable_root {
15654            Some(root) => match root.open_directory(&relative_dir) {
15655                Ok(_) => true,
15656                Err(error) if error.kind() == std::io::ErrorKind::NotFound => false,
15657                Err(error) => return Err(error.into()),
15658            },
15659            None => root.join(&relative_dir).is_dir(),
15660        };
15661        if !table_exists && !created_table_ids.contains(table_id) {
15662            return Err(MongrelError::NotFound(format!(
15663                "TTL recovery table {table_id} storage is missing"
15664            )));
15665        }
15666    }
15667    reconcile.sort_unstable();
15668    reconcile.dedup();
15669    Ok(reconcile)
15670}
15671
15672fn validate_catalog_table_storage(
15673    root: &crate::durable_file::DurableRoot,
15674    catalog: &Catalog,
15675    meta_dek: Option<&[u8; META_DEK_LEN]>,
15676) -> Result<()> {
15677    for entry in &catalog.tables {
15678        if !matches!(entry.state, TableState::Live | TableState::Building { .. }) {
15679            continue;
15680        }
15681        let table_dir = Path::new(TABLES_DIR).join(entry.table_id.to_string());
15682        let manifest = crate::manifest::read_durable(root, &table_dir, meta_dek)?;
15683        if manifest.table_id != entry.table_id || manifest.schema_id != entry.schema.schema_id {
15684            return Err(MongrelError::Conflict(format!(
15685                "catalog table {} storage identity mismatch",
15686                entry.table_id
15687            )));
15688        }
15689        root.open_regular(table_dir.join(crate::engine::SCHEMA_FILENAME))?;
15690    }
15691    Ok(())
15692}
15693
15694fn apply_recovered_column_def(schema: &mut Schema, column: ColumnDef) -> Result<bool> {
15695    match schema.columns.iter_mut().find(|c| c.id == column.id) {
15696        Some(existing) if *existing == column => Ok(false),
15697        Some(existing) => {
15698            *existing = column;
15699            schema.schema_id = schema
15700                .schema_id
15701                .checked_add(1)
15702                .ok_or_else(|| MongrelError::Schema("schema id space exhausted".into()))?;
15703            Ok(true)
15704        }
15705        None => {
15706            schema.columns.push(column);
15707            schema.schema_id = schema
15708                .schema_id
15709                .checked_add(1)
15710                .ok_or_else(|| MongrelError::Schema("schema id space exhausted".into()))?;
15711            Ok(true)
15712        }
15713    }
15714}
15715
15716fn permission_table(permission: &crate::auth::Permission) -> Option<&str> {
15717    use crate::auth::Permission;
15718    match permission {
15719        Permission::Select { table }
15720        | Permission::Insert { table }
15721        | Permission::Update { table }
15722        | Permission::Delete { table }
15723        | Permission::SelectColumns { table, .. }
15724        | Permission::InsertColumns { table, .. }
15725        | Permission::UpdateColumns { table, .. } => Some(table),
15726        Permission::All | Permission::Ddl | Permission::Admin => None,
15727    }
15728}
15729
15730fn apply_rebuilding_publish(
15731    catalog: &mut Catalog,
15732    table_id: u64,
15733    replaced_table_id: u64,
15734    new_name: &str,
15735    epoch: u64,
15736) -> Result<bool> {
15737    let already_published = catalog.tables.iter().any(|entry| {
15738        entry.table_id == table_id
15739            && entry.name == new_name
15740            && matches!(entry.state, TableState::Live)
15741    }) && catalog.tables.iter().any(|entry| {
15742        entry.table_id == replaced_table_id && matches!(entry.state, TableState::Dropped { .. })
15743    });
15744    if already_published {
15745        return Ok(false);
15746    }
15747    let schema = catalog
15748        .tables
15749        .iter()
15750        .find(|entry| entry.table_id == table_id)
15751        .ok_or_else(|| MongrelError::NotFound(format!("table id {table_id} not found")))?
15752        .schema
15753        .clone();
15754    let replaced = catalog
15755        .tables
15756        .iter_mut()
15757        .find(|entry| entry.table_id == replaced_table_id)
15758        .ok_or_else(|| MongrelError::NotFound(format!("table id {replaced_table_id} not found")))?;
15759    replaced.state = TableState::Dropped { at_epoch: epoch };
15760    let replacement = catalog
15761        .tables
15762        .iter_mut()
15763        .find(|entry| entry.table_id == table_id)
15764        .ok_or_else(|| MongrelError::NotFound(format!("table id {table_id} not found")))?;
15765    replacement.name = new_name.to_string();
15766    replacement.state = TableState::Live;
15767
15768    for role in &mut catalog.roles {
15769        role.permissions.retain_mut(|permission| {
15770            retain_rebuilt_permission_columns(permission, new_name, &schema)
15771        });
15772    }
15773    for definition in &mut catalog.materialized_views {
15774        if let Some(incremental) = definition.incremental.as_mut() {
15775            if incremental.source_table == new_name
15776                && incremental.source_table_id == replaced_table_id
15777            {
15778                incremental.source_table_id = table_id;
15779            }
15780        }
15781    }
15782    advance_security_version(catalog)?;
15783    Ok(true)
15784}
15785
15786fn retain_rebuilt_permission_columns(
15787    permission: &mut crate::auth::Permission,
15788    target_table: &str,
15789    schema: &Schema,
15790) -> bool {
15791    use crate::auth::Permission;
15792    let columns = match permission {
15793        Permission::SelectColumns { table, columns }
15794        | Permission::InsertColumns { table, columns }
15795        | Permission::UpdateColumns { table, columns }
15796            if table == target_table =>
15797        {
15798            Some(columns)
15799        }
15800        _ => None,
15801    };
15802    if let Some(columns) = columns {
15803        columns.retain(|column| schema.column(column).is_some());
15804        !columns.is_empty()
15805    } else {
15806        true
15807    }
15808}
15809
15810fn rename_permission_table(permission: &mut crate::auth::Permission, old: &str, new: &str) {
15811    use crate::auth::Permission;
15812    let table = match permission {
15813        Permission::Select { table }
15814        | Permission::Insert { table }
15815        | Permission::Update { table }
15816        | Permission::Delete { table }
15817        | Permission::SelectColumns { table, .. }
15818        | Permission::InsertColumns { table, .. }
15819        | Permission::UpdateColumns { table, .. } => Some(table),
15820        Permission::All | Permission::Ddl | Permission::Admin => None,
15821    };
15822    if let Some(table) = table.filter(|table| table.as_str() == old) {
15823        *table = new.to_string();
15824    }
15825}
15826
15827fn rename_permission_column(
15828    permission: &mut crate::auth::Permission,
15829    target_table: &str,
15830    old: &str,
15831    new: &str,
15832) {
15833    use crate::auth::Permission;
15834    let columns = match permission {
15835        Permission::SelectColumns { table, columns }
15836        | Permission::InsertColumns { table, columns }
15837        | Permission::UpdateColumns { table, columns }
15838            if table == target_table =>
15839        {
15840            Some(columns)
15841        }
15842        _ => None,
15843    };
15844    if let Some(column) = columns
15845        .into_iter()
15846        .flatten()
15847        .find(|column| column.as_str() == old)
15848    {
15849        *column = new.to_string();
15850    }
15851}
15852
15853fn merge_permission(
15854    permissions: &mut Vec<crate::auth::Permission>,
15855    permission: crate::auth::Permission,
15856) {
15857    use crate::auth::Permission;
15858    let (kind, table, mut columns) = match permission {
15859        Permission::SelectColumns { table, columns } => (0, table, columns),
15860        Permission::InsertColumns { table, columns } => (1, table, columns),
15861        Permission::UpdateColumns { table, columns } => (2, table, columns),
15862        permission if !permissions.contains(&permission) => {
15863            permissions.push(permission);
15864            return;
15865        }
15866        _ => return,
15867    };
15868    for permission in permissions.iter_mut() {
15869        let existing = match permission {
15870            Permission::SelectColumns {
15871                table: existing_table,
15872                columns,
15873            } if kind == 0 && existing_table == &table => Some(columns),
15874            Permission::InsertColumns {
15875                table: existing_table,
15876                columns,
15877            } if kind == 1 && existing_table == &table => Some(columns),
15878            Permission::UpdateColumns {
15879                table: existing_table,
15880                columns,
15881            } if kind == 2 && existing_table == &table => Some(columns),
15882            _ => None,
15883        };
15884        if let Some(existing) = existing {
15885            existing.append(&mut columns);
15886            existing.sort();
15887            existing.dedup();
15888            return;
15889        }
15890    }
15891    columns.sort();
15892    columns.dedup();
15893    let permission = if kind == 0 {
15894        Permission::SelectColumns { table, columns }
15895    } else if kind == 1 {
15896        Permission::InsertColumns { table, columns }
15897    } else {
15898        Permission::UpdateColumns { table, columns }
15899    };
15900    permissions.push(permission);
15901}
15902
15903fn revoke_permission_from(
15904    permissions: &mut Vec<crate::auth::Permission>,
15905    revoked: &crate::auth::Permission,
15906) {
15907    use crate::auth::Permission;
15908    let revoked_columns = match revoked {
15909        Permission::SelectColumns { table, columns } => Some((0, table, columns)),
15910        Permission::InsertColumns { table, columns } => Some((1, table, columns)),
15911        Permission::UpdateColumns { table, columns } => Some((2, table, columns)),
15912        _ => None,
15913    };
15914    let Some((kind, table, columns)) = revoked_columns else {
15915        permissions.retain(|permission| permission != revoked);
15916        return;
15917    };
15918    for permission in permissions.iter_mut() {
15919        let current = match permission {
15920            Permission::SelectColumns {
15921                table: current_table,
15922                columns,
15923            } if kind == 0 && current_table == table => Some(columns),
15924            Permission::InsertColumns {
15925                table: current_table,
15926                columns,
15927            } if kind == 1 && current_table == table => Some(columns),
15928            Permission::UpdateColumns {
15929                table: current_table,
15930                columns,
15931            } if kind == 2 && current_table == table => Some(columns),
15932            _ => None,
15933        };
15934        if let Some(current) = current {
15935            current.retain(|column| !columns.contains(column));
15936        }
15937    }
15938    permissions.retain(|permission| match permission {
15939        Permission::SelectColumns { columns, .. }
15940        | Permission::InsertColumns { columns, .. }
15941        | Permission::UpdateColumns { columns, .. } => !columns.is_empty(),
15942        _ => true,
15943    });
15944}
15945
15946fn validate_security_catalog(
15947    catalog: &Catalog,
15948    security: &crate::security::SecurityCatalog,
15949) -> Result<()> {
15950    let mut policy_names = HashSet::new();
15951    for table in &security.rls_tables {
15952        if catalog.live(table).is_none() {
15953            return Err(MongrelError::NotFound(format!(
15954                "RLS table {table:?} not found"
15955            )));
15956        }
15957    }
15958    for policy in &security.policies {
15959        if !policy_names.insert((policy.table.clone(), policy.name.clone())) {
15960            return Err(MongrelError::InvalidArgument(format!(
15961                "duplicate policy {:?} on {:?}",
15962                policy.name, policy.table
15963            )));
15964        }
15965        let schema = &catalog
15966            .live(&policy.table)
15967            .ok_or_else(|| {
15968                MongrelError::NotFound(format!("policy table {:?} not found", policy.table))
15969            })?
15970            .schema;
15971        if let Some(expression) = &policy.using {
15972            validate_security_expression(expression, schema)?;
15973        }
15974        if let Some(expression) = &policy.with_check {
15975            validate_security_expression(expression, schema)?;
15976        }
15977    }
15978    let mut mask_names = HashSet::new();
15979    for mask in &security.masks {
15980        if !mask_names.insert((mask.table.clone(), mask.name.clone())) {
15981            return Err(MongrelError::InvalidArgument(format!(
15982                "duplicate mask {:?} on {:?}",
15983                mask.name, mask.table
15984            )));
15985        }
15986        let column = catalog
15987            .live(&mask.table)
15988            .and_then(|entry| {
15989                entry
15990                    .schema
15991                    .columns
15992                    .iter()
15993                    .find(|column| column.id == mask.column)
15994            })
15995            .ok_or_else(|| {
15996                MongrelError::NotFound(format!(
15997                    "mask column {} on {:?} not found",
15998                    mask.column, mask.table
15999                ))
16000            })?;
16001        if matches!(
16002            mask.strategy,
16003            crate::security::MaskStrategy::Redact { .. } | crate::security::MaskStrategy::Sha256
16004        ) && !matches!(column.ty, TypeId::Bytes | TypeId::Enum { .. })
16005        {
16006            return Err(MongrelError::InvalidArgument(format!(
16007                "mask {:?} requires a string/bytes column",
16008                mask.name
16009            )));
16010        }
16011    }
16012    Ok(())
16013}
16014
16015fn validate_security_expression(
16016    expression: &crate::security::SecurityExpr,
16017    schema: &Schema,
16018) -> Result<()> {
16019    use crate::security::SecurityExpr;
16020    match expression {
16021        SecurityExpr::True => Ok(()),
16022        SecurityExpr::ColumnEqCurrentUser { column }
16023        | SecurityExpr::ColumnEqValue { column, .. } => {
16024            if schema
16025                .columns
16026                .iter()
16027                .any(|candidate| candidate.id == *column)
16028            {
16029                Ok(())
16030            } else {
16031                Err(MongrelError::InvalidArgument(format!(
16032                    "security expression references unknown column id {column}"
16033                )))
16034            }
16035        }
16036        SecurityExpr::And { left, right } | SecurityExpr::Or { left, right } => {
16037            validate_security_expression(left, schema)?;
16038            validate_security_expression(right, schema)
16039        }
16040        SecurityExpr::Not { expression } => validate_security_expression(expression, schema),
16041    }
16042}
16043
16044/// Remove canonical numeric table directories that no catalog generation owns.
16045fn sweep_unreferenced_table_dirs(root: &Path, cat: &Catalog) -> Result<()> {
16046    let referenced = cat
16047        .tables
16048        .iter()
16049        .filter(|entry| matches!(entry.state, TableState::Live | TableState::Building { .. }))
16050        .map(|entry| entry.table_id)
16051        .collect::<HashSet<_>>();
16052    let tables_dir = root.join(TABLES_DIR);
16053    let entries = match std::fs::read_dir(&tables_dir) {
16054        Ok(entries) => entries,
16055        Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(()),
16056        Err(error) => return Err(error.into()),
16057    };
16058    for entry in entries {
16059        let entry = entry?;
16060        if !entry.file_type()?.is_dir() {
16061            continue;
16062        }
16063        let file_name = entry.file_name();
16064        let Some(name) = file_name.to_str() else {
16065            continue;
16066        };
16067        let Ok(table_id) = name.parse::<u64>() else {
16068            continue;
16069        };
16070        if name != table_id.to_string() {
16071            continue;
16072        }
16073        if !referenced.contains(&table_id) {
16074            crate::durable_file::remove_directory_all(&entry.path())?;
16075        }
16076    }
16077    Ok(())
16078}
16079
16080/// Sweep stale `_txn/<txn_id>/` dirs from every table (spec §8.5, review fix
16081/// #14). These dirs hold pending uniform-epoch runs from large transactions
16082/// that were aborted or crashed before commit. On open, all such dirs are safe
16083/// to remove because committed txns moved their runs to `_runs/` at publish.
16084fn sweep_pending_txn_dirs(root: &Path, cat: &Catalog) {
16085    for entry in &cat.tables {
16086        let txn_dir = root
16087            .join(TABLES_DIR)
16088            .join(entry.table_id.to_string())
16089            .join("_txn");
16090        if txn_dir.exists() {
16091            let _ = std::fs::remove_dir_all(&txn_dir);
16092        }
16093    }
16094}
16095
16096#[cfg(test)]
16097mod write_permission_tests {
16098    use super::*;
16099    use crate::txn::Staged;
16100
16101    struct NoopExternalBridge;
16102
16103    impl ExternalTriggerBridge for NoopExternalBridge {
16104        fn apply_trigger_external_write(
16105            &self,
16106            _entry: &ExternalTableEntry,
16107            base_state: Vec<u8>,
16108            _op: ExternalTriggerWrite,
16109        ) -> Result<ExternalTriggerWriteResult> {
16110            Ok(ExternalTriggerWriteResult::new(base_state))
16111        }
16112    }
16113
16114    fn assert_txn_namespace_full<T>(result: Result<T>) {
16115        assert!(matches!(result, Err(MongrelError::Full(_))));
16116    }
16117
16118    #[test]
16119    fn every_begin_api_preserves_transaction_id_exhaustion_without_wal_mutation() {
16120        let directory = tempfile::tempdir().unwrap();
16121        let database = Database::create(directory.path()).unwrap();
16122        let generation = (*database.next_txn_id.lock() >> 32).saturating_add(1);
16123        *database.next_txn_id.lock() = generation << 32;
16124        let before = crate::wal::SharedWal::replay(directory.path())
16125            .unwrap()
16126            .len();
16127        let bridge = NoopExternalBridge;
16128
16129        assert_txn_namespace_full(database.begin().commit());
16130        assert_txn_namespace_full(database.begin_as(None).commit_with_row_ids());
16131        assert_txn_namespace_full(
16132            database
16133                .begin_with_isolation(crate::txn::IsolationLevel::Serializable)
16134                .commit(),
16135        );
16136        assert_txn_namespace_full(
16137            database
16138                .begin_with_external_trigger_bridge(&bridge)
16139                .commit(),
16140        );
16141        assert_txn_namespace_full(
16142            database
16143                .begin_with_external_trigger_bridge_as(&bridge, None)
16144                .commit_controlled(&crate::ExecutionControl::new(None), || Ok(())),
16145        );
16146
16147        assert_eq!(
16148            crate::wal::SharedWal::replay(directory.path())
16149                .unwrap()
16150                .len(),
16151            before
16152        );
16153        drop(database);
16154        Database::open(directory.path()).unwrap();
16155    }
16156
16157    #[test]
16158    fn recovered_storage_identity_mismatch_does_not_mutate_directory() {
16159        let directory = tempfile::tempdir().unwrap();
16160        let table_dir = directory.path().join("7");
16161        crate::durable_file::create_directory_all(&table_dir).unwrap();
16162        let original_schema = test_schema();
16163        crate::engine::write_schema(&table_dir, &original_schema).unwrap();
16164        let mut manifest = crate::manifest::Manifest::new(8, original_schema.schema_id);
16165        crate::manifest::write_atomic(&table_dir, &mut manifest, None).unwrap();
16166        let schema_path = table_dir.join(crate::engine::SCHEMA_FILENAME);
16167        let original_bytes = std::fs::read(&schema_path).unwrap();
16168
16169        let mut replacement_schema = original_schema;
16170        replacement_schema.schema_id += 1;
16171        assert!(matches!(
16172            ensure_recovered_table_storage(None, None, &table_dir, 7, &replacement_schema, None,),
16173            Err(MongrelError::Conflict(_))
16174        ));
16175
16176        assert_eq!(std::fs::read(schema_path).unwrap(), original_bytes);
16177        assert!(!table_dir.join(crate::engine::WAL_DIR).exists());
16178        assert!(!table_dir.join(crate::engine::RUNS_DIR).exists());
16179        assert_eq!(crate::manifest::read(&table_dir, None).unwrap().table_id, 8);
16180    }
16181
16182    #[test]
16183    fn catalog_table_missing_storage_fails_without_recreating_it() {
16184        let directory = tempfile::tempdir().unwrap();
16185        let table_dir = {
16186            let database = Database::create(directory.path()).unwrap();
16187            database.create_table("docs", test_schema()).unwrap();
16188            directory
16189                .path()
16190                .join(TABLES_DIR)
16191                .join(database.table_id("docs").unwrap().to_string())
16192        };
16193        std::fs::remove_dir_all(&table_dir).unwrap();
16194
16195        assert!(matches!(
16196            Database::open(directory.path()),
16197            Err(MongrelError::NotFound(_))
16198        ));
16199        assert!(!table_dir.exists());
16200    }
16201
16202    #[test]
16203    fn authentication_and_principal_resolution_share_one_catalog_snapshot() {
16204        let directory = tempfile::tempdir().unwrap();
16205        let database = std::sync::Arc::new(
16206            Database::create_with_credentials(directory.path(), "admin", "admin-password").unwrap(),
16207        );
16208        database.create_user("alice", "old-password").unwrap();
16209        let old_identity = database.user_identity("alice").unwrap();
16210        let (verified_tx, verified_rx) = std::sync::mpsc::channel();
16211        let (resume_tx, resume_rx) = std::sync::mpsc::channel();
16212        let (mutation_started_tx, mutation_started_rx) = std::sync::mpsc::channel();
16213        let (mutation_done_tx, mutation_done_rx) = std::sync::mpsc::channel();
16214
16215        std::thread::scope(|scope| {
16216            let authenticate = {
16217                let database = std::sync::Arc::clone(&database);
16218                scope.spawn(move || {
16219                    database.authenticate_principal_inner("alice", "old-password", || {
16220                        verified_tx.send(()).unwrap();
16221                        resume_rx.recv().unwrap();
16222                    })
16223                })
16224            };
16225            verified_rx.recv().unwrap();
16226            let mutate = {
16227                let database = std::sync::Arc::clone(&database);
16228                scope.spawn(move || {
16229                    mutation_started_tx.send(()).unwrap();
16230                    database.drop_user("alice").unwrap();
16231                    database.create_user("alice", "new-password").unwrap();
16232                    mutation_done_tx.send(()).unwrap();
16233                })
16234            };
16235            mutation_started_rx.recv().unwrap();
16236            assert!(mutation_done_rx
16237                .recv_timeout(std::time::Duration::from_millis(50))
16238                .is_err());
16239            resume_tx.send(()).unwrap();
16240            let principal = authenticate.join().unwrap().unwrap().unwrap();
16241            assert_eq!((principal.user_id, principal.created_epoch), old_identity);
16242            mutate.join().unwrap();
16243        });
16244
16245        assert_ne!(database.user_identity("alice").unwrap(), old_identity);
16246        assert!(database
16247            .authenticate_principal("alice", "old-password")
16248            .unwrap()
16249            .is_none());
16250        assert!(database
16251            .authenticate_principal("alice", "new-password")
16252            .unwrap()
16253            .is_some());
16254    }
16255
16256    #[test]
16257    fn homogeneous_batch_summarizes_to_one_permission_decision() {
16258        let staging = (0..10_050)
16259            .map(|_| {
16260                (
16261                    7,
16262                    Staged::Put(vec![(2, Value::Int64(2)), (1, Value::Int64(1))]),
16263                )
16264            })
16265            .collect::<Vec<_>>();
16266
16267        let needs = summarize_write_permissions(&staging);
16268        let table = needs.get(&7).unwrap();
16269        assert_eq!(needs.len(), 1);
16270        assert!(table.insert);
16271        assert_eq!(table.insert_columns, [1, 2]);
16272        assert!(!table.update);
16273        assert!(!table.delete);
16274        assert!(!table.truncate);
16275    }
16276
16277    #[test]
16278    fn mixed_writes_union_columns_and_preserve_empty_operations() {
16279        let staging = vec![
16280            (7, Staged::Put(vec![(2, Value::Int64(2))])),
16281            (7, Staged::Put(vec![(1, Value::Int64(1))])),
16282            (
16283                7,
16284                Staged::Update {
16285                    row_id: RowId(1),
16286                    new_row: vec![(1, Value::Int64(1)), (2, Value::Int64(2))],
16287                    changed_columns: vec![2],
16288                },
16289            ),
16290            (7, Staged::Delete(RowId(2))),
16291            (8, Staged::Truncate),
16292        ];
16293
16294        let needs = summarize_write_permissions(&staging);
16295        let table = needs.get(&7).unwrap();
16296        assert_eq!(table.insert_columns, [1, 2]);
16297        assert!(table.update);
16298        assert_eq!(table.update_columns, [2]);
16299        assert!(table.delete);
16300        assert!(needs.get(&8).unwrap().truncate);
16301    }
16302
16303    #[test]
16304    fn final_permission_decisions_do_not_scale_with_rows() {
16305        let credentialless_dir = tempfile::tempdir().unwrap();
16306        let credentialless = Database::create(credentialless_dir.path()).unwrap();
16307        credentialless.create_table("docs", test_schema()).unwrap();
16308        WRITE_PERMISSION_DECISIONS.with(|decisions| decisions.set(0));
16309        credentialless
16310            .validate_write_permissions(&puts(credentialless.table_id("docs").unwrap()), None, None)
16311            .unwrap();
16312        WRITE_PERMISSION_DECISIONS.with(|decisions| assert_eq!(decisions.get(), 0));
16313
16314        let authenticated_dir = tempfile::tempdir().unwrap();
16315        let authenticated =
16316            Database::create_with_credentials(authenticated_dir.path(), "admin", "admin-password")
16317                .unwrap();
16318        authenticated.create_table("docs", test_schema()).unwrap();
16319        let admin = authenticated.resolve_principal("admin").unwrap();
16320        WRITE_PERMISSION_DECISIONS.with(|decisions| decisions.set(0));
16321        authenticated
16322            .validate_write_permissions(
16323                &puts(authenticated.table_id("docs").unwrap()),
16324                Some(&admin),
16325                None,
16326            )
16327            .unwrap();
16328        WRITE_PERMISSION_DECISIONS.with(|decisions| assert_eq!(decisions.get(), 1));
16329    }
16330
16331    #[test]
16332    fn delete_batch_checks_permission_once_when_staged_and_once_when_committed() {
16333        let dir = tempfile::tempdir().unwrap();
16334        let db = Database::create_with_credentials(dir.path(), "admin", "admin-password").unwrap();
16335        db.create_table("docs", test_schema()).unwrap();
16336        let admin = db.resolve_principal("admin").unwrap();
16337        TABLE_PERMISSION_DECISIONS.with(|decisions| decisions.set(0));
16338
16339        let mut transaction = db.begin_as(Some(admin));
16340        transaction
16341            .delete_batch("docs", (0..100).map(RowId).collect())
16342            .unwrap();
16343        transaction.commit().unwrap();
16344
16345        TABLE_PERMISSION_DECISIONS.with(|decisions| assert_eq!(decisions.get(), 2));
16346    }
16347
16348    #[test]
16349    fn truncate_validation_checks_admin_once_for_all_tables() {
16350        let dir = tempfile::tempdir().unwrap();
16351        let db = Database::create_with_credentials(dir.path(), "admin", "admin-password").unwrap();
16352        db.create_table("first", test_schema()).unwrap();
16353        db.create_table("second", test_schema()).unwrap();
16354        let admin = db.resolve_principal("admin").unwrap();
16355        let staging = vec![
16356            (db.table_id("first").unwrap(), Staged::Truncate),
16357            (db.table_id("second").unwrap(), Staged::Truncate),
16358        ];
16359
16360        TABLE_PERMISSION_DECISIONS.with(|decisions| decisions.set(0));
16361        db.validate_write_permissions(&staging, Some(&admin), None)
16362            .unwrap();
16363        TABLE_PERMISSION_DECISIONS.with(|decisions| assert_eq!(decisions.get(), 1));
16364    }
16365
16366    #[test]
16367    fn one_table_commit_batches_structural_work() {
16368        let dir = tempfile::tempdir().unwrap();
16369        let db = Database::create(dir.path()).unwrap();
16370        db.create_table("docs", test_schema()).unwrap();
16371        let table_id = db.table_id("docs").unwrap();
16372
16373        AUTO_INCREMENT_TABLE_LOCKS.with(|count| count.set(0));
16374        PREBUILD_TABLE_LOCKS.with(|count| count.set(0));
16375        PUBLISH_TABLE_LOCKS.with(|count| count.set(0));
16376        COMMIT_MANIFEST_WRITES.with(|count| count.set(0));
16377        db.transaction(|transaction| {
16378            for id in 0..100 {
16379                transaction.put("docs", vec![(1, Value::Int64(id))])?;
16380            }
16381            Ok(())
16382        })
16383        .unwrap();
16384
16385        AUTO_INCREMENT_TABLE_LOCKS.with(|count| assert_eq!(count.get(), 2));
16386        PREBUILD_TABLE_LOCKS.with(|count| assert_eq!(count.get(), 1));
16387        PUBLISH_TABLE_LOCKS.with(|count| assert_eq!(count.get(), 1));
16388        COMMIT_MANIFEST_WRITES.with(|count| assert_eq!(count.get(), 1));
16389
16390        let puts = crate::wal::SharedWal::replay(dir.path())
16391            .unwrap()
16392            .into_iter()
16393            .filter_map(|record| match record.op {
16394                crate::wal::Op::Put { table_id: id, rows } if id == table_id => Some(
16395                    bincode::deserialize::<Vec<crate::memtable::Row>>(&rows)
16396                        .unwrap()
16397                        .len(),
16398                ),
16399                _ => None,
16400            })
16401            .collect::<Vec<_>>();
16402        assert_eq!(puts, [100]);
16403
16404        let row_ids = db
16405            .table("docs")
16406            .unwrap()
16407            .lock()
16408            .visible_rows(db.snapshot().0)
16409            .unwrap()
16410            .into_iter()
16411            .take(2)
16412            .map(|row| row.row_id)
16413            .collect::<Vec<_>>();
16414        PREBUILD_TABLE_LOCKS.with(|count| count.set(0));
16415        PUBLISH_TABLE_LOCKS.with(|count| count.set(0));
16416        COMMIT_MANIFEST_WRITES.with(|count| count.set(0));
16417        db.transaction(|transaction| {
16418            for row_id in row_ids {
16419                transaction.delete("docs", row_id)?;
16420            }
16421            Ok(())
16422        })
16423        .unwrap();
16424        PREBUILD_TABLE_LOCKS.with(|count| assert_eq!(count.get(), 1));
16425        PUBLISH_TABLE_LOCKS.with(|count| assert_eq!(count.get(), 1));
16426        COMMIT_MANIFEST_WRITES.with(|count| assert_eq!(count.get(), 1));
16427
16428        let deletes = crate::wal::SharedWal::replay(dir.path())
16429            .unwrap()
16430            .into_iter()
16431            .filter_map(|record| match record.op {
16432                crate::wal::Op::Delete {
16433                    table_id: id,
16434                    row_ids,
16435                } if id == table_id => Some(row_ids.len()),
16436                _ => None,
16437            })
16438            .collect::<Vec<_>>();
16439        assert_eq!(deletes, [2]);
16440    }
16441
16442    fn puts(table_id: u64) -> Vec<(u64, Staged)> {
16443        (0..10_050)
16444            .map(|id| (table_id, Staged::Put(vec![(1, Value::Int64(id))])))
16445            .collect()
16446    }
16447
16448    fn test_schema() -> Schema {
16449        Schema {
16450            columns: vec![ColumnDef {
16451                id: 1,
16452                name: "id".into(),
16453                ty: TypeId::Int64,
16454                flags: crate::schema::ColumnFlags::empty()
16455                    .with(crate::schema::ColumnFlags::PRIMARY_KEY),
16456                default_value: None,
16457            }],
16458            ..Schema::default()
16459        }
16460    }
16461}
16462
16463#[cfg(test)]
16464mod cdc_bounds_tests {
16465    use super::*;
16466
16467    #[test]
16468    fn retained_byte_limit_rejects_without_allocating_payload() {
16469        let mut retained = 0;
16470        let error = charge_cdc_bytes(
16471            &mut retained,
16472            CDC_MAX_RETAINED_BYTES.saturating_add(1),
16473            "CDC retained bytes",
16474        )
16475        .unwrap_err();
16476        assert!(matches!(
16477            error,
16478            MongrelError::ResourceLimitExceeded {
16479                resource: "CDC retained bytes",
16480                ..
16481            }
16482        ));
16483    }
16484
16485    #[test]
16486    fn row_json_estimate_accounts_for_byte_array_expansion() {
16487        let row = crate::memtable::Row::new(RowId(1), Epoch(1))
16488            .with_column(1, Value::Bytes(vec![0; 1024]));
16489        assert!(cdc_row_json_bytes(&row) >= 1024 * std::mem::size_of::<serde_json::Value>());
16490    }
16491}
16492
16493#[cfg(test)]
16494mod generation_metrics_tests {
16495    use super::*;
16496    use crate::schema::{ColumnDef, ColumnFlags, Schema, TypeId};
16497
16498    #[test]
16499    fn legacy_cow_fallback_is_measured() {
16500        let dir = tempfile::tempdir().unwrap();
16501        let table = Table::create(
16502            dir.path(),
16503            Schema {
16504                columns: vec![ColumnDef {
16505                    id: 1,
16506                    name: "id".into(),
16507                    ty: TypeId::Int64,
16508                    flags: ColumnFlags::empty().with(ColumnFlags::PRIMARY_KEY),
16509                    default_value: None,
16510                }],
16511                ..Schema::default()
16512            },
16513            1,
16514        )
16515        .unwrap();
16516        let handle = TableHandle::from_table(table);
16517        let held = match &handle.inner {
16518            TableHandleInner::CopyOnWrite(slot) => Arc::clone(&slot.read()),
16519            TableHandleInner::Direct(_) => unreachable!(),
16520        };
16521
16522        handle.lock().set_sync_byte_threshold(1);
16523
16524        let stats = handle.generation_stats();
16525        assert_eq!(stats.cow_clone_count, 1);
16526        assert!(stats.estimated_cow_clone_bytes > 0);
16527        drop(held);
16528    }
16529}
16530
16531#[cfg(test)]
16532mod trigger_engine_tests {
16533    use super::*;
16534
16535    fn event_with(new_cells: &[(u16, Value)], old_cells: &[(u16, Value)]) -> WriteEvent {
16536        WriteEvent {
16537            table: "test".into(),
16538            kind: TriggerEvent::Insert,
16539            new: Some(TriggerRowImage {
16540                columns: new_cells.iter().cloned().collect(),
16541            }),
16542            old: Some(TriggerRowImage {
16543                columns: old_cells.iter().cloned().collect(),
16544            }),
16545            changed_columns: Vec::new(),
16546            op_indices: Vec::new(),
16547            put_idx: None,
16548            trigger_stack: Vec::new(),
16549        }
16550    }
16551
16552    fn event_insert(new_cells: &[(u16, Value)]) -> WriteEvent {
16553        WriteEvent {
16554            table: "test".into(),
16555            kind: TriggerEvent::Insert,
16556            new: Some(TriggerRowImage {
16557                columns: new_cells.iter().cloned().collect(),
16558            }),
16559            old: None,
16560            changed_columns: Vec::new(),
16561            op_indices: Vec::new(),
16562            put_idx: None,
16563            trigger_stack: Vec::new(),
16564        }
16565    }
16566
16567    #[test]
16568    fn value_order_int64_vs_float64() {
16569        assert_eq!(
16570            value_order(&Value::Int64(5), &Value::Float64(5.0)),
16571            Some(std::cmp::Ordering::Equal)
16572        );
16573        assert_eq!(
16574            value_order(&Value::Int64(5), &Value::Float64(3.0)),
16575            Some(std::cmp::Ordering::Greater)
16576        );
16577        assert_eq!(
16578            value_order(&Value::Int64(2), &Value::Float64(3.0)),
16579            Some(std::cmp::Ordering::Less)
16580        );
16581    }
16582
16583    #[test]
16584    fn value_order_null_returns_none() {
16585        assert_eq!(value_order(&Value::Int64(5), &Value::Null), None);
16586        assert_eq!(value_order(&Value::Null, &Value::Int64(5)), None);
16587        assert_eq!(value_order(&Value::Null, &Value::Null), None);
16588    }
16589
16590    #[test]
16591    fn value_order_cross_group_returns_none() {
16592        assert_eq!(
16593            value_order(&Value::Int64(5), &Value::Bytes(b"x".to_vec())),
16594            None
16595        );
16596        assert_eq!(value_order(&Value::Bool(true), &Value::Int64(1)), None);
16597        assert_eq!(
16598            value_order(
16599                &Value::Embedding(vec![1.0, 2.0]),
16600                &Value::Embedding(vec![1.0, 2.0])
16601            ),
16602            None
16603        );
16604    }
16605
16606    #[test]
16607    fn eval_trigger_expr_ranges_and_booleans() {
16608        let expr = TriggerExpr::And {
16609            left: Box::new(TriggerExpr::Gt {
16610                left: TriggerValue::NewColumn(1),
16611                right: TriggerValue::Literal(Value::Int64(0)),
16612            }),
16613            right: Box::new(TriggerExpr::Lte {
16614                left: TriggerValue::NewColumn(1),
16615                right: TriggerValue::Literal(Value::Int64(100)),
16616            }),
16617        };
16618        assert!(eval_trigger_expr(&expr, &event_insert(&[(1, Value::Int64(50))])).unwrap());
16619        assert!(!eval_trigger_expr(&expr, &event_insert(&[(1, Value::Int64(200))])).unwrap());
16620        assert!(!eval_trigger_expr(&expr, &event_insert(&[(1, Value::Null)])).unwrap());
16621
16622        let or_expr = TriggerExpr::Or {
16623            left: Box::new(TriggerExpr::Lt {
16624                left: TriggerValue::NewColumn(1),
16625                right: TriggerValue::Literal(Value::Int64(0)),
16626            }),
16627            right: Box::new(TriggerExpr::Not(Box::new(TriggerExpr::IsNull(
16628                TriggerValue::OldColumn(2),
16629            )))),
16630        };
16631        assert!(eval_trigger_expr(
16632            &or_expr,
16633            &event_with(&[(1, Value::Int64(5))], &[(2, Value::Int64(99))])
16634        )
16635        .unwrap());
16636        assert!(!eval_trigger_expr(
16637            &or_expr,
16638            &event_with(&[(1, Value::Int64(5))], &[(2, Value::Null)])
16639        )
16640        .unwrap());
16641
16642        assert!(eval_trigger_expr(
16643            &TriggerExpr::Value(TriggerValue::Literal(Value::Bool(true))),
16644            &event_insert(&[])
16645        )
16646        .unwrap());
16647        assert!(!eval_trigger_expr(
16648            &TriggerExpr::Value(TriggerValue::Literal(Value::Bool(false))),
16649            &event_insert(&[])
16650        )
16651        .unwrap());
16652        assert!(!eval_trigger_expr(
16653            &TriggerExpr::Value(TriggerValue::Literal(Value::Null)),
16654            &event_insert(&[])
16655        )
16656        .unwrap());
16657    }
16658}