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_database_locks() -> &'static Mutex<HashMap<PathBuf, std::sync::Weak<SharedDatabaseLock>>>
56{
57    static LOCKS: std::sync::OnceLock<
58        Mutex<HashMap<PathBuf, std::sync::Weak<SharedDatabaseLock>>>,
59    > = std::sync::OnceLock::new();
60    LOCKS.get_or_init(|| Mutex::new(HashMap::new()))
61}
62
63struct SharedDatabaseLock {
64    bootstrap_file: std::fs::File,
65    legacy_file: Mutex<Option<std::fs::File>>,
66    canonical_path: PathBuf,
67}
68
69struct DatabaseFileLock {
70    shared: Arc<SharedDatabaseLock>,
71    canonical_path: PathBuf,
72    durable_root: Option<Arc<crate::durable_file::DurableRoot>>,
73}
74
75impl Drop for SharedDatabaseLock {
76    fn drop(&mut self) {
77        if let Some(file) = self.legacy_file.get_mut().take() {
78            let _ = fs2::FileExt::unlock(&file);
79        }
80        let _ = fs2::FileExt::unlock(&self.bootstrap_file);
81        process_database_locks().lock().remove(&self.canonical_path);
82    }
83}
84
85fn commit_prepare_checkpoint(
86    control: Option<&crate::ExecutionControl>,
87    index: usize,
88) -> Result<()> {
89    if index.is_multiple_of(256) {
90        if let Some(control) = control {
91            control.checkpoint()?;
92        }
93    }
94    Ok(())
95}
96
97fn finish_controlled_commit_attempt(
98    result: Result<Epoch>,
99    after_commit: &mut Option<&mut dyn FnMut(Option<Epoch>) -> Result<()>>,
100) -> Result<Epoch> {
101    let Some(after_commit) = after_commit.as_mut() else {
102        return result;
103    };
104    match result {
105        Ok(epoch) => match (**after_commit)(Some(epoch)) {
106            Ok(()) => Ok(epoch),
107            Err(error) => Err(MongrelError::DurableCommit {
108                epoch: epoch.0,
109                message: error.to_string(),
110            }),
111        },
112        Err(MongrelError::DurableCommit { epoch, message }) => {
113            let callback_error = (**after_commit)(Some(Epoch(epoch))).err();
114            Err(MongrelError::DurableCommit {
115                epoch,
116                message: callback_error
117                    .map(|error| format!("{message}; commit callback: {error}"))
118                    .unwrap_or(message),
119            })
120        }
121        Err(error) => match (**after_commit)(None) {
122            Ok(()) => Err(error),
123            Err(callback_error) => Err(MongrelError::Other(format!(
124                "{error}; commit callback: {callback_error}"
125            ))),
126        },
127    }
128}
129
130fn current_unix_nanos() -> u64 {
131    std::time::SystemTime::now()
132        .duration_since(std::time::UNIX_EPOCH)
133        .unwrap_or_default()
134        .as_nanos() as u64
135}
136
137#[cfg(feature = "encryption")]
138fn read_encryption_salt(
139    root: &crate::durable_file::DurableRoot,
140) -> Result<[u8; crate::encryption::SALT_LEN]> {
141    let mut file = root
142        .open_regular(Path::new(META_DIR).join(KEYS_FILENAME))
143        .map_err(|error| MongrelError::NotFound(format!("encryption salt file: {error}")))?;
144    let length = file.metadata()?.len();
145    if length != crate::encryption::SALT_LEN as u64 {
146        return Err(MongrelError::Encryption(format!(
147            "invalid encryption salt length: got {length}, expected {}",
148            crate::encryption::SALT_LEN
149        )));
150    }
151    let mut salt = [0_u8; crate::encryption::SALT_LEN];
152    file.read_exact(&mut salt)?;
153    Ok(salt)
154}
155
156fn incremental_aggregate_cache_key(
157    table: &str,
158    conditions: &[crate::query::Condition],
159    column: Option<u16>,
160    agg: crate::engine::NativeAgg,
161    principal: Option<&crate::auth::Principal>,
162    security_version: u64,
163) -> u64 {
164    use std::hash::{Hash, Hasher};
165    let projection = column.as_ref().map(std::slice::from_ref);
166    let query_key = crate::query::canonical_query_key(conditions, projection, security_version);
167    let mut hasher = std::collections::hash_map::DefaultHasher::new();
168    table.hash(&mut hasher);
169    query_key.hash(&mut hasher);
170    match agg {
171        crate::engine::NativeAgg::Count => 0u8,
172        crate::engine::NativeAgg::Sum => 1,
173        crate::engine::NativeAgg::Min => 2,
174        crate::engine::NativeAgg::Max => 3,
175        crate::engine::NativeAgg::Avg => 4,
176    }
177    .hash(&mut hasher);
178    if let Some(principal) = principal {
179        principal.user_id.hash(&mut hasher);
180        principal.created_epoch.hash(&mut hasher);
181        principal.username.hash(&mut hasher);
182        principal.is_admin.hash(&mut hasher);
183        let mut roles = principal.roles.clone();
184        roles.sort_unstable();
185        roles.hash(&mut hasher);
186    }
187    hasher.finish()
188}
189
190fn read_history_retention(
191    root: &crate::durable_file::DurableRoot,
192    current_epoch: Epoch,
193) -> Result<(u64, Epoch)> {
194    const MAX_HISTORY_RETENTION_BYTES: u64 = 128;
195    let file = match root.open_regular(Path::new(META_DIR).join(HISTORY_RETENTION_FILENAME)) {
196        Ok(file) => file,
197        Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
198            return Ok((0, current_epoch));
199        }
200        Err(error) => return Err(error.into()),
201    };
202    let length = file.metadata()?.len();
203    if length > MAX_HISTORY_RETENTION_BYTES {
204        return Err(MongrelError::ResourceLimitExceeded {
205            resource: "history retention bytes",
206            requested: usize::try_from(length).unwrap_or(usize::MAX),
207            limit: MAX_HISTORY_RETENTION_BYTES as usize,
208        });
209    }
210    let mut bytes = Vec::with_capacity(length as usize);
211    file.take(MAX_HISTORY_RETENTION_BYTES + 1)
212        .read_to_end(&mut bytes)?;
213    if bytes.len() as u64 != length {
214        return Err(MongrelError::Other(
215            "history retention length changed while reading".into(),
216        ));
217    }
218    let text = std::str::from_utf8(&bytes)
219        .map_err(|error| MongrelError::Other(format!("history retention encoding: {error}")))?;
220    let mut fields = text.split_whitespace();
221    let epochs = fields
222        .next()
223        .ok_or_else(|| MongrelError::Other("history retention file is empty".into()))?
224        .parse::<u64>()
225        .map_err(|error| MongrelError::Other(format!("history retention epochs: {error}")))?;
226    let start = fields
227        .next()
228        .ok_or_else(|| MongrelError::Other("history retention start is missing".into()))?
229        .parse::<u64>()
230        .map_err(|error| MongrelError::Other(format!("history retention start: {error}")))?;
231    if fields.next().is_some() || start > current_epoch.0 {
232        return Err(MongrelError::Other(
233            "history retention file has trailing fields or a future start epoch".into(),
234        ));
235    }
236    Ok((epochs, Epoch(start)))
237}
238
239fn write_history_retention<F>(
240    root: &Path,
241    epochs: u64,
242    start: Epoch,
243    after_publish: F,
244) -> Result<()>
245where
246    F: FnOnce(),
247{
248    let meta = root.join(META_DIR);
249    let path = meta.join(HISTORY_RETENTION_FILENAME);
250    let bytes = format!("{epochs} {}\n", start.0);
251    crate::durable_file::write_atomic_with_after(&path, bytes.as_bytes(), after_publish)?;
252    Ok(())
253}
254
255struct PreparedBackupDestination {
256    parent: crate::durable_file::DurableRoot,
257    destination_name: std::ffi::OsString,
258    destination_path: PathBuf,
259    stage_name: std::ffi::OsString,
260    stage: Option<Box<crate::durable_file::DurableRoot>>,
261}
262
263fn prepare_backup_destination(
264    source: &Path,
265    destination: &Path,
266) -> Result<PreparedBackupDestination> {
267    let destination_name = destination
268        .file_name()
269        .ok_or_else(|| MongrelError::InvalidArgument("invalid backup destination".into()))?
270        .to_os_string();
271    let requested_parent = destination
272        .parent()
273        .filter(|path| !path.as_os_str().is_empty())
274        .unwrap_or_else(|| Path::new("."));
275    crate::durable_file::create_directory_all(requested_parent)?;
276    let parent = crate::durable_file::DurableRoot::open(requested_parent)?;
277    prepare_backup_destination_in(source, &parent, &destination_name)
278}
279
280fn prepare_backup_destination_in(
281    source: &Path,
282    parent: &crate::durable_file::DurableRoot,
283    destination_name: &std::ffi::OsStr,
284) -> Result<PreparedBackupDestination> {
285    let source = source.canonicalize()?;
286    if parent.canonical_path().starts_with(&source) {
287        return Err(MongrelError::InvalidArgument(
288            "backup destination must not be inside the source database".into(),
289        ));
290    }
291    if parent.entry_exists(Path::new(&destination_name))? {
292        return Err(MongrelError::Conflict(format!(
293            "backup destination already exists: {}",
294            parent.canonical_path().join(destination_name).display()
295        )));
296    }
297    let mut stage_name = None;
298    for _ in 0..128 {
299        let mut nonce = [0_u8; 8];
300        crate::encryption::fill_random(&mut nonce)?;
301        let suffix = nonce
302            .iter()
303            .map(|byte| format!("{byte:02x}"))
304            .collect::<String>();
305        let name = std::ffi::OsString::from(format!(
306            ".{}.backup-stage-{}-{suffix}",
307            destination_name.to_string_lossy(),
308            std::process::id()
309        ));
310        match parent.create_directory_new(Path::new(&name)) {
311            Ok(()) => {
312                stage_name = Some(name);
313                break;
314            }
315            Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => continue,
316            Err(error) => return Err(error.into()),
317        }
318    }
319    let stage_name = stage_name
320        .ok_or_else(|| MongrelError::Conflict("could not allocate backup staging path".into()))?;
321    let stage = parent.open_directory(Path::new(&stage_name))?;
322    Ok(PreparedBackupDestination {
323        destination_path: parent.canonical_path().join(destination_name),
324        destination_name: destination_name.to_os_string(),
325        stage_name,
326        stage: Some(Box::new(stage)),
327        parent: parent.try_clone()?,
328    })
329}
330
331fn copy_backup_boundary(
332    source_root: &Path,
333    destination_root: &crate::durable_file::DurableRoot,
334    deferred_runs: &HashSet<PathBuf>,
335    copied: &mut Vec<PathBuf>,
336    control: Option<&crate::ExecutionControl>,
337) -> Result<()> {
338    let mut visited = 0;
339    crate::durable_file::walk_regular_files_nofollow(
340        source_root,
341        |relative, is_directory| {
342            if visited % 256 == 0 {
343                if let Some(control) = control {
344                    control.checkpoint()?;
345                }
346            }
347            visited += 1;
348            if backup_path_excluded(relative) {
349                return Ok(false);
350            }
351            if is_directory {
352                return Ok(true);
353            }
354            if deferred_runs.contains(relative) {
355                return Ok(false);
356            }
357            Ok(!(relative
358                .parent()
359                .and_then(Path::file_name)
360                .is_some_and(|parent| parent == "_runs")
361                && relative
362                    .extension()
363                    .is_some_and(|extension| extension == "sr")))
364        },
365        |relative| {
366            destination_root.create_directory_all(relative)?;
367            Ok(())
368        },
369        |relative, source| {
370            destination_root.copy_new_from(relative, source)?;
371            copied.push(relative.to_path_buf());
372            Ok(())
373        },
374    )
375}
376
377fn backup_path_excluded(relative: &Path) -> bool {
378    relative == Path::new("_meta/.lock")
379        || relative == Path::new("_meta/replica")
380        || relative == Path::new("_meta/repl_epoch")
381        || relative == Path::new(crate::backup::BACKUP_MANIFEST_PATH)
382        || relative.components().any(|component| {
383            matches!(component, std::path::Component::Normal(name) if name == "_cache" || name == "_txn" || name == "backup-pins")
384        })
385}
386
387#[derive(Debug, Clone)]
388pub enum ExternalTriggerWrite {
389    Insert {
390        table: String,
391        cells: Vec<(u16, Value)>,
392    },
393    UpdateByPk {
394        table: String,
395        pk: Value,
396        cells: Vec<(u16, Value)>,
397    },
398    DeleteByPk {
399        table: String,
400        pk: Value,
401    },
402}
403
404impl ExternalTriggerWrite {
405    fn table(&self) -> &str {
406        match self {
407            Self::Insert { table, .. }
408            | Self::UpdateByPk { table, .. }
409            | Self::DeleteByPk { table, .. } => table,
410        }
411    }
412}
413
414#[derive(Debug, Clone, PartialEq)]
415pub enum ExternalTriggerBaseWrite {
416    Put {
417        table: String,
418        cells: Vec<(u16, Value)>,
419    },
420    Delete {
421        table: String,
422        row_id: RowId,
423    },
424}
425
426#[derive(Debug, Clone, PartialEq)]
427pub struct ExternalTriggerWriteResult {
428    pub state: Vec<u8>,
429    pub base_writes: Vec<ExternalTriggerBaseWrite>,
430}
431
432impl ExternalTriggerWriteResult {
433    pub fn new(state: Vec<u8>) -> Self {
434        Self {
435            state,
436            base_writes: Vec::new(),
437        }
438    }
439}
440
441pub trait ExternalTriggerBridge: Send + Sync {
442    fn apply_trigger_external_write(
443        &self,
444        entry: &ExternalTableEntry,
445        base_state: Vec<u8>,
446        op: ExternalTriggerWrite,
447    ) -> Result<ExternalTriggerWriteResult>;
448}
449
450/// A pending uniform-epoch run written during a large transaction (spec §8.5).
451struct SpilledRun {
452    table_id: u64,
453    run_id: u128,
454    pending_path: PathBuf,
455    final_path: PathBuf,
456    rows: Vec<crate::memtable::Row>,
457    row_count: u64,
458    min_rid: u64,
459    max_rid: u64,
460    content_hash: [u8; 32],
461}
462
463const SPILLED_WAL_PAYLOAD_MAX_BYTES: usize = 24 * 1024 * 1024;
464const SPILLED_WAL_TOTAL_MAX_BYTES: usize = 256 * 1024 * 1024;
465
466fn encode_spilled_row_chunks(
467    rows: &[crate::memtable::Row],
468    total_bytes: &mut usize,
469    total_limit: usize,
470    control: Option<&crate::ExecutionControl>,
471) -> Result<Vec<Vec<u8>>> {
472    let mut output = Vec::new();
473    let mut start = 0;
474    while start < rows.len() {
475        // Bincode's sequence length prefix is a u64 with the workspace's
476        // fixed-int options. `serialized_size` computes exact row sizes
477        // without first allocating one transaction-sized buffer.
478        let mut estimated_bytes = std::mem::size_of::<u64>();
479        let mut end = start;
480        while end < rows.len() {
481            if end % 256 == 0 {
482                if let Some(control) = control {
483                    control.checkpoint()?;
484                }
485            }
486            let row_bytes =
487                usize::try_from(bincode::serialized_size(&rows[end])?).map_err(|_| {
488                    MongrelError::ResourceLimitExceeded {
489                        resource: "spilled WAL row bytes",
490                        requested: usize::MAX,
491                        limit: SPILLED_WAL_PAYLOAD_MAX_BYTES,
492                    }
493                })?;
494            let next_bytes = estimated_bytes.checked_add(row_bytes).ok_or(
495                MongrelError::ResourceLimitExceeded {
496                    resource: "spilled WAL row bytes",
497                    requested: usize::MAX,
498                    limit: SPILLED_WAL_PAYLOAD_MAX_BYTES,
499                },
500            )?;
501            if next_bytes > SPILLED_WAL_PAYLOAD_MAX_BYTES {
502                break;
503            }
504            estimated_bytes = next_bytes;
505            end += 1;
506        }
507        if end == start {
508            return Err(MongrelError::ResourceLimitExceeded {
509                resource: "spilled WAL row bytes",
510                requested: estimated_bytes.saturating_add(1),
511                limit: SPILLED_WAL_PAYLOAD_MAX_BYTES,
512            });
513        }
514        let payload = bincode::serialize(&rows[start..end])?;
515        if payload.len() > SPILLED_WAL_PAYLOAD_MAX_BYTES {
516            return Err(MongrelError::ResourceLimitExceeded {
517                resource: "spilled WAL row bytes",
518                requested: payload.len(),
519                limit: SPILLED_WAL_PAYLOAD_MAX_BYTES,
520            });
521        }
522        let requested = total_bytes.checked_add(payload.len()).unwrap_or(usize::MAX);
523        if requested > total_limit {
524            return Err(MongrelError::ResourceLimitExceeded {
525                resource: "spilled WAL transaction bytes",
526                requested,
527                limit: total_limit,
528            });
529        }
530        *total_bytes = requested;
531        output.push(payload);
532        start = end;
533    }
534    Ok(output)
535}
536
537#[cfg(test)]
538mod spilled_wal_encoding_tests {
539    use super::*;
540
541    #[test]
542    fn logical_spill_payload_has_a_total_bound() {
543        let rows = (0..4)
544            .map(|row_id| crate::memtable::Row {
545                row_id: crate::rowid::RowId(row_id),
546                committed_epoch: Epoch::ZERO,
547                columns: [(1, Value::Bytes(vec![0; 64]))].into_iter().collect(),
548                deleted: false,
549            })
550            .collect::<Vec<_>>();
551        let mut total = 0;
552        let error = encode_spilled_row_chunks(&rows, &mut total, 32, None).unwrap_err();
553        assert!(matches!(
554            error,
555            MongrelError::ResourceLimitExceeded {
556                resource: "spilled WAL transaction bytes",
557                ..
558            }
559        ));
560    }
561}
562
563/// Move spill files to their final names before the WAL commit. Dropping this
564/// guard restores pending names while commit is still known not to have begun.
565/// It is disarmed immediately before the first WAL append, where the outcome
566/// can become ambiguous and recovery may need the final names.
567struct PreparedRunLinks {
568    links: Vec<(PathBuf, PathBuf)>,
569    armed: bool,
570}
571
572impl PreparedRunLinks {
573    fn prepare(spilled: &[SpilledRun]) -> Result<Self> {
574        let mut guard = Self {
575            links: Vec::with_capacity(spilled.len()),
576            armed: true,
577        };
578        for run in spilled {
579            crate::durable_file::rename(&run.pending_path, &run.final_path)?;
580            guard
581                .links
582                .push((run.pending_path.clone(), run.final_path.clone()));
583        }
584        Ok(guard)
585    }
586
587    fn disarm(&mut self) {
588        self.armed = false;
589        for (pending, _) in &self.links {
590            if let Some(parent) = pending.parent() {
591                let _ = std::fs::remove_dir_all(parent);
592            }
593        }
594    }
595}
596
597impl Drop for PreparedRunLinks {
598    fn drop(&mut self) {
599        if !self.armed {
600            return;
601        }
602        for (pending, final_path) in self.links.iter().rev() {
603            let _ = std::fs::rename(final_path, pending);
604        }
605    }
606}
607
608struct TableApplyBatch {
609    table_id: u64,
610    handle: TableHandle,
611    ops: Vec<crate::txn::StagedOp>,
612}
613
614#[derive(Debug, Clone)]
615struct TriggerRowImage {
616    columns: HashMap<u16, Value>,
617}
618
619impl TriggerRowImage {
620    fn from_row(row: crate::memtable::Row) -> Self {
621        Self {
622            columns: row.columns,
623        }
624    }
625
626    fn from_cells(cells: &[(u16, Value)]) -> Self {
627        Self {
628            columns: cells.iter().cloned().collect(),
629        }
630    }
631}
632
633#[derive(Debug, Clone)]
634struct WriteEvent {
635    table: String,
636    kind: TriggerEvent,
637    old: Option<TriggerRowImage>,
638    new: Option<TriggerRowImage>,
639    changed_columns: Vec<u16>,
640    op_indices: Vec<usize>,
641    put_idx: Option<usize>,
642    trigger_stack: Vec<String>,
643}
644
645#[derive(Default)]
646struct TriggerExpansion {
647    before: Vec<(u64, crate::txn::Staged)>,
648    before_stacks: Vec<Vec<String>>,
649    before_external: Vec<ExternalTriggerWrite>,
650    after: Vec<(u64, crate::txn::Staged)>,
651    after_stacks: Vec<Vec<String>>,
652    after_external: Vec<ExternalTriggerWrite>,
653    ignored_indices: std::collections::BTreeSet<usize>,
654}
655
656#[derive(Clone, PartialEq)]
657struct TriggerCatalogBinding {
658    triggers: Vec<TriggerEntry>,
659    tables: Vec<(String, u64, u64)>,
660    external_tables: Vec<(String, u64, u64)>,
661}
662
663fn trigger_catalog_binding(catalog: &Catalog) -> Option<TriggerCatalogBinding> {
664    let mut triggers = catalog
665        .triggers
666        .iter()
667        .filter(|entry| entry.trigger.enabled)
668        .cloned()
669        .collect::<Vec<_>>();
670    if triggers.is_empty() {
671        return None;
672    }
673    triggers.sort_by(|left, right| left.trigger.name.cmp(&right.trigger.name));
674    let mut tables = catalog
675        .tables
676        .iter()
677        .filter(|entry| matches!(entry.state, TableState::Live))
678        .map(|entry| (entry.name.clone(), entry.table_id, entry.schema.schema_id))
679        .collect::<Vec<_>>();
680    tables.sort_unstable();
681    let mut external_tables = catalog
682        .external_tables
683        .iter()
684        .map(|entry| {
685            (
686                entry.name.clone(),
687                entry.created_epoch,
688                entry.declared_schema.schema_id,
689            )
690        })
691        .collect::<Vec<_>>();
692    external_tables.sort_unstable();
693    Some(TriggerCatalogBinding {
694        triggers,
695        tables,
696        external_tables,
697    })
698}
699
700struct TriggerProgramOutput<'a> {
701    added: &'a mut Vec<(u64, crate::txn::Staged)>,
702    added_stacks: &'a mut Vec<Vec<String>>,
703    added_external: &'a mut Vec<ExternalTriggerWrite>,
704    ignored_indices: &'a mut std::collections::BTreeSet<usize>,
705}
706
707#[derive(Debug, Clone, Copy, PartialEq, Eq)]
708enum TriggerProgramOutcome {
709    Continue,
710    Ignore,
711}
712
713/// An integrity issue found by [`Database::check`] (spec §16).
714#[derive(Debug, Clone)]
715pub struct CheckIssue {
716    pub table_id: u64,
717    pub table_name: String,
718    pub severity: String,
719    pub description: String,
720}
721
722/// One optimistic authorization snapshot for a complete scored read.
723#[derive(Debug, Clone)]
724pub struct AuthorizedReadSnapshot {
725    pub table: String,
726    pub table_snapshot: Snapshot,
727    pub data_generation: u64,
728    pub security_version: u64,
729    pub allowed_row_ids: Option<HashSet<RowId>>,
730}
731
732/// Exact table/security generation used by one successful authorized read.
733#[derive(Debug, Clone, Copy, PartialEq, Eq)]
734pub struct AuthorizedReadStamp {
735    pub table_id: u64,
736    pub schema_id: u64,
737    pub data_generation: u64,
738    pub security_version: u64,
739    pub snapshot: Snapshot,
740}
741
742type RlsCacheKey = (String, u64, u64, String);
743
744/// Runtime statistics for the byte-bounded RLS candidate cache.
745#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
746pub struct RlsCacheStats {
747    pub entries: usize,
748    pub bytes: usize,
749    pub hits: u64,
750    pub misses: u64,
751    pub evictions: u64,
752    pub build_nanos: u64,
753    pub rows_evaluated: u64,
754}
755
756const RLS_CACHE_MAX_BYTES: usize = 64 * 1024 * 1024;
757const CDC_MAX_WAL_RECORDS: usize = 1_000_000;
758const CDC_MAX_WAL_REPLAY_BYTES: usize = 256 * 1024 * 1024;
759const CDC_MAX_EVENTS: usize = 100_000;
760const CDC_MAX_ROWS: usize = 1_000_000;
761const CDC_MAX_INLINE_PAYLOAD_BYTES: usize = 32 * 1024 * 1024;
762const CDC_MAX_RETAINED_BYTES: usize = 256 * 1024 * 1024;
763
764fn charge_cdc_bytes(total: &mut usize, amount: usize, resource: &'static str) -> Result<()> {
765    let requested = total.saturating_add(amount);
766    if requested > CDC_MAX_RETAINED_BYTES {
767        return Err(MongrelError::ResourceLimitExceeded {
768            resource,
769            requested,
770            limit: CDC_MAX_RETAINED_BYTES,
771        });
772    }
773    *total = requested;
774    Ok(())
775}
776
777fn cdc_row_storage_bytes(row: &crate::memtable::Row) -> usize {
778    usize::try_from(row.estimated_bytes())
779        .unwrap_or(usize::MAX)
780        .saturating_add(std::mem::size_of::<crate::memtable::Row>())
781}
782
783fn cdc_row_json_bytes(row: &crate::memtable::Row) -> usize {
784    let value_slot = std::mem::size_of::<serde_json::Value>();
785    row.columns.values().fold(512_usize, |bytes, value| {
786        let values = match value {
787            Value::Bytes(values) => values.len(),
788            Value::Json(values) => values.len(),
789            Value::Embedding(values) => values.len(),
790            _ => 1,
791        };
792        bytes.saturating_add(values.saturating_mul(value_slot))
793    })
794}
795
796fn cdc_rows_json_bytes(rows: &[crate::memtable::Row]) -> usize {
797    rows.iter().fold(0_usize, |bytes, row| {
798        bytes.saturating_add(cdc_row_json_bytes(row))
799    })
800}
801
802#[derive(Default)]
803struct RlsCache {
804    entries: HashMap<RlsCacheKey, (Arc<HashSet<RowId>>, usize)>,
805    lru: VecDeque<RlsCacheKey>,
806    bytes: usize,
807    hits: u64,
808    misses: u64,
809    evictions: u64,
810    build_nanos: u64,
811    rows_evaluated: u64,
812}
813
814impl RlsCache {
815    fn get(&mut self, key: &RlsCacheKey) -> Option<Arc<HashSet<RowId>>> {
816        let value = self.entries.get(key).map(|(value, _)| Arc::clone(value));
817        if value.is_some() {
818            self.hits = self.hits.saturating_add(1);
819            self.touch(key);
820        } else {
821            self.misses = self.misses.saturating_add(1);
822        }
823        value
824    }
825
826    fn insert(&mut self, key: RlsCacheKey, value: Arc<HashSet<RowId>>) {
827        let bytes = key
828            .0
829            .len()
830            .saturating_add(key.3.len())
831            .saturating_add(
832                value
833                    .capacity()
834                    .saturating_mul(std::mem::size_of::<RowId>() * 3),
835            )
836            .saturating_add(std::mem::size_of::<RlsCacheKey>());
837        if bytes > RLS_CACHE_MAX_BYTES {
838            return;
839        }
840        if let Some((_, old_bytes)) = self.entries.remove(&key) {
841            self.bytes = self.bytes.saturating_sub(old_bytes);
842        }
843        self.lru.retain(|candidate| candidate != &key);
844        while self.bytes.saturating_add(bytes) > RLS_CACHE_MAX_BYTES {
845            let Some(oldest) = self.lru.pop_front() else {
846                break;
847            };
848            if let Some((_, old_bytes)) = self.entries.remove(&oldest) {
849                self.bytes = self.bytes.saturating_sub(old_bytes);
850                self.evictions = self.evictions.saturating_add(1);
851            }
852        }
853        self.bytes = self.bytes.saturating_add(bytes);
854        self.lru.push_back(key.clone());
855        self.entries.insert(key, (value, bytes));
856    }
857
858    fn touch(&mut self, key: &RlsCacheKey) {
859        self.lru.retain(|candidate| candidate != key);
860        self.lru.push_back(key.clone());
861    }
862
863    fn stats(&self) -> RlsCacheStats {
864        RlsCacheStats {
865            entries: self.entries.len(),
866            bytes: self.bytes,
867            hits: self.hits,
868            misses: self.misses,
869            evictions: self.evictions,
870            build_nanos: self.build_nanos,
871            rows_evaluated: self.rows_evaluated,
872        }
873    }
874}
875
876/// Mounted table with immutable, structurally shared scored-read generations.
877#[derive(Clone)]
878pub struct TableHandle {
879    inner: TableHandleInner,
880    generation_metrics: Arc<TableGenerationMetrics>,
881}
882
883#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
884pub struct TableGenerationStats {
885    pub active_read_generations: usize,
886    pub max_live_read_generations: usize,
887    pub cow_clone_count: u64,
888    pub cow_clone_nanos: u64,
889    pub estimated_cow_clone_bytes: u64,
890    pub writer_wait_nanos: u64,
891}
892
893#[derive(Default)]
894#[doc(hidden)]
895pub struct TableGenerationMetrics {
896    active_read_generations: AtomicUsize,
897    max_live_read_generations: AtomicUsize,
898    cow_clone_count: AtomicU64,
899    cow_clone_nanos: AtomicU64,
900    estimated_cow_clone_bytes: AtomicU64,
901    writer_wait_nanos: AtomicU64,
902}
903
904impl TableGenerationMetrics {
905    fn activate(self: &Arc<Self>, table: Table) -> Arc<TableReadGeneration> {
906        let active = self.active_read_generations.fetch_add(1, Ordering::Relaxed) + 1;
907        self.max_live_read_generations
908            .fetch_max(active, Ordering::Relaxed);
909        Arc::new(TableReadGeneration {
910            table,
911            metrics: Arc::clone(self),
912        })
913    }
914
915    fn stats(&self) -> TableGenerationStats {
916        TableGenerationStats {
917            active_read_generations: self.active_read_generations.load(Ordering::Relaxed),
918            max_live_read_generations: self.max_live_read_generations.load(Ordering::Relaxed),
919            cow_clone_count: self.cow_clone_count.load(Ordering::Relaxed),
920            cow_clone_nanos: self.cow_clone_nanos.load(Ordering::Relaxed),
921            estimated_cow_clone_bytes: self.estimated_cow_clone_bytes.load(Ordering::Relaxed),
922            writer_wait_nanos: self.writer_wait_nanos.load(Ordering::Relaxed),
923        }
924    }
925}
926
927/// Immutable, structurally shared snapshot used by scored readers.
928pub struct TableReadGeneration {
929    table: Table,
930    metrics: Arc<TableGenerationMetrics>,
931}
932
933impl std::ops::Deref for TableReadGeneration {
934    type Target = Table;
935
936    fn deref(&self) -> &Self::Target {
937        &self.table
938    }
939}
940
941impl Drop for TableReadGeneration {
942    fn drop(&mut self) {
943        self.metrics
944            .active_read_generations
945            .fetch_sub(1, Ordering::Relaxed);
946    }
947}
948
949#[derive(Clone)]
950enum TableHandleInner {
951    CopyOnWrite(Arc<RwLock<Arc<Table>>>),
952    Direct(Arc<Mutex<Table>>),
953}
954
955pub enum TableGuard<'a> {
956    CopyOnWrite {
957        table: parking_lot::RwLockWriteGuard<'a, Arc<Table>>,
958        metrics: Arc<TableGenerationMetrics>,
959    },
960    Direct {
961        table: parking_lot::MutexGuard<'a, Table>,
962    },
963}
964
965impl TableHandle {
966    fn new(table: Table) -> Self {
967        Self {
968            inner: TableHandleInner::CopyOnWrite(Arc::new(RwLock::new(Arc::new(table)))),
969            generation_metrics: Arc::new(TableGenerationMetrics::default()),
970        }
971    }
972
973    pub fn from_table(table: Table) -> Self {
974        Self::new(table)
975    }
976
977    pub fn lock(&self) -> TableGuard<'_> {
978        let started = std::time::Instant::now();
979        let guard = match &self.inner {
980            TableHandleInner::CopyOnWrite(table) => TableGuard::CopyOnWrite {
981                table: table.write(),
982                metrics: Arc::clone(&self.generation_metrics),
983            },
984            TableHandleInner::Direct(table) => TableGuard::Direct {
985                table: table.lock(),
986            },
987        };
988        self.generation_metrics.writer_wait_nanos.fetch_add(
989            started.elapsed().as_nanos().min(u128::from(u64::MAX)) as u64,
990            Ordering::Relaxed,
991        );
992        guard
993    }
994
995    fn try_lock_for(&self, timeout: std::time::Duration) -> Option<TableGuard<'_>> {
996        let started = std::time::Instant::now();
997        let guard = match &self.inner {
998            TableHandleInner::CopyOnWrite(table) => {
999                table
1000                    .try_write_for(timeout)
1001                    .map(|table| TableGuard::CopyOnWrite {
1002                        table,
1003                        metrics: Arc::clone(&self.generation_metrics),
1004                    })
1005            }
1006            TableHandleInner::Direct(table) => table
1007                .try_lock_for(timeout)
1008                .map(|table| TableGuard::Direct { table }),
1009        };
1010        self.generation_metrics.writer_wait_nanos.fetch_add(
1011            started.elapsed().as_nanos().min(u128::from(u64::MAX)) as u64,
1012            Ordering::Relaxed,
1013        );
1014        guard
1015    }
1016
1017    pub fn ptr_eq(&self, other: &Self) -> bool {
1018        match (&self.inner, &other.inner) {
1019            (TableHandleInner::CopyOnWrite(left), TableHandleInner::CopyOnWrite(right)) => {
1020                Arc::ptr_eq(left, right)
1021            }
1022            (TableHandleInner::Direct(left), TableHandleInner::Direct(right)) => {
1023                Arc::ptr_eq(left, right)
1024            }
1025            _ => false,
1026        }
1027    }
1028
1029    pub fn read_generation_with_context(
1030        &self,
1031        context: Option<&crate::query::AiExecutionContext>,
1032    ) -> Result<(Arc<TableReadGeneration>, Snapshot)> {
1033        let mut table = if let Some(context) = context {
1034            loop {
1035                context.checkpoint()?;
1036                let wait = context
1037                    .remaining_duration()
1038                    .unwrap_or(std::time::Duration::from_millis(5))
1039                    .min(std::time::Duration::from_millis(5));
1040                if let Some(table) = self.try_lock_for(wait) {
1041                    break table;
1042                }
1043            }
1044        } else {
1045            self.lock()
1046        };
1047        let snapshot = table.snapshot();
1048        let generation = table.clone_read_generation()?;
1049        Ok((self.generation_metrics.activate(generation), snapshot))
1050    }
1051
1052    pub fn generation_stats(&self) -> TableGenerationStats {
1053        self.generation_metrics.stats()
1054    }
1055}
1056
1057impl From<Arc<Mutex<Table>>> for TableHandle {
1058    fn from(table: Arc<Mutex<Table>>) -> Self {
1059        Self {
1060            inner: TableHandleInner::Direct(table),
1061            generation_metrics: Arc::new(TableGenerationMetrics::default()),
1062        }
1063    }
1064}
1065
1066impl std::ops::Deref for TableGuard<'_> {
1067    type Target = Table;
1068
1069    fn deref(&self) -> &Self::Target {
1070        match self {
1071            Self::CopyOnWrite { table, .. } => table.as_ref(),
1072            Self::Direct { table } => table,
1073        }
1074    }
1075}
1076
1077impl std::ops::DerefMut for TableGuard<'_> {
1078    fn deref_mut(&mut self) -> &mut Self::Target {
1079        match self {
1080            Self::CopyOnWrite { table, metrics } => {
1081                if Arc::strong_count(table) > 1 || Arc::weak_count(table) > 0 {
1082                    let estimated_bytes = table.estimated_clone_bytes();
1083                    let started = std::time::Instant::now();
1084                    let table = Arc::make_mut(table);
1085                    metrics.cow_clone_count.fetch_add(1, Ordering::Relaxed);
1086                    metrics.cow_clone_nanos.fetch_add(
1087                        started.elapsed().as_nanos().min(u128::from(u64::MAX)) as u64,
1088                        Ordering::Relaxed,
1089                    );
1090                    metrics
1091                        .estimated_cow_clone_bytes
1092                        .fetch_add(estimated_bytes, Ordering::Relaxed);
1093                    table
1094                } else {
1095                    Arc::make_mut(table)
1096                }
1097            }
1098            Self::Direct { table } => table,
1099        }
1100    }
1101}
1102
1103#[derive(Clone, Debug)]
1104pub struct ReadAuthorization {
1105    pub operation: crate::auth::ColumnOperation,
1106    pub columns: Vec<u16>,
1107    pub permissions: Vec<crate::auth::Permission>,
1108}
1109
1110#[derive(Default, Debug)]
1111struct TableWritePermissionNeeds {
1112    insert: bool,
1113    insert_columns: Vec<u16>,
1114    update: bool,
1115    update_columns: Vec<u16>,
1116    delete: bool,
1117    truncate: bool,
1118}
1119
1120#[cfg(test)]
1121thread_local! {
1122    static WRITE_PERMISSION_DECISIONS: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
1123    static AUTO_INCREMENT_TABLE_LOCKS: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
1124    static PREBUILD_TABLE_LOCKS: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
1125    static PUBLISH_TABLE_LOCKS: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
1126    static COMMIT_MANIFEST_WRITES: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
1127    static TABLE_PERMISSION_DECISIONS: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
1128}
1129
1130fn summarize_write_permissions(
1131    staging: &[(u64, crate::txn::Staged)],
1132) -> HashMap<u64, TableWritePermissionNeeds> {
1133    use crate::txn::Staged;
1134
1135    let mut needs = HashMap::<u64, TableWritePermissionNeeds>::new();
1136    for (table_id, operation) in staging {
1137        let table = needs.entry(*table_id).or_default();
1138        match operation {
1139            Staged::Put(cells) => {
1140                table.insert = true;
1141                table
1142                    .insert_columns
1143                    .extend(cells.iter().map(|(column, _)| *column));
1144            }
1145            Staged::Update {
1146                changed_columns, ..
1147            } => {
1148                table.update = true;
1149                table.update_columns.extend(changed_columns);
1150            }
1151            Staged::Delete(_) => table.delete = true,
1152            Staged::Truncate => table.truncate = true,
1153        }
1154    }
1155    for table in needs.values_mut() {
1156        table.insert_columns.sort_unstable();
1157        table.insert_columns.dedup();
1158        table.update_columns.sort_unstable();
1159        table.update_columns.dedup();
1160    }
1161    needs
1162}
1163
1164struct SecurityCoordinator {
1165    /// Lock order: security gate -> commit lock -> shared WAL -> table locks.
1166    gate: RwLock<()>,
1167    version: AtomicU64,
1168}
1169
1170fn security_coordinator(root: &Path, version: u64) -> Arc<SecurityCoordinator> {
1171    static COORDINATORS: std::sync::OnceLock<
1172        Mutex<HashMap<PathBuf, std::sync::Weak<SecurityCoordinator>>>,
1173    > = std::sync::OnceLock::new();
1174
1175    let root = root.canonicalize().unwrap_or_else(|_| root.to_path_buf());
1176    let mut coordinators = COORDINATORS
1177        .get_or_init(|| Mutex::new(HashMap::new()))
1178        .lock();
1179    coordinators.retain(|_, coordinator| coordinator.strong_count() > 0);
1180    if let Some(coordinator) = coordinators.get(&root).and_then(std::sync::Weak::upgrade) {
1181        return coordinator;
1182    }
1183    let coordinator = Arc::new(SecurityCoordinator {
1184        gate: RwLock::new(()),
1185        version: AtomicU64::new(version),
1186    });
1187    coordinators.insert(root, Arc::downgrade(&coordinator));
1188    coordinator
1189}
1190
1191pub fn lock_table_with_context<'a>(
1192    handle: &'a TableHandle,
1193    context: Option<&crate::query::AiExecutionContext>,
1194) -> Result<TableGuard<'a>> {
1195    let Some(context) = context else {
1196        return Ok(handle.lock());
1197    };
1198    loop {
1199        context.checkpoint()?;
1200        let wait = context
1201            .remaining_duration()
1202            .unwrap_or(std::time::Duration::from_millis(5))
1203            .min(std::time::Duration::from_millis(5));
1204        if let Some(guard) = handle.try_lock_for(wait) {
1205            return Ok(guard);
1206        }
1207    }
1208}
1209
1210/// Knobs for [`Database::open_with_options`].
1211///
1212/// All fields default to the same values the convenience
1213/// [`Database::open`] / [`Database::open_encrypted`] / etc. constructors use,
1214/// so `OpenOptions::default()` round-trips the historical behavior exactly.
1215#[derive(Clone, Debug, Default)]
1216pub struct OpenOptions {
1217    /// Maximum time, in milliseconds, to wait for the cross-process database
1218    /// lock (`_meta/.lock`) before failing the open with `MongrelError::Io`.
1219    ///
1220    /// `0` (the default) preserves the historical fail-fast semantics: a
1221    /// single `try_lock_exclusive` call, no retry, no sleep. SQLite-style
1222    /// `busy_timeout` semantics kick in once this is non-zero — the open
1223    /// sleeps with progressively wider backoff (1ms → 10ms → 50ms) until
1224    /// either the lock is acquired or `lock_timeout_ms` elapses, at which
1225    /// point the open returns the same `Io(WouldBlock)` error the fail-fast
1226    /// path would.
1227    ///
1228    /// Only the cross-process lock is affected. Mounted tables, page-cache
1229    /// misses, and WAL appends already serialize through in-process locks
1230    /// that handle their own contention.
1231    pub lock_timeout_ms: u32,
1232}
1233
1234impl OpenOptions {
1235    /// Set [`OpenOptions::lock_timeout_ms`]. `0` keeps the fail-fast default;
1236    /// SQLite-style applications typically pick 1_000 – 5_000ms.
1237    pub fn with_lock_timeout_ms(mut self, ms: u32) -> Self {
1238        self.lock_timeout_ms = ms;
1239        self
1240    }
1241}
1242
1243/// A multi-table database: one catalog, one epoch clock, shared caches, a
1244/// shared WAL, and a live map of name → `Arc<Table>`.
1245pub struct Database {
1246    root: PathBuf,
1247    durable_root: Arc<crate::durable_file::DurableRoot>,
1248    /// Set by `_meta/replica`; user writes are rejected on follower copies.
1249    read_only: bool,
1250    catalog: RwLock<Catalog>,
1251    security_coordinator: Arc<SecurityCoordinator>,
1252    security_catalog_disk_reads: AtomicU64,
1253    rls_cache: Mutex<RlsCache>,
1254    epoch: Arc<EpochAuthority>,
1255    snapshots: Arc<SnapshotRegistry>,
1256    page_cache: Arc<crate::cache::Sharded<crate::cache::PageCache>>,
1257    decoded_cache: Arc<crate::cache::Sharded<crate::cache::DecodedPageCache>>,
1258    commit_lock: Arc<Mutex<()>>,
1259    /// One shared WAL multiplexing every table's records (spec §7.2). Owned
1260    /// behind a `Mutex` so the transaction layer can append + group-sync. Shared
1261    /// (via `Arc`) with every mounted `Table` so single-table `put`/`commit`
1262    /// writes also land in this one WAL (B1 — one WAL per database).
1263    shared_wal: Arc<Mutex<crate::wal::SharedWal>>,
1264    /// Monotonic per-open transaction-id counter. Scoped by `open_generation`
1265    /// in P2.7; here it just needs to be unique within an open. Shared with
1266    /// mounted tables so their auto-commit txn ids never alias cross-table ones.
1267    next_txn_id: Arc<Mutex<u64>>,
1268    tables: RwLock<HashMap<u64, TableHandle>>,
1269    kek: Option<Arc<crate::encryption::Kek>>,
1270    /// Serializes DDL (create/drop table); data commits serialize through
1271    /// `commit_lock` shared via `SharedCtx`.
1272    ddl_lock: Mutex<()>,
1273    meta_dek: Option<[u8; META_DEK_LEN]>,
1274    /// P3.4: when staged bytes per table exceed this, write a uniform-epoch
1275    /// pending run to `_txn/<txn_id>/` instead of streaming Put records (§8.5).
1276    spill_threshold: std::sync::atomic::AtomicU64,
1277    /// P3.1: write-key → commit_epoch for first-committer-wins conflict
1278    /// detection (spec §9.2).
1279    conflicts: crate::txn::ConflictIndex,
1280    /// P3.1: min read_epoch of all in-flight txns, drives conflict-index
1281    /// pruning (spec §9.2, review fix #12).
1282    active_txns: crate::txn::ActiveTxns,
1283    /// P3.2: set on fsync error — all subsequent writes fail fast (spec §9.3e).
1284    /// Shared with mounted tables so a single-table commit also honors poison.
1285    poisoned: Arc<std::sync::atomic::AtomicBool>,
1286    /// P3.2: group-commit coordinator. The sequencer appends under the WAL lock
1287    /// but defers the fsync to one leader here, so concurrent commits share a
1288    /// single fsync (spec §9.3). Shared with mounted tables.
1289    group: Arc<crate::txn::GroupCommit>,
1290    /// P3.6: txn ids currently spilling into `_txn/<id>/`. GC never deletes a
1291    /// live spill's pending dir (review fix #14, spec §6.4).
1292    active_spills: Arc<crate::retention::ActiveSpills>,
1293    /// A write lock captures a consistent bootstrap image; transaction commits
1294    /// hold a read lock across spill preparation, WAL append, and publish.
1295    replication_barrier: parking_lot::RwLock<()>,
1296    /// Number of rotated WAL segments retained for lagging followers.
1297    replication_wal_retention_segments: AtomicUsize,
1298    /// Live immutable run files used by online backups or scored read
1299    /// generations. GC cannot unlink them until every owning guard drops.
1300    backup_pins: Arc<Mutex<HashMap<(u64, u128), usize>>>,
1301    /// Test-only barrier invoked after a transaction writes its spill runs but
1302    /// before the sequencer/publish, so tests can race `gc()` against an
1303    /// in-flight spill. `None` in production.
1304    #[doc(hidden)]
1305    spill_hook: Mutex<Option<Box<dyn Fn() + Send + Sync>>>,
1306    /// Test seam after the security read gate is held and before WAL append.
1307    #[doc(hidden)]
1308    security_commit_hook: Mutex<Option<Box<dyn Fn() + Send + Sync>>>,
1309    /// Test seam after transaction preparation and before catalog generation
1310    /// validation under the commit sequencer.
1311    #[doc(hidden)]
1312    catalog_commit_hook: Mutex<Option<Box<dyn Fn() + Send + Sync>>>,
1313    /// Test seam after a backup boundary is captured and before pinned runs are
1314    /// copied. Lets tests compact+GC the source at the worst possible moment.
1315    #[doc(hidden)]
1316    backup_hook: Mutex<Option<Box<dyn Fn() + Send + Sync>>>,
1317    replication_hook: Mutex<Option<Box<dyn Fn() + Send + Sync>>>,
1318    trigger_recursive: AtomicBool,
1319    trigger_max_depth: AtomicU32,
1320    trigger_max_loop_iterations: AtomicU32,
1321    /// Exclusive cross-process lock held for the database's lifetime to prevent
1322    /// two processes from opening the same directory concurrently.
1323    _lock: Option<DatabaseFileLock>,
1324    /// Lightweight channel for ephemeral SQL NOTIFY messages. Durable row CDC
1325    /// is reconstructed from the WAL by [`Database::change_events_since`].
1326    notify: tokio::sync::broadcast::Sender<ChangeEvent>,
1327    /// Commit-time wake-up for durable CDC consumers. Payloads are reconstructed
1328    /// from the WAL, so lagged receivers lose only a wake-up, never data.
1329    change_wake: tokio::sync::broadcast::Sender<()>,
1330    /// The authenticated principal for this handle. `None` on databases
1331    /// opened without credentials (the default — `require_auth = false`),
1332    /// `Some` on credentialed opens. Consulted by every enforcement point
1333    /// when the catalog's `require_auth` flag is set. Behind an `RwLock`
1334    /// because the access pattern is read-heavy: every `require()` call
1335    /// reads the principal, while writes happen only at open, `enable_auth`,
1336    /// and `refresh_principal`. This matches the engine's existing use of
1337    /// `RwLock` for `catalog` and `tables`.
1338    /// See `docs/15-credential-enforcement.md`.
1339    principal: RwLock<Option<crate::auth::Principal>>,
1340    /// Shared, cloneable handle to the auth state (require_auth flag from the
1341    /// catalog + the principal). Cloned into every mounted `Table` so the
1342    /// Table layer can enforce permissions without holding a reference back
1343    /// to `Database` (which would create a cycle). `AuthState` is already
1344    /// cheaply cloneable (inner `Arc`), so no outer `Arc` is needed.
1345    auth_state: crate::auth_state::AuthState,
1346}
1347
1348struct RunPins {
1349    pins: Arc<Mutex<HashMap<(u64, u128), usize>>>,
1350    runs: Vec<(u64, u128)>,
1351}
1352
1353struct BackupFilePins {
1354    root: PathBuf,
1355}
1356
1357struct PendingTableDir {
1358    path: PathBuf,
1359    armed: bool,
1360}
1361
1362impl PendingTableDir {
1363    fn new(path: PathBuf) -> Self {
1364        Self { path, armed: true }
1365    }
1366
1367    fn disarm(&mut self) {
1368        self.armed = false;
1369    }
1370}
1371
1372impl Drop for PendingTableDir {
1373    fn drop(&mut self) {
1374        if self.armed {
1375            let _ = std::fs::remove_dir_all(&self.path);
1376        }
1377    }
1378}
1379
1380impl Drop for BackupFilePins {
1381    fn drop(&mut self) {
1382        let _ = std::fs::remove_dir_all(&self.root);
1383    }
1384}
1385
1386impl Drop for RunPins {
1387    fn drop(&mut self) {
1388        let mut pins = self.pins.lock();
1389        for run in &self.runs {
1390            if let Some(count) = pins.get_mut(run) {
1391                *count -= 1;
1392                if *count == 0 {
1393                    pins.remove(run);
1394                }
1395            }
1396        }
1397    }
1398}
1399
1400/// A durable data-change event reconstructed from committed WAL records, or an
1401/// ephemeral SQL `NOTIFY` event when `id` is `None`.
1402#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
1403pub struct ChangeEvent {
1404    pub id: Option<String>,
1405    pub channel: String,
1406    pub table_id: Option<u64>,
1407    pub table: String,
1408    pub op: String,
1409    pub epoch: u64,
1410    pub txn_id: Option<u64>,
1411    pub message: Option<String>,
1412    pub data: Option<serde_json::Value>,
1413}
1414
1415#[derive(Debug, Clone)]
1416pub struct CdcBatch {
1417    pub events: Vec<ChangeEvent>,
1418    pub current_epoch: u64,
1419    pub earliest_epoch: Option<u64>,
1420    pub gap: bool,
1421}
1422
1423/// Manual `Debug` for `Database` — surfaces the diagnostics-relevant fields
1424/// (root, epoch, table count, encryption/auth state) without requiring every
1425/// internal type (Table, GroupCommit, broadcast sender, etc.) to impl Debug.
1426/// The raw field types carry locks, trait objects, and channels that have no
1427/// useful `Debug` output, so a hand-written impl is clearer than peppering
1428/// `#[allow(dead_code)]` skip attributes across two dozen fields.
1429impl std::fmt::Debug for Database {
1430    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1431        let cat = self.catalog.read();
1432        let principal_guard = self.principal.read();
1433        let principal: &str = principal_guard
1434            .as_ref()
1435            .map(|p| p.username.as_str())
1436            .unwrap_or("<none>");
1437        f.debug_struct("Database")
1438            .field("root", &self.root)
1439            .field("db_epoch", &cat.db_epoch)
1440            .field("open_generation", &"sidecar")
1441            .field("tables", &cat.tables.len())
1442            .field("visible_epoch", &self.epoch.visible().0)
1443            .field("encrypted", &self.kek.is_some())
1444            .field("require_auth", &cat.require_auth)
1445            .field("principal", &principal)
1446            .finish()
1447    }
1448}
1449
1450impl Database {
1451    fn canonical_lock_target(root: &Path) -> std::io::Result<(PathBuf, PathBuf)> {
1452        if let Ok(canonical) = root.canonicalize() {
1453            let lock_dir = canonical.parent().ok_or_else(|| {
1454                std::io::Error::new(
1455                    std::io::ErrorKind::InvalidInput,
1456                    "database root must have a parent directory",
1457                )
1458            })?;
1459            return Ok((canonical.clone(), lock_dir.to_path_buf()));
1460        }
1461
1462        let absolute = if root.is_absolute() {
1463            root.to_path_buf()
1464        } else {
1465            std::env::current_dir()?.join(root)
1466        };
1467        let mut cursor = absolute.as_path();
1468        let mut suffix = Vec::new();
1469        while !cursor.exists() {
1470            let name = cursor.file_name().ok_or_else(|| {
1471                std::io::Error::new(
1472                    std::io::ErrorKind::NotFound,
1473                    format!("no existing ancestor for database root {}", root.display()),
1474                )
1475            })?;
1476            suffix.push(name.to_os_string());
1477            cursor = cursor.parent().ok_or_else(|| {
1478                std::io::Error::new(
1479                    std::io::ErrorKind::NotFound,
1480                    format!("no existing ancestor for database root {}", root.display()),
1481                )
1482            })?;
1483        }
1484        let lock_dir = cursor.canonicalize()?;
1485        let mut canonical = lock_dir.clone();
1486        for component in suffix.iter().rev() {
1487            canonical.push(component);
1488        }
1489        Ok((canonical, lock_dir))
1490    }
1491
1492    fn acquire_database_lock(root: &Path, timeout_ms: u32) -> Result<DatabaseFileLock> {
1493        use std::hash::{Hash, Hasher};
1494
1495        let (canonical_path, lock_dir) = Self::canonical_lock_target(root)?;
1496        let mut process_locks = process_database_locks().lock();
1497        if let Some(shared) = process_locks
1498            .get(&canonical_path)
1499            .and_then(std::sync::Weak::upgrade)
1500        {
1501            return Ok(DatabaseFileLock {
1502                shared,
1503                canonical_path,
1504                durable_root: None,
1505            });
1506        }
1507
1508        let mut hasher = std::collections::hash_map::DefaultHasher::new();
1509        canonical_path.hash(&mut hasher);
1510        let lock_path = lock_dir.join(format!(".mongreldb-{:016x}.lock", hasher.finish()));
1511        let file = std::fs::OpenOptions::new()
1512            .create(true)
1513            .truncate(false)
1514            .write(true)
1515            .open(lock_path)?;
1516        if let Err(error) = Self::fs_lock_exclusive(&file, timeout_ms) {
1517            return Err(MongrelError::DatabaseLocked {
1518                path: root.to_path_buf(),
1519                message: error.to_string(),
1520            });
1521        }
1522        let shared = Arc::new(SharedDatabaseLock {
1523            bootstrap_file: file,
1524            legacy_file: Mutex::new(None),
1525            canonical_path: canonical_path.clone(),
1526        });
1527        process_locks.insert(canonical_path.clone(), Arc::downgrade(&shared));
1528        Ok(DatabaseFileLock {
1529            shared,
1530            canonical_path,
1531            durable_root: None,
1532        })
1533    }
1534
1535    fn acquire_legacy_database_lock(
1536        lock: &mut DatabaseFileLock,
1537        root: &Path,
1538        timeout_ms: u32,
1539    ) -> Result<()> {
1540        let durable_root = lock
1541            .durable_root
1542            .as_ref()
1543            .ok_or_else(|| MongrelError::Other("database root descriptor was not pinned".into()))?;
1544        let mut legacy_file = lock.shared.legacy_file.lock();
1545        if legacy_file.is_some() {
1546            return Ok(());
1547        }
1548        let file = durable_root.open_lock_file(Path::new(META_DIR).join(".lock"))?;
1549        if let Err(error) = Self::fs_lock_exclusive(&file, timeout_ms) {
1550            return Err(MongrelError::DatabaseLocked {
1551                path: root.to_path_buf(),
1552                message: error.to_string(),
1553            });
1554        }
1555        *legacy_file = Some(file);
1556        Ok(())
1557    }
1558
1559    fn pin_or_create_database_root(path: &Path) -> Result<crate::durable_file::DurableRoot> {
1560        if path.exists() {
1561            return crate::durable_file::DurableRoot::open(path).map_err(Into::into);
1562        }
1563        let mut ancestor = path;
1564        while !ancestor.exists() {
1565            ancestor = ancestor.parent().ok_or_else(|| {
1566                MongrelError::NotFound(format!(
1567                    "no existing ancestor for database root {}",
1568                    path.display()
1569                ))
1570            })?;
1571        }
1572        let relative = path.strip_prefix(ancestor).map_err(|error| {
1573            MongrelError::InvalidArgument(format!("invalid database root: {error}"))
1574        })?;
1575        crate::durable_file::DurableRoot::open(ancestor)?
1576            .create_directory_all_pinned(relative)
1577            .map_err(Into::into)
1578    }
1579
1580    fn begin_create(root: impl AsRef<Path>) -> Result<(PathBuf, DatabaseFileLock)> {
1581        let requested_root = root.as_ref();
1582        let mut lock = Self::acquire_database_lock(requested_root, 0)?;
1583        let root = lock.canonical_path.clone();
1584        Self::reject_existing_database(&root)?;
1585        let durable_root = Arc::new(Self::pin_or_create_database_root(&root)?);
1586        if durable_root.canonical_path() != lock.canonical_path {
1587            return Err(MongrelError::Conflict(
1588                "database root changed while it was being created".into(),
1589            ));
1590        }
1591        durable_root.create_directory_all(META_DIR)?;
1592        lock.durable_root = Some(durable_root);
1593        let io_root = lock
1594            .durable_root
1595            .as_ref()
1596            .ok_or_else(|| MongrelError::Other("database root descriptor was not pinned".into()))?
1597            .io_path()?;
1598        Self::acquire_legacy_database_lock(&mut lock, &io_root, 0)?;
1599        Self::reject_existing_database(&io_root)?;
1600        Ok((io_root, lock))
1601    }
1602
1603    fn begin_open(
1604        root: impl AsRef<Path>,
1605        lock_timeout_ms: u32,
1606    ) -> Result<(PathBuf, DatabaseFileLock)> {
1607        let root = root.as_ref();
1608        let durable_root = crate::durable_file::DurableRoot::open(root).map_err(|error| {
1609            if error.kind() == std::io::ErrorKind::NotFound {
1610                MongrelError::NotFound(format!("database root {}: {error}", root.display()))
1611            } else {
1612                error.into()
1613            }
1614        })?;
1615        Self::begin_open_durable(durable_root, lock_timeout_ms)
1616    }
1617
1618    fn begin_open_durable(
1619        durable_root: crate::durable_file::DurableRoot,
1620        lock_timeout_ms: u32,
1621    ) -> Result<(PathBuf, DatabaseFileLock)> {
1622        let io_root = durable_root.io_path()?;
1623        let current_root = io_root.canonicalize()?;
1624        let mut lock = Self::acquire_database_lock(&current_root, lock_timeout_ms)?;
1625        lock.durable_root = Some(Arc::new(durable_root));
1626        let io_root = lock
1627            .durable_root
1628            .as_ref()
1629            .ok_or_else(|| MongrelError::Other("database root descriptor was not pinned".into()))?
1630            .io_path()?;
1631        if lock
1632            .durable_root
1633            .as_ref()
1634            .ok_or_else(|| MongrelError::Other("database root descriptor was not pinned".into()))?
1635            .open_directory(META_DIR)
1636            .is_err()
1637        {
1638            return Err(MongrelError::NotFound(format!(
1639                "no database metadata found at {:?}",
1640                current_root
1641            )));
1642        }
1643        Self::acquire_legacy_database_lock(&mut lock, &io_root, lock_timeout_ms)?;
1644        Ok((io_root, lock))
1645    }
1646
1647    /// Create a fresh plaintext database at `root`.
1648    pub fn create(root: impl AsRef<Path>) -> Result<Self> {
1649        let (root, lock) = Self::begin_create(root)?;
1650        Self::create_inner(root, None, lock)
1651    }
1652
1653    /// Create a fresh encrypted database, deriving the DB-wide KEK from a
1654    /// passphrase (Argon2id + HKDF). The salt is persisted at `_meta/keys`.
1655    #[cfg(feature = "encryption")]
1656    pub fn create_encrypted(root: impl AsRef<Path>, passphrase: &str) -> 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::derive(passphrase, &salt)?);
1661        Self::create_inner(root, Some(kek), lock)
1662    }
1663
1664    /// Create a fresh encrypted database, deriving the DB-wide KEK from a raw
1665    /// high-entropy key via HKDF. The salt is persisted at `_meta/keys`.
1666    #[cfg(feature = "encryption")]
1667    pub fn create_with_key(root: impl AsRef<Path>, key: &[u8]) -> Result<Self> {
1668        let (root, lock) = Self::begin_create(root)?;
1669        let salt = crate::encryption::random_salt()?;
1670        crate::durable_file::write_atomic(&root.join(META_DIR).join(KEYS_FILENAME), &salt)?;
1671        let kek = Arc::new(crate::encryption::Kek::from_raw_key(key, &salt)?);
1672        Self::create_inner(root, Some(kek), lock)
1673    }
1674
1675    fn create_inner(
1676        root: PathBuf,
1677        kek: Option<Arc<crate::encryption::Kek>>,
1678        lock: DatabaseFileLock,
1679    ) -> Result<Self> {
1680        crate::durable_file::create_directory_all(&root.join(TABLES_DIR))?;
1681        let meta_dek = crate::encryption::meta_dek_for(kek.as_deref());
1682        let cat = Catalog::empty();
1683        catalog::write_atomic(&root, &cat, meta_dek.as_ref())?;
1684        Self::finish_open(root, cat, kek, meta_dek, false, None, None, None, lock)
1685    }
1686
1687    /// Open an existing plaintext database.
1688    pub fn open(root: impl AsRef<Path>) -> Result<Self> {
1689        Self::open_inner(root, None, None)
1690    }
1691
1692    /// Open an existing encrypted database with a passphrase.
1693    #[cfg(feature = "encryption")]
1694    pub fn open_encrypted(root: impl AsRef<Path>, passphrase: &str) -> Result<Self> {
1695        let (root, lock) = Self::begin_open(root, 0)?;
1696        let salt = read_encryption_salt(lock.durable_root.as_deref().ok_or_else(|| {
1697            MongrelError::Other("database root descriptor was not pinned".into())
1698        })?)?;
1699        let kek = Arc::new(crate::encryption::Kek::derive(passphrase, &salt)?);
1700        Self::open_inner_locked(root, Some(kek), lock)
1701    }
1702
1703    /// Open an existing encrypted database with a configurable cross-process
1704    /// lock timeout. Mirrors [`open_with_options`](Self::open_with_options).
1705    #[cfg(feature = "encryption")]
1706    pub fn open_encrypted_with_options(
1707        root: impl AsRef<Path>,
1708        passphrase: &str,
1709        options: OpenOptions,
1710    ) -> Result<Self> {
1711        let (root, lock) = Self::begin_open(root, options.lock_timeout_ms)?;
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::derive(passphrase, &salt)?);
1716        Self::open_inner_locked(root, Some(kek), lock)
1717    }
1718
1719    /// Open an existing encrypted database using a raw high-entropy key.
1720    #[cfg(feature = "encryption")]
1721    pub fn open_with_key(root: impl AsRef<Path>, key: &[u8]) -> Result<Self> {
1722        let (root, lock) = Self::begin_open(root, 0)?;
1723        let salt = read_encryption_salt(lock.durable_root.as_deref().ok_or_else(|| {
1724            MongrelError::Other("database root descriptor was not pinned".into())
1725        })?)?;
1726        let kek = Arc::new(crate::encryption::Kek::from_raw_key(key, &salt)?);
1727        Self::open_inner_locked(root, Some(kek), lock)
1728    }
1729
1730    /// Open an existing plaintext database that has `require_auth = true`,
1731    /// verifying the supplied credentials up front and caching the resolved
1732    /// [`Principal`] on the returned handle. Every subsequent operation will
1733    /// be checked against that principal.
1734    ///
1735    /// Returns [`MongrelError::AuthNotRequired`] if the database does not have
1736    /// `require_auth` enabled — callers must pick the matching constructor for
1737    /// the database's mode. Returns [`MongrelError::InvalidCredentials`] on a
1738    /// bad username/password.
1739    ///
1740    /// See `docs/15-credential-enforcement.md`.
1741    pub fn open_with_credentials(
1742        root: impl AsRef<Path>,
1743        username: &str,
1744        password: &str,
1745    ) -> Result<Self> {
1746        Self::open_inner_with_credentials(root, None, username, password)
1747    }
1748
1749    /// Open with credentials and a configurable cross-process lock timeout.
1750    /// Mirrors [`open_with_options`](Self::open_with_options) for the
1751    /// credentialed path.
1752    pub fn open_with_credentials_and_options(
1753        root: impl AsRef<Path>,
1754        username: &str,
1755        password: &str,
1756        options: OpenOptions,
1757    ) -> Result<Self> {
1758        Self::open_inner_with_credentials_and_lock_timeout(
1759            root,
1760            None,
1761            username,
1762            password,
1763            options.lock_timeout_ms,
1764        )
1765    }
1766
1767    /// Open an existing encrypted database that has `require_auth = true`,
1768    /// combining the encryption passphrase flow with credential verification.
1769    #[cfg(feature = "encryption")]
1770    pub fn open_encrypted_with_credentials(
1771        root: impl AsRef<Path>,
1772        passphrase: &str,
1773        username: &str,
1774        password: &str,
1775    ) -> Result<Self> {
1776        let (root, lock) = Self::begin_open(root, 0)?;
1777        let salt = read_encryption_salt(lock.durable_root.as_deref().ok_or_else(|| {
1778            MongrelError::Other("database root descriptor was not pinned".into())
1779        })?)?;
1780        let kek = Arc::new(crate::encryption::Kek::derive(passphrase, &salt)?);
1781        Self::open_inner_with_credentials_locked(root, Some(kek), username, password, lock)
1782    }
1783
1784    /// Open an encrypted + credentialed database with a configurable
1785    /// cross-process lock timeout. Mirrors
1786    /// [`open_encrypted_with_options`](Self::open_encrypted_with_options).
1787    #[cfg(feature = "encryption")]
1788    pub fn open_encrypted_with_credentials_and_options(
1789        root: impl AsRef<Path>,
1790        passphrase: &str,
1791        username: &str,
1792        password: &str,
1793        options: OpenOptions,
1794    ) -> Result<Self> {
1795        let (root, lock) = Self::begin_open(root, options.lock_timeout_ms)?;
1796        let salt = read_encryption_salt(lock.durable_root.as_deref().ok_or_else(|| {
1797            MongrelError::Other("database root descriptor was not pinned".into())
1798        })?)?;
1799        let kek = Arc::new(crate::encryption::Kek::derive(passphrase, &salt)?);
1800        Self::open_inner_with_credentials_locked(root, Some(kek), username, password, lock)
1801    }
1802
1803    /// Open an existing database with non-default [`OpenOptions`].
1804    ///
1805    /// Use this when you need cross-process lock retries (`lock_timeout_ms`)
1806    /// rather than the fail-fast default. The other open constructors keep
1807    /// their previous defaults; use their `*_with_options` variants when they
1808    /// need the same timeout behavior.
1809    pub fn open_with_options(root: impl AsRef<Path>, options: OpenOptions) -> Result<Self> {
1810        // No encryption, no auth; encrypted and credentialed paths have their
1811        // own `*_with_options` constructors.
1812        Self::open_inner_with_lock_timeout(root, None, None, options.lock_timeout_ms)
1813    }
1814
1815    fn open_inner_with_lock_timeout(
1816        root: impl AsRef<Path>,
1817        kek: Option<Arc<crate::encryption::Kek>>,
1818        _meta_dek_override: Option<[u8; META_DEK_LEN]>,
1819        lock_timeout_ms: u32,
1820    ) -> Result<Self> {
1821        let (root, lock) = Self::begin_open(root, lock_timeout_ms)?;
1822        Self::open_inner_locked(root, kek, lock)
1823    }
1824
1825    fn open_inner_locked(
1826        root: PathBuf,
1827        kek: Option<Arc<crate::encryption::Kek>>,
1828        lock: DatabaseFileLock,
1829    ) -> Result<Self> {
1830        let meta_dek = crate::encryption::meta_dek_for(kek.as_deref());
1831        let mut cat = catalog::read_durable(
1832            lock.durable_root.as_deref().ok_or_else(|| {
1833                MongrelError::Other("database root descriptor was not pinned".into())
1834            })?,
1835            meta_dek.as_ref(),
1836        )?
1837        .ok_or_else(|| MongrelError::NotFound(format!("no catalog found at {:?}", root)))?;
1838        let recovery_checkpoint = cat.clone();
1839
1840        // CATALOG is only a checkpoint. Authentication must use the
1841        // authoritative catalog after committed WAL DDL/security replay.
1842        let wal_dek = crate::encryption::wal_dek_for(kek.as_deref());
1843        let recovery_records = crate::wal::SharedWal::replay_durable_with_dek(
1844            lock.durable_root.as_deref().ok_or_else(|| {
1845                MongrelError::Other("database root descriptor was not pinned".into())
1846            })?,
1847            wal_dek.as_ref(),
1848        )?;
1849        recover_ddl_from_records(
1850            &root,
1851            Some(lock.durable_root.as_deref().ok_or_else(|| {
1852                MongrelError::Other("database root descriptor was not pinned".into())
1853            })?),
1854            &mut cat,
1855            meta_dek.as_ref(),
1856            false,
1857            None,
1858            &recovery_records,
1859        )?;
1860        Self::finish_open(
1861            root,
1862            cat,
1863            kek,
1864            meta_dek,
1865            true,
1866            Some(recovery_checkpoint),
1867            Some(recovery_records),
1868            None,
1869            lock,
1870        )
1871    }
1872
1873    /// Shared credentialed-open inner: read the catalog, verify the database
1874    /// requires auth, verify the password, resolve the principal, and pass
1875    /// everything to `finish_open` in one shot. This avoids the chicken-and-egg
1876    /// problem where `finish_open`'s fail-closed check (`require_auth &&
1877    /// principal.is_none()`) would fire before a post-open `authenticate()`
1878    /// could supply the principal.
1879    fn open_inner_with_credentials(
1880        root: impl AsRef<Path>,
1881        kek: Option<Arc<crate::encryption::Kek>>,
1882        username: &str,
1883        password: &str,
1884    ) -> Result<Self> {
1885        Self::open_inner_with_credentials_and_lock_timeout(root, kek, username, password, 0)
1886    }
1887
1888    /// Credentialed-open with an explicit cross-process lock timeout. The
1889    /// timeout is opt-in: callers that don't pass `OpenOptions` keep the
1890    /// historical fail-fast behavior via the wrapper above.
1891    fn open_inner_with_credentials_and_lock_timeout(
1892        root: impl AsRef<Path>,
1893        kek: Option<Arc<crate::encryption::Kek>>,
1894        username: &str,
1895        password: &str,
1896        lock_timeout_ms: u32,
1897    ) -> Result<Self> {
1898        let (root, lock) = Self::begin_open(root, lock_timeout_ms)?;
1899        Self::open_inner_with_credentials_locked(root, kek, username, password, lock)
1900    }
1901
1902    fn open_inner_with_credentials_locked(
1903        root: PathBuf,
1904        kek: Option<Arc<crate::encryption::Kek>>,
1905        username: &str,
1906        password: &str,
1907        lock: DatabaseFileLock,
1908    ) -> Result<Self> {
1909        let meta_dek = crate::encryption::meta_dek_for(kek.as_deref());
1910        let mut cat = catalog::read_durable(
1911            lock.durable_root.as_deref().ok_or_else(|| {
1912                MongrelError::Other("database root descriptor was not pinned".into())
1913            })?,
1914            meta_dek.as_ref(),
1915        )?
1916        .ok_or_else(|| MongrelError::NotFound(format!("no catalog found at {:?}", root)))?;
1917        let recovery_checkpoint = cat.clone();
1918
1919        // Never verify against a stale checkpoint. A committed password,
1920        // user, role, or auth-mode change in WAL is authoritative.
1921        let wal_dek = crate::encryption::wal_dek_for(kek.as_deref());
1922        let recovery_records = crate::wal::SharedWal::replay_durable_with_dek(
1923            lock.durable_root.as_deref().ok_or_else(|| {
1924                MongrelError::Other("database root descriptor was not pinned".into())
1925            })?,
1926            wal_dek.as_ref(),
1927        )?;
1928        recover_ddl_from_records(
1929            &root,
1930            Some(lock.durable_root.as_deref().ok_or_else(|| {
1931                MongrelError::Other("database root descriptor was not pinned".into())
1932            })?),
1933            &mut cat,
1934            meta_dek.as_ref(),
1935            false,
1936            None,
1937            &recovery_records,
1938        )?;
1939
1940        // Fail early if the database is not in require_auth mode — the caller
1941        // picked the wrong constructor.
1942        if !cat.require_auth {
1943            return Err(MongrelError::AuthNotRequired);
1944        }
1945
1946        // Verify credentials against the on-disk catalog before constructing
1947        // the full Database handle. This reads users/hashes directly from the
1948        // loaded catalog rather than going through the Database::verify_user
1949        // method (which requires a constructed Database).
1950        let user = cat
1951            .users
1952            .iter()
1953            .find(|u| u.username == username)
1954            .filter(|u| !u.password_hash.is_empty())
1955            .ok_or_else(|| MongrelError::InvalidCredentials {
1956                username: username.to_string(),
1957            })?;
1958        let password_ok = crate::auth::verify_password(password, &user.password_hash)
1959            .map_err(MongrelError::Other)?;
1960        if !password_ok {
1961            return Err(MongrelError::InvalidCredentials {
1962                username: username.to_string(),
1963            });
1964        }
1965
1966        // Resolve the principal from the catalog (roles + permissions).
1967        let principal =
1968            Self::resolve_principal_from_catalog(&cat, &user.username).ok_or_else(|| {
1969                MongrelError::InvalidCredentials {
1970                    username: username.to_string(),
1971                }
1972            })?;
1973
1974        Self::finish_open(
1975            root,
1976            cat,
1977            kek,
1978            meta_dek,
1979            true,
1980            Some(recovery_checkpoint),
1981            Some(recovery_records),
1982            Some(principal),
1983            lock,
1984        )
1985    }
1986
1987    /// Create a fresh plaintext database with `require_auth = true` and a
1988    /// single admin user. The returned handle is already authenticated as
1989    /// that admin — every subsequent operation is checked against the admin
1990    /// principal (which bypasses all permission checks via `is_admin`).
1991    ///
1992    /// This is the bootstrap path: there is no window where the database
1993    /// requires auth but has no users.
1994    ///
1995    /// See `docs/15-credential-enforcement.md`.
1996    pub fn create_with_credentials(
1997        root: impl AsRef<Path>,
1998        admin_username: &str,
1999        admin_password: &str,
2000    ) -> Result<Self> {
2001        let (root, lock) = Self::begin_create(root)?;
2002        Self::create_inner_with_credentials(root, None, admin_username, admin_password, lock)
2003    }
2004
2005    /// Create a fresh encrypted database with `require_auth = true` and a
2006    /// single admin user. Composes encryption-at-rest with credential
2007    /// enforcement.
2008    #[cfg(feature = "encryption")]
2009    pub fn create_encrypted_with_credentials(
2010        root: impl AsRef<Path>,
2011        passphrase: &str,
2012        admin_username: &str,
2013        admin_password: &str,
2014    ) -> Result<Self> {
2015        let (root, lock) = Self::begin_create(root)?;
2016        let salt = crate::encryption::random_salt()?;
2017        crate::durable_file::write_atomic(&root.join(META_DIR).join(KEYS_FILENAME), &salt)?;
2018        let kek = Arc::new(crate::encryption::Kek::derive(passphrase, &salt)?);
2019        Self::create_inner_with_credentials(root, Some(kek), admin_username, admin_password, lock)
2020    }
2021
2022    fn create_inner_with_credentials(
2023        root: PathBuf,
2024        kek: Option<Arc<crate::encryption::Kek>>,
2025        admin_username: &str,
2026        admin_password: &str,
2027        lock: DatabaseFileLock,
2028    ) -> Result<Self> {
2029        crate::durable_file::create_directory_all(&root.join(TABLES_DIR))?;
2030        let meta_dek = crate::encryption::meta_dek_for(kek.as_deref());
2031
2032        // Build the initial catalog with require_auth = true and one admin user.
2033        let password_hash =
2034            crate::auth::hash_password(admin_password).map_err(MongrelError::Other)?;
2035        let mut cat = Catalog::empty();
2036        cat.require_auth = true;
2037        cat.next_user_id = 2;
2038        cat.users.push(crate::auth::UserEntry {
2039            id: 1,
2040            username: admin_username.to_string(),
2041            password_hash,
2042            roles: Vec::new(),
2043            is_admin: true,
2044            created_epoch: 0,
2045        });
2046        catalog::write_atomic(&root, &cat, meta_dek.as_ref())?;
2047
2048        // The handle is constructed already authenticated as the admin user
2049        // it just created — no separate verify step needed.
2050        let admin_principal = crate::auth::Principal {
2051            user_id: 1,
2052            created_epoch: 0,
2053            username: admin_username.to_string(),
2054            is_admin: true,
2055            roles: Vec::new(),
2056            permissions: Vec::new(),
2057        };
2058        Self::finish_open(
2059            root,
2060            cat,
2061            kek,
2062            meta_dek,
2063            false,
2064            None,
2065            None,
2066            Some(admin_principal),
2067            lock,
2068        )
2069    }
2070
2071    fn reject_existing_database(root: &Path) -> Result<()> {
2072        // Refuse to overwrite an existing database. If CATALOG exists, the
2073        // directory already contains a real database; replacing it destroys data.
2074        if root.join(catalog::CATALOG_FILENAME).exists() {
2075            return Err(MongrelError::InvalidArgument(format!(
2076                "database already exists at {}; use Database::open() to open it, \
2077                 or remove the directory first",
2078                root.display()
2079            )));
2080        }
2081        Ok(())
2082    }
2083
2084    fn open_inner(
2085        root: impl AsRef<Path>,
2086        kek: Option<Arc<crate::encryption::Kek>>,
2087        _meta_dek_override: Option<[u8; META_DEK_LEN]>,
2088    ) -> Result<Self> {
2089        Self::open_inner_with_lock_timeout(root, kek, None, 0)
2090    }
2091
2092    /// Internal recovery open for a staging directory explicitly marked as a
2093    /// read-only replica. It bypasses user authentication only so PITR can
2094    /// replay auth-mode and password transitions; it is not public API.
2095    pub(crate) fn open_replica_recovery_durable(
2096        root: &crate::durable_file::DurableRoot,
2097    ) -> Result<Self> {
2098        let (root, lock) = Self::begin_open_durable(root.try_clone()?, 0)?;
2099        Self::open_replica_recovery_inner(root, None, lock)
2100    }
2101
2102    #[cfg(feature = "encryption")]
2103    pub(crate) fn open_encrypted_replica_recovery_durable(
2104        root: &crate::durable_file::DurableRoot,
2105        passphrase: &str,
2106    ) -> Result<Self> {
2107        let (root_path, lock) = Self::begin_open_durable(root.try_clone()?, 0)?;
2108        let salt = read_encryption_salt(root)?;
2109        let kek = Arc::new(crate::encryption::Kek::derive(passphrase, &salt)?);
2110        Self::open_replica_recovery_inner(root_path, Some(kek), lock)
2111    }
2112
2113    fn open_replica_recovery_inner(
2114        root: PathBuf,
2115        kek: Option<Arc<crate::encryption::Kek>>,
2116        lock: DatabaseFileLock,
2117    ) -> Result<Self> {
2118        if !root.join(META_DIR).join("replica").is_file() {
2119            return Err(MongrelError::InvalidArgument(
2120                "recovery auth bypass requires a marked replica staging directory".into(),
2121            ));
2122        }
2123        let meta_dek = crate::encryption::meta_dek_for(kek.as_deref());
2124        let mut cat = catalog::read_durable(
2125            lock.durable_root.as_deref().ok_or_else(|| {
2126                MongrelError::Other("database root descriptor was not pinned".into())
2127            })?,
2128            meta_dek.as_ref(),
2129        )?
2130        .ok_or_else(|| MongrelError::NotFound(format!("no catalog found at {:?}", root)))?;
2131        let recovery_checkpoint = cat.clone();
2132        let wal_dek = crate::encryption::wal_dek_for(kek.as_deref());
2133        let recovery_records = crate::wal::SharedWal::replay_durable_with_dek(
2134            lock.durable_root.as_deref().ok_or_else(|| {
2135                MongrelError::Other("database root descriptor was not pinned".into())
2136            })?,
2137            wal_dek.as_ref(),
2138        )?;
2139        recover_ddl_from_records(
2140            &root,
2141            Some(lock.durable_root.as_deref().ok_or_else(|| {
2142                MongrelError::Other("database root descriptor was not pinned".into())
2143            })?),
2144            &mut cat,
2145            meta_dek.as_ref(),
2146            false,
2147            None,
2148            &recovery_records,
2149        )?;
2150        let principal = if cat.require_auth {
2151            cat.users
2152                .iter()
2153                .find(|user| user.is_admin)
2154                .and_then(|user| Self::resolve_principal_from_catalog(&cat, &user.username))
2155                .ok_or_else(|| {
2156                    MongrelError::Schema(
2157                        "authenticated replica catalog has no recoverable admin".into(),
2158                    )
2159                })?
2160                .into()
2161        } else {
2162            None
2163        };
2164        Self::finish_open(
2165            root,
2166            cat,
2167            kek,
2168            meta_dek,
2169            true,
2170            Some(recovery_checkpoint),
2171            Some(recovery_records),
2172            principal,
2173            lock,
2174        )
2175    }
2176
2177    /// Acquire an exclusive advisory lock on `f`, retrying on `EAGAIN`/`EWOULDBLOCK`
2178    /// until `timeout_ms` elapses, mirroring SQLite's `busy_timeout` semantics.
2179    ///
2180    /// `timeout_ms == 0` is the fail-fast path: a single `try_lock_exclusive` call,
2181    /// no retry, no sleep. Existing open paths rely on that fail-fast default for
2182    /// backwards compatibility — opt in with `OpenOptions::lock_timeout_ms`.
2183    ///
2184    /// Backoff schedule: 1ms → 10ms → 50ms → 50ms → ... until `timeout_ms`.
2185    /// Total elapsed (not just sleep time) is bounded by `timeout_ms`, so the
2186    /// caller never blocks past its budget even at the tail of a busy lock
2187    /// holder's lock-window.
2188    fn fs_lock_exclusive(f: &std::fs::File, timeout_ms: u32) -> std::io::Result<()> {
2189        use fs2::FileExt;
2190        if timeout_ms == 0 {
2191            return f.try_lock_exclusive();
2192        }
2193        // Per-call deadline so a single stray 50ms sleep can't overshoot the budget.
2194        let deadline =
2195            std::time::Instant::now() + std::time::Duration::from_millis(timeout_ms as u64);
2196        let mut next_sleep = std::time::Duration::from_millis(1);
2197        loop {
2198            match f.try_lock_exclusive() {
2199                Ok(()) => return Ok(()),
2200                Err(e) if e.kind() == std::io::ErrorKind::WouldBlock => {
2201                    let now = std::time::Instant::now();
2202                    if now >= deadline {
2203                        return Err(std::io::Error::new(
2204                            std::io::ErrorKind::WouldBlock,
2205                            format!("could not acquire database lock within {timeout_ms}ms"),
2206                        ));
2207                    }
2208                    let remaining = deadline - now;
2209                    let sleep = next_sleep.min(remaining);
2210                    std::thread::sleep(sleep);
2211                    // Cap the per-iteration sleep so a single back-off step
2212                    // never overshoots the remaining budget.
2213                    next_sleep = next_sleep
2214                        .saturating_mul(10)
2215                        .min(std::time::Duration::from_millis(50));
2216                }
2217                Err(e) => return Err(e),
2218            }
2219        }
2220    }
2221
2222    #[allow(clippy::too_many_arguments)]
2223    fn finish_open(
2224        root: PathBuf,
2225        cat: Catalog,
2226        kek: Option<Arc<crate::encryption::Kek>>,
2227        meta_dek: Option<[u8; META_DEK_LEN]>,
2228        existing: bool,
2229        recovery_checkpoint: Option<Catalog>,
2230        recovery_records: Option<Vec<crate::wal::Record>>,
2231        principal: Option<crate::auth::Principal>,
2232        lock: DatabaseFileLock,
2233    ) -> Result<Self> {
2234        let durable_root = Arc::clone(lock.durable_root.as_ref().ok_or_else(|| {
2235            MongrelError::Other("database root descriptor was not pinned".into())
2236        })?);
2237        let read_only = if existing {
2238            match durable_root.open_regular(Path::new(META_DIR).join("replica")) {
2239                Ok(_) => true,
2240                Err(error) if error.kind() == std::io::ErrorKind::NotFound => false,
2241                Err(error) => return Err(error.into()),
2242            }
2243        } else {
2244            false
2245        };
2246        let recovered_catalog = cat;
2247        let mut cat = recovered_catalog.clone();
2248        let abandoned = if existing && !read_only {
2249            let abandoned = cat
2250                .tables
2251                .iter()
2252                .filter(|entry| matches!(entry.state, TableState::Building { .. }))
2253                .map(|entry| entry.table_id)
2254                .collect::<Vec<_>>();
2255            for entry in &mut cat.tables {
2256                if abandoned.contains(&entry.table_id) {
2257                    entry.state = TableState::Dropped {
2258                        at_epoch: cat.db_epoch,
2259                    };
2260                }
2261            }
2262            abandoned
2263        } else {
2264            Vec::new()
2265        };
2266        let wal_dek = crate::encryption::wal_dek_for(kek.as_deref());
2267        let recovery_records = match (existing, recovery_records) {
2268            (true, Some(records)) => records,
2269            (true, None) => {
2270                return Err(MongrelError::Other(
2271                    "existing open has no validated WAL recovery plan".into(),
2272                ))
2273            }
2274            (false, _) => Vec::new(),
2275        };
2276        let (history_epochs, history_start) =
2277            read_history_retention(&durable_root, Epoch(cat.db_epoch))?;
2278        let open_generation = if existing {
2279            let checkpoint = recovery_checkpoint.as_ref().ok_or_else(|| {
2280                MongrelError::Other("existing open has no catalog recovery checkpoint".into())
2281            })?;
2282            let recovered_table_ids = cat
2283                .tables
2284                .iter()
2285                .filter(|entry| {
2286                    checkpoint
2287                        .tables
2288                        .iter()
2289                        .all(|checkpoint| checkpoint.table_id != entry.table_id)
2290                })
2291                .map(|entry| entry.table_id)
2292                .collect::<HashSet<_>>();
2293            let reconciled_table_ids = cat
2294                .tables
2295                .iter()
2296                .filter(|entry| {
2297                    checkpoint
2298                        .tables
2299                        .iter()
2300                        .find(|checkpoint| checkpoint.table_id == entry.table_id)
2301                        .is_some_and(|checkpoint| {
2302                            crate::wal::DdlOp::encode_schema(&checkpoint.schema).ok()
2303                                != crate::wal::DdlOp::encode_schema(&entry.schema).ok()
2304                        })
2305                })
2306                .map(|entry| entry.table_id)
2307                .collect::<HashSet<_>>();
2308            validate_shared_wal_recovery_plan(
2309                &durable_root,
2310                &cat,
2311                &recovered_table_ids,
2312                &reconciled_table_ids,
2313                meta_dek.as_ref(),
2314                kek.clone(),
2315                &recovery_records,
2316            )?;
2317            let retained_generation = recovery_records
2318                .iter()
2319                .filter(|record| record.txn_id != crate::wal::SYSTEM_TXN_ID)
2320                .map(|record| record.txn_id >> 32)
2321                .max()
2322                .unwrap_or(0);
2323            let head_generation =
2324                crate::wal::SharedWal::durable_open_generation(&durable_root, wal_dek.as_ref())?;
2325            let durable_floor = match head_generation {
2326                Some(head) if retained_generation > head => {
2327                    return Err(MongrelError::CorruptWal {
2328                        offset: retained_generation,
2329                        reason: format!(
2330                            "retained transaction generation {retained_generation} exceeds WAL head generation {head}"
2331                        ),
2332                    })
2333                }
2334                Some(head) => head,
2335                None => retained_generation,
2336            };
2337            let stored = catalog::read_generation(&durable_root)?;
2338            if stored.is_some_and(|generation| generation < durable_floor) {
2339                return Err(MongrelError::Other(format!(
2340                    "open-generation {stored:?} precedes durable WAL generation {durable_floor}"
2341                )));
2342            }
2343            let bumped = stored
2344                .unwrap_or(durable_floor)
2345                .max(durable_floor)
2346                .checked_add(1)
2347                .ok_or_else(|| MongrelError::Full("open-generation namespace exhausted".into()))?;
2348            if bumped > u32::MAX as u64 {
2349                return Err(MongrelError::Full(
2350                    "open-generation namespace exhausted".into(),
2351                ));
2352            }
2353            bumped
2354        } else {
2355            0
2356        };
2357        let principal = if cat.require_auth {
2358            let supplied = principal.as_ref().ok_or(MongrelError::AuthRequired)?;
2359            Some(
2360                Self::resolve_bound_principal_from_catalog(&cat, supplied)
2361                    .ok_or(MongrelError::AuthRequired)?,
2362            )
2363        } else {
2364            principal
2365        };
2366        let mut table_roots = HashMap::<u64, Arc<crate::durable_file::DurableRoot>>::new();
2367        if existing {
2368            for entry in &cat.tables {
2369                if !matches!(entry.state, TableState::Live) {
2370                    continue;
2371                }
2372                match durable_root
2373                    .open_directory(Path::new(TABLES_DIR).join(entry.table_id.to_string()))
2374                {
2375                    Ok(root) => {
2376                        table_roots.insert(entry.table_id, Arc::new(root));
2377                    }
2378                    Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
2379                    Err(error) => return Err(error.into()),
2380                }
2381            }
2382        }
2383
2384        // No database-tree mutation occurs above this point. DDL, row payloads,
2385        // immutable runs, auth state, retention, and generation state have all
2386        // been validated against the authoritative recovered catalog.
2387        if existing {
2388            let mut applied = recovery_checkpoint.ok_or_else(|| {
2389                MongrelError::Other("existing open has no catalog recovery checkpoint".into())
2390            })?;
2391            recover_ddl_from_records(
2392                &root,
2393                Some(&durable_root),
2394                &mut applied,
2395                meta_dek.as_ref(),
2396                true,
2397                Some(&table_roots),
2398                &recovery_records,
2399            )?;
2400            let catalog_value = |catalog: &Catalog| {
2401                serde_json::to_value(catalog)
2402                    .map_err(|error| MongrelError::Other(format!("catalog compare: {error}")))
2403            };
2404            if catalog_value(&applied)? != catalog_value(&recovered_catalog)? {
2405                return Err(MongrelError::CorruptWal {
2406                    offset: 0,
2407                    reason: "validated and applied DDL recovery plans differ".into(),
2408                });
2409            }
2410            if catalog_value(&cat)? != catalog_value(&applied)? {
2411                catalog::write_atomic(&root, &cat, meta_dek.as_ref())?;
2412            }
2413            validate_catalog_table_storage(&durable_root, &cat, meta_dek.as_ref())?;
2414            if !read_only {
2415                sweep_unreferenced_table_dirs(&root, &cat)?;
2416            }
2417            match durable_root.remove_directory_all(Path::new(META_DIR).join("backup-pins")) {
2418                Ok(()) => {}
2419                Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
2420                Err(error) => return Err(error.into()),
2421            }
2422        }
2423
2424        let epoch = Arc::new(EpochAuthority::new(cat.db_epoch));
2425        let snapshots = Arc::new(SnapshotRegistry::new());
2426        snapshots.configure_history(history_epochs, history_start);
2427        let page_cache = Arc::new(crate::cache::Sharded::new(
2428            crate::cache::CACHE_SHARDS,
2429            || {
2430                crate::cache::PageCache::new(
2431                    crate::engine::PAGE_CACHE_CAPACITY / crate::cache::CACHE_SHARDS as u64,
2432                )
2433            },
2434        ));
2435        let decoded_cache = Arc::new(crate::cache::Sharded::new(
2436            crate::cache::CACHE_SHARDS,
2437            || {
2438                crate::cache::DecodedPageCache::new(
2439                    crate::engine::DECODED_CACHE_CAPACITY / crate::cache::CACHE_SHARDS as u64,
2440                )
2441            },
2442        ));
2443        let commit_lock = Arc::new(Mutex::new(()));
2444        let shared_wal = Arc::new(Mutex::new(if existing {
2445            crate::wal::SharedWal::open_durable_root_validated(
2446                Arc::clone(&durable_root),
2447                Epoch(cat.db_epoch),
2448                wal_dek.clone(),
2449                Some(&recovery_records),
2450            )?
2451        } else {
2452            crate::wal::SharedWal::create_with_durable_root(
2453                Arc::clone(&durable_root),
2454                Epoch(cat.db_epoch),
2455                wal_dek.clone(),
2456            )?
2457        }));
2458        // Shared write-path state handed to every mounted table so single-table
2459        // `put`/`commit` writes route through the one shared WAL, the one group-
2460        // commit coordinator, and the one poison flag (B1).
2461        let poisoned = Arc::new(std::sync::atomic::AtomicBool::new(false));
2462        let group = Arc::new(crate::txn::GroupCommit::new(
2463            shared_wal.lock().durable_seq(),
2464        ));
2465        let (change_wake, _change_rx) = tokio::sync::broadcast::channel(256);
2466        // Final base value is set after the open-generation bump below; tables
2467        // only draw ids once the user issues a write (post-open), so the
2468        // placeholder is never observed.
2469        let txn_ids = Arc::new(Mutex::new(1u64));
2470        let _ = abandoned;
2471
2472        // Build the shared auth state early — it's cloned into every mounted
2473        // Table's SharedCtx so the Table layer can enforce permissions without
2474        // a reference back to Database. The `require_auth` flag is mirrored
2475        // from the catalog; `enable_auth` / `refresh_principal` update it live.
2476        let auth_state = crate::auth_state::AuthState::new(cat.require_auth, principal.clone());
2477        let security_coordinator = security_coordinator(&root, cat.security_version);
2478        let auth_checker: Option<Arc<dyn crate::auth_state::TableAuthChecker>> = Some(Arc::new(
2479            crate::auth_state::DefaultTableAuthChecker::new(auth_state.clone()),
2480        ));
2481
2482        // Open every live table against the shared context. Mounted tables have
2483        // no private WAL (B1) — `open_in` just loads the manifest/runs and
2484        // advances the shared epoch authority to its manifest epoch, so the
2485        // final shared watermark is the max across all tables. All of a mounted
2486        // table's committed records are replayed below from the shared WAL.
2487        let mut tables: HashMap<u64, TableHandle> = HashMap::new();
2488        for entry in &cat.tables {
2489            if !matches!(entry.state, TableState::Live) {
2490                continue;
2491            }
2492            let table_root = match table_roots.remove(&entry.table_id) {
2493                Some(root) => root,
2494                None => Arc::new(
2495                    durable_root
2496                        .open_directory(Path::new(TABLES_DIR).join(entry.table_id.to_string()))?,
2497                ),
2498            };
2499            let tdir = table_root.io_path()?;
2500            let ctx = SharedCtx {
2501                root_guard: Some(table_root),
2502                epoch: Arc::clone(&epoch),
2503                page_cache: Arc::clone(&page_cache),
2504                decoded_cache: Arc::clone(&decoded_cache),
2505                snapshots: Arc::clone(&snapshots),
2506                kek: kek.clone(),
2507                commit_lock: Arc::clone(&commit_lock),
2508                shared: Some(crate::engine::SharedWalCtx {
2509                    wal: Arc::clone(&shared_wal),
2510                    group: Arc::clone(&group),
2511                    poisoned: Arc::clone(&poisoned),
2512                    txn_ids: Arc::clone(&txn_ids),
2513                    change_wake: change_wake.clone(),
2514                }),
2515                table_name: Some(entry.name.clone()),
2516                auth: auth_checker.clone(),
2517                read_only,
2518            };
2519            let t = Table::open_in(&tdir, ctx)?;
2520            tables.insert(entry.table_id, TableHandle::new(t));
2521        }
2522
2523        // Recover transaction writes from the shared WAL (spec §15). This is the
2524        // single durability source for mounted tables: it applies every committed
2525        // record — both single-table `Table::commit` writes and cross-table
2526        // transactions — gated by each table's `flushed_epoch` (records already
2527        // durable in a run are not re-applied).
2528        if existing {
2529            recover_shared_wal(&durable_root, &tables, &cat, &epoch, &recovery_records)?;
2530            reconcile_recovered_table_metadata(&tables, epoch.visible())?;
2531            if read_only {
2532                crate::replication::reconcile_replica_epoch_durable(
2533                    &durable_root,
2534                    epoch.visible().0,
2535                )?;
2536            }
2537            // P3.4: sweep stale `_txn/<txn_id>/` dirs left by aborted/crashed
2538            // large transactions (spec §8.5, review fix #14).
2539            sweep_pending_txn_dirs(&root, &cat);
2540        }
2541
2542        // Persist only after all semantic recovery and table mounting succeeds.
2543        catalog::write_generation(&durable_root, open_generation)?;
2544        shared_wal.lock().seal_open_generation(open_generation)?;
2545        crate::replication::replication_identity_durable(&durable_root)?;
2546        let next_txn_id = (open_generation << 32) | 1;
2547        // Seed the shared txn-id allocator now that the generation is final.
2548        *txn_ids.lock() = next_txn_id;
2549
2550        Ok(Self {
2551            root,
2552            durable_root,
2553            read_only,
2554            catalog: RwLock::new(cat),
2555            security_coordinator,
2556            security_catalog_disk_reads: AtomicU64::new(0),
2557            rls_cache: Mutex::new(RlsCache::default()),
2558            epoch,
2559            snapshots,
2560            page_cache,
2561            decoded_cache,
2562            commit_lock,
2563            shared_wal,
2564            next_txn_id: txn_ids,
2565            tables: RwLock::new(tables),
2566            kek,
2567            ddl_lock: Mutex::new(()),
2568            meta_dek,
2569            conflicts: crate::txn::ConflictIndex::new(),
2570            active_txns: crate::txn::ActiveTxns::new(),
2571            poisoned,
2572            group,
2573            spill_threshold: std::sync::atomic::AtomicU64::new(64 * 1024 * 1024),
2574            active_spills: Arc::new(crate::retention::ActiveSpills::new()),
2575            replication_barrier: parking_lot::RwLock::new(()),
2576            replication_wal_retention_segments: AtomicUsize::new(0),
2577            backup_pins: Arc::new(Mutex::new(HashMap::new())),
2578            spill_hook: Mutex::new(None),
2579            security_commit_hook: Mutex::new(None),
2580            catalog_commit_hook: Mutex::new(None),
2581            backup_hook: Mutex::new(None),
2582            replication_hook: Mutex::new(None),
2583            trigger_recursive: AtomicBool::new(TriggerConfig::default().recursive_triggers),
2584            trigger_max_depth: AtomicU32::new(TriggerConfig::default().max_depth),
2585            trigger_max_loop_iterations: AtomicU32::new(
2586                TriggerConfig::default().max_loop_iterations,
2587            ),
2588            _lock: Some(lock),
2589            notify: {
2590                let (tx, _rx) = tokio::sync::broadcast::channel(256);
2591                tx
2592            },
2593            change_wake,
2594            principal: RwLock::new(principal),
2595            auth_state,
2596        })
2597    }
2598
2599    /// The current reader-visible epoch.
2600    pub fn visible_epoch(&self) -> Epoch {
2601        self.epoch.visible()
2602    }
2603
2604    /// Clone the in-memory catalog (for diagnostics / tests).
2605    pub fn catalog_snapshot(&self) -> Catalog {
2606        self.catalog.read().clone()
2607    }
2608
2609    /// Read SQLite-compatible application metadata persisted in the catalog.
2610    pub fn sql_pragma_i64(&self, key: &str) -> Result<Option<i64>> {
2611        let catalog = self.catalog.read();
2612        match key {
2613            "user_version" => Ok(catalog.user_version),
2614            "application_id" => Ok(catalog.application_id),
2615            _ => Err(MongrelError::InvalidArgument(format!(
2616                "unsupported persistent SQL pragma {key:?}"
2617            ))),
2618        }
2619    }
2620
2621    /// Persist SQLite-compatible application metadata and return its exact
2622    /// publication epoch. An unchanged value performs no durable write.
2623    pub fn set_sql_pragma_i64_with_epoch(&self, key: &str, value: i64) -> Result<Option<Epoch>> {
2624        self.set_sql_pragma_i64_with_epoch_inner(key, value, None)
2625    }
2626
2627    pub fn set_sql_pragma_i64_with_epoch_controlled<F>(
2628        &self,
2629        key: &str,
2630        value: i64,
2631        mut before_commit: F,
2632    ) -> Result<Option<Epoch>>
2633    where
2634        F: FnMut() -> Result<()>,
2635    {
2636        self.set_sql_pragma_i64_with_epoch_inner(key, value, Some(&mut before_commit))
2637    }
2638
2639    fn set_sql_pragma_i64_with_epoch_inner(
2640        &self,
2641        key: &str,
2642        value: i64,
2643        before_commit: Option<&mut dyn FnMut() -> Result<()>>,
2644    ) -> Result<Option<Epoch>> {
2645        use crate::wal::DdlOp;
2646
2647        self.require(&crate::auth::Permission::Ddl)?;
2648        if self.read_only {
2649            return Err(MongrelError::ReadOnlyReplica);
2650        }
2651        if self.poisoned.load(Ordering::Relaxed) {
2652            return Err(MongrelError::Other(
2653                "database poisoned by fsync error".into(),
2654            ));
2655        }
2656        let _ddl = self.ddl_lock.lock();
2657        let _security_write = self.security_write()?;
2658        self.require(&crate::auth::Permission::Ddl)?;
2659        let mut next_catalog = self.catalog.read().clone();
2660        let target = match key {
2661            "user_version" => &mut next_catalog.user_version,
2662            "application_id" => &mut next_catalog.application_id,
2663            _ => {
2664                return Err(MongrelError::InvalidArgument(format!(
2665                    "unsupported persistent SQL pragma {key:?}"
2666                )))
2667            }
2668        };
2669        if *target == Some(value) {
2670            return Ok(None);
2671        }
2672        *target = Some(value);
2673
2674        let _commit = self.commit_lock.lock();
2675        let epoch = self.epoch.bump_assigned();
2676        let mut epoch_guard = EpochGuard::new(self.epoch.as_ref(), epoch);
2677        let txn_id = self.alloc_txn_id()?;
2678        next_catalog.db_epoch = next_catalog.db_epoch.max(epoch.0);
2679        let commit_seq = {
2680            let mut wal = self.shared_wal.lock();
2681            if let Some(before_commit) = before_commit {
2682                before_commit()?;
2683            }
2684            let append: Result<u64> = (|| {
2685                wal.append(
2686                    txn_id,
2687                    WAL_TABLE_ID,
2688                    crate::wal::Op::Ddl(DdlOp::SetSqlPragma {
2689                        key: key.to_string(),
2690                        value,
2691                    }),
2692                )?;
2693                append_catalog_snapshot(&mut wal, txn_id, &next_catalog)?;
2694                wal.append_commit(txn_id, epoch, &[])
2695            })();
2696            append.map_err(|error| self.commit_outcome_unknown(epoch, error))?
2697        };
2698        self.await_durable_commit(commit_seq, epoch)?;
2699        let checkpoint = self.checkpoint_catalog_after_durable(next_catalog);
2700        self.finish_durable_publish(epoch, &mut epoch_guard, checkpoint)?;
2701        Ok(Some(epoch))
2702    }
2703
2704    pub fn materialized_view(&self, name: &str) -> Option<crate::catalog::MaterializedViewEntry> {
2705        self.catalog
2706            .read()
2707            .materialized_views
2708            .iter()
2709            .find(|definition| definition.name == name)
2710            .cloned()
2711    }
2712
2713    pub fn materialized_views(&self) -> Vec<crate::catalog::MaterializedViewEntry> {
2714        self.catalog.read().materialized_views.clone()
2715    }
2716
2717    pub fn security_catalog(&self) -> crate::security::SecurityCatalog {
2718        self.catalog.read().security.clone()
2719    }
2720
2721    pub fn security_active_for(&self, table: &str) -> bool {
2722        self.catalog.read().security.table_has_security(table)
2723    }
2724
2725    fn refresh_security_catalog_if_stale(&self, expected_version: u64) -> Result<()> {
2726        if self.catalog.read().security_version == expected_version {
2727            return Ok(());
2728        }
2729        self.security_catalog_disk_reads
2730            .fetch_add(1, Ordering::Relaxed);
2731        let fresh = catalog::read_durable(&self.durable_root, self.meta_dek.as_ref())?
2732            .ok_or_else(|| MongrelError::NotFound("catalog vanished during write".into()))?;
2733        let principal = self.principal.read().clone();
2734        let principal = if fresh.require_auth {
2735            principal
2736                .as_ref()
2737                .and_then(|principal| Self::resolve_bound_principal_from_catalog(&fresh, principal))
2738        } else {
2739            principal
2740        };
2741        self.auth_state.set_require_auth(fresh.require_auth);
2742        *self.catalog.write() = fresh;
2743        *self.principal.write() = principal.clone();
2744        self.auth_state.set_principal(principal);
2745        Ok(())
2746    }
2747
2748    fn security_write(&self) -> Result<parking_lot::RwLockWriteGuard<'_, ()>> {
2749        let guard = self.security_coordinator.gate.write();
2750        let version = self.security_coordinator.version.load(Ordering::Acquire);
2751        self.refresh_security_catalog_if_stale(version)?;
2752        Ok(guard)
2753    }
2754
2755    /// Commit an exact catalog image through the shared WAL, then checkpoint it.
2756    /// The WAL image is the authoritative PITR and replication delta; CATALOG is
2757    /// only its restart checkpoint.
2758    fn publish_catalog_candidate(
2759        &self,
2760        catalog: Catalog,
2761        epoch: Epoch,
2762        epoch_guard: &mut EpochGuard<'_>,
2763        before_publish: Option<&mut dyn FnMut() -> Result<()>>,
2764    ) -> Result<()> {
2765        self.publish_catalog_candidate_with_prelude(
2766            catalog,
2767            epoch,
2768            epoch_guard,
2769            before_publish,
2770            Vec::new(),
2771        )
2772    }
2773
2774    fn publish_catalog_candidate_with_prelude(
2775        &self,
2776        catalog: Catalog,
2777        epoch: Epoch,
2778        epoch_guard: &mut EpochGuard<'_>,
2779        mut before_publish: Option<&mut dyn FnMut() -> Result<()>>,
2780        prelude: Vec<(u64, crate::wal::Op)>,
2781    ) -> Result<()> {
2782        use crate::wal::DdlOp;
2783
2784        if self.read_only {
2785            return Err(MongrelError::ReadOnlyReplica);
2786        }
2787        if self.poisoned.load(Ordering::Relaxed) {
2788            return Err(MongrelError::Other(
2789                "database poisoned by fsync error".into(),
2790            ));
2791        }
2792        if let Some(before_publish) = before_publish.as_mut() {
2793            (**before_publish)()?;
2794        }
2795        if catalog.db_epoch != epoch.0 {
2796            return Err(MongrelError::InvalidArgument(format!(
2797                "catalog epoch {} does not match commit epoch {}",
2798                catalog.db_epoch, epoch.0
2799            )));
2800        }
2801        {
2802            let current = self.catalog.read();
2803            validate_catalog_transition(&current, &catalog)?;
2804        }
2805        validate_recovered_catalog(&catalog)?;
2806        let catalog_json = DdlOp::encode_catalog(&catalog)?;
2807        let txn_id = self.alloc_txn_id()?;
2808        let commit_seq = {
2809            let mut wal = self.shared_wal.lock();
2810            let append: Result<u64> = (|| {
2811                for (table_id, op) in prelude {
2812                    wal.append(txn_id, table_id, op)?;
2813                }
2814                wal.append(
2815                    txn_id,
2816                    WAL_TABLE_ID,
2817                    crate::wal::Op::Ddl(DdlOp::CatalogSnapshot { catalog_json }),
2818                )?;
2819                wal.append_commit(txn_id, epoch, &[])
2820            })();
2821            append.map_err(|error| self.commit_outcome_unknown(epoch, error))?
2822        };
2823        self.await_durable_commit(commit_seq, epoch)?;
2824        let checkpoint = self.checkpoint_catalog_after_durable(catalog);
2825        self.finish_durable_publish(epoch, epoch_guard, checkpoint)
2826    }
2827
2828    /// A WAL commit is already durable. Publish the matching catalog in memory
2829    /// even when its checkpoint rewrite fails; recovery can rebuild the file,
2830    /// while the live handle must never continue with pre-commit metadata.
2831    fn checkpoint_catalog_after_durable(&self, catalog: Catalog) -> Result<()> {
2832        let checkpoint = catalog::write_atomic(&self.root, &catalog, self.meta_dek.as_ref());
2833        let version = catalog.security_version;
2834        let principal = self.principal.read().clone();
2835        let principal = if catalog.require_auth {
2836            principal.as_ref().and_then(|principal| {
2837                Self::resolve_bound_principal_from_catalog(&catalog, principal)
2838            })
2839        } else {
2840            principal
2841        };
2842        *self.catalog.write() = catalog;
2843        self.security_coordinator
2844            .version
2845            .store(version, Ordering::Release);
2846        self.auth_state
2847            .set_require_auth(self.catalog.read().require_auth);
2848        *self.principal.write() = principal.clone();
2849        self.auth_state.set_principal(principal);
2850        checkpoint
2851    }
2852
2853    fn finish_durable_publish(
2854        &self,
2855        epoch: Epoch,
2856        epoch_guard: &mut EpochGuard<'_>,
2857        post_step: Result<()>,
2858    ) -> Result<()> {
2859        self.epoch.publish_in_order(epoch);
2860        epoch_guard.disarm();
2861        match post_step {
2862            Ok(()) => Ok(()),
2863            Err(error) => {
2864                self.poisoned.store(true, Ordering::Relaxed);
2865                Err(MongrelError::DurableCommit {
2866                    epoch: epoch.0,
2867                    message: error.to_string(),
2868                })
2869            }
2870        }
2871    }
2872
2873    /// Wait for a commit marker to reach stable storage. A failed append/fsync
2874    /// acknowledgement is ambiguous, so poison the live handle and preserve
2875    /// the assigned epoch in a structured unknown-outcome error.
2876    fn await_durable_commit(&self, commit_seq: u64, epoch: Epoch) -> Result<()> {
2877        match self.group.await_durable(&self.shared_wal, commit_seq) {
2878            Ok(()) => Ok(()),
2879            Err(error) => {
2880                self.poisoned.store(true, Ordering::Relaxed);
2881                Err(MongrelError::CommitOutcomeUnknown {
2882                    epoch: epoch.0,
2883                    message: error.to_string(),
2884                })
2885            }
2886        }
2887    }
2888
2889    fn commit_outcome_unknown(&self, epoch: Epoch, error: impl std::fmt::Display) -> MongrelError {
2890        self.poisoned.store(true, Ordering::Relaxed);
2891        MongrelError::CommitOutcomeUnknown {
2892            epoch: epoch.0,
2893            message: error.to_string(),
2894        }
2895    }
2896
2897    /// Persist a complete validated RLS/masking catalog through the WAL.
2898    pub fn set_security_catalog(&self, security: crate::security::SecurityCatalog) -> Result<()> {
2899        self.set_security_catalog_as_with_epoch(security, None)
2900            .map(|_| ())
2901    }
2902
2903    /// Persist security policy changes on behalf of an explicit request principal.
2904    pub fn set_security_catalog_as(
2905        &self,
2906        security: crate::security::SecurityCatalog,
2907        principal: Option<&crate::auth::Principal>,
2908    ) -> Result<()> {
2909        self.set_security_catalog_as_with_epoch(security, principal)
2910            .map(|_| ())
2911    }
2912
2913    /// Persist security policy changes and return the exact publication epoch.
2914    pub fn set_security_catalog_as_with_epoch(
2915        &self,
2916        security: crate::security::SecurityCatalog,
2917        principal: Option<&crate::auth::Principal>,
2918    ) -> Result<Epoch> {
2919        self.set_security_catalog_as_with_epoch_inner(security, principal, None)
2920    }
2921
2922    /// Persist security policy changes, entering the commit fence immediately
2923    /// before the first WAL record can become visible to recovery.
2924    pub fn set_security_catalog_as_with_epoch_controlled<F>(
2925        &self,
2926        security: crate::security::SecurityCatalog,
2927        principal: Option<&crate::auth::Principal>,
2928        mut before_commit: F,
2929    ) -> Result<Epoch>
2930    where
2931        F: FnMut() -> Result<()>,
2932    {
2933        self.set_security_catalog_as_with_epoch_inner(security, principal, Some(&mut before_commit))
2934    }
2935
2936    fn set_security_catalog_as_with_epoch_inner(
2937        &self,
2938        security: crate::security::SecurityCatalog,
2939        principal: Option<&crate::auth::Principal>,
2940        before_commit: Option<&mut dyn FnMut() -> Result<()>>,
2941    ) -> Result<Epoch> {
2942        use crate::wal::DdlOp;
2943        use std::sync::atomic::Ordering;
2944
2945        self.require_for(principal, &crate::auth::Permission::Admin)?;
2946        if self.poisoned.load(Ordering::Relaxed) {
2947            return Err(MongrelError::Other(
2948                "database poisoned by fsync error".into(),
2949            ));
2950        }
2951        let _ddl = self.ddl_lock.lock();
2952        // DDL serializes first; write-path order after that is security gate ->
2953        // commit lock -> shared WAL.
2954        let _security_write = self.security_write()?;
2955        self.require_for(principal, &crate::auth::Permission::Admin)?;
2956        let mut next_catalog = self.catalog.read().clone();
2957        validate_security_catalog(&next_catalog, &security)?;
2958        let payload = DdlOp::encode_security(&security)?;
2959        let _commit = self.commit_lock.lock();
2960        let epoch = self.epoch.bump_assigned();
2961        let mut epoch_guard = EpochGuard::new(self.epoch.as_ref(), epoch);
2962        let txn_id = self.alloc_txn_id()?;
2963        next_catalog.security = security;
2964        advance_security_version(&mut next_catalog)?;
2965        next_catalog.db_epoch = next_catalog.db_epoch.max(epoch.0);
2966        let commit_seq = {
2967            let mut wal = self.shared_wal.lock();
2968            if let Some(before_commit) = before_commit {
2969                before_commit()?;
2970            }
2971            let append: Result<u64> = (|| {
2972                wal.append(
2973                    txn_id,
2974                    WAL_TABLE_ID,
2975                    crate::wal::Op::Ddl(DdlOp::SetSecurityCatalog {
2976                        security_json: payload,
2977                    }),
2978                )?;
2979                append_catalog_snapshot(&mut wal, txn_id, &next_catalog)?;
2980                wal.append_commit(txn_id, epoch, &[])
2981            })();
2982            append.map_err(|error| self.commit_outcome_unknown(epoch, error))?
2983        };
2984        self.await_durable_commit(commit_seq, epoch)?;
2985        let checkpoint = self.checkpoint_catalog_after_durable(next_catalog);
2986        self.finish_durable_publish(epoch, &mut epoch_guard, checkpoint)?;
2987        Ok(epoch)
2988    }
2989
2990    pub fn require_for(
2991        &self,
2992        principal: Option<&crate::auth::Principal>,
2993        permission: &crate::auth::Permission,
2994    ) -> Result<()> {
2995        let Some(principal) = principal else {
2996            return self.require(permission);
2997        };
2998        let resolved;
2999        let principal = if self.auth_state.require_auth() || principal.user_id != 0 {
3000            resolved = Self::resolve_bound_principal_from_catalog(&self.catalog.read(), principal)
3001                .ok_or(MongrelError::AuthRequired)?;
3002            &resolved
3003        } else {
3004            principal
3005        };
3006        #[cfg(test)]
3007        TABLE_PERMISSION_DECISIONS.with(|decisions| decisions.set(decisions.get() + 1));
3008        if principal.has_permission(permission) {
3009            Ok(())
3010        } else {
3011            Err(MongrelError::PermissionDenied {
3012                required: permission.clone(),
3013                principal: principal.username.clone(),
3014            })
3015        }
3016    }
3017
3018    /// Recheck the exact operation principal while the caller holds the
3019    /// security gate. This deliberately performs no refresh or nested gate
3020    /// acquisition.
3021    fn require_exact_principal_current(
3022        &self,
3023        principal: Option<&crate::auth::Principal>,
3024        permission: &crate::auth::Permission,
3025    ) -> Result<()> {
3026        let catalog = self.catalog.read();
3027        if !catalog.require_auth {
3028            return Ok(());
3029        }
3030        let supplied = principal.ok_or(MongrelError::AuthRequired)?;
3031        let current = Self::resolve_bound_principal_from_catalog(&catalog, supplied)
3032            .ok_or(MongrelError::AuthRequired)?;
3033        if current.has_permission(permission) {
3034            Ok(())
3035        } else {
3036            Err(MongrelError::PermissionDenied {
3037                required: permission.clone(),
3038                principal: current.username,
3039            })
3040        }
3041    }
3042
3043    pub(crate) fn with_exact_principal_current<T, F>(
3044        &self,
3045        principal: Option<&crate::auth::Principal>,
3046        permission: &crate::auth::Permission,
3047        operation: F,
3048    ) -> Result<T>
3049    where
3050        F: FnOnce() -> Result<T>,
3051    {
3052        let _security = self.security_coordinator.gate.read();
3053        self.require_exact_principal_current(principal, permission)?;
3054        operation()
3055    }
3056
3057    pub fn principal_snapshot(&self) -> Option<crate::auth::Principal> {
3058        self.principal.read().clone()
3059    }
3060
3061    #[cfg(test)]
3062    pub(crate) fn set_cached_principal_for_test(&self, principal: Option<crate::auth::Principal>) {
3063        *self.principal.write() = principal.clone();
3064        self.auth_state.set_principal(principal);
3065    }
3066
3067    pub fn require_columns_for(
3068        &self,
3069        table: &str,
3070        operation: crate::auth::ColumnOperation,
3071        column_ids: &[u16],
3072        principal: Option<&crate::auth::Principal>,
3073    ) -> Result<()> {
3074        if principal.is_none() && !self.auth_state.require_auth() {
3075            return Ok(());
3076        }
3077        let cached = self.principal.read().clone();
3078        let principal = principal.or(cached.as_ref());
3079        let Some(principal) = principal else {
3080            let permission = match operation {
3081                crate::auth::ColumnOperation::Select => crate::auth::Permission::Select {
3082                    table: table.to_string(),
3083                },
3084                crate::auth::ColumnOperation::Insert => crate::auth::Permission::Insert {
3085                    table: table.to_string(),
3086                },
3087                crate::auth::ColumnOperation::Update => crate::auth::Permission::Update {
3088                    table: table.to_string(),
3089                },
3090            };
3091            return self.require(&permission);
3092        };
3093        let catalog = self.catalog.read();
3094        let resolved;
3095        let principal = if catalog.require_auth || principal.user_id != 0 {
3096            resolved = Self::resolve_bound_principal_from_catalog(&catalog, principal)
3097                .ok_or(MongrelError::AuthRequired)?;
3098            &resolved
3099        } else {
3100            principal
3101        };
3102        let schema = &catalog
3103            .live(table)
3104            .ok_or_else(|| MongrelError::NotFound(format!("table {table:?} not found")))?
3105            .schema;
3106        Self::require_columns_for_principal(table, schema, operation, column_ids, principal)
3107    }
3108
3109    fn require_columns_for_principal(
3110        table: &str,
3111        schema: &Schema,
3112        operation: crate::auth::ColumnOperation,
3113        column_ids: &[u16],
3114        principal: &crate::auth::Principal,
3115    ) -> Result<()> {
3116        #[cfg(test)]
3117        WRITE_PERMISSION_DECISIONS.with(|decisions| decisions.set(decisions.get() + 1));
3118        match principal.column_access(table, operation) {
3119            crate::auth::ColumnAccess::All => Ok(()),
3120            crate::auth::ColumnAccess::Columns(allowed) => {
3121                let denied = column_ids.iter().find_map(|column_id| {
3122                    schema
3123                        .columns
3124                        .iter()
3125                        .find(|column| column.id == *column_id)
3126                        .filter(|column| !allowed.contains(&column.name))
3127                });
3128                if denied.is_none() {
3129                    Ok(())
3130                } else {
3131                    Err(MongrelError::PermissionDenied {
3132                        required: match operation {
3133                            crate::auth::ColumnOperation::Select => {
3134                                crate::auth::Permission::SelectColumns {
3135                                    table: table.to_string(),
3136                                    columns: denied
3137                                        .into_iter()
3138                                        .map(|column| column.name.clone())
3139                                        .collect(),
3140                                }
3141                            }
3142                            crate::auth::ColumnOperation::Insert => {
3143                                crate::auth::Permission::InsertColumns {
3144                                    table: table.to_string(),
3145                                    columns: denied
3146                                        .into_iter()
3147                                        .map(|column| column.name.clone())
3148                                        .collect(),
3149                                }
3150                            }
3151                            crate::auth::ColumnOperation::Update => {
3152                                crate::auth::Permission::UpdateColumns {
3153                                    table: table.to_string(),
3154                                    columns: denied
3155                                        .into_iter()
3156                                        .map(|column| column.name.clone())
3157                                        .collect(),
3158                                }
3159                            }
3160                        },
3161                        principal: principal.username.clone(),
3162                    })
3163                }
3164            }
3165            crate::auth::ColumnAccess::Denied => Err(MongrelError::PermissionDenied {
3166                required: match operation {
3167                    crate::auth::ColumnOperation::Select => crate::auth::Permission::Select {
3168                        table: table.to_string(),
3169                    },
3170                    crate::auth::ColumnOperation::Insert => crate::auth::Permission::Insert {
3171                        table: table.to_string(),
3172                    },
3173                    crate::auth::ColumnOperation::Update => crate::auth::Permission::Update {
3174                        table: table.to_string(),
3175                    },
3176                },
3177                principal: principal.username.clone(),
3178            }),
3179        }
3180    }
3181
3182    pub fn select_column_ids_for(
3183        &self,
3184        table: &str,
3185        principal: Option<&crate::auth::Principal>,
3186    ) -> Result<Vec<u16>> {
3187        let catalog = self.catalog.read();
3188        let columns = catalog
3189            .live(table)
3190            .ok_or_else(|| MongrelError::NotFound(format!("table {table:?} not found")))?
3191            .schema
3192            .columns
3193            .iter()
3194            .map(|column| (column.id, column.name.clone()))
3195            .collect::<Vec<_>>();
3196        let principal = self.principal_for_authorized_read(&catalog, principal, false)?;
3197        drop(catalog);
3198        let Some(principal) = principal.as_ref() else {
3199            self.require(&crate::auth::Permission::Select {
3200                table: table.to_string(),
3201            })?;
3202            return Ok(columns.iter().map(|(id, _)| *id).collect());
3203        };
3204        match principal.column_access(table, crate::auth::ColumnOperation::Select) {
3205            crate::auth::ColumnAccess::All => Ok(columns.iter().map(|(id, _)| *id).collect()),
3206            crate::auth::ColumnAccess::Columns(allowed) => Ok(columns
3207                .iter()
3208                .filter(|(_, name)| allowed.contains(name))
3209                .map(|(id, _)| *id)
3210                .collect()),
3211            crate::auth::ColumnAccess::Denied => Err(MongrelError::PermissionDenied {
3212                required: crate::auth::Permission::Select {
3213                    table: table.to_string(),
3214                },
3215                principal: principal.username.clone(),
3216            }),
3217        }
3218    }
3219
3220    pub fn secure_rows_for(
3221        &self,
3222        table: &str,
3223        rows: Vec<crate::memtable::Row>,
3224        principal: Option<&crate::auth::Principal>,
3225    ) -> Result<Vec<crate::memtable::Row>> {
3226        self.secure_rows_for_with_context(table, rows, principal, None)
3227    }
3228
3229    pub fn secure_rows_for_with_context(
3230        &self,
3231        table: &str,
3232        rows: Vec<crate::memtable::Row>,
3233        principal: Option<&crate::auth::Principal>,
3234        context: Option<&crate::query::AiExecutionContext>,
3235    ) -> Result<Vec<crate::memtable::Row>> {
3236        let (security, principal) = {
3237            let catalog = self.catalog.read();
3238            (
3239                catalog.security.clone(),
3240                self.principal_for_authorized_read(&catalog, principal, false)?,
3241            )
3242        };
3243        if !security.table_has_security(table) {
3244            return Ok(rows);
3245        }
3246        let principal = principal.as_ref().ok_or(MongrelError::AuthRequired)?;
3247        let mut output = Vec::new();
3248        for mut row in rows {
3249            if let Some(context) = context {
3250                context.consume(1)?;
3251            }
3252            if security.row_allowed(
3253                table,
3254                crate::security::PolicyCommand::Select,
3255                &row,
3256                principal,
3257                false,
3258            ) {
3259                security.apply_masks(table, &mut row, principal);
3260                output.push(row);
3261            }
3262        }
3263        Ok(output)
3264    }
3265
3266    /// Apply column masks to already RLS-authorized scored hits without a
3267    /// second row gather or policy evaluation.
3268    pub fn mask_search_hits_for(
3269        &self,
3270        table: &str,
3271        hits: &mut [crate::query::SearchHit],
3272        principal: Option<&crate::auth::Principal>,
3273    ) -> Result<()> {
3274        let (security, principal) = {
3275            let catalog = self.catalog.read();
3276            (
3277                catalog.security.clone(),
3278                self.principal_for_authorized_read(&catalog, principal, false)?,
3279            )
3280        };
3281        if !security.table_has_security(table) {
3282            return Ok(());
3283        }
3284        let principal = principal.as_ref().ok_or(MongrelError::AuthRequired)?;
3285        for hit in hits {
3286            security.apply_masks_to_cells(table, &mut hit.cells, principal);
3287        }
3288        Ok(())
3289    }
3290
3291    /// Apply masks to rows already admitted by candidate-aware RLS.
3292    pub fn mask_rows_for(
3293        &self,
3294        table: &str,
3295        rows: &mut [crate::memtable::Row],
3296        principal: Option<&crate::auth::Principal>,
3297    ) -> Result<()> {
3298        let (security, principal) = {
3299            let catalog = self.catalog.read();
3300            (
3301                catalog.security.clone(),
3302                self.principal_for_authorized_read(&catalog, principal, false)?,
3303            )
3304        };
3305        if !security.table_has_security(table) {
3306            return Ok(());
3307        }
3308        let principal = principal.as_ref().ok_or(MongrelError::AuthRequired)?;
3309        for row in rows {
3310            security.apply_masks(table, row, principal);
3311        }
3312        Ok(())
3313    }
3314
3315    /// Row IDs allowed to enter scored ranking. `None` means no RLS filter.
3316    pub fn authorized_candidate_ids_for(
3317        &self,
3318        table: &str,
3319        principal: Option<&crate::auth::Principal>,
3320    ) -> Result<Option<std::collections::HashSet<RowId>>> {
3321        Ok(self
3322            .authorized_read_snapshot(table, principal)?
3323            .allowed_row_ids)
3324    }
3325
3326    fn allowed_row_ids_locked(
3327        &self,
3328        table_name: &str,
3329        table: &Table,
3330        table_snapshot: Snapshot,
3331        security_state: (&crate::security::SecurityCatalog, u64),
3332        principal: Option<&crate::auth::Principal>,
3333        context: Option<&crate::query::AiExecutionContext>,
3334    ) -> Result<Option<Arc<HashSet<RowId>>>> {
3335        let (security, security_version) = security_state;
3336        if !security.rls_enabled(table_name) {
3337            return Ok(None);
3338        }
3339        let authorization_started = std::time::Instant::now();
3340        let principal = principal.ok_or(MongrelError::AuthRequired)?;
3341        let mut roles = principal.roles.clone();
3342        roles.sort_unstable();
3343        let principal_key = format!(
3344            "{}:{}:{}:{}:{roles:?}",
3345            principal.user_id, principal.created_epoch, principal.username, principal.is_admin
3346        );
3347        let cache_key = (
3348            table_name.to_string(),
3349            table.data_generation(),
3350            security_version,
3351            principal_key,
3352        );
3353        if let Some(allowed) = self.rls_cache.lock().get(&cache_key) {
3354            crate::trace::QueryTrace::record(|trace| {
3355                trace.rls_cache_hit = true;
3356                trace.authorization_nanos = trace
3357                    .authorization_nanos
3358                    .saturating_add(authorization_started.elapsed().as_nanos() as u64);
3359            });
3360            return Ok(Some(allowed));
3361        }
3362        if let Some(context) = context {
3363            context.checkpoint()?;
3364        }
3365        // ponytail: full RLS universe scan; replace with policy-column candidate checks if RLS search throughput matters.
3366        let started = std::time::Instant::now();
3367        let rows = table.visible_rows(table_snapshot)?;
3368        let rows_evaluated = rows.len() as u64;
3369        let mut allowed = HashSet::new();
3370        for chunk in rows.chunks(256) {
3371            if let Some(context) = context {
3372                context.consume(chunk.len())?;
3373            }
3374            allowed.extend(chunk.iter().filter_map(|row| {
3375                security
3376                    .row_allowed(
3377                        table_name,
3378                        crate::security::PolicyCommand::Select,
3379                        row,
3380                        principal,
3381                        false,
3382                    )
3383                    .then_some(row.row_id)
3384            }));
3385        }
3386        let allowed = Arc::new(allowed);
3387        let mut cache = self.rls_cache.lock();
3388        cache.build_nanos = cache
3389            .build_nanos
3390            .saturating_add(started.elapsed().as_nanos() as u64);
3391        cache.rows_evaluated = cache.rows_evaluated.saturating_add(rows_evaluated);
3392        cache.insert(cache_key, Arc::clone(&allowed));
3393        crate::trace::QueryTrace::record(|trace| {
3394            trace.rls_rows_evaluated = trace
3395                .rls_rows_evaluated
3396                .saturating_add(rows_evaluated as usize);
3397            trace.authorization_nanos = trace
3398                .authorization_nanos
3399                .saturating_add(authorization_started.elapsed().as_nanos() as u64);
3400        });
3401        Ok(Some(allowed))
3402    }
3403
3404    fn principal_for_authorized_read(
3405        &self,
3406        catalog: &Catalog,
3407        principal: Option<&crate::auth::Principal>,
3408        catalog_bound: bool,
3409    ) -> Result<Option<crate::auth::Principal>> {
3410        let principal = principal.cloned().or_else(|| self.principal.read().clone());
3411        let Some(principal) = principal else {
3412            return Ok(None);
3413        };
3414        if catalog.require_auth || catalog_bound || principal.user_id != 0 {
3415            return Self::resolve_bound_principal_from_catalog(catalog, &principal)
3416                .map(Some)
3417                .ok_or(MongrelError::AuthRequired);
3418        }
3419        Ok(Some(principal))
3420    }
3421
3422    /// Run authorization, candidate generation, ranking, and materialization
3423    /// while holding one table generation. Security changes cause a bounded
3424    /// retry before any result is published.
3425    pub fn with_authorized_read<T, F>(
3426        &self,
3427        table_name: &str,
3428        principal: Option<&crate::auth::Principal>,
3429        catalog_bound: bool,
3430        read: F,
3431    ) -> Result<T>
3432    where
3433        F: FnMut(
3434            &mut Table,
3435            Snapshot,
3436            Option<&HashSet<RowId>>,
3437            Option<&crate::auth::Principal>,
3438        ) -> Result<T>,
3439    {
3440        self.with_authorized_read_context(
3441            table_name,
3442            principal,
3443            catalog_bound,
3444            None,
3445            None,
3446            None,
3447            read,
3448        )
3449    }
3450
3451    #[allow(clippy::too_many_arguments)]
3452    pub fn with_authorized_read_context<T, F>(
3453        &self,
3454        table_name: &str,
3455        principal: Option<&crate::auth::Principal>,
3456        catalog_bound: bool,
3457        authorization: Option<&ReadAuthorization>,
3458        context: Option<&crate::query::AiExecutionContext>,
3459        snapshot_override: Option<Snapshot>,
3460        read: F,
3461    ) -> Result<T>
3462    where
3463        F: FnMut(
3464            &mut Table,
3465            Snapshot,
3466            Option<&HashSet<RowId>>,
3467            Option<&crate::auth::Principal>,
3468        ) -> Result<T>,
3469    {
3470        self.with_authorized_read_context_stamped(
3471            table_name,
3472            principal,
3473            catalog_bound,
3474            authorization,
3475            context,
3476            snapshot_override,
3477            read,
3478        )
3479        .map(|(result, _)| result)
3480    }
3481
3482    #[allow(clippy::too_many_arguments)]
3483    pub fn with_authorized_read_context_stamped<T, F>(
3484        &self,
3485        table_name: &str,
3486        principal: Option<&crate::auth::Principal>,
3487        catalog_bound: bool,
3488        authorization: Option<&ReadAuthorization>,
3489        context: Option<&crate::query::AiExecutionContext>,
3490        snapshot_override: Option<Snapshot>,
3491        mut read: F,
3492    ) -> Result<(T, AuthorizedReadStamp)>
3493    where
3494        F: FnMut(
3495            &mut Table,
3496            Snapshot,
3497            Option<&HashSet<RowId>>,
3498            Option<&crate::auth::Principal>,
3499        ) -> Result<T>,
3500    {
3501        if principal.is_none() && self.principal.read().is_some() {
3502            self.refresh_principal()?;
3503        }
3504        const RETRIES: usize = 3;
3505        let handle = self.table(table_name)?;
3506        for attempt in 0..RETRIES {
3507            crate::trace::QueryTrace::record(|trace| {
3508                trace.authorization_retries = attempt;
3509            });
3510            let (security, security_version, effective_principal) = {
3511                let catalog = self.catalog.read();
3512                (
3513                    catalog.security.clone(),
3514                    catalog.security_version,
3515                    self.principal_for_authorized_read(&catalog, principal, catalog_bound)?,
3516                )
3517            };
3518            if let Some(authorization) = authorization {
3519                for permission in &authorization.permissions {
3520                    self.require_for(effective_principal.as_ref(), permission)?;
3521                }
3522                self.require_columns_for(
3523                    table_name,
3524                    authorization.operation,
3525                    &authorization.columns,
3526                    effective_principal.as_ref(),
3527                )?;
3528            }
3529            let result = {
3530                let mut table = lock_table_with_context(&handle, context)?;
3531                let snapshot = snapshot_override.unwrap_or_else(|| table.snapshot());
3532                let allowed = self.allowed_row_ids_locked(
3533                    table_name,
3534                    &table,
3535                    snapshot,
3536                    (&security, security_version),
3537                    effective_principal.as_ref(),
3538                    context,
3539                )?;
3540                let stamp = AuthorizedReadStamp {
3541                    table_id: table.table_id(),
3542                    schema_id: table.schema().schema_id,
3543                    data_generation: table.data_generation(),
3544                    security_version,
3545                    snapshot,
3546                };
3547                let result = read(
3548                    &mut table,
3549                    snapshot,
3550                    allowed.as_deref(),
3551                    effective_principal.as_ref(),
3552                )?;
3553                (result, stamp)
3554            };
3555            if let Some(context) = context {
3556                context.checkpoint()?;
3557            }
3558            if self.catalog.read().security_version == security_version {
3559                return Ok(result);
3560            }
3561            if attempt + 1 == RETRIES {
3562                return Err(MongrelError::Conflict(
3563                    "security policy changed during scored read".into(),
3564                ));
3565            }
3566        }
3567        Err(MongrelError::Conflict(
3568            "authorization retry loop exhausted".into(),
3569        ))
3570    }
3571
3572    fn with_authorized_aggregate_table<T, F>(
3573        &self,
3574        table_name: &str,
3575        columns: &[u16],
3576        principal: Option<&crate::auth::Principal>,
3577        catalog_bound: bool,
3578        allow_table_security: bool,
3579        mut aggregate: F,
3580    ) -> Result<T>
3581    where
3582        F: FnMut(
3583            &mut Table,
3584            Option<&crate::security::CandidateAuthorization<'_>>,
3585            Option<&crate::auth::Principal>,
3586            u64,
3587        ) -> Result<T>,
3588    {
3589        if principal.is_none() && self.principal.read().is_some() {
3590            self.refresh_principal()?;
3591        }
3592        const RETRIES: usize = 3;
3593        let handle = self.table(table_name)?;
3594        for attempt in 0..RETRIES {
3595            let (security, security_version, effective_principal) = {
3596                let catalog = self.catalog.read();
3597                (
3598                    catalog.security.clone(),
3599                    catalog.security_version,
3600                    self.principal_for_authorized_read(&catalog, principal, catalog_bound)?,
3601                )
3602            };
3603            self.require_columns_for(
3604                table_name,
3605                crate::auth::ColumnOperation::Select,
3606                columns,
3607                effective_principal.as_ref(),
3608            )?;
3609            if !allow_table_security && security.table_has_security(table_name) {
3610                return Err(MongrelError::InvalidArgument(
3611                    "incremental aggregate is unsupported while RLS or column masks are active"
3612                        .into(),
3613                ));
3614            }
3615            let result = {
3616                let mut table = handle.lock();
3617                let authorization = if security.rls_enabled(table_name) {
3618                    Some(crate::security::CandidateAuthorization {
3619                        table: table_name,
3620                        security: &security,
3621                        principal: effective_principal
3622                            .as_ref()
3623                            .ok_or(MongrelError::AuthRequired)?,
3624                    })
3625                } else {
3626                    None
3627                };
3628                aggregate(
3629                    &mut table,
3630                    authorization.as_ref(),
3631                    effective_principal.as_ref(),
3632                    security_version,
3633                )?
3634            };
3635            if self.catalog.read().security_version == security_version {
3636                return Ok(result);
3637            }
3638            if attempt + 1 == RETRIES {
3639                return Err(MongrelError::Conflict(
3640                    "security policy changed during aggregate read".into(),
3641                ));
3642            }
3643        }
3644        Err(MongrelError::Conflict(
3645            "aggregate authorization retry loop exhausted".into(),
3646        ))
3647    }
3648
3649    /// Scored-read authorization that evaluates RLS only for approximate
3650    /// candidates. This avoids a full-table policy scan on cache misses while
3651    /// preserving one table generation and security-version retry.
3652    pub fn with_authorized_scored_read_context<T, F>(
3653        &self,
3654        table_name: &str,
3655        principal: Option<&crate::auth::Principal>,
3656        catalog_bound: bool,
3657        authorization: Option<&ReadAuthorization>,
3658        context: Option<&crate::query::AiExecutionContext>,
3659        mut read: F,
3660    ) -> Result<T>
3661    where
3662        F: FnMut(
3663            &mut Table,
3664            Snapshot,
3665            Option<&crate::security::CandidateAuthorization<'_>>,
3666            Option<&crate::auth::Principal>,
3667        ) -> Result<T>,
3668    {
3669        self.with_authorized_scored_read_context_at(
3670            table_name,
3671            principal,
3672            catalog_bound,
3673            authorization,
3674            context,
3675            None,
3676            |table, snapshot, authorization, principal| {
3677                let mut table = table.clone();
3678                read(&mut table, snapshot, authorization, principal)
3679            },
3680        )
3681    }
3682
3683    #[allow(clippy::too_many_arguments)]
3684    pub fn with_authorized_scored_read_context_at<T, F>(
3685        &self,
3686        table_name: &str,
3687        principal: Option<&crate::auth::Principal>,
3688        catalog_bound: bool,
3689        authorization: Option<&ReadAuthorization>,
3690        context: Option<&crate::query::AiExecutionContext>,
3691        snapshot_override: Option<Snapshot>,
3692        read: F,
3693    ) -> Result<T>
3694    where
3695        F: FnMut(
3696            &Table,
3697            Snapshot,
3698            Option<&crate::security::CandidateAuthorization<'_>>,
3699            Option<&crate::auth::Principal>,
3700        ) -> Result<T>,
3701    {
3702        self.with_authorized_scored_read_context_at_stamped(
3703            table_name,
3704            principal,
3705            catalog_bound,
3706            authorization,
3707            context,
3708            snapshot_override,
3709            read,
3710        )
3711        .map(|(result, _)| result)
3712    }
3713
3714    #[allow(clippy::too_many_arguments)]
3715    pub fn with_authorized_scored_read_context_at_stamped<T, F>(
3716        &self,
3717        table_name: &str,
3718        principal: Option<&crate::auth::Principal>,
3719        catalog_bound: bool,
3720        authorization: Option<&ReadAuthorization>,
3721        context: Option<&crate::query::AiExecutionContext>,
3722        snapshot_override: Option<Snapshot>,
3723        mut read: F,
3724    ) -> Result<(T, AuthorizedReadStamp)>
3725    where
3726        F: FnMut(
3727            &Table,
3728            Snapshot,
3729            Option<&crate::security::CandidateAuthorization<'_>>,
3730            Option<&crate::auth::Principal>,
3731        ) -> Result<T>,
3732    {
3733        if principal.is_none() && self.principal.read().is_some() {
3734            self.refresh_principal()?;
3735        }
3736        const RETRIES: usize = 3;
3737        let handle = self.table(table_name)?;
3738        for attempt in 0..RETRIES {
3739            if let Some(context) = context {
3740                context.checkpoint()?;
3741            }
3742            crate::trace::QueryTrace::record(|trace| {
3743                trace.authorization_retries = attempt;
3744            });
3745            let (security, security_version, effective_principal) = {
3746                let catalog = self.catalog.read();
3747                (
3748                    catalog.security.clone(),
3749                    catalog.security_version,
3750                    self.principal_for_authorized_read(&catalog, principal, catalog_bound)?,
3751                )
3752            };
3753            if let Some(authorization) = authorization {
3754                for permission in &authorization.permissions {
3755                    self.require_for(effective_principal.as_ref(), permission)?;
3756                }
3757                self.require_columns_for(
3758                    table_name,
3759                    authorization.operation,
3760                    &authorization.columns,
3761                    effective_principal.as_ref(),
3762                )?;
3763            }
3764            let result = {
3765                let (table, snapshot, _snapshot_guard, _run_pins) =
3766                    self.scored_read_generation(&handle, context, snapshot_override)?;
3767                let candidate_authorization = if security.rls_enabled(table_name) {
3768                    Some(crate::security::CandidateAuthorization {
3769                        table: table_name,
3770                        security: &security,
3771                        principal: effective_principal
3772                            .as_ref()
3773                            .ok_or(MongrelError::AuthRequired)?,
3774                    })
3775                } else {
3776                    None
3777                };
3778                let stamp = AuthorizedReadStamp {
3779                    table_id: table.table_id(),
3780                    schema_id: table.schema().schema_id,
3781                    data_generation: table.data_generation(),
3782                    security_version,
3783                    snapshot,
3784                };
3785                let result = read(
3786                    table.as_ref(),
3787                    snapshot,
3788                    candidate_authorization.as_ref(),
3789                    effective_principal.as_ref(),
3790                )?;
3791                (result, stamp)
3792            };
3793            if let Some(context) = context {
3794                context.checkpoint()?;
3795            }
3796            if self.catalog.read().security_version == security_version {
3797                return Ok(result);
3798            }
3799            if attempt + 1 == RETRIES {
3800                return Err(MongrelError::Conflict(
3801                    "security policy changed during scored read".into(),
3802                ));
3803            }
3804        }
3805        Err(MongrelError::Conflict(
3806            "scored-read authorization retry loop exhausted".into(),
3807        ))
3808    }
3809
3810    fn scored_read_generation(
3811        &self,
3812        handle: &TableHandle,
3813        context: Option<&crate::query::AiExecutionContext>,
3814        snapshot_override: Option<Snapshot>,
3815    ) -> Result<(
3816        Arc<TableReadGeneration>,
3817        Snapshot,
3818        crate::retention::OwnedSnapshotGuard,
3819        RunPins,
3820    )> {
3821        let mut table = if let Some(context) = context {
3822            loop {
3823                context.checkpoint()?;
3824                let wait = context
3825                    .remaining_duration()
3826                    .unwrap_or(std::time::Duration::from_millis(5))
3827                    .min(std::time::Duration::from_millis(5));
3828                if let Some(table) = handle.try_lock_for(wait) {
3829                    break table;
3830                }
3831            }
3832        } else {
3833            handle.lock()
3834        };
3835        let (snapshot, snapshot_guard) = if let Some(snapshot) = snapshot_override {
3836            self.snapshot_at_owned(snapshot.epoch)?
3837        } else {
3838            let snapshot = table.snapshot();
3839            let guard = self.snapshots.register_owned(snapshot.epoch);
3840            (snapshot, guard)
3841        };
3842        let table_id = table.table_id();
3843        let run_keys: Vec<_> = table
3844            .active_run_ids()
3845            .map(|run_id| (table_id, run_id))
3846            .collect();
3847        let generation = handle
3848            .generation_metrics
3849            .activate(table.clone_read_generation()?);
3850        let run_pins = self.pin_runs(&run_keys);
3851        Ok((generation, snapshot, snapshot_guard, run_pins))
3852    }
3853
3854    fn pin_runs(&self, runs: &[(u64, u128)]) -> RunPins {
3855        let mut pins = self.backup_pins.lock();
3856        for run in runs {
3857            *pins.entry(*run).or_insert(0) += 1;
3858        }
3859        drop(pins);
3860        RunPins {
3861            pins: Arc::clone(&self.backup_pins),
3862            runs: runs.to_vec(),
3863        }
3864    }
3865
3866    /// Execute a native conjunctive read with the database principal's row
3867    /// policy, column grants, and masks applied. Raw [`Table`] methods remain
3868    /// policy-unaware; language bindings must use this boundary for reads.
3869    pub fn query_for_current_principal(
3870        &self,
3871        table_name: &str,
3872        query: &crate::query::Query,
3873        projection: Option<&[u16]>,
3874    ) -> Result<Vec<crate::memtable::Row>> {
3875        let condition_columns = crate::query::condition_columns(&query.conditions);
3876        self.with_authorized_read(
3877            table_name,
3878            None,
3879            true,
3880            |table, snapshot, allowed, principal| {
3881                let allowed_columns = self.select_column_ids_for(table_name, principal)?;
3882                self.require_columns_for(
3883                    table_name,
3884                    crate::auth::ColumnOperation::Select,
3885                    &condition_columns,
3886                    principal,
3887                )?;
3888                if let Some(projection) = projection {
3889                    self.require_columns_for(
3890                        table_name,
3891                        crate::auth::ColumnOperation::Select,
3892                        projection,
3893                        principal,
3894                    )?;
3895                }
3896                let mut rows = table.query_at_with_allowed(query, snapshot, allowed)?;
3897                let projection =
3898                    projection.map(|columns| columns.iter().copied().collect::<HashSet<_>>());
3899                for row in &mut rows {
3900                    row.columns.retain(|column, _| {
3901                        allowed_columns.contains(column)
3902                            && projection
3903                                .as_ref()
3904                                .is_none_or(|projection| projection.contains(column))
3905                    });
3906                }
3907                self.secure_rows_for(table_name, rows, principal)
3908            },
3909        )
3910    }
3911
3912    /// Execute a secured native read with cooperative cancellation across
3913    /// authorization, candidate generation, materialization, masking, and
3914    /// projection.
3915    pub fn query_for_current_principal_controlled(
3916        &self,
3917        table_name: &str,
3918        query: &crate::query::Query,
3919        projection: Option<&[u16]>,
3920        control: &crate::ExecutionControl,
3921    ) -> Result<Vec<crate::memtable::Row>> {
3922        self.query_for_principal_controlled(table_name, query, projection, None, true, control)
3923    }
3924
3925    fn query_for_principal_controlled(
3926        &self,
3927        table_name: &str,
3928        query: &crate::query::Query,
3929        projection: Option<&[u16]>,
3930        principal: Option<&crate::auth::Principal>,
3931        catalog_bound: bool,
3932        control: &crate::ExecutionControl,
3933    ) -> Result<Vec<crate::memtable::Row>> {
3934        control.checkpoint()?;
3935        let context = crate::query::AiExecutionContext::with_control(
3936            control.clone(),
3937            usize::MAX,
3938            crate::query::MAX_FUSED_CANDIDATES,
3939        );
3940        let condition_columns = crate::query::condition_columns(&query.conditions);
3941        self.with_authorized_read_context(
3942            table_name,
3943            principal,
3944            catalog_bound,
3945            None,
3946            Some(&context),
3947            None,
3948            |table, snapshot, allowed, principal| {
3949                control.checkpoint()?;
3950                let allowed_columns = self.select_column_ids_for(table_name, principal)?;
3951                self.require_columns_for(
3952                    table_name,
3953                    crate::auth::ColumnOperation::Select,
3954                    &condition_columns,
3955                    principal,
3956                )?;
3957                if let Some(projection) = projection {
3958                    self.require_columns_for(
3959                        table_name,
3960                        crate::auth::ColumnOperation::Select,
3961                        projection,
3962                        principal,
3963                    )?;
3964                }
3965                let rows =
3966                    table.query_at_with_allowed_controlled(query, snapshot, allowed, control)?;
3967                let projection =
3968                    projection.map(|columns| columns.iter().copied().collect::<HashSet<_>>());
3969                let mut projected = Vec::with_capacity(rows.len());
3970                for (index, mut row) in rows.into_iter().enumerate() {
3971                    if index & 255 == 0 {
3972                        control.checkpoint()?;
3973                    }
3974                    row.columns.retain(|column, _| {
3975                        allowed_columns.contains(column)
3976                            && projection
3977                                .as_ref()
3978                                .is_none_or(|projection| projection.contains(column))
3979                    });
3980                    projected.push(row);
3981                }
3982                self.secure_rows_for_with_context(table_name, projected, principal, Some(&context))
3983            },
3984        )
3985    }
3986
3987    /// Reservoir aggregate with column grants, RLS, masks, and security-version
3988    /// retry applied at the database boundary.
3989    pub fn approx_aggregate_for_current_principal(
3990        &self,
3991        table_name: &str,
3992        conditions: &[crate::query::Condition],
3993        column: Option<u16>,
3994        agg: crate::engine::ApproxAgg,
3995        z: f64,
3996    ) -> Result<Option<crate::engine::ApproxResult>> {
3997        if !z.is_finite() || z <= 0.0 {
3998            return Err(MongrelError::InvalidArgument(
3999                "z must be finite and > 0".into(),
4000            ));
4001        }
4002        let mut columns = crate::query::condition_columns(conditions);
4003        columns.extend(column);
4004        columns.sort_unstable();
4005        columns.dedup();
4006        self.with_authorized_aggregate_table(
4007            table_name,
4008            &columns,
4009            None,
4010            true,
4011            true,
4012            |table, authorization, _, _| {
4013                table.approx_aggregate_with_candidate_authorization(
4014                    conditions,
4015                    column,
4016                    agg,
4017                    z,
4018                    authorization,
4019                )
4020            },
4021        )
4022    }
4023
4024    /// Incremental aggregate over an append-only table. Active RLS or masks are
4025    /// rejected because the table-global delta cache cannot safely represent a
4026    /// secured row universe.
4027    pub fn incremental_aggregate_for_current_principal(
4028        &self,
4029        table_name: &str,
4030        conditions: &[crate::query::Condition],
4031        column: Option<u16>,
4032        agg: crate::engine::NativeAgg,
4033    ) -> Result<crate::engine::IncrementalAggResult> {
4034        self.incremental_aggregate_for_principal(table_name, conditions, column, agg, None, true)
4035    }
4036
4037    /// Incremental aggregate using an explicit request principal. A
4038    /// catalog-bound principal is re-resolved on every retry so live grants,
4039    /// revocations, RLS, and masks cannot reuse a stale cache entry.
4040    pub fn incremental_aggregate_for_principal(
4041        &self,
4042        table_name: &str,
4043        conditions: &[crate::query::Condition],
4044        column: Option<u16>,
4045        agg: crate::engine::NativeAgg,
4046        principal: Option<&crate::auth::Principal>,
4047        catalog_bound: bool,
4048    ) -> Result<crate::engine::IncrementalAggResult> {
4049        let mut columns = crate::query::condition_columns(conditions);
4050        columns.extend(column);
4051        columns.sort_unstable();
4052        columns.dedup();
4053        self.with_authorized_aggregate_table(
4054            table_name,
4055            &columns,
4056            principal,
4057            catalog_bound,
4058            false,
4059            |table, _, principal, security_version| {
4060                let cache_key = incremental_aggregate_cache_key(
4061                    table_name,
4062                    conditions,
4063                    column,
4064                    agg,
4065                    principal,
4066                    security_version,
4067                );
4068                table.aggregate_incremental(cache_key, conditions, column, agg)
4069            },
4070        )
4071    }
4072
4073    /// Read one row with the database principal's row policy, column grants,
4074    /// and masks applied.
4075    pub fn get_for_current_principal(
4076        &self,
4077        table_name: &str,
4078        row_id: RowId,
4079    ) -> Result<Option<crate::memtable::Row>> {
4080        self.with_authorized_read(
4081            table_name,
4082            None,
4083            true,
4084            |table, snapshot, allowed, principal| {
4085                let allowed_columns = self.select_column_ids_for(table_name, principal)?;
4086                let Some(row) = table.get(row_id, snapshot) else {
4087                    return Ok(None);
4088                };
4089                if allowed.is_some_and(|allowed| !allowed.contains(&row.row_id)) {
4090                    return Ok(None);
4091                }
4092                let mut rows = self.secure_rows_for(table_name, vec![row], principal)?;
4093                if let Some(row) = rows.first_mut() {
4094                    row.columns
4095                        .retain(|column, _| allowed_columns.contains(column));
4096                }
4097                Ok(rows.pop())
4098            },
4099        )
4100    }
4101
4102    /// Run exact ANN reranking over only rows authorized for this database
4103    /// handle. The embedding column still requires normal column access.
4104    pub fn ann_rerank_for_current_principal(
4105        &self,
4106        table_name: &str,
4107        request: &crate::query::AnnRerankRequest,
4108    ) -> Result<Vec<crate::query::AnnRerankHit>> {
4109        self.with_authorized_scored_read_context_at(
4110            table_name,
4111            None,
4112            true,
4113            Some(&ReadAuthorization {
4114                operation: crate::auth::ColumnOperation::Select,
4115                columns: vec![request.column_id],
4116                permissions: Vec::new(),
4117            }),
4118            None,
4119            None,
4120            |table, snapshot, authorization, principal| {
4121                self.require_columns_for(
4122                    table_name,
4123                    crate::auth::ColumnOperation::Select,
4124                    &[request.column_id],
4125                    principal,
4126                )?;
4127                table.ann_rerank_at_with_candidate_authorization_on_generation(
4128                    request,
4129                    snapshot,
4130                    authorization,
4131                    None,
4132                )
4133            },
4134        )
4135    }
4136
4137    /// Capture one table snapshot and the security version used to authorize it.
4138    /// The caller must validate the returned version before publishing results.
4139    pub fn authorized_read_snapshot(
4140        &self,
4141        table: &str,
4142        principal: Option<&crate::auth::Principal>,
4143    ) -> Result<AuthorizedReadSnapshot> {
4144        let (security, security_version, effective_principal) = {
4145            let catalog = self.catalog.read();
4146            (
4147                catalog.security.clone(),
4148                catalog.security_version,
4149                self.principal_for_authorized_read(&catalog, principal, false)?,
4150            )
4151        };
4152        let handle = self.table(table)?;
4153        let (table_snapshot, data_generation, allowed_row_ids) = {
4154            let table_handle = handle.lock();
4155            let table_snapshot = table_handle.snapshot();
4156            let data_generation = table_handle.data_generation();
4157            let allowed = self.allowed_row_ids_locked(
4158                table,
4159                &table_handle,
4160                table_snapshot,
4161                (&security, security_version),
4162                effective_principal.as_ref(),
4163                None,
4164            )?;
4165            (
4166                table_snapshot,
4167                data_generation,
4168                allowed.map(|allowed| (*allowed).clone()),
4169            )
4170        };
4171        Ok(AuthorizedReadSnapshot {
4172            table: table.to_string(),
4173            table_snapshot,
4174            data_generation,
4175            security_version,
4176            allowed_row_ids,
4177        })
4178    }
4179
4180    pub fn authorized_read_snapshot_valid(&self, snapshot: &AuthorizedReadSnapshot) -> bool {
4181        if self.catalog.read().security_version != snapshot.security_version {
4182            return false;
4183        }
4184        self.table(&snapshot.table)
4185            .ok()
4186            .is_some_and(|table| table.lock().data_generation() == snapshot.data_generation)
4187    }
4188
4189    pub fn rls_cache_stats(&self) -> RlsCacheStats {
4190        self.rls_cache.lock().stats()
4191    }
4192
4193    /// Read visible rows with column authorization, RLS, and masks applied.
4194    pub fn rows_for(
4195        &self,
4196        table: &str,
4197        principal: Option<&crate::auth::Principal>,
4198    ) -> Result<Vec<crate::memtable::Row>> {
4199        if principal.is_none() && self.principal.read().is_some() {
4200            self.refresh_principal()?;
4201        }
4202        let allowed = self.select_column_ids_for(table, principal)?;
4203        let handle = self.table(table)?;
4204        let rows = {
4205            let table = handle.lock();
4206            table.visible_rows(table.snapshot())?
4207        };
4208        let mut rows = self.secure_rows_for(table, rows, principal)?;
4209        for row in &mut rows {
4210            row.columns.retain(|column, _| allowed.contains(column));
4211        }
4212        Ok(rows)
4213    }
4214
4215    /// Historical rows use the current principal and security catalog against
4216    /// the row values visible at the requested snapshot.
4217    pub fn rows_at_epoch_for_current_principal(
4218        &self,
4219        table_name: &str,
4220        snapshot: Snapshot,
4221    ) -> Result<Vec<crate::memtable::Row>> {
4222        self.with_authorized_read_context(
4223            table_name,
4224            None,
4225            true,
4226            Some(&ReadAuthorization {
4227                operation: crate::auth::ColumnOperation::Select,
4228                columns: Vec::new(),
4229                permissions: Vec::new(),
4230            }),
4231            None,
4232            Some(snapshot),
4233            |table, snapshot, allowed, principal| {
4234                let allowed_columns = self.select_column_ids_for(table_name, principal)?;
4235                let mut rows = table.visible_rows(snapshot)?;
4236                if let Some(allowed) = allowed {
4237                    rows.retain(|row| allowed.contains(&row.row_id));
4238                }
4239                rows = self.secure_rows_for(table_name, rows, principal)?;
4240                for row in &mut rows {
4241                    row.columns
4242                        .retain(|column, _| allowed_columns.contains(column));
4243                }
4244                Ok(rows)
4245            },
4246        )
4247    }
4248
4249    /// Count rows visible to a principal without bypassing RLS.
4250    pub fn count_for(
4251        &self,
4252        table: &str,
4253        principal: Option<&crate::auth::Principal>,
4254    ) -> Result<u64> {
4255        if principal.is_none() && self.principal.read().is_some() {
4256            self.refresh_principal()?;
4257        }
4258        self.select_column_ids_for(table, principal)?;
4259        if self.security_active_for(table) {
4260            Ok(self.rows_for(table, principal)?.len() as u64)
4261        } else {
4262            Ok(self.table(table)?.lock().count())
4263        }
4264    }
4265
4266    /// Authorize and write one native-API row for an explicit principal.
4267    pub fn put_for(
4268        &self,
4269        table: &str,
4270        mut cells: Vec<(u16, crate::memtable::Value)>,
4271        principal: Option<&crate::auth::Principal>,
4272    ) -> Result<RowId> {
4273        let columns = cells.iter().map(|(column, _)| *column).collect::<Vec<_>>();
4274        self.require_columns_for(
4275            table,
4276            crate::auth::ColumnOperation::Insert,
4277            &columns,
4278            principal,
4279        )?;
4280        let handle = self.table(table)?;
4281        let mut table_handle = handle.lock();
4282        table_handle.fill_auto_inc(&mut cells)?;
4283        table_handle.apply_defaults(&mut cells)?;
4284        let mut row = crate::memtable::Row::new(RowId(0), self.epoch.visible());
4285        row.columns.extend(cells.iter().cloned());
4286        self.check_row_policy_for(
4287            table,
4288            crate::security::PolicyCommand::Insert,
4289            &row,
4290            true,
4291            principal,
4292        )?;
4293        table_handle.put(cells)
4294    }
4295
4296    pub fn check_row_policy_for(
4297        &self,
4298        table: &str,
4299        command: crate::security::PolicyCommand,
4300        row: &crate::memtable::Row,
4301        check_new: bool,
4302        principal: Option<&crate::auth::Principal>,
4303    ) -> Result<()> {
4304        let security = self.catalog.read().security.clone();
4305        if !security.rls_enabled(table) {
4306            return Ok(());
4307        }
4308        let cached = self.principal.read().clone();
4309        let principal = principal
4310            .or(cached.as_ref())
4311            .ok_or(MongrelError::AuthRequired)?;
4312        if security.row_allowed(table, command, row, principal, check_new) {
4313            return Ok(());
4314        }
4315        let required = match command {
4316            crate::security::PolicyCommand::Insert => crate::auth::Permission::Insert {
4317                table: table.to_string(),
4318            },
4319            crate::security::PolicyCommand::Update => crate::auth::Permission::Update {
4320                table: table.to_string(),
4321            },
4322            crate::security::PolicyCommand::Select => crate::auth::Permission::Select {
4323                table: table.to_string(),
4324            },
4325            crate::security::PolicyCommand::Delete | crate::security::PolicyCommand::All => {
4326                crate::auth::Permission::Delete {
4327                    table: table.to_string(),
4328                }
4329            }
4330        };
4331        Err(MongrelError::PermissionDenied {
4332            required,
4333            principal: principal.username.clone(),
4334        })
4335    }
4336
4337    /// Durably create or replace a materialized-view definition after its
4338    /// physical table has been populated.
4339    pub fn set_materialized_view(
4340        &self,
4341        definition: crate::catalog::MaterializedViewEntry,
4342    ) -> Result<()> {
4343        self.set_materialized_view_with_epoch(definition)
4344            .map(|_| ())
4345    }
4346
4347    /// Durably create or replace a materialized-view definition and return its epoch.
4348    pub fn set_materialized_view_with_epoch(
4349        &self,
4350        definition: crate::catalog::MaterializedViewEntry,
4351    ) -> Result<Epoch> {
4352        use crate::wal::DdlOp;
4353        use std::sync::atomic::Ordering;
4354
4355        self.require(&crate::auth::Permission::Ddl)?;
4356        if self.poisoned.load(Ordering::Relaxed) {
4357            return Err(MongrelError::Other(
4358                "database poisoned by fsync error".into(),
4359            ));
4360        }
4361        if definition.name.is_empty() || definition.query.trim().is_empty() {
4362            return Err(MongrelError::InvalidArgument(
4363                "materialized view name and query must not be empty".into(),
4364            ));
4365        }
4366
4367        let _ddl = self.ddl_lock.lock();
4368        let _security_write = self.security_write()?;
4369        self.require(&crate::auth::Permission::Ddl)?;
4370        let table_id = self
4371            .catalog
4372            .read()
4373            .live(&definition.name)
4374            .ok_or_else(|| {
4375                MongrelError::NotFound(format!(
4376                    "materialized view table {:?} not found",
4377                    definition.name
4378                ))
4379            })?
4380            .table_id;
4381        let definition_json = DdlOp::encode_materialized_view(&definition)?;
4382        let _commit = self.commit_lock.lock();
4383        let epoch = self.epoch.bump_assigned();
4384        let mut epoch_guard = EpochGuard::new(self.epoch.as_ref(), epoch);
4385        let txn_id = self.alloc_txn_id()?;
4386        let mut next_catalog = self.catalog.read().clone();
4387        if let Some(existing) = next_catalog
4388            .materialized_views
4389            .iter_mut()
4390            .find(|existing| existing.name == definition.name)
4391        {
4392            *existing = definition.clone();
4393        } else {
4394            next_catalog.materialized_views.push(definition.clone());
4395        }
4396        next_catalog.db_epoch = next_catalog.db_epoch.max(epoch.0);
4397        let commit_seq = {
4398            let mut wal = self.shared_wal.lock();
4399            let append: Result<u64> = (|| {
4400                wal.append(
4401                    txn_id,
4402                    table_id,
4403                    crate::wal::Op::Ddl(DdlOp::SetMaterializedView {
4404                        name: definition.name.clone(),
4405                        definition_json,
4406                    }),
4407                )?;
4408                append_catalog_snapshot(&mut wal, txn_id, &next_catalog)?;
4409                wal.append_commit(txn_id, epoch, &[])
4410            })();
4411            append.map_err(|error| self.commit_outcome_unknown(epoch, error))?
4412        };
4413        self.await_durable_commit(commit_seq, epoch)?;
4414
4415        let checkpoint = self.checkpoint_catalog_after_durable(next_catalog);
4416        self.finish_durable_publish(epoch, &mut epoch_guard, checkpoint)?;
4417        Ok(epoch)
4418    }
4419
4420    /// The filesystem root this database was opened/created at.
4421    pub fn root(&self) -> &Path {
4422        self.durable_root.canonical_path()
4423    }
4424
4425    /// Open a descriptor-pinned view of this database root for durable
4426    /// extension state such as server idempotency receipts.
4427    pub fn durable_root(&self) -> Arc<crate::durable_file::DurableRoot> {
4428        Arc::clone(&self.durable_root)
4429    }
4430
4431    /// Domain-separated authentication key for server idempotency state.
4432    /// Encrypted databases derive it from the in-memory KEK. Plain databases
4433    /// return `None`; their server persists a random key under the pinned root.
4434    #[cfg(feature = "encryption")]
4435    pub fn derive_server_idempotency_key(&self) -> Option<zeroize::Zeroizing<[u8; 32]>> {
4436        self.kek
4437            .as_deref()
4438            .map(|kek| kek.derive_subkey(b"mongreldb/server/idempotency/v1"))
4439    }
4440
4441    #[cfg(not(feature = "encryption"))]
4442    pub fn derive_server_idempotency_key(&self) -> Option<zeroize::Zeroizing<[u8; 32]>> {
4443        None
4444    }
4445
4446    pub fn is_read_only_replica(&self) -> bool {
4447        self.read_only
4448    }
4449
4450    /// Reject reads whose backing state may require WAL recovery after a
4451    /// post-commit publication failure. Ordinary table/catalog state is made
4452    /// coherent before poison; file-backed external modules use this gate.
4453    pub fn ensure_consistent_read(&self) -> Result<()> {
4454        if self.poisoned.load(Ordering::Relaxed) {
4455            return Err(MongrelError::Other(
4456                "database poisoned by post-commit failure; reopen required".into(),
4457            ));
4458        }
4459        Ok(())
4460    }
4461
4462    pub fn set_replication_wal_retention_segments(&self, segments: usize) {
4463        self.replication_wal_retention_segments
4464            .store(segments, std::sync::atomic::Ordering::Relaxed);
4465    }
4466
4467    /// Capture a consistent bootstrap image. DDL, transaction spill/publish,
4468    /// direct table commits, compaction, and WAL append are quiesced while the
4469    /// file image is read. WAL records newer than manifests remain sufficient
4470    /// for recovery, so no flush or compaction is required.
4471    pub fn replication_snapshot(&self) -> Result<crate::replication::ReplicationSnapshot> {
4472        let admin = crate::auth::Permission::Admin;
4473        self.require(&admin)?;
4474        let operation_principal = self.principal_snapshot();
4475        let _barrier = self.replication_barrier.write();
4476        let _ddl = self.ddl_lock.lock();
4477        let _security = self.security_coordinator.gate.read();
4478        self.require_exact_principal_current(operation_principal.as_ref(), &admin)?;
4479        let mut handles: Vec<_> = self
4480            .tables
4481            .read()
4482            .iter()
4483            .map(|(id, handle)| (*id, handle.clone()))
4484            .collect();
4485        handles.sort_by_key(|(id, _)| *id);
4486        let _table_guards: Vec<_> = handles.iter().map(|(_, handle)| handle.lock()).collect();
4487        let _commit = self.commit_lock.lock();
4488        let mut wal = self.shared_wal.lock();
4489        wal.group_sync()?;
4490        let wal_dek = crate::encryption::wal_dek_for(self.kek.as_deref());
4491        let records = crate::wal::SharedWal::replay_with_dek(&self.root, wal_dek.as_ref())?;
4492        let epoch = records
4493            .iter()
4494            .filter_map(|record| match &record.op {
4495                crate::wal::Op::TxnCommit { epoch, .. } => Some(*epoch),
4496                _ => None,
4497            })
4498            .max()
4499            .unwrap_or(0)
4500            .max(self.epoch.committed().0);
4501        let files = crate::replication::capture_files(&self.root)?;
4502        let source_id = crate::replication::replication_identity_durable(&self.durable_root)?;
4503        drop(wal);
4504        Ok(crate::replication::ReplicationSnapshot::new(
4505            source_id, epoch, files,
4506        ))
4507    }
4508
4509    /// Create an online, directly-openable backup at `destination`.
4510    ///
4511    /// The short boundary phase quiesces commits/DDL, syncs the WAL, copies
4512    /// mutable metadata, and pins the exact immutable runs named by the copied
4513    /// manifests. Writers resume while those runs stream into a sibling staging
4514    /// directory. A checksummed backup manifest is written last, then the stage
4515    /// is atomically renamed into place.
4516    pub fn hot_backup(&self, destination: impl AsRef<Path>) -> Result<crate::backup::BackupReport> {
4517        let control = crate::ExecutionControl::new(None);
4518        self.hot_backup_controlled(destination, &control, || true)
4519    }
4520
4521    pub(crate) fn hot_backup_to_durable_child(
4522        &self,
4523        parent: &crate::durable_file::DurableRoot,
4524        child: &Path,
4525        control: &crate::ExecutionControl,
4526    ) -> Result<crate::backup::BackupReport> {
4527        let mut components = child.components();
4528        if !matches!(components.next(), Some(std::path::Component::Normal(_)))
4529            || components.next().is_some()
4530        {
4531            return Err(MongrelError::InvalidArgument(
4532                "durable backup child must be one relative path component".into(),
4533            ));
4534        }
4535        let destination_name = child.file_name().ok_or_else(|| {
4536            MongrelError::InvalidArgument("durable backup child has no filename".into())
4537        })?;
4538        let prepared = prepare_backup_destination_in(&self.root, parent, destination_name)?;
4539        self.hot_backup_prepared(prepared, control, || true)
4540    }
4541
4542    /// Build a backup cooperatively, then invoke `before_publish` immediately
4543    /// before the staging directory is atomically renamed into place.
4544    #[doc(hidden)]
4545    pub fn hot_backup_controlled<F>(
4546        &self,
4547        destination: impl AsRef<Path>,
4548        control: &crate::ExecutionControl,
4549        before_publish: F,
4550    ) -> Result<crate::backup::BackupReport>
4551    where
4552        F: FnOnce() -> bool,
4553    {
4554        let prepared = prepare_backup_destination(&self.root, destination.as_ref())?;
4555        self.hot_backup_prepared(prepared, control, before_publish)
4556    }
4557
4558    fn hot_backup_prepared<F>(
4559        &self,
4560        mut prepared: PreparedBackupDestination,
4561        control: &crate::ExecutionControl,
4562        before_publish: F,
4563    ) -> Result<crate::backup::BackupReport>
4564    where
4565        F: FnOnce() -> bool,
4566    {
4567        let admin = crate::auth::Permission::Admin;
4568        self.require(&admin)?;
4569        let operation_principal = self.principal_snapshot();
4570        control.checkpoint()?;
4571        let destination = prepared.destination_path.clone();
4572        let mut before_publish = Some(before_publish);
4573
4574        let outcome = (|| {
4575            control.checkpoint()?;
4576            let barrier = self.replication_barrier.write();
4577            let ddl = self.ddl_lock.lock();
4578            let security = self.security_coordinator.gate.read();
4579            self.require_exact_principal_current(operation_principal.as_ref(), &admin)?;
4580            let mut handles: Vec<_> = self
4581                .tables
4582                .read()
4583                .iter()
4584                .map(|(id, handle)| (*id, handle.clone()))
4585                .collect();
4586            handles.sort_by_key(|(id, _)| *id);
4587            let table_guards: Vec<_> = handles.iter().map(|(_, handle)| handle.lock()).collect();
4588            let commit = self.commit_lock.lock();
4589            let mut wal = self.shared_wal.lock();
4590            wal.group_sync()?;
4591            let epoch = self.epoch.committed().0;
4592            let boundary_unix_nanos = current_unix_nanos();
4593
4594            let pin_nonce = std::time::SystemTime::now()
4595                .duration_since(std::time::UNIX_EPOCH)
4596                .unwrap_or_default()
4597                .as_nanos();
4598            let file_pin_root = self
4599                .root
4600                .join(META_DIR)
4601                .join("backup-pins")
4602                .join(format!("{}-{pin_nonce}", std::process::id()));
4603            std::fs::create_dir_all(&file_pin_root)?;
4604            let _file_pins = BackupFilePins {
4605                root: file_pin_root.clone(),
4606            };
4607            let mut run_files = Vec::new();
4608            for (index, (table_id, _)) in handles.iter().enumerate() {
4609                if index % 256 == 0 {
4610                    control.checkpoint()?;
4611                }
4612                let table = &table_guards[index];
4613                for (run_index, run) in table.run_refs().iter().enumerate() {
4614                    if run_index % 256 == 0 {
4615                        control.checkpoint()?;
4616                    }
4617                    let source = table.run_path(run.run_id as u64);
4618                    let relative = Path::new(TABLES_DIR)
4619                        .join(table_id.to_string())
4620                        .join(crate::engine::RUNS_DIR)
4621                        .join(format!("r-{}.sr", run.run_id));
4622                    let pinned = file_pin_root.join(format!("{table_id}-{}.sr", run.run_id));
4623                    if std::fs::hard_link(&source, &pinned).is_err() {
4624                        crate::backup::copy_file_synced(&source, &pinned)?;
4625                    }
4626                    run_files.push(((*table_id, run.run_id), pinned, relative));
4627                }
4628            }
4629            crate::durable_file::sync_directory(&file_pin_root)?;
4630            let run_keys: Vec<_> = run_files.iter().map(|(key, _, _)| *key).collect();
4631            {
4632                let mut pins = self.backup_pins.lock();
4633                for key in &run_keys {
4634                    *pins.entry(*key).or_insert(0) += 1;
4635                }
4636            }
4637            let _run_pins = RunPins {
4638                pins: Arc::clone(&self.backup_pins),
4639                runs: run_keys,
4640            };
4641            let deferred: HashSet<_> = run_files
4642                .iter()
4643                .map(|(_, _, relative)| relative.clone())
4644                .collect();
4645            let mut copied = Vec::new();
4646            copy_backup_boundary(
4647                &self.root,
4648                prepared.stage.as_deref().ok_or_else(|| {
4649                    MongrelError::Other("backup staging root was already released".into())
4650                })?,
4651                &deferred,
4652                &mut copied,
4653                Some(control),
4654            )?;
4655
4656            drop(wal);
4657            drop(commit);
4658            drop(table_guards);
4659            drop(security);
4660            drop(ddl);
4661            drop(barrier);
4662
4663            if let Some(hook) = self.backup_hook.lock().as_ref() {
4664                hook();
4665            }
4666            for (index, (_, source, relative)) in run_files.into_iter().enumerate() {
4667                if index % 256 == 0 {
4668                    control.checkpoint()?;
4669                }
4670                let mut source = crate::durable_file::open_regular_nofollow(&source)?;
4671                prepared
4672                    .stage
4673                    .as_deref()
4674                    .ok_or_else(|| {
4675                        MongrelError::Other("backup staging root was already released".into())
4676                    })?
4677                    .copy_new_from(&relative, &mut source)?;
4678                copied.push(relative);
4679            }
4680
4681            let manifest = crate::backup::BackupManifest::create_controlled_durable(
4682                prepared.stage.as_deref().ok_or_else(|| {
4683                    MongrelError::Other("backup staging root was already released".into())
4684                })?,
4685                epoch,
4686                &copied,
4687                control,
4688            )?;
4689            manifest.write_to_durable(prepared.stage.as_deref().ok_or_else(|| {
4690                MongrelError::Other("backup staging root was already released".into())
4691            })?)?;
4692            control.checkpoint()?;
4693            let publish = before_publish.take().ok_or_else(|| {
4694                MongrelError::Other("backup publication callback already consumed".into())
4695            })?;
4696            if !publish() {
4697                return Err(MongrelError::Cancelled);
4698            }
4699            let final_security = self.security_coordinator.gate.read();
4700            self.require_exact_principal_current(operation_principal.as_ref(), &admin)?;
4701            // Windows pins directories without delete sharing. Release the
4702            // stage handle before renaming that directory, while the parent
4703            // remains descriptor-pinned for the no-replace publication.
4704            drop(prepared.stage.take().ok_or_else(|| {
4705                MongrelError::Other("backup staging root was already released".into())
4706            })?);
4707            let published = std::cell::Cell::new(false);
4708            if let Err(error) = prepared.parent.rename_directory_new_with_after(
4709                Path::new(&prepared.stage_name),
4710                &prepared.parent,
4711                Path::new(&prepared.destination_name),
4712                || published.set(true),
4713            ) {
4714                if published.get() {
4715                    return Err(MongrelError::CommitOutcomeUnknown {
4716                        epoch,
4717                        message: format!("backup publication was not durable: {error}"),
4718                    });
4719                }
4720                return Err(error.into());
4721            }
4722            drop(final_security);
4723            Ok(crate::backup::BackupReport {
4724                destination,
4725                epoch,
4726                boundary_unix_nanos,
4727                files: manifest.files.len(),
4728                bytes: manifest.total_bytes(),
4729            })
4730        })();
4731
4732        if outcome.is_err() {
4733            drop(prepared.stage.take());
4734            let _ = prepared
4735                .parent
4736                .remove_directory_all(Path::new(&prepared.stage_name));
4737        }
4738        outcome
4739    }
4740
4741    /// Return complete committed transactions after `since_epoch`. A gap or a
4742    /// transaction backed by a spilled run requires a fresh bootstrap image.
4743    pub fn replication_batch_since(
4744        &self,
4745        since_epoch: u64,
4746    ) -> Result<crate::replication::ReplicationBatch> {
4747        use crate::wal::Op;
4748
4749        let admin = crate::auth::Permission::Admin;
4750        self.require(&admin)?;
4751        let operation_principal = self.principal_snapshot();
4752
4753        let mut wal = self.shared_wal.lock();
4754        wal.group_sync()?;
4755        let wal_dek = crate::encryption::wal_dek_for(self.kek.as_deref());
4756        let records = crate::wal::SharedWal::replay_with_dek(&self.root, wal_dek.as_ref())?;
4757        drop(wal);
4758
4759        let commits: HashMap<u64, u64> = records
4760            .iter()
4761            .filter_map(|record| match &record.op {
4762                Op::TxnCommit { epoch, .. } => Some((record.txn_id, *epoch)),
4763                _ => None,
4764            })
4765            .collect();
4766        let earliest_epoch = commits.values().copied().min();
4767        let current_epoch = commits
4768            .values()
4769            .copied()
4770            .max()
4771            .unwrap_or(0)
4772            .max(self.epoch.committed().0);
4773        let selected: HashSet<u64> = commits
4774            .iter()
4775            .filter_map(|(txn_id, epoch)| (*epoch > since_epoch).then_some(*txn_id))
4776            .collect();
4777        let retention_gap = since_epoch < current_epoch
4778            && since_epoch < crate::replication::replication_wal_floor(&self.root)?;
4779        let spilled = records.iter().any(|record| {
4780            selected.contains(&record.txn_id)
4781                && matches!(
4782                    &record.op,
4783                    Op::TxnCommit { added_runs, .. } if !added_runs.is_empty()
4784                )
4785        });
4786        let records = records
4787            .into_iter()
4788            .filter(|record| record.txn_id != crate::wal::SYSTEM_TXN_ID)
4789            .filter(|record| selected.contains(&record.txn_id))
4790            .collect();
4791        let source_id = crate::replication::replication_identity_durable(&self.durable_root)?;
4792        let batch = crate::replication::ReplicationBatch::complete_for_source(
4793            source_id,
4794            since_epoch,
4795            current_epoch,
4796            earliest_epoch,
4797            retention_gap,
4798            spilled,
4799            records,
4800        )?;
4801        if let Some(hook) = self.replication_hook.lock().as_ref() {
4802            hook();
4803        }
4804        let _security = self.security_coordinator.gate.read();
4805        self.require_exact_principal_current(operation_principal.as_ref(), &admin)?;
4806        Ok(batch)
4807    }
4808
4809    /// Durably append a leader batch to a follower's local WAL and checkpoint
4810    /// its catalog metadata. Security changes apply to this live handle before
4811    /// success returns. The caller must reopen to mount new table state.
4812    pub fn append_replication_batch(
4813        &self,
4814        batch: &crate::replication::ReplicationBatch,
4815    ) -> Result<u64> {
4816        use crate::wal::Op;
4817
4818        if !self.read_only {
4819            return Err(MongrelError::InvalidArgument(
4820                "replication batches may only target a marked replica".into(),
4821            ));
4822        }
4823        let current = crate::replication::replica_epoch(&self.root)?;
4824        if batch.is_source_bound() {
4825            let source_id = crate::replication::replica_source_id_durable(&self.durable_root)?;
4826            if batch.source_id != source_id {
4827                return Err(MongrelError::Conflict(
4828                    "replication batch source does not match follower binding".into(),
4829                ));
4830            }
4831        }
4832        if batch.requires_snapshot {
4833            return Err(MongrelError::Conflict(
4834                "replication snapshot required for this batch".into(),
4835            ));
4836        }
4837        batch.validate_proof()?;
4838        if batch.from_epoch != current {
4839            if batch.from_epoch < current && batch.current_epoch == current {
4840                let wal_dek = crate::encryption::wal_dek_for(self.kek.as_deref());
4841                let _wal = self.shared_wal.lock();
4842                let existing: HashSet<(u64, u64)> =
4843                    crate::wal::SharedWal::replay_with_dek(&self.root, wal_dek.as_ref())?
4844                        .into_iter()
4845                        .filter_map(|record| match record.op {
4846                            Op::TxnCommit { epoch, .. } => Some((record.txn_id, epoch)),
4847                            _ => None,
4848                        })
4849                        .collect();
4850                let already_applied = batch.records.iter().all(|record| match &record.op {
4851                    Op::TxnCommit { epoch, .. } => existing.contains(&(record.txn_id, *epoch)),
4852                    _ => true,
4853                });
4854                if already_applied {
4855                    return Ok(current);
4856                }
4857            }
4858            return Err(MongrelError::Conflict(format!(
4859                "replication batch starts at epoch {}, follower is at epoch {current}",
4860                batch.from_epoch
4861            )));
4862        }
4863        if batch.current_epoch < current {
4864            return Err(MongrelError::InvalidArgument(format!(
4865                "replication batch current epoch {} precedes follower epoch {current}",
4866                batch.current_epoch
4867            )));
4868        }
4869        let records = &batch.records;
4870        let mut commits = HashMap::new();
4871        let mut commit_epochs = HashSet::new();
4872        let mut commit_timestamps = HashMap::new();
4873        for record in records {
4874            match &record.op {
4875                Op::TxnCommit { epoch, added_runs } => {
4876                    if !added_runs.is_empty() {
4877                        return Err(MongrelError::Conflict(
4878                            "replication snapshot required for spilled-run transaction".into(),
4879                        ));
4880                    }
4881                    if commits.insert(record.txn_id, *epoch).is_some() {
4882                        return Err(MongrelError::InvalidArgument(format!(
4883                            "duplicate commit for replication transaction {}",
4884                            record.txn_id
4885                        )));
4886                    }
4887                    if *epoch <= current || *epoch > batch.current_epoch {
4888                        return Err(MongrelError::InvalidArgument(format!(
4889                            "replication commit epoch {epoch} is outside ({current}, {}]",
4890                            batch.current_epoch
4891                        )));
4892                    }
4893                    if !commit_epochs.insert(*epoch) {
4894                        return Err(MongrelError::InvalidArgument(format!(
4895                            "duplicate replication commit epoch {epoch}"
4896                        )));
4897                    }
4898                }
4899                Op::CommitTimestamp { unix_nanos } => {
4900                    commit_timestamps.insert(record.txn_id, *unix_nanos);
4901                }
4902                _ => {}
4903            }
4904        }
4905        for record in records {
4906            if record.txn_id == crate::wal::SYSTEM_TXN_ID
4907                || matches!(&record.op, Op::TxnAbort | Op::Flush { .. })
4908            {
4909                return Err(MongrelError::InvalidArgument(
4910                    "replication batch contains a non-committed record".into(),
4911                ));
4912            }
4913            if !commits.contains_key(&record.txn_id) {
4914                return Err(MongrelError::InvalidArgument(format!(
4915                    "incomplete replication transaction {}",
4916                    record.txn_id
4917                )));
4918            }
4919        }
4920        let target_epoch = commits
4921            .values()
4922            .copied()
4923            .filter(|epoch| *epoch > current)
4924            .max()
4925            .unwrap_or(current);
4926        if target_epoch != batch.current_epoch {
4927            return Err(MongrelError::InvalidArgument(format!(
4928                "replication batch ends at epoch {target_epoch}, expected {}",
4929                batch.current_epoch
4930            )));
4931        }
4932        let mut selected: HashSet<u64> = commits
4933            .iter()
4934            .filter_map(|(txn_id, epoch)| (*epoch > current).then_some(*txn_id))
4935            .collect();
4936        if selected.is_empty() {
4937            return Ok(current);
4938        }
4939        let mut wal = self.shared_wal.lock();
4940        wal.group_sync()?;
4941        let wal_dek = crate::encryption::wal_dek_for(self.kek.as_deref());
4942        let existing: HashSet<(u64, u64)> =
4943            crate::wal::SharedWal::replay_with_dek(&self.root, wal_dek.as_ref())?
4944                .into_iter()
4945                .filter_map(|record| match record.op {
4946                    Op::TxnCommit { epoch, .. } => Some((record.txn_id, epoch)),
4947                    _ => None,
4948                })
4949                .collect();
4950        selected.retain(|txn_id| {
4951            commits
4952                .get(txn_id)
4953                .is_some_and(|epoch| !existing.contains(&(*txn_id, *epoch)))
4954        });
4955        for record in records {
4956            if !selected.contains(&record.txn_id) {
4957                continue;
4958            }
4959            match &record.op {
4960                Op::TxnCommit { epoch, added_runs } => {
4961                    let timestamp = commit_timestamps
4962                        .get(&record.txn_id)
4963                        .copied()
4964                        .unwrap_or_else(current_unix_nanos);
4965                    wal.append_commit_at(record.txn_id, Epoch(*epoch), added_runs, timestamp)?;
4966                }
4967                Op::TxnAbort | Op::Flush { .. } | Op::CommitTimestamp { .. } => {}
4968                op => {
4969                    wal.append(record.txn_id, 0, op.clone())?;
4970                }
4971            }
4972        }
4973        if !selected.is_empty() {
4974            wal.group_sync()?;
4975        }
4976        drop(wal);
4977
4978        // Auth mode is selected before `finish_open` replays the WAL. Make the
4979        // catalog transition durable and publish security state to this live
4980        // handle before reporting success.
4981        let mut recovered_catalog = self.catalog.read().clone();
4982        if let Err(error) = recover_ddl_from_wal(
4983            &self.root,
4984            Some(&self.durable_root),
4985            &mut recovered_catalog,
4986            self.meta_dek.as_ref(),
4987            wal_dek.as_ref(),
4988            true,
4989            None,
4990        ) {
4991            return Err(MongrelError::DurableCommit {
4992                epoch: target_epoch,
4993                message: format!(
4994                    "replication WAL is durable but catalog checkpoint failed: {error}"
4995                ),
4996            });
4997        }
4998        let _security = self.security_coordinator.gate.write();
4999        let old_security_version = self.catalog.read().security_version;
5000        let security_changed = old_security_version != recovered_catalog.security_version
5001            || self.catalog.read().require_auth != recovered_catalog.require_auth;
5002        let require_auth = recovered_catalog.require_auth;
5003        let principal = if security_changed {
5004            None
5005        } else {
5006            self.principal.read().as_ref().and_then(|principal| {
5007                Self::resolve_bound_principal_from_catalog(&recovered_catalog, principal)
5008            })
5009        };
5010        if require_auth {
5011            self.auth_state.set_require_auth(true);
5012        }
5013        self.auth_state.set_principal(principal.clone());
5014        *self.principal.write() = principal;
5015        let security_version = recovered_catalog.security_version;
5016        *self.catalog.write() = recovered_catalog;
5017        self.security_coordinator
5018            .version
5019            .store(security_version, Ordering::Release);
5020        if !require_auth {
5021            self.auth_state.set_require_auth(false);
5022        }
5023        if let Err(error) =
5024            crate::replication::reconcile_replica_epoch_durable(&self.durable_root, target_epoch)
5025        {
5026            return Err(MongrelError::DurableCommit {
5027                epoch: target_epoch,
5028                message: format!(
5029                    "replication WAL and catalog are durable but follower watermark failed: {error}"
5030                ),
5031            });
5032        }
5033        Ok(target_epoch)
5034    }
5035
5036    /// Resolve a table name → id (live tables only). pub(crate) so the
5037    /// transaction layer can stage by name.
5038    pub fn table_id(&self, name: &str) -> Result<u64> {
5039        let cat = self.catalog.read();
5040        cat.live(name)
5041            .map(|e| e.table_id)
5042            .ok_or_else(|| MongrelError::NotFound(format!("table {name:?} not found")))
5043    }
5044
5045    /// Return the stable table id and current schema generation from one
5046    /// catalog snapshot. Callers can bind retries to this identity so a table
5047    /// dropped and recreated under the same name is never mistaken for the
5048    /// original resource.
5049    pub fn table_identity(&self, name: &str) -> Result<(u64, u64)> {
5050        let catalog = self.catalog.read();
5051        catalog
5052            .live(name)
5053            .map(|entry| (entry.table_id, entry.schema.schema_id))
5054            .ok_or_else(|| MongrelError::NotFound(format!("table {name:?} not found")))
5055    }
5056
5057    pub(crate) fn building_table_id(&self, name: &str) -> Result<u64> {
5058        self.catalog
5059            .read()
5060            .building(name)
5061            .map(|entry| entry.table_id)
5062            .ok_or_else(|| MongrelError::NotFound(format!("building table {name:?} not found")))
5063    }
5064
5065    pub fn procedures(&self) -> Vec<StoredProcedure> {
5066        self.catalog
5067            .read()
5068            .procedures
5069            .iter()
5070            .map(|p| p.procedure.clone())
5071            .collect()
5072    }
5073
5074    pub fn procedure(&self, name: &str) -> Option<StoredProcedure> {
5075        self.catalog
5076            .read()
5077            .procedures
5078            .iter()
5079            .find(|p| p.procedure.name == name)
5080            .map(|p| p.procedure.clone())
5081    }
5082
5083    pub fn create_procedure(&self, procedure: StoredProcedure) -> Result<StoredProcedure> {
5084        self.create_procedure_inner(procedure, None)
5085    }
5086
5087    pub fn create_procedure_controlled<F>(
5088        &self,
5089        procedure: StoredProcedure,
5090        mut before_publish: F,
5091    ) -> Result<StoredProcedure>
5092    where
5093        F: FnMut() -> Result<()>,
5094    {
5095        self.create_procedure_inner(procedure, Some(&mut before_publish))
5096    }
5097
5098    fn create_procedure_inner(
5099        &self,
5100        mut procedure: StoredProcedure,
5101        before_publish: Option<&mut dyn FnMut() -> Result<()>>,
5102    ) -> Result<StoredProcedure> {
5103        self.require(&crate::auth::Permission::Ddl)?;
5104        let _g = self.ddl_lock.lock();
5105        let _security_write = self.security_write()?;
5106        self.require(&crate::auth::Permission::Ddl)?;
5107        procedure.validate()?;
5108        self.validate_procedure_references(&procedure)?;
5109        {
5110            let cat = self.catalog.read();
5111            if cat
5112                .procedures
5113                .iter()
5114                .any(|p| p.procedure.name == procedure.name)
5115            {
5116                return Err(MongrelError::InvalidArgument(format!(
5117                    "procedure {:?} already exists",
5118                    procedure.name
5119                )));
5120            }
5121        }
5122        let commit_lock = Arc::clone(&self.commit_lock);
5123        let _c = commit_lock.lock();
5124        let epoch = self.epoch.bump_assigned();
5125        let mut _epoch_guard = EpochGuard::new(self.epoch.as_ref(), epoch);
5126        procedure.created_epoch = epoch.0;
5127        procedure.updated_epoch = epoch.0;
5128        let mut next_catalog = self.catalog.read().clone();
5129        next_catalog
5130            .procedures
5131            .push(ProcedureEntry::from(procedure.clone()));
5132        next_catalog.db_epoch = epoch.0;
5133        self.publish_catalog_candidate(next_catalog, epoch, &mut _epoch_guard, before_publish)?;
5134        Ok(procedure)
5135    }
5136
5137    pub fn create_or_replace_procedure(
5138        &self,
5139        procedure: StoredProcedure,
5140    ) -> Result<StoredProcedure> {
5141        self.create_or_replace_procedure_inner(procedure, None)
5142    }
5143
5144    pub fn create_or_replace_procedure_controlled<F>(
5145        &self,
5146        procedure: StoredProcedure,
5147        mut before_publish: F,
5148    ) -> Result<StoredProcedure>
5149    where
5150        F: FnMut() -> Result<()>,
5151    {
5152        self.create_or_replace_procedure_inner(procedure, Some(&mut before_publish))
5153    }
5154
5155    fn create_or_replace_procedure_inner(
5156        &self,
5157        procedure: StoredProcedure,
5158        before_publish: Option<&mut dyn FnMut() -> Result<()>>,
5159    ) -> Result<StoredProcedure> {
5160        self.require(&crate::auth::Permission::Ddl)?;
5161        let _g = self.ddl_lock.lock();
5162        let _security_write = self.security_write()?;
5163        self.require(&crate::auth::Permission::Ddl)?;
5164        procedure.validate()?;
5165        self.validate_procedure_references(&procedure)?;
5166        let commit_lock = Arc::clone(&self.commit_lock);
5167        let _c = commit_lock.lock();
5168        let epoch = self.epoch.bump_assigned();
5169        let mut _epoch_guard = EpochGuard::new(self.epoch.as_ref(), epoch);
5170        let mut next_catalog = self.catalog.read().clone();
5171        let replaced = {
5172            let next = match next_catalog
5173                .procedures
5174                .iter()
5175                .position(|p| p.procedure.name == procedure.name)
5176            {
5177                Some(idx) => {
5178                    let next = next_catalog.procedures[idx]
5179                        .procedure
5180                        .replaced(procedure.clone(), epoch.0)?;
5181                    next_catalog.procedures[idx] = ProcedureEntry::from(next.clone());
5182                    next
5183                }
5184                None => {
5185                    let mut next = procedure;
5186                    next.created_epoch = epoch.0;
5187                    next.updated_epoch = epoch.0;
5188                    next_catalog
5189                        .procedures
5190                        .push(ProcedureEntry::from(next.clone()));
5191                    next
5192                }
5193            };
5194            next_catalog.db_epoch = epoch.0;
5195            next
5196        };
5197        self.publish_catalog_candidate(next_catalog, epoch, &mut _epoch_guard, before_publish)?;
5198        Ok(replaced)
5199    }
5200
5201    pub fn drop_procedure(&self, name: &str) -> Result<()> {
5202        self.drop_procedure_with_epoch(name).map(|_| ())
5203    }
5204
5205    pub fn drop_procedure_with_epoch(&self, name: &str) -> Result<Epoch> {
5206        self.drop_procedure_with_epoch_inner(name, None)
5207    }
5208
5209    pub fn drop_procedure_with_epoch_controlled<F>(
5210        &self,
5211        name: &str,
5212        mut before_publish: F,
5213    ) -> Result<Epoch>
5214    where
5215        F: FnMut() -> Result<()>,
5216    {
5217        self.drop_procedure_with_epoch_inner(name, Some(&mut before_publish))
5218    }
5219
5220    fn drop_procedure_with_epoch_inner(
5221        &self,
5222        name: &str,
5223        before_publish: Option<&mut dyn FnMut() -> Result<()>>,
5224    ) -> Result<Epoch> {
5225        self.require(&crate::auth::Permission::Ddl)?;
5226        let _g = self.ddl_lock.lock();
5227        let _security_write = self.security_write()?;
5228        self.require(&crate::auth::Permission::Ddl)?;
5229        let commit_lock = Arc::clone(&self.commit_lock);
5230        let _c = commit_lock.lock();
5231        let epoch = self.epoch.bump_assigned();
5232        let mut _epoch_guard = EpochGuard::new(self.epoch.as_ref(), epoch);
5233        let mut next_catalog = self.catalog.read().clone();
5234        let before = next_catalog.procedures.len();
5235        next_catalog.procedures.retain(|p| p.procedure.name != name);
5236        if next_catalog.procedures.len() == before {
5237            return Err(MongrelError::NotFound(format!(
5238                "procedure {name:?} not found"
5239            )));
5240        }
5241        next_catalog.db_epoch = epoch.0;
5242        self.publish_catalog_candidate(next_catalog, epoch, &mut _epoch_guard, before_publish)?;
5243        Ok(epoch)
5244    }
5245
5246    // ── User / role / credentials management ─────────────────────────────
5247
5248    /// List all catalog users (password hashes included — callers should not
5249    /// serialize them externally).
5250    pub fn users(&self) -> Vec<crate::auth::UserEntry> {
5251        self.catalog.read().users.clone()
5252    }
5253
5254    /// Resolve only the stable, non-secret identity fields needed to scope
5255    /// request receipts. Password hashes never leave the catalog lock.
5256    pub fn user_identity(&self, username: &str) -> Option<(u64, u64)> {
5257        self.catalog
5258            .read()
5259            .users
5260            .iter()
5261            .find(|user| user.username == username)
5262            .map(|user| (user.id, user.created_epoch))
5263    }
5264
5265    /// Current catalog authorization generation. Retry bindings can include
5266    /// this value to fail closed after roles, grants, or row policies change.
5267    pub fn security_version(&self) -> u64 {
5268        self.catalog.read().security_version
5269    }
5270
5271    /// List all catalog roles.
5272    pub fn roles(&self) -> Vec<crate::auth::RoleEntry> {
5273        self.catalog.read().roles.clone()
5274    }
5275
5276    /// Create a new user with an Argon2id-hashed password.
5277    pub fn create_user(&self, username: &str, password: &str) -> Result<crate::auth::UserEntry> {
5278        self.require(&crate::auth::Permission::Admin)?;
5279        let hash = crate::auth::hash_password(password).map_err(MongrelError::Other)?;
5280        self.create_user_with_password_hash(username, hash)
5281    }
5282
5283    /// Create a user from a password hash prepared before a commit fence.
5284    pub fn create_user_with_password_hash(
5285        &self,
5286        username: &str,
5287        hash: String,
5288    ) -> Result<crate::auth::UserEntry> {
5289        self.create_user_with_password_hash_inner(username, hash, None)
5290    }
5291
5292    pub fn create_user_with_password_hash_controlled<F>(
5293        &self,
5294        username: &str,
5295        hash: String,
5296        mut before_publish: F,
5297    ) -> Result<crate::auth::UserEntry>
5298    where
5299        F: FnMut() -> Result<()>,
5300    {
5301        self.create_user_with_password_hash_inner(username, hash, Some(&mut before_publish))
5302    }
5303
5304    fn create_user_with_password_hash_inner(
5305        &self,
5306        username: &str,
5307        hash: String,
5308        before_publish: Option<&mut dyn FnMut() -> Result<()>>,
5309    ) -> Result<crate::auth::UserEntry> {
5310        self.require(&crate::auth::Permission::Admin)?;
5311        let _ddl = self.ddl_lock.lock();
5312        let _security_write = self.security_write()?;
5313        self.require(&crate::auth::Permission::Admin)?;
5314        let _commit = self.commit_lock.lock();
5315        let epoch = self.epoch.bump_assigned();
5316        let mut _epoch_guard = EpochGuard::new(self.epoch.as_ref(), epoch);
5317        let mut next_catalog = self.catalog.read().clone();
5318        if next_catalog.users.iter().any(|u| u.username == username) {
5319            return Err(MongrelError::InvalidArgument(format!(
5320                "user {username:?} already exists"
5321            )));
5322        }
5323        next_catalog.next_user_id = next_catalog.next_user_id.max(1);
5324        let id = next_catalog.next_user_id;
5325        next_catalog.next_user_id = id
5326            .checked_add(1)
5327            .ok_or_else(|| MongrelError::Full("user-id namespace exhausted".into()))?;
5328        let entry = crate::auth::UserEntry {
5329            id,
5330            username: username.into(),
5331            password_hash: hash,
5332            roles: Vec::new(),
5333            is_admin: false,
5334            created_epoch: epoch.0,
5335        };
5336        next_catalog.users.push(entry.clone());
5337        advance_security_version(&mut next_catalog)?;
5338        next_catalog.db_epoch = epoch.0;
5339        self.publish_catalog_candidate(next_catalog, epoch, &mut _epoch_guard, before_publish)?;
5340        Ok(entry)
5341    }
5342
5343    /// Drop a user by username.
5344    pub fn drop_user(&self, username: &str) -> Result<()> {
5345        self.drop_user_with_epoch(username).map(|_| ())
5346    }
5347
5348    pub fn drop_user_with_epoch(&self, username: &str) -> Result<Epoch> {
5349        self.drop_user_with_epoch_inner(username, None)
5350    }
5351
5352    pub fn drop_user_with_epoch_controlled<F>(
5353        &self,
5354        username: &str,
5355        mut before_publish: F,
5356    ) -> Result<Epoch>
5357    where
5358        F: FnMut() -> Result<()>,
5359    {
5360        self.drop_user_with_epoch_inner(username, Some(&mut before_publish))
5361    }
5362
5363    fn drop_user_with_epoch_inner(
5364        &self,
5365        username: &str,
5366        before_publish: Option<&mut dyn FnMut() -> Result<()>>,
5367    ) -> Result<Epoch> {
5368        self.require(&crate::auth::Permission::Admin)?;
5369        let _ddl = self.ddl_lock.lock();
5370        let _security_write = self.security_write()?;
5371        self.require(&crate::auth::Permission::Admin)?;
5372        let _commit = self.commit_lock.lock();
5373        let epoch = self.epoch.bump_assigned();
5374        let mut _epoch_guard = EpochGuard::new(self.epoch.as_ref(), epoch);
5375        let mut next_catalog = self.catalog.read().clone();
5376        let before = next_catalog.users.len();
5377        next_catalog.users.retain(|u| u.username != username);
5378        if next_catalog.users.len() == before {
5379            return Err(MongrelError::NotFound(format!(
5380                "user {username:?} not found"
5381            )));
5382        }
5383        advance_security_version(&mut next_catalog)?;
5384        next_catalog.db_epoch = epoch.0;
5385        self.publish_catalog_candidate(next_catalog, epoch, &mut _epoch_guard, before_publish)?;
5386        Ok(epoch)
5387    }
5388
5389    /// Change a user's password.
5390    pub fn alter_user_password(&self, username: &str, new_password: &str) -> Result<()> {
5391        self.alter_user_password_with_epoch(username, new_password)
5392            .map(|_| ())
5393    }
5394
5395    pub fn alter_user_password_with_epoch(
5396        &self,
5397        username: &str,
5398        new_password: &str,
5399    ) -> Result<Epoch> {
5400        self.require(&crate::auth::Permission::Admin)?;
5401        let hash = crate::auth::hash_password(new_password).map_err(MongrelError::Other)?;
5402        self.alter_user_password_hash_with_epoch(username, hash)
5403    }
5404
5405    pub fn alter_user_password_hash_with_epoch(
5406        &self,
5407        username: &str,
5408        hash: String,
5409    ) -> Result<Epoch> {
5410        self.alter_user_password_hash_with_epoch_inner(username, hash, None)
5411    }
5412
5413    pub fn alter_user_password_hash_with_epoch_controlled<F>(
5414        &self,
5415        username: &str,
5416        hash: String,
5417        mut before_publish: F,
5418    ) -> Result<Epoch>
5419    where
5420        F: FnMut() -> Result<()>,
5421    {
5422        self.alter_user_password_hash_with_epoch_inner(username, hash, Some(&mut before_publish))
5423    }
5424
5425    fn alter_user_password_hash_with_epoch_inner(
5426        &self,
5427        username: &str,
5428        hash: String,
5429        before_publish: Option<&mut dyn FnMut() -> Result<()>>,
5430    ) -> Result<Epoch> {
5431        self.require(&crate::auth::Permission::Admin)?;
5432        let _ddl = self.ddl_lock.lock();
5433        let _security_write = self.security_write()?;
5434        self.require(&crate::auth::Permission::Admin)?;
5435        let _commit = self.commit_lock.lock();
5436        let epoch = self.epoch.bump_assigned();
5437        let mut _epoch_guard = EpochGuard::new(self.epoch.as_ref(), epoch);
5438        let mut next_catalog = self.catalog.read().clone();
5439        let user = next_catalog
5440            .users
5441            .iter_mut()
5442            .find(|u| u.username == username)
5443            .ok_or_else(|| MongrelError::NotFound(format!("user {username:?} not found")))?;
5444        user.password_hash = hash;
5445        advance_security_version(&mut next_catalog)?;
5446        next_catalog.db_epoch = epoch.0;
5447        self.publish_catalog_candidate(next_catalog, epoch, &mut _epoch_guard, before_publish)?;
5448        Ok(epoch)
5449    }
5450
5451    /// Verify credentials. Returns `Some(entry)` on success, `None` on
5452    /// mismatch, `Err` on engine error.
5453    pub fn verify_user(
5454        &self,
5455        username: &str,
5456        password: &str,
5457    ) -> Result<Option<crate::auth::UserEntry>> {
5458        let cat = self.catalog.read();
5459        let Some(user) = cat.users.iter().find(|u| u.username == username) else {
5460            return Ok(None);
5461        };
5462        if user.password_hash.is_empty() {
5463            return Ok(None);
5464        }
5465        let ok = crate::auth::verify_password(password, &user.password_hash)
5466            .map_err(MongrelError::Other)?;
5467        if ok {
5468            Ok(Some(user.clone()))
5469        } else {
5470            Ok(None)
5471        }
5472    }
5473
5474    /// Authenticate and resolve one immutable principal from the same catalog
5475    /// snapshot. Username reuse cannot bridge the password check and principal
5476    /// resolution.
5477    pub fn authenticate_principal(
5478        &self,
5479        username: &str,
5480        password: &str,
5481    ) -> Result<Option<crate::auth::Principal>> {
5482        self.authenticate_principal_inner(username, password, || {})
5483    }
5484
5485    fn authenticate_principal_inner<F>(
5486        &self,
5487        username: &str,
5488        password: &str,
5489        after_verify: F,
5490    ) -> Result<Option<crate::auth::Principal>>
5491    where
5492        F: FnOnce(),
5493    {
5494        let catalog = self.catalog.read();
5495        let Some(user) = catalog.users.iter().find(|user| user.username == username) else {
5496            return Ok(None);
5497        };
5498        if user.password_hash.is_empty()
5499            || !crate::auth::verify_password(password, &user.password_hash)
5500                .map_err(MongrelError::Other)?
5501        {
5502            return Ok(None);
5503        }
5504        after_verify();
5505        Ok(Self::resolve_user_principal_from_catalog(&catalog, user))
5506    }
5507
5508    /// Grant admin privileges to a user (bypasses all permission checks).
5509    pub fn set_user_admin(&self, username: &str, is_admin: bool) -> Result<()> {
5510        self.set_user_admin_with_epoch(username, is_admin)
5511            .map(|_| ())
5512    }
5513
5514    pub fn set_user_admin_with_epoch(
5515        &self,
5516        username: &str,
5517        is_admin: bool,
5518    ) -> Result<Option<Epoch>> {
5519        self.set_user_admin_with_epoch_inner(username, is_admin, None)
5520    }
5521
5522    pub fn set_user_admin_with_epoch_controlled<F>(
5523        &self,
5524        username: &str,
5525        is_admin: bool,
5526        mut before_publish: F,
5527    ) -> Result<Option<Epoch>>
5528    where
5529        F: FnMut() -> Result<()>,
5530    {
5531        self.set_user_admin_with_epoch_inner(username, is_admin, Some(&mut before_publish))
5532    }
5533
5534    fn set_user_admin_with_epoch_inner(
5535        &self,
5536        username: &str,
5537        is_admin: bool,
5538        before_publish: Option<&mut dyn FnMut() -> Result<()>>,
5539    ) -> Result<Option<Epoch>> {
5540        self.require(&crate::auth::Permission::Admin)?;
5541        let _ddl = self.ddl_lock.lock();
5542        let _security_write = self.security_write()?;
5543        self.require(&crate::auth::Permission::Admin)?;
5544        let _commit = self.commit_lock.lock();
5545        let mut next_catalog = self.catalog.read().clone();
5546        let user = next_catalog
5547            .users
5548            .iter_mut()
5549            .find(|u| u.username == username)
5550            .ok_or_else(|| MongrelError::NotFound(format!("user {username:?} not found")))?;
5551        if user.is_admin == is_admin {
5552            return Ok(None);
5553        }
5554        user.is_admin = is_admin;
5555        let epoch = self.epoch.bump_assigned();
5556        let mut _epoch_guard = EpochGuard::new(self.epoch.as_ref(), epoch);
5557        advance_security_version(&mut next_catalog)?;
5558        next_catalog.db_epoch = epoch.0;
5559        self.publish_catalog_candidate(next_catalog, epoch, &mut _epoch_guard, before_publish)?;
5560        Ok(Some(epoch))
5561    }
5562
5563    /// Create a new role.
5564    pub fn create_role(&self, name: &str) -> Result<crate::auth::RoleEntry> {
5565        self.create_role_inner(name, None)
5566    }
5567
5568    pub fn create_role_controlled<F>(
5569        &self,
5570        name: &str,
5571        mut before_publish: F,
5572    ) -> Result<crate::auth::RoleEntry>
5573    where
5574        F: FnMut() -> Result<()>,
5575    {
5576        self.create_role_inner(name, Some(&mut before_publish))
5577    }
5578
5579    fn create_role_inner(
5580        &self,
5581        name: &str,
5582        before_publish: Option<&mut dyn FnMut() -> Result<()>>,
5583    ) -> Result<crate::auth::RoleEntry> {
5584        self.require(&crate::auth::Permission::Admin)?;
5585        let _ddl = self.ddl_lock.lock();
5586        let _security_write = self.security_write()?;
5587        self.require(&crate::auth::Permission::Admin)?;
5588        let _commit = self.commit_lock.lock();
5589        let epoch = self.epoch.bump_assigned();
5590        let mut _epoch_guard = EpochGuard::new(self.epoch.as_ref(), epoch);
5591        let mut next_catalog = self.catalog.read().clone();
5592        if next_catalog.roles.iter().any(|r| r.name == name) {
5593            return Err(MongrelError::InvalidArgument(format!(
5594                "role {name:?} already exists"
5595            )));
5596        }
5597        let entry = crate::auth::RoleEntry {
5598            name: name.into(),
5599            permissions: Vec::new(),
5600            created_epoch: epoch.0,
5601        };
5602        next_catalog.roles.push(entry.clone());
5603        advance_security_version(&mut next_catalog)?;
5604        next_catalog.db_epoch = epoch.0;
5605        self.publish_catalog_candidate(next_catalog, epoch, &mut _epoch_guard, before_publish)?;
5606        Ok(entry)
5607    }
5608
5609    /// Drop a role by name.
5610    pub fn drop_role(&self, name: &str) -> Result<()> {
5611        self.drop_role_with_epoch(name).map(|_| ())
5612    }
5613
5614    pub fn drop_role_with_epoch(&self, name: &str) -> Result<Epoch> {
5615        self.drop_role_with_epoch_inner(name, None)
5616    }
5617
5618    pub fn drop_role_with_epoch_controlled<F>(
5619        &self,
5620        name: &str,
5621        mut before_publish: F,
5622    ) -> Result<Epoch>
5623    where
5624        F: FnMut() -> Result<()>,
5625    {
5626        self.drop_role_with_epoch_inner(name, Some(&mut before_publish))
5627    }
5628
5629    fn drop_role_with_epoch_inner(
5630        &self,
5631        name: &str,
5632        before_publish: Option<&mut dyn FnMut() -> Result<()>>,
5633    ) -> Result<Epoch> {
5634        self.require(&crate::auth::Permission::Admin)?;
5635        let _ddl = self.ddl_lock.lock();
5636        let _security_write = self.security_write()?;
5637        self.require(&crate::auth::Permission::Admin)?;
5638        let _commit = self.commit_lock.lock();
5639        let epoch = self.epoch.bump_assigned();
5640        let mut _epoch_guard = EpochGuard::new(self.epoch.as_ref(), epoch);
5641        let mut next_catalog = self.catalog.read().clone();
5642        let before = next_catalog.roles.len();
5643        next_catalog.roles.retain(|r| r.name != name);
5644        if next_catalog.roles.len() == before {
5645            return Err(MongrelError::NotFound(format!("role {name:?} not found")));
5646        }
5647        for user in &mut next_catalog.users {
5648            user.roles.retain(|r| r != name);
5649        }
5650        advance_security_version(&mut next_catalog)?;
5651        next_catalog.db_epoch = epoch.0;
5652        self.publish_catalog_candidate(next_catalog, epoch, &mut _epoch_guard, before_publish)?;
5653        Ok(epoch)
5654    }
5655
5656    /// Grant a role to a user.
5657    pub fn grant_role(&self, username: &str, role_name: &str) -> Result<()> {
5658        self.grant_role_with_epoch(username, role_name).map(|_| ())
5659    }
5660
5661    pub fn grant_role_with_epoch(&self, username: &str, role_name: &str) -> Result<Option<Epoch>> {
5662        self.grant_role_with_epoch_inner(username, role_name, None)
5663    }
5664
5665    pub fn grant_role_with_epoch_controlled<F>(
5666        &self,
5667        username: &str,
5668        role_name: &str,
5669        mut before_publish: F,
5670    ) -> Result<Option<Epoch>>
5671    where
5672        F: FnMut() -> Result<()>,
5673    {
5674        self.grant_role_with_epoch_inner(username, role_name, Some(&mut before_publish))
5675    }
5676
5677    fn grant_role_with_epoch_inner(
5678        &self,
5679        username: &str,
5680        role_name: &str,
5681        before_publish: Option<&mut dyn FnMut() -> Result<()>>,
5682    ) -> Result<Option<Epoch>> {
5683        self.require(&crate::auth::Permission::Admin)?;
5684        let _ddl = self.ddl_lock.lock();
5685        let _security_write = self.security_write()?;
5686        self.require(&crate::auth::Permission::Admin)?;
5687        let _commit = self.commit_lock.lock();
5688        let mut next_catalog = self.catalog.read().clone();
5689        if !next_catalog.roles.iter().any(|r| r.name == role_name) {
5690            return Err(MongrelError::NotFound(format!(
5691                "role {role_name:?} not found"
5692            )));
5693        }
5694        let user = next_catalog
5695            .users
5696            .iter_mut()
5697            .find(|u| u.username == username)
5698            .ok_or_else(|| MongrelError::NotFound(format!("user {username:?} not found")))?;
5699        if user.roles.iter().any(|role| role == role_name) {
5700            return Ok(None);
5701        }
5702        user.roles.push(role_name.into());
5703        let epoch = self.epoch.bump_assigned();
5704        let mut _epoch_guard = EpochGuard::new(self.epoch.as_ref(), epoch);
5705        advance_security_version(&mut next_catalog)?;
5706        next_catalog.db_epoch = epoch.0;
5707        self.publish_catalog_candidate(next_catalog, epoch, &mut _epoch_guard, before_publish)?;
5708        Ok(Some(epoch))
5709    }
5710
5711    /// Revoke a role from a user.
5712    pub fn revoke_role(&self, username: &str, role_name: &str) -> Result<()> {
5713        self.revoke_role_with_epoch(username, role_name).map(|_| ())
5714    }
5715
5716    pub fn revoke_role_with_epoch(&self, username: &str, role_name: &str) -> Result<Option<Epoch>> {
5717        self.revoke_role_with_epoch_inner(username, role_name, None)
5718    }
5719
5720    pub fn revoke_role_with_epoch_controlled<F>(
5721        &self,
5722        username: &str,
5723        role_name: &str,
5724        mut before_publish: F,
5725    ) -> Result<Option<Epoch>>
5726    where
5727        F: FnMut() -> Result<()>,
5728    {
5729        self.revoke_role_with_epoch_inner(username, role_name, Some(&mut before_publish))
5730    }
5731
5732    fn revoke_role_with_epoch_inner(
5733        &self,
5734        username: &str,
5735        role_name: &str,
5736        before_publish: Option<&mut dyn FnMut() -> Result<()>>,
5737    ) -> Result<Option<Epoch>> {
5738        self.require(&crate::auth::Permission::Admin)?;
5739        let _ddl = self.ddl_lock.lock();
5740        let _security_write = self.security_write()?;
5741        self.require(&crate::auth::Permission::Admin)?;
5742        let _commit = self.commit_lock.lock();
5743        let mut next_catalog = self.catalog.read().clone();
5744        let user = next_catalog
5745            .users
5746            .iter_mut()
5747            .find(|u| u.username == username)
5748            .ok_or_else(|| MongrelError::NotFound(format!("user {username:?} not found")))?;
5749        let before = user.roles.len();
5750        user.roles.retain(|r| r != role_name);
5751        if user.roles.len() == before {
5752            return Ok(None);
5753        }
5754        let epoch = self.epoch.bump_assigned();
5755        let mut _epoch_guard = EpochGuard::new(self.epoch.as_ref(), epoch);
5756        advance_security_version(&mut next_catalog)?;
5757        next_catalog.db_epoch = epoch.0;
5758        self.publish_catalog_candidate(next_catalog, epoch, &mut _epoch_guard, before_publish)?;
5759        Ok(Some(epoch))
5760    }
5761
5762    /// Grant a permission to a role.
5763    pub fn grant_permission(
5764        &self,
5765        role_name: &str,
5766        permission: crate::auth::Permission,
5767    ) -> Result<()> {
5768        self.grant_permission_with_epoch(role_name, permission)
5769            .map(|_| ())
5770    }
5771
5772    pub fn grant_permission_with_epoch(
5773        &self,
5774        role_name: &str,
5775        permission: crate::auth::Permission,
5776    ) -> Result<Option<Epoch>> {
5777        self.grant_permission_with_epoch_inner(role_name, permission, None)
5778    }
5779
5780    pub fn grant_permission_with_epoch_controlled<F>(
5781        &self,
5782        role_name: &str,
5783        permission: crate::auth::Permission,
5784        mut before_publish: F,
5785    ) -> Result<Option<Epoch>>
5786    where
5787        F: FnMut() -> Result<()>,
5788    {
5789        self.grant_permission_with_epoch_inner(role_name, permission, Some(&mut before_publish))
5790    }
5791
5792    fn grant_permission_with_epoch_inner(
5793        &self,
5794        role_name: &str,
5795        permission: crate::auth::Permission,
5796        before_publish: Option<&mut dyn FnMut() -> Result<()>>,
5797    ) -> Result<Option<Epoch>> {
5798        self.require(&crate::auth::Permission::Admin)?;
5799        let _ddl = self.ddl_lock.lock();
5800        let _security_write = self.security_write()?;
5801        self.require(&crate::auth::Permission::Admin)?;
5802        let _commit = self.commit_lock.lock();
5803        let mut next_catalog = self.catalog.read().clone();
5804        let role = next_catalog
5805            .roles
5806            .iter_mut()
5807            .find(|r| r.name == role_name)
5808            .ok_or_else(|| MongrelError::NotFound(format!("role {role_name:?} not found")))?;
5809        let before = role.permissions.clone();
5810        merge_permission(&mut role.permissions, permission);
5811        if role.permissions == before {
5812            return Ok(None);
5813        }
5814        let epoch = self.epoch.bump_assigned();
5815        let mut _epoch_guard = EpochGuard::new(self.epoch.as_ref(), epoch);
5816        advance_security_version(&mut next_catalog)?;
5817        next_catalog.db_epoch = epoch.0;
5818        self.publish_catalog_candidate(next_catalog, epoch, &mut _epoch_guard, before_publish)?;
5819        Ok(Some(epoch))
5820    }
5821
5822    /// Revoke a permission from a role.
5823    pub fn revoke_permission(
5824        &self,
5825        role_name: &str,
5826        permission: crate::auth::Permission,
5827    ) -> Result<()> {
5828        self.revoke_permission_with_epoch(role_name, permission)
5829            .map(|_| ())
5830    }
5831
5832    pub fn revoke_permission_with_epoch(
5833        &self,
5834        role_name: &str,
5835        permission: crate::auth::Permission,
5836    ) -> Result<Option<Epoch>> {
5837        self.revoke_permission_with_epoch_inner(role_name, permission, None)
5838    }
5839
5840    pub fn revoke_permission_with_epoch_controlled<F>(
5841        &self,
5842        role_name: &str,
5843        permission: crate::auth::Permission,
5844        mut before_publish: F,
5845    ) -> Result<Option<Epoch>>
5846    where
5847        F: FnMut() -> Result<()>,
5848    {
5849        self.revoke_permission_with_epoch_inner(role_name, permission, Some(&mut before_publish))
5850    }
5851
5852    fn revoke_permission_with_epoch_inner(
5853        &self,
5854        role_name: &str,
5855        permission: crate::auth::Permission,
5856        before_publish: Option<&mut dyn FnMut() -> Result<()>>,
5857    ) -> Result<Option<Epoch>> {
5858        self.require(&crate::auth::Permission::Admin)?;
5859        let _ddl = self.ddl_lock.lock();
5860        let _security_write = self.security_write()?;
5861        self.require(&crate::auth::Permission::Admin)?;
5862        let _commit = self.commit_lock.lock();
5863        let mut next_catalog = self.catalog.read().clone();
5864        let role = next_catalog
5865            .roles
5866            .iter_mut()
5867            .find(|r| r.name == role_name)
5868            .ok_or_else(|| MongrelError::NotFound(format!("role {role_name:?} not found")))?;
5869        let before = role.permissions.clone();
5870        revoke_permission_from(&mut role.permissions, &permission);
5871        if role.permissions == before {
5872            return Ok(None);
5873        }
5874        let epoch = self.epoch.bump_assigned();
5875        let mut _epoch_guard = EpochGuard::new(self.epoch.as_ref(), epoch);
5876        advance_security_version(&mut next_catalog)?;
5877        next_catalog.db_epoch = epoch.0;
5878        self.publish_catalog_candidate(next_catalog, epoch, &mut _epoch_guard, before_publish)?;
5879        Ok(Some(epoch))
5880    }
5881
5882    /// Resolve a user into a [`crate::auth::Principal`] by collecting all
5883    /// permissions from their roles. Returns `None` if the user doesn't exist.
5884    pub fn resolve_principal(&self, username: &str) -> Option<crate::auth::Principal> {
5885        let cat = self.catalog.read();
5886        Self::resolve_principal_from_catalog(&cat, username)
5887    }
5888
5889    /// Re-resolve only when the immutable user identity still exists. This is
5890    /// the server/session validation path; username reuse never matches.
5891    pub fn resolve_current_principal(
5892        &self,
5893        principal: &crate::auth::Principal,
5894    ) -> Option<crate::auth::Principal> {
5895        Self::resolve_bound_principal_from_catalog(&self.catalog.read(), principal)
5896    }
5897
5898    /// Resolve a username to a [`Principal`] directly from a catalog snapshot,
5899    /// without needing a constructed `Database`. Used by the credentialed open
5900    /// path (which must verify credentials before the `Database` exists) and
5901    /// by [`resolve_principal`](Self::resolve_principal).
5902    fn resolve_principal_from_catalog(
5903        cat: &Catalog,
5904        username: &str,
5905    ) -> Option<crate::auth::Principal> {
5906        let user = cat.users.iter().find(|u| u.username == username)?;
5907        Self::resolve_user_principal_from_catalog(cat, user)
5908    }
5909
5910    fn resolve_bound_principal_from_catalog(
5911        cat: &Catalog,
5912        principal: &crate::auth::Principal,
5913    ) -> Option<crate::auth::Principal> {
5914        let user = cat.users.iter().find(|user| {
5915            user.id == principal.user_id
5916                && user.created_epoch == principal.created_epoch
5917                && user.username == principal.username
5918        })?;
5919        Self::resolve_user_principal_from_catalog(cat, user)
5920    }
5921
5922    fn resolve_user_principal_from_catalog(
5923        cat: &Catalog,
5924        user: &crate::auth::UserEntry,
5925    ) -> Option<crate::auth::Principal> {
5926        let mut permissions = Vec::new();
5927        for role_name in &user.roles {
5928            if let Some(role) = cat.roles.iter().find(|r| &r.name == role_name) {
5929                permissions.extend(role.permissions.iter().cloned());
5930            }
5931        }
5932        Some(crate::auth::Principal {
5933            user_id: user.id,
5934            created_epoch: user.created_epoch,
5935            username: user.username.clone(),
5936            is_admin: user.is_admin,
5937            roles: user.roles.clone(),
5938            permissions,
5939        })
5940    }
5941
5942    /// Check whether a user has a specific permission (via their roles).
5943    pub fn check_permission(&self, username: &str, permission: &crate::auth::Permission) -> bool {
5944        match self.resolve_principal(username) {
5945            Some(p) => p.has_permission(permission),
5946            None => false,
5947        }
5948    }
5949
5950    /// Returns `true` if this database's catalog has `require_auth = true`.
5951    /// When true, every operation consults the cached [`Principal`] via
5952    /// [`require`](Self::require).
5953    pub fn require_auth_enabled(&self) -> bool {
5954        self.catalog.read().require_auth
5955    }
5956
5957    /// A snapshot of the cached principal for this handle, if any. `None` for
5958    /// databases opened without credentials (the default). Returns a clone
5959    /// because the principal lives behind an `RwLock`.
5960    pub fn principal(&self) -> Option<crate::auth::Principal> {
5961        self.principal.read().clone()
5962    }
5963
5964    /// Build a `TableAuthChecker` from the current auth state. Used when
5965    /// mounting a new table (`create_table`) so the table inherits the
5966    /// database's enforcement configuration. The checker reads the live
5967    /// `require_auth` flag and cached principal, so changes via `enable_auth`
5968    /// / `refresh_principal` propagate to already-mounted tables.
5969    fn table_auth_checker(&self) -> Option<Arc<dyn crate::auth_state::TableAuthChecker>> {
5970        Some(Arc::new(crate::auth_state::DefaultTableAuthChecker::new(
5971            self.auth_state.clone(),
5972        )))
5973    }
5974
5975    /// Re-resolve the cached principal from the shared current catalog.
5976    /// Long-lived
5977    /// handles (e.g. a daemon) call this after a `REVOKE` or role change —
5978    /// possibly made by a different handle to the same database — to pick up
5979    /// the new effective permissions without re-verifying the password.
5980    ///
5981    /// The process-wide security version reloads from disk only when another
5982    /// handle published a newer catalog. The username is taken from
5983    /// the existing cached principal; if the user has since been dropped,
5984    /// returns [`MongrelError::InvalidCredentials`].
5985    ///
5986    /// No-op (returns `Ok(())`) on a credentialless database, or on a
5987    /// credentialed database whose cached principal is `None`.
5988    pub fn refresh_principal(&self) -> Result<()> {
5989        let previous = match self.principal.read().clone() {
5990            Some(principal) => principal,
5991            None => return Ok(()),
5992        };
5993        let observed_version = self.security_coordinator.version.load(Ordering::Acquire);
5994        self.refresh_security_catalog_if_stale(observed_version)?;
5995        let cat = self.catalog.read();
5996        match Self::resolve_bound_principal_from_catalog(&cat, &previous) {
5997            Some(p) => {
5998                *self.principal.write() = Some(p.clone());
5999                // Update the shared auth state so mounted Tables see the new
6000                // permissions immediately (Tables read from AuthState, not from
6001                // self.principal).
6002                self.auth_state.set_principal(Some(p));
6003                Ok(())
6004            }
6005            None => Err(MongrelError::InvalidCredentials {
6006                username: previous.username,
6007            }),
6008        }
6009    }
6010
6011    /// Number of security-catalog disk reloads performed by this open handle.
6012    /// Initial open reads are excluded.
6013    pub fn security_catalog_disk_read_count(&self) -> u64 {
6014        self.security_catalog_disk_reads.load(Ordering::Relaxed)
6015    }
6016
6017    /// Convert a credentialless database to a credentialed one: create the
6018    /// first admin user, set `require_auth = true`, and cache the admin
6019    /// principal on this handle so subsequent operations on the same handle
6020    /// continue to work. After this call, the database can only be reopened
6021    /// via `open_with_credentials` / `open_encrypted_with_credentials`.
6022    ///
6023    /// Refuses if the database already has `require_auth = true`. This is
6024    /// the conversion path for existing databases; for fresh databases,
6025    /// `create_with_credentials` sets everything up atomically.
6026    ///
6027    /// See `docs/15-credential-enforcement.md`.
6028    pub fn enable_auth(&self, admin_username: &str, admin_password: &str) -> Result<()> {
6029        let password_hash =
6030            crate::auth::hash_password(admin_password).map_err(MongrelError::Other)?;
6031        let _ddl = self.ddl_lock.lock();
6032        let _security_write = self.security_write()?;
6033        let _commit = self.commit_lock.lock();
6034        let epoch = self.epoch.bump_assigned();
6035        let mut _epoch_guard = EpochGuard::new(self.epoch.as_ref(), epoch);
6036        let mut next_catalog = self.catalog.read().clone();
6037        if next_catalog.require_auth {
6038            return Err(MongrelError::InvalidArgument(
6039                "database already has require_auth enabled".into(),
6040            ));
6041        }
6042        if next_catalog
6043            .users
6044            .iter()
6045            .any(|u| u.username == admin_username)
6046        {
6047            return Err(MongrelError::InvalidArgument(format!(
6048                "user {admin_username:?} already exists"
6049            )));
6050        }
6051        next_catalog.next_user_id = next_catalog.next_user_id.max(1);
6052        let id = next_catalog.next_user_id;
6053        next_catalog.next_user_id = id
6054            .checked_add(1)
6055            .ok_or_else(|| MongrelError::Full("user-id namespace exhausted".into()))?;
6056        next_catalog.users.push(crate::auth::UserEntry {
6057            id,
6058            username: admin_username.to_string(),
6059            password_hash,
6060            roles: Vec::new(),
6061            is_admin: true,
6062            created_epoch: epoch.0,
6063        });
6064        next_catalog.require_auth = true;
6065        advance_security_version(&mut next_catalog)?;
6066        next_catalog.db_epoch = epoch.0;
6067        let publish = self.publish_catalog_candidate(next_catalog, epoch, &mut _epoch_guard, None);
6068        // Cache the admin principal on this handle + update the shared auth
6069        // state whenever rename published, even if directory fsync was
6070        // inconclusive.
6071        if publish.is_ok() || matches!(&publish, Err(MongrelError::CommitOutcomeUnknown { .. })) {
6072            let principal = crate::auth::Principal {
6073                user_id: id,
6074                created_epoch: epoch.0,
6075                username: admin_username.to_string(),
6076                is_admin: true,
6077                roles: Vec::new(),
6078                permissions: Vec::new(),
6079            };
6080            *self.principal.write() = Some(principal.clone());
6081            self.auth_state.set_principal(Some(principal));
6082        }
6083        publish
6084    }
6085
6086    /// Disable `require_auth` on this database, reverting it to credentialless
6087    /// mode. This is the **recovery** path — it requires the handle to already
6088    /// be open (and therefore already authenticated if `require_auth` was on).
6089    ///
6090    /// After this call, the database can be reopened with plain
6091    /// [`open`](Self::open) / [`open_encrypted`](Self::open_encrypted) without
6092    /// credentials. All existing users and roles are preserved in the catalog
6093    /// (so `require_auth` can be re-enabled without recreating them), but they
6094    /// are no longer consulted for enforcement.
6095    ///
6096    /// For true **offline** recovery (when credentials are lost and no
6097    /// authenticated handle is available), the caller opens the database
6098    /// directly via the catalog file (filesystem access required) and calls
6099    /// this method — see the CLI's `auth disable-offline` command.
6100    ///
6101    /// See `docs/15-credential-enforcement.md` §4.7.
6102    pub fn disable_auth(&self) -> Result<()> {
6103        let _ddl = self.ddl_lock.lock();
6104        let _security_write = self.security_write()?;
6105        let _commit = self.commit_lock.lock();
6106        let epoch = self.epoch.bump_assigned();
6107        let mut _epoch_guard = EpochGuard::new(self.epoch.as_ref(), epoch);
6108        let mut next_catalog = self.catalog.read().clone();
6109        if !next_catalog.require_auth {
6110            return Err(MongrelError::InvalidArgument(
6111                "database does not have require_auth enabled".into(),
6112            ));
6113        }
6114        next_catalog.require_auth = false;
6115        advance_security_version(&mut next_catalog)?;
6116        next_catalog.db_epoch = epoch.0;
6117        let publish = self.publish_catalog_candidate(next_catalog, epoch, &mut _epoch_guard, None);
6118        // Clear the cached principal — enforcement is now off.
6119        if publish.is_ok() || matches!(&publish, Err(MongrelError::CommitOutcomeUnknown { .. })) {
6120            *self.principal.write() = None;
6121        }
6122        publish
6123    }
6124
6125    /// Enforcement check: if the catalog has `require_auth = true`, verify
6126    /// that the cached principal satisfies `perm`. Called by every
6127    /// enforcement point (DDL, admin, maintenance, and — in Phase 2 —
6128    /// Table/Transaction/MongrelSession operations).
6129    ///
6130    /// On a credentialless database this is a no-op (`Ok(())`).
6131    pub fn require(&self, perm: &crate::auth::Permission) -> Result<()> {
6132        if self.read_only && !matches!(perm, crate::auth::Permission::Select { .. }) {
6133            return Err(MongrelError::ReadOnlyReplica);
6134        }
6135        if self.principal.read().is_some() {
6136            self.refresh_principal().map_err(|error| match error {
6137                MongrelError::InvalidCredentials { .. } => MongrelError::AuthRequired,
6138                error => error,
6139            })?;
6140        }
6141        if !self.catalog.read().require_auth {
6142            return Ok(());
6143        }
6144        let guard = self.principal.read();
6145        let p = guard.as_ref().ok_or(MongrelError::AuthRequired)?;
6146        if p.has_permission(perm) {
6147            Ok(())
6148        } else {
6149            Err(MongrelError::PermissionDenied {
6150                required: perm.clone(),
6151                principal: p.username.clone(),
6152            })
6153        }
6154    }
6155
6156    /// Convenience: enforce a table-level permission (`Select`/`Insert`/
6157    /// `Update`/`Delete`) by table name. Used by the Transaction layer and
6158    /// other callers that know the operation kind + table name but don't want
6159    /// to construct the full `Permission` enum value themselves.
6160    pub fn require_table(
6161        &self,
6162        table: &str,
6163        perm: crate::auth_state::RequiredPermission,
6164    ) -> Result<()> {
6165        self.require(&perm.into_permission(table))
6166    }
6167
6168    pub fn triggers(&self) -> Vec<StoredTrigger> {
6169        self.catalog
6170            .read()
6171            .triggers
6172            .iter()
6173            .map(|t| t.trigger.clone())
6174            .collect()
6175    }
6176
6177    pub fn trigger(&self, name: &str) -> Option<StoredTrigger> {
6178        self.catalog
6179            .read()
6180            .triggers
6181            .iter()
6182            .find(|t| t.trigger.name == name)
6183            .map(|t| t.trigger.clone())
6184    }
6185
6186    pub fn create_trigger(&self, trigger: StoredTrigger) -> Result<StoredTrigger> {
6187        self.create_trigger_inner(trigger, None, None)
6188    }
6189
6190    pub fn create_trigger_controlled<F>(
6191        &self,
6192        trigger: StoredTrigger,
6193        mut before_publish: F,
6194    ) -> Result<StoredTrigger>
6195    where
6196        F: FnMut() -> Result<()>,
6197    {
6198        self.create_trigger_inner(trigger, None, Some(&mut before_publish))
6199    }
6200
6201    pub fn create_trigger_as_controlled<F>(
6202        &self,
6203        trigger: StoredTrigger,
6204        principal: Option<&crate::auth::Principal>,
6205        mut before_publish: F,
6206    ) -> Result<StoredTrigger>
6207    where
6208        F: FnMut() -> Result<()>,
6209    {
6210        self.create_trigger_inner(trigger, principal, Some(&mut before_publish))
6211    }
6212
6213    fn create_trigger_inner(
6214        &self,
6215        mut trigger: StoredTrigger,
6216        principal: Option<&crate::auth::Principal>,
6217        before_publish: Option<&mut dyn FnMut() -> Result<()>>,
6218    ) -> Result<StoredTrigger> {
6219        self.require_for(principal, &crate::auth::Permission::Ddl)?;
6220        let _g = self.ddl_lock.lock();
6221        let _security_write = self.security_write()?;
6222        self.require_for(principal, &crate::auth::Permission::Ddl)?;
6223        trigger.validate()?;
6224        self.validate_trigger_references(&trigger)
6225            .map_err(trigger_validation_error)?;
6226        {
6227            let cat = self.catalog.read();
6228            if cat.triggers.iter().any(|t| t.trigger.name == trigger.name) {
6229                return Err(MongrelError::TriggerValidation(format!(
6230                    "trigger {:?} already exists",
6231                    trigger.name
6232                )));
6233            }
6234        }
6235        let commit_lock = Arc::clone(&self.commit_lock);
6236        let _c = commit_lock.lock();
6237        let epoch = self.epoch.bump_assigned();
6238        let mut _epoch_guard = EpochGuard::new(self.epoch.as_ref(), epoch);
6239        trigger.created_epoch = epoch.0;
6240        trigger.updated_epoch = epoch.0;
6241        let mut next_catalog = self.catalog.read().clone();
6242        next_catalog
6243            .triggers
6244            .push(TriggerEntry::from(trigger.clone()));
6245        next_catalog.db_epoch = epoch.0;
6246        self.publish_catalog_candidate(next_catalog, epoch, &mut _epoch_guard, before_publish)?;
6247        Ok(trigger)
6248    }
6249
6250    pub fn create_or_replace_trigger(&self, trigger: StoredTrigger) -> Result<StoredTrigger> {
6251        self.create_or_replace_trigger_inner(trigger, None, None)
6252    }
6253
6254    pub fn create_or_replace_trigger_controlled<F>(
6255        &self,
6256        trigger: StoredTrigger,
6257        mut before_publish: F,
6258    ) -> Result<StoredTrigger>
6259    where
6260        F: FnMut() -> Result<()>,
6261    {
6262        self.create_or_replace_trigger_inner(trigger, None, Some(&mut before_publish))
6263    }
6264
6265    pub fn create_or_replace_trigger_as_controlled<F>(
6266        &self,
6267        trigger: StoredTrigger,
6268        principal: Option<&crate::auth::Principal>,
6269        mut before_publish: F,
6270    ) -> Result<StoredTrigger>
6271    where
6272        F: FnMut() -> Result<()>,
6273    {
6274        self.create_or_replace_trigger_inner(trigger, principal, Some(&mut before_publish))
6275    }
6276
6277    fn create_or_replace_trigger_inner(
6278        &self,
6279        trigger: StoredTrigger,
6280        principal: Option<&crate::auth::Principal>,
6281        before_publish: Option<&mut dyn FnMut() -> Result<()>>,
6282    ) -> Result<StoredTrigger> {
6283        self.require_for(principal, &crate::auth::Permission::Ddl)?;
6284        let _g = self.ddl_lock.lock();
6285        let _security_write = self.security_write()?;
6286        self.require_for(principal, &crate::auth::Permission::Ddl)?;
6287        trigger.validate()?;
6288        self.validate_trigger_references(&trigger)
6289            .map_err(trigger_validation_error)?;
6290        let commit_lock = Arc::clone(&self.commit_lock);
6291        let _c = commit_lock.lock();
6292        let epoch = self.epoch.bump_assigned();
6293        let mut _epoch_guard = EpochGuard::new(self.epoch.as_ref(), epoch);
6294        let mut next_catalog = self.catalog.read().clone();
6295        let replaced = {
6296            let next = match next_catalog
6297                .triggers
6298                .iter()
6299                .position(|t| t.trigger.name == trigger.name)
6300            {
6301                Some(idx) => {
6302                    let next = next_catalog.triggers[idx]
6303                        .trigger
6304                        .replaced(trigger.clone(), epoch.0)?;
6305                    next_catalog.triggers[idx] = TriggerEntry::from(next.clone());
6306                    next
6307                }
6308                None => {
6309                    let mut next = trigger;
6310                    next.created_epoch = epoch.0;
6311                    next.updated_epoch = epoch.0;
6312                    next_catalog.triggers.push(TriggerEntry::from(next.clone()));
6313                    next
6314                }
6315            };
6316            next_catalog.db_epoch = epoch.0;
6317            next
6318        };
6319        self.publish_catalog_candidate(next_catalog, epoch, &mut _epoch_guard, before_publish)?;
6320        Ok(replaced)
6321    }
6322
6323    pub fn drop_trigger(&self, name: &str) -> Result<()> {
6324        self.drop_trigger_with_epoch(name).map(|_| ())
6325    }
6326
6327    /// Drop one trigger and return the exact catalog publication epoch.
6328    pub fn drop_trigger_with_epoch(&self, name: &str) -> Result<Epoch> {
6329        self.drop_triggers_with_epoch(&[name.to_string()])
6330    }
6331
6332    pub fn drop_trigger_with_epoch_controlled<F>(
6333        &self,
6334        name: &str,
6335        before_publish: F,
6336    ) -> Result<Epoch>
6337    where
6338        F: FnMut() -> Result<()>,
6339    {
6340        self.drop_triggers_with_epoch_controlled(&[name.to_string()], before_publish)
6341    }
6342
6343    /// Atomically drop several triggers in one catalog publication.
6344    pub fn drop_triggers_with_epoch(&self, names: &[String]) -> Result<Epoch> {
6345        self.drop_triggers_with_epoch_inner(names, None, None)
6346    }
6347
6348    pub fn drop_triggers_with_epoch_controlled<F>(
6349        &self,
6350        names: &[String],
6351        mut before_publish: F,
6352    ) -> Result<Epoch>
6353    where
6354        F: FnMut() -> Result<()>,
6355    {
6356        self.drop_triggers_with_epoch_inner(names, None, Some(&mut before_publish))
6357    }
6358
6359    pub fn drop_triggers_with_epoch_as_controlled<F>(
6360        &self,
6361        names: &[String],
6362        principal: Option<&crate::auth::Principal>,
6363        mut before_publish: F,
6364    ) -> Result<Epoch>
6365    where
6366        F: FnMut() -> Result<()>,
6367    {
6368        self.drop_triggers_with_epoch_inner(names, principal, Some(&mut before_publish))
6369    }
6370
6371    fn drop_triggers_with_epoch_inner(
6372        &self,
6373        names: &[String],
6374        principal: Option<&crate::auth::Principal>,
6375        before_publish: Option<&mut dyn FnMut() -> Result<()>>,
6376    ) -> Result<Epoch> {
6377        self.require_for(principal, &crate::auth::Permission::Ddl)?;
6378        if names.is_empty() {
6379            return Err(MongrelError::InvalidArgument(
6380                "at least one trigger name is required".into(),
6381            ));
6382        }
6383        let _g = self.ddl_lock.lock();
6384        let _security_write = self.security_write()?;
6385        self.require_for(principal, &crate::auth::Permission::Ddl)?;
6386        {
6387            let cat = self.catalog.read();
6388            for name in names {
6389                if !cat.triggers.iter().any(|t| t.trigger.name == *name) {
6390                    return Err(MongrelError::NotFound(format!(
6391                        "trigger {name:?} not found"
6392                    )));
6393                }
6394            }
6395        }
6396        let commit_lock = Arc::clone(&self.commit_lock);
6397        let _c = commit_lock.lock();
6398        let epoch = self.epoch.bump_assigned();
6399        let mut _epoch_guard = EpochGuard::new(self.epoch.as_ref(), epoch);
6400        let mut next_catalog = self.catalog.read().clone();
6401        next_catalog
6402            .triggers
6403            .retain(|trigger| !names.contains(&trigger.trigger.name));
6404        next_catalog.db_epoch = epoch.0;
6405        self.publish_catalog_candidate(next_catalog, epoch, &mut _epoch_guard, before_publish)?;
6406        Ok(epoch)
6407    }
6408
6409    pub fn external_tables(&self) -> Vec<ExternalTableEntry> {
6410        self.catalog.read().external_tables.clone()
6411    }
6412
6413    pub fn external_table(&self, name: &str) -> Option<ExternalTableEntry> {
6414        self.catalog
6415            .read()
6416            .external_tables
6417            .iter()
6418            .find(|t| t.name == name)
6419            .cloned()
6420    }
6421
6422    pub fn create_external_table(&self, entry: ExternalTableEntry) -> Result<ExternalTableEntry> {
6423        self.create_external_table_inner(entry, None)
6424    }
6425
6426    pub fn create_external_table_controlled<F>(
6427        &self,
6428        entry: ExternalTableEntry,
6429        mut before_publish: F,
6430    ) -> Result<ExternalTableEntry>
6431    where
6432        F: FnMut() -> Result<()>,
6433    {
6434        self.create_external_table_inner(entry, Some(&mut before_publish))
6435    }
6436
6437    fn create_external_table_inner(
6438        &self,
6439        mut entry: ExternalTableEntry,
6440        before_publish: Option<&mut dyn FnMut() -> Result<()>>,
6441    ) -> Result<ExternalTableEntry> {
6442        self.require(&crate::auth::Permission::Ddl)?;
6443        let _g = self.ddl_lock.lock();
6444        let _security_write = self.security_write()?;
6445        self.require(&crate::auth::Permission::Ddl)?;
6446        entry.validate()?;
6447        {
6448            let cat = self.catalog.read();
6449            if cat.live(&entry.name).is_some()
6450                || cat.external_tables.iter().any(|t| t.name == entry.name)
6451            {
6452                return Err(MongrelError::InvalidArgument(format!(
6453                    "table {:?} already exists",
6454                    entry.name
6455                )));
6456            }
6457        }
6458        let commit_lock = Arc::clone(&self.commit_lock);
6459        let _c = commit_lock.lock();
6460        // A prior durable drop may have left connector state behind if its
6461        // cleanup failed or the process crashed. Never let a new table with
6462        // the same name inherit that stale state.
6463        crate::durable_file::create_directory(&self.root.join(VTAB_DIR))?;
6464        crate::durable_file::remove_directory_all(&self.root.join(VTAB_DIR).join(&entry.name))?;
6465        let epoch = self.epoch.bump_assigned();
6466        let mut _epoch_guard = EpochGuard::new(self.epoch.as_ref(), epoch);
6467        entry.created_epoch = epoch.0;
6468        let mut next_catalog = self.catalog.read().clone();
6469        next_catalog.external_tables.push(entry.clone());
6470        next_catalog.db_epoch = epoch.0;
6471        self.publish_catalog_candidate_with_prelude(
6472            next_catalog,
6473            epoch,
6474            &mut _epoch_guard,
6475            before_publish,
6476            vec![(
6477                EXTERNAL_TABLE_ID,
6478                crate::wal::Op::Ddl(crate::wal::DdlOp::ResetExternalTableState {
6479                    name: entry.name.clone(),
6480                    generation_epoch: epoch.0,
6481                }),
6482            )],
6483        )?;
6484        Ok(entry)
6485    }
6486
6487    pub fn drop_external_table(&self, name: &str) -> Result<()> {
6488        self.drop_external_table_with_epoch(name).map(|_| ())
6489    }
6490
6491    /// Drop an external table and return the exact catalog publication epoch.
6492    pub fn drop_external_table_with_epoch(&self, name: &str) -> Result<Epoch> {
6493        self.drop_external_table_with_epoch_inner(name, None)
6494    }
6495
6496    pub fn drop_external_table_with_epoch_controlled<F>(
6497        &self,
6498        name: &str,
6499        mut before_publish: F,
6500    ) -> Result<Epoch>
6501    where
6502        F: FnMut() -> Result<()>,
6503    {
6504        self.drop_external_table_with_epoch_inner(name, Some(&mut before_publish))
6505    }
6506
6507    fn drop_external_table_with_epoch_inner(
6508        &self,
6509        name: &str,
6510        before_publish: Option<&mut dyn FnMut() -> Result<()>>,
6511    ) -> Result<Epoch> {
6512        self.require(&crate::auth::Permission::Ddl)?;
6513        let _g = self.ddl_lock.lock();
6514        let _security_write = self.security_write()?;
6515        self.require(&crate::auth::Permission::Ddl)?;
6516        let commit_lock = Arc::clone(&self.commit_lock);
6517        let _c = commit_lock.lock();
6518        let epoch = self.epoch.bump_assigned();
6519        let mut _epoch_guard = EpochGuard::new(self.epoch.as_ref(), epoch);
6520        let mut next_catalog = self.catalog.read().clone();
6521        let before = next_catalog.external_tables.len();
6522        next_catalog.external_tables.retain(|t| t.name != name);
6523        if next_catalog.external_tables.len() == before {
6524            return Err(MongrelError::NotFound(format!(
6525                "external table {name:?} not found"
6526            )));
6527        }
6528        next_catalog.db_epoch = epoch.0;
6529        self.publish_catalog_candidate_with_prelude(
6530            next_catalog,
6531            epoch,
6532            &mut _epoch_guard,
6533            before_publish,
6534            vec![(
6535                EXTERNAL_TABLE_ID,
6536                crate::wal::Op::Ddl(crate::wal::DdlOp::ResetExternalTableState {
6537                    name: name.to_string(),
6538                    generation_epoch: epoch.0,
6539                }),
6540            )],
6541        )?;
6542        let state_dir = self.root.join(VTAB_DIR).join(name);
6543        if let Err(error) = crate::durable_file::remove_directory_all(&state_dir) {
6544            return Err(MongrelError::DurableCommit {
6545                epoch: epoch.0,
6546                message: format!(
6547                    "external table was dropped but connector-state cleanup failed: {error}"
6548                ),
6549            });
6550        }
6551        Ok(epoch)
6552    }
6553
6554    pub fn commit_external_table_state(&self, name: &str, state: &[u8]) -> Result<Epoch> {
6555        let txn_id = self.alloc_txn_id()?;
6556        let (principal, catalog_bound) = self.transaction_principal_snapshot();
6557        self.commit_transaction_with_external_states(
6558            txn_id,
6559            self.epoch.visible(),
6560            Vec::new(),
6561            vec![(name.to_string(), state.to_vec())],
6562            Vec::new(),
6563            principal,
6564            catalog_bound,
6565            None,
6566        )
6567        .map(|(epoch, _)| epoch)
6568    }
6569
6570    pub fn trigger_config(&self) -> TriggerConfig {
6571        use std::sync::atomic::Ordering;
6572        TriggerConfig {
6573            recursive_triggers: self.trigger_recursive.load(Ordering::Relaxed),
6574            max_depth: self.trigger_max_depth.load(Ordering::Relaxed),
6575            max_loop_iterations: self.trigger_max_loop_iterations.load(Ordering::Relaxed),
6576        }
6577    }
6578
6579    pub fn set_trigger_config(&self, config: TriggerConfig) -> Result<()> {
6580        use std::sync::atomic::Ordering;
6581        if config.max_depth == 0 {
6582            return Err(MongrelError::InvalidArgument(
6583                "trigger max_depth must be greater than 0".into(),
6584            ));
6585        }
6586        self.trigger_recursive
6587            .store(config.recursive_triggers, Ordering::Relaxed);
6588        self.trigger_max_depth
6589            .store(config.max_depth, Ordering::Relaxed);
6590        self.trigger_max_loop_iterations
6591            .store(config.max_loop_iterations, Ordering::Relaxed);
6592        Ok(())
6593    }
6594
6595    pub fn set_recursive_triggers(&self, recursive: bool) {
6596        use std::sync::atomic::Ordering;
6597        self.trigger_recursive.store(recursive, Ordering::Relaxed);
6598    }
6599
6600    /// Subscribe to ephemeral SQL NOTIFY messages. Durable row changes use
6601    /// [`Self::change_events_since`], with [`Self::subscribe_change_commits`]
6602    /// as a low-latency wake-up.
6603    pub fn subscribe_changes(&self) -> tokio::sync::broadcast::Receiver<ChangeEvent> {
6604        self.notify.subscribe()
6605    }
6606
6607    pub fn subscribe_change_commits(&self) -> tokio::sync::broadcast::Receiver<()> {
6608        self.change_wake.subscribe()
6609    }
6610
6611    /// Reconstruct committed row changes from the retained shared WAL. Event
6612    /// ids are stable `<commit_epoch>:<operation_index>` pairs. A caller that
6613    /// resumes before the oldest retained commit receives `gap = true` and
6614    /// must rebootstrap instead of silently skipping changes.
6615    pub fn change_events_since(&self, last_event_id: Option<&str>) -> Result<CdcBatch> {
6616        let control = crate::ExecutionControl::new(None);
6617        self.change_events_since_controlled(last_event_id, &control)
6618    }
6619
6620    /// Reconstruct committed changes with cooperative cancellation and bounds.
6621    pub fn change_events_since_controlled(
6622        &self,
6623        last_event_id: Option<&str>,
6624        control: &crate::ExecutionControl,
6625    ) -> Result<CdcBatch> {
6626        use crate::wal::Op;
6627
6628        control.checkpoint()?;
6629        let resume = match last_event_id {
6630            Some(id) => {
6631                let (epoch, index) = id.split_once(':').ok_or_else(|| {
6632                    MongrelError::InvalidArgument(format!(
6633                        "invalid CDC event id {id:?}; expected <epoch>:<index>"
6634                    ))
6635                })?;
6636                Some((
6637                    epoch.parse::<u64>().map_err(|error| {
6638                        MongrelError::InvalidArgument(format!("invalid CDC epoch: {error}"))
6639                    })?,
6640                    index.parse::<u32>().map_err(|error| {
6641                        MongrelError::InvalidArgument(format!("invalid CDC index: {error}"))
6642                    })?,
6643                ))
6644            }
6645            None => None,
6646        };
6647
6648        let mut wal = self.shared_wal.lock();
6649        wal.group_sync()?;
6650        let wal_dek = crate::encryption::wal_dek_for(self.kek.as_deref());
6651        let records = crate::wal::SharedWal::replay_with_dek_controlled(
6652            &self.root,
6653            wal_dek.as_ref(),
6654            control,
6655            CDC_MAX_WAL_RECORDS,
6656            CDC_MAX_WAL_REPLAY_BYTES,
6657        )?;
6658        drop(wal);
6659        control.checkpoint()?;
6660
6661        let mut commits: HashMap<u64, (u64, Vec<crate::wal::AddedRun>)> = HashMap::new();
6662        let mut spilled_payloads: HashMap<(u64, u64), Vec<&[u8]>> = HashMap::new();
6663        for (index, record) in records.iter().enumerate() {
6664            if index % 256 == 0 {
6665                control.checkpoint()?;
6666            }
6667            if let Op::TxnCommit { epoch, added_runs } = &record.op {
6668                commits.insert(record.txn_id, (*epoch, added_runs.clone()));
6669            }
6670            if let Op::SpilledRows { table_id, rows } = &record.op {
6671                spilled_payloads
6672                    .entry((record.txn_id, *table_id))
6673                    .or_default()
6674                    .push(rows);
6675            }
6676        }
6677        let earliest_epoch = commits.values().map(|(epoch, _)| *epoch).min();
6678        let current_epoch = self.epoch.committed().0;
6679        let retention_floor = crate::replication::replication_wal_floor(&self.root)?;
6680        let gap = resume.is_some_and(|(epoch, _)| {
6681            retention_floor != 0 && epoch <= retention_floor && epoch <= current_epoch
6682        });
6683        if gap {
6684            return Ok(CdcBatch {
6685                events: Vec::new(),
6686                current_epoch,
6687                earliest_epoch,
6688                gap: true,
6689            });
6690        }
6691
6692        let table_names: HashMap<u64, String> = self
6693            .catalog
6694            .read()
6695            .tables
6696            .iter()
6697            .map(|entry| (entry.table_id, entry.name.clone()))
6698            .collect();
6699        let mut before_images: HashMap<(u64, u64, u64), crate::memtable::Row> = HashMap::new();
6700        let mut retained_bytes = 0_usize;
6701        for (index, record) in records.iter().enumerate() {
6702            if index % 256 == 0 {
6703                control.checkpoint()?;
6704            }
6705            if !commits.contains_key(&record.txn_id) {
6706                continue;
6707            }
6708            let Op::BeforeImage {
6709                table_id,
6710                row_id,
6711                row,
6712            } = &record.op
6713            else {
6714                continue;
6715            };
6716            if row.len() > CDC_MAX_INLINE_PAYLOAD_BYTES {
6717                return Err(MongrelError::ResourceLimitExceeded {
6718                    resource: "CDC before-image bytes",
6719                    requested: row.len(),
6720                    limit: CDC_MAX_INLINE_PAYLOAD_BYTES,
6721                });
6722            }
6723            let before: crate::memtable::Row = bincode::deserialize(row)?;
6724            if before_images.len() >= CDC_MAX_ROWS {
6725                return Err(MongrelError::ResourceLimitExceeded {
6726                    resource: "CDC before-image rows",
6727                    requested: before_images.len().saturating_add(1),
6728                    limit: CDC_MAX_ROWS,
6729                });
6730            }
6731            charge_cdc_bytes(
6732                &mut retained_bytes,
6733                cdc_row_storage_bytes(&before),
6734                "CDC retained bytes",
6735            )?;
6736            before_images.insert((record.txn_id, *table_id, row_id.0), before);
6737        }
6738        let mut operation_indices: HashMap<u64, u32> = HashMap::new();
6739        let mut events = Vec::new();
6740        let mut decoded_rows = before_images.len();
6741        for (record_index, record) in records.iter().enumerate() {
6742            if record_index % 256 == 0 {
6743                control.checkpoint()?;
6744            }
6745            let Some((commit_epoch, _)) = commits.get(&record.txn_id) else {
6746                continue;
6747            };
6748            let event = match &record.op {
6749                Op::Put { table_id, rows } => {
6750                    if rows.len() > CDC_MAX_INLINE_PAYLOAD_BYTES {
6751                        return Err(MongrelError::ResourceLimitExceeded {
6752                            resource: "CDC inline row bytes",
6753                            requested: rows.len(),
6754                            limit: CDC_MAX_INLINE_PAYLOAD_BYTES,
6755                        });
6756                    }
6757                    let rows: Vec<crate::memtable::Row> = bincode::deserialize(rows)?;
6758                    decoded_rows = decoded_rows.saturating_add(rows.len());
6759                    if decoded_rows > CDC_MAX_ROWS {
6760                        return Err(MongrelError::ResourceLimitExceeded {
6761                            resource: "CDC decoded rows",
6762                            requested: decoded_rows,
6763                            limit: CDC_MAX_ROWS,
6764                        });
6765                    }
6766                    let event_bytes = cdc_rows_json_bytes(&rows).saturating_add(512);
6767                    let mut peak_bytes = retained_bytes;
6768                    charge_cdc_bytes(&mut peak_bytes, event_bytes, "CDC retained event bytes")?;
6769                    let data = serde_json::to_value(rows)
6770                        .map_err(|error| MongrelError::Other(format!("CDC JSON: {error}")))?;
6771                    Some((*table_id, "put", data, event_bytes))
6772                }
6773                Op::Delete { table_id, row_ids } => {
6774                    let before = row_ids
6775                        .iter()
6776                        .filter_map(|row_id| {
6777                            before_images
6778                                .get(&(record.txn_id, *table_id, row_id.0))
6779                                .cloned()
6780                        })
6781                        .collect::<Vec<_>>();
6782                    let event_bytes = cdc_rows_json_bytes(&before)
6783                        .saturating_add(
6784                            row_ids
6785                                .len()
6786                                .saturating_mul(std::mem::size_of::<serde_json::Value>()),
6787                        )
6788                        .saturating_add(512);
6789                    let mut peak_bytes = retained_bytes;
6790                    charge_cdc_bytes(&mut peak_bytes, event_bytes, "CDC retained event bytes")?;
6791                    Some((
6792                        *table_id,
6793                        "delete",
6794                        serde_json::json!({
6795                            "row_ids": row_ids.iter().map(|row_id| row_id.0).collect::<Vec<_>>(),
6796                            "before": before,
6797                        }),
6798                        event_bytes,
6799                    ))
6800                }
6801                Op::TruncateTable { table_id } => {
6802                    Some((*table_id, "truncate", serde_json::Value::Null, 512))
6803                }
6804                _ => None,
6805            };
6806            if let Some((table_id, op, data, event_bytes)) = event {
6807                let index = operation_indices.entry(record.txn_id).or_insert(0);
6808                let event_position = (*commit_epoch, *index);
6809                *index = index.saturating_add(1);
6810                if resume.is_some_and(|position| event_position <= position) {
6811                    continue;
6812                }
6813                if events.len() >= CDC_MAX_EVENTS {
6814                    return Err(MongrelError::ResourceLimitExceeded {
6815                        resource: "CDC events",
6816                        requested: events.len().saturating_add(1),
6817                        limit: CDC_MAX_EVENTS,
6818                    });
6819                }
6820                charge_cdc_bytes(&mut retained_bytes, event_bytes, "CDC retained event bytes")?;
6821                events.push(ChangeEvent {
6822                    id: Some(format!("{}:{}", event_position.0, event_position.1)),
6823                    channel: "changes".into(),
6824                    table_id: Some(table_id),
6825                    table: table_names.get(&table_id).cloned().unwrap_or_default(),
6826                    op: op.into(),
6827                    epoch: *commit_epoch,
6828                    txn_id: Some(record.txn_id),
6829                    message: None,
6830                    data: Some(data),
6831                });
6832            }
6833            if let Op::TxnCommit { added_runs, .. } = &record.op {
6834                for run in added_runs {
6835                    control.checkpoint()?;
6836                    let index = operation_indices.entry(record.txn_id).or_insert(0);
6837                    let event_position = (*commit_epoch, *index);
6838                    *index = index.saturating_add(1);
6839                    if resume.is_some_and(|position| event_position <= position) {
6840                        continue;
6841                    }
6842                    let mut rows = if let Some(payloads) =
6843                        spilled_payloads.get(&(record.txn_id, run.table_id))
6844                    {
6845                        let mut rows = Vec::new();
6846                        for payload in payloads {
6847                            control.checkpoint()?;
6848                            if payload.len() > CDC_MAX_INLINE_PAYLOAD_BYTES {
6849                                return Err(MongrelError::ResourceLimitExceeded {
6850                                    resource: "CDC spilled row bytes",
6851                                    requested: payload.len(),
6852                                    limit: CDC_MAX_INLINE_PAYLOAD_BYTES,
6853                                });
6854                            }
6855                            let chunk: Vec<crate::memtable::Row> = bincode::deserialize(payload)?;
6856                            if decoded_rows
6857                                .saturating_add(rows.len())
6858                                .saturating_add(chunk.len())
6859                                > CDC_MAX_ROWS
6860                            {
6861                                return Err(MongrelError::ResourceLimitExceeded {
6862                                    resource: "CDC decoded rows",
6863                                    requested: decoded_rows
6864                                        .saturating_add(rows.len())
6865                                        .saturating_add(chunk.len()),
6866                                    limit: CDC_MAX_ROWS,
6867                                });
6868                            }
6869                            rows.extend(chunk);
6870                        }
6871                        rows
6872                    } else {
6873                        let Some(handle) = self.tables.read().get(&run.table_id).cloned() else {
6874                            return Ok(CdcBatch {
6875                                events: Vec::new(),
6876                                current_epoch,
6877                                earliest_epoch,
6878                                gap: true,
6879                            });
6880                        };
6881                        let table = handle.lock();
6882                        let mut reader = match table.open_reader(run.run_id) {
6883                            Ok(reader) => reader,
6884                            Err(_) => {
6885                                return Ok(CdcBatch {
6886                                    events: Vec::new(),
6887                                    current_epoch,
6888                                    earliest_epoch,
6889                                    gap: true,
6890                                })
6891                            }
6892                        };
6893                        let remaining = CDC_MAX_ROWS.saturating_sub(decoded_rows);
6894                        let rows = reader.all_rows_controlled(control, remaining)?;
6895                        drop(reader);
6896                        drop(table);
6897                        rows
6898                    };
6899                    for row in &mut rows {
6900                        row.committed_epoch = Epoch(*commit_epoch);
6901                    }
6902                    decoded_rows = decoded_rows.saturating_add(rows.len());
6903                    let event_bytes = cdc_rows_json_bytes(&rows).saturating_add(768);
6904                    charge_cdc_bytes(&mut retained_bytes, event_bytes, "CDC retained event bytes")?;
6905                    if events.len() >= CDC_MAX_EVENTS {
6906                        return Err(MongrelError::ResourceLimitExceeded {
6907                            resource: "CDC events",
6908                            requested: events.len().saturating_add(1),
6909                            limit: CDC_MAX_EVENTS,
6910                        });
6911                    }
6912                    events.push(ChangeEvent {
6913                        id: Some(format!("{}:{}", event_position.0, event_position.1)),
6914                        channel: "changes".into(),
6915                        table_id: Some(run.table_id),
6916                        table: table_names.get(&run.table_id).cloned().unwrap_or_default(),
6917                        op: "put_run".into(),
6918                        epoch: *commit_epoch,
6919                        txn_id: Some(record.txn_id),
6920                        message: None,
6921                        data: Some(serde_json::json!({
6922                            "run_id": run.run_id.to_string(),
6923                            "row_count": run.row_count,
6924                            "min_row_id": run.min_row_id,
6925                            "max_row_id": run.max_row_id,
6926                            "rows": rows,
6927                        })),
6928                    });
6929                }
6930            }
6931        }
6932        control.checkpoint()?;
6933        Ok(CdcBatch {
6934            events,
6935            current_epoch,
6936            earliest_epoch,
6937            gap: false,
6938        })
6939    }
6940
6941    /// Publish a notification message on a named channel. Reaches all active
6942    /// subscribers (daemon `/events`, application listeners).
6943    pub fn notify(&self, channel: &str, message: Option<String>) {
6944        let _ = self.notify.send(ChangeEvent {
6945            id: None,
6946            channel: channel.to_string(),
6947            table_id: None,
6948            table: String::new(),
6949            op: "notify".into(),
6950            epoch: self.epoch.visible().0,
6951            txn_id: None,
6952            message,
6953            data: None,
6954        });
6955    }
6956
6957    pub fn call_procedure(
6958        &self,
6959        name: &str,
6960        args: HashMap<String, crate::Value>,
6961    ) -> Result<ProcedureCallResult> {
6962        self.call_procedure_as(name, args, None)
6963    }
6964
6965    pub fn call_procedure_as(
6966        &self,
6967        name: &str,
6968        args: HashMap<String, crate::Value>,
6969        principal: Option<&crate::auth::Principal>,
6970    ) -> Result<ProcedureCallResult> {
6971        let control = crate::ExecutionControl::new(None);
6972        self.call_procedure_as_controlled(name, args, principal, &control, || true)
6973    }
6974
6975    /// Execute only the exact procedure revision previously authorized by the
6976    /// caller. A dropped or replaced definition fails closed.
6977    #[doc(hidden)]
6978    pub fn call_procedure_as_bound(
6979        &self,
6980        expected: &StoredProcedure,
6981        args: HashMap<String, crate::Value>,
6982        principal: Option<&crate::auth::Principal>,
6983    ) -> Result<ProcedureCallResult> {
6984        self.require_for(principal, &crate::auth::Permission::All)?;
6985        let procedure = self.procedure(&expected.name).ok_or_else(|| {
6986            MongrelError::NotFound(format!("procedure {:?} not found", expected.name))
6987        })?;
6988        if &procedure != expected {
6989            return Err(MongrelError::Conflict(format!(
6990                "procedure {:?} changed after request authorization",
6991                expected.name
6992            )));
6993        }
6994        let control = crate::ExecutionControl::new(None);
6995        self.execute_procedure_as_controlled(procedure, args, principal, &control, || true)
6996    }
6997
6998    /// Execute a procedure with cooperative cancellation during preparation.
6999    /// `before_commit` runs after every procedure step has succeeded and
7000    /// immediately before a write procedure commits. Returning `false` aborts
7001    /// the transaction without publishing it.
7002    #[doc(hidden)]
7003    pub fn call_procedure_as_controlled<F>(
7004        &self,
7005        name: &str,
7006        args: HashMap<String, crate::Value>,
7007        principal: Option<&crate::auth::Principal>,
7008        control: &crate::ExecutionControl,
7009        before_commit: F,
7010    ) -> Result<ProcedureCallResult>
7011    where
7012        F: FnOnce() -> bool,
7013    {
7014        // v1 requires ALL to call procedures on a require_auth database; a
7015        // finer SECURITY DEFINER-style marker is a future extension (spec §9
7016        // decision 1).
7017        self.require_for(principal, &crate::auth::Permission::All)?;
7018        let procedure = self
7019            .procedure(name)
7020            .ok_or_else(|| MongrelError::NotFound(format!("procedure {name:?} not found")))?;
7021        self.execute_procedure_as_controlled(procedure, args, principal, control, before_commit)
7022    }
7023
7024    fn execute_procedure_as_controlled<F>(
7025        &self,
7026        procedure: StoredProcedure,
7027        args: HashMap<String, crate::Value>,
7028        principal: Option<&crate::auth::Principal>,
7029        control: &crate::ExecutionControl,
7030        before_commit: F,
7031    ) -> Result<ProcedureCallResult>
7032    where
7033        F: FnOnce() -> bool,
7034    {
7035        let args = bind_procedure_args(&procedure, args)?;
7036        let has_writes = procedure.body.steps.iter().any(ProcedureStep::is_write);
7037        let mut outputs: HashMap<String, ProcedureCallOutput> = HashMap::new();
7038        if has_writes {
7039            let mut tx = self.begin_as(principal.cloned());
7040            let run = (|| {
7041                for (step_index, step) in procedure.body.steps.iter().enumerate() {
7042                    if step_index % 256 == 0 {
7043                        control.checkpoint()?;
7044                    }
7045                    let output = self.execute_procedure_step(
7046                        step,
7047                        &args,
7048                        &outputs,
7049                        Some(&mut tx),
7050                        principal,
7051                        Some(control),
7052                    )?;
7053                    outputs.insert(step.id().to_string(), output);
7054                }
7055                control.checkpoint()?;
7056                eval_return_output(&procedure.body.return_value, &args, &outputs)
7057            })();
7058            match run {
7059                Ok(output) => {
7060                    control.checkpoint()?;
7061                    if !before_commit() {
7062                        tx.rollback();
7063                        return Err(MongrelError::Cancelled);
7064                    }
7065                    let epoch = tx.commit()?.0;
7066                    Ok(ProcedureCallResult {
7067                        epoch: Some(epoch),
7068                        output,
7069                    })
7070                }
7071                Err(e) => {
7072                    tx.rollback();
7073                    Err(e)
7074                }
7075            }
7076        } else {
7077            for (step_index, step) in procedure.body.steps.iter().enumerate() {
7078                if step_index % 256 == 0 {
7079                    control.checkpoint()?;
7080                }
7081                let output = self.execute_procedure_step(
7082                    step,
7083                    &args,
7084                    &outputs,
7085                    None,
7086                    principal,
7087                    Some(control),
7088                )?;
7089                outputs.insert(step.id().to_string(), output);
7090            }
7091            control.checkpoint()?;
7092            Ok(ProcedureCallResult {
7093                epoch: None,
7094                output: eval_return_output(&procedure.body.return_value, &args, &outputs)?,
7095            })
7096        }
7097    }
7098
7099    fn execute_procedure_step(
7100        &self,
7101        step: &ProcedureStep,
7102        args: &HashMap<String, crate::Value>,
7103        outputs: &HashMap<String, ProcedureCallOutput>,
7104        tx: Option<&mut crate::txn::Transaction<'_>>,
7105        principal: Option<&crate::auth::Principal>,
7106        control: Option<&crate::ExecutionControl>,
7107    ) -> Result<ProcedureCallOutput> {
7108        if let Some(control) = control {
7109            control.checkpoint()?;
7110        }
7111        match step {
7112            ProcedureStep::NativeQuery {
7113                table,
7114                conditions,
7115                projection,
7116                limit,
7117                ..
7118            } => {
7119                let mut q = crate::Query::new();
7120                for condition in conditions {
7121                    q = q.and(eval_condition(condition, args, outputs)?);
7122                }
7123                let fallback_control = crate::ExecutionControl::new(None);
7124                let query_control = control.unwrap_or(&fallback_control);
7125                let mut rows = self.query_for_principal_controlled(
7126                    table,
7127                    &q,
7128                    projection.as_deref(),
7129                    principal,
7130                    false,
7131                    query_control,
7132                )?;
7133                if let Some(limit) = limit {
7134                    rows.truncate(*limit);
7135                }
7136                let mut output = Vec::with_capacity(rows.len());
7137                for (row_index, row) in rows.into_iter().enumerate() {
7138                    if row_index % 256 == 0 {
7139                        if let Some(control) = control {
7140                            control.checkpoint()?;
7141                        }
7142                    }
7143                    output.push(ProcedureCallRow {
7144                        row_id: Some(row.row_id),
7145                        columns: row.columns,
7146                    });
7147                }
7148                Ok(ProcedureCallOutput::Rows(output))
7149            }
7150            ProcedureStep::Put {
7151                table,
7152                cells,
7153                returning,
7154                ..
7155            } => {
7156                let tx = tx.ok_or_else(|| {
7157                    MongrelError::InvalidArgument(
7158                        "write procedure step requires a transaction".into(),
7159                    )
7160                })?;
7161                let cells = eval_cells(cells, args, outputs)?;
7162                if *returning {
7163                    let out = tx.put_returning(table, cells)?;
7164                    Ok(ProcedureCallOutput::Row(ProcedureCallRow {
7165                        row_id: None,
7166                        columns: out.row.columns.into_iter().collect(),
7167                    }))
7168                } else {
7169                    tx.put(table, cells)?;
7170                    Ok(ProcedureCallOutput::Null)
7171                }
7172            }
7173            ProcedureStep::Upsert {
7174                table,
7175                cells,
7176                update_cells,
7177                returning,
7178                ..
7179            } => {
7180                let tx = tx.ok_or_else(|| {
7181                    MongrelError::InvalidArgument(
7182                        "write procedure step requires a transaction".into(),
7183                    )
7184                })?;
7185                let cells = eval_cells(cells, args, outputs)?;
7186                let action = match update_cells {
7187                    Some(update_cells) => {
7188                        crate::UpsertAction::DoUpdate(eval_cells(update_cells, args, outputs)?)
7189                    }
7190                    None => crate::UpsertAction::DoNothing,
7191                };
7192                let out = tx.upsert(table, cells, action)?;
7193                if *returning {
7194                    Ok(ProcedureCallOutput::Row(ProcedureCallRow {
7195                        row_id: None,
7196                        columns: out.row.columns.into_iter().collect(),
7197                    }))
7198                } else {
7199                    Ok(ProcedureCallOutput::Null)
7200                }
7201            }
7202            ProcedureStep::DeleteByPk { table, pk, .. } => {
7203                let tx = tx.ok_or_else(|| {
7204                    MongrelError::InvalidArgument(
7205                        "write procedure step requires a transaction".into(),
7206                    )
7207                })?;
7208                let pk = eval_value(pk, args, outputs)?;
7209                let handle = self.table(table)?;
7210                let row_id = handle.lock().lookup_pk(&pk.encode_key()).ok_or_else(|| {
7211                    MongrelError::NotFound("procedure delete_by_pk target not found".into())
7212                })?;
7213                tx.delete(table, row_id)?;
7214                Ok(ProcedureCallOutput::Scalar(crate::Value::Bool(true)))
7215            }
7216            ProcedureStep::DeleteRows { .. } => Err(MongrelError::InvalidArgument(
7217                "DeleteRows procedure step is not supported by the core executor yet".into(),
7218            )),
7219            ProcedureStep::SqlQuery { .. } => Err(MongrelError::InvalidArgument(
7220                "SqlQuery procedure step must be executed by mongreldb-query".into(),
7221            )),
7222        }
7223    }
7224
7225    fn validate_procedure_references(&self, procedure: &StoredProcedure) -> Result<()> {
7226        let cat = self.catalog.read();
7227        for step in &procedure.body.steps {
7228            let Some(table_name) = step.table() else {
7229                continue;
7230            };
7231            let schema = &cat
7232                .live(table_name)
7233                .ok_or_else(|| {
7234                    MongrelError::InvalidArgument(format!(
7235                        "procedure {:?} references unknown table {table_name:?}",
7236                        procedure.name
7237                    ))
7238                })?
7239                .schema;
7240            match step {
7241                ProcedureStep::NativeQuery {
7242                    conditions,
7243                    projection,
7244                    ..
7245                } => {
7246                    for condition in conditions {
7247                        validate_condition_columns(condition, schema)?;
7248                    }
7249                    if let Some(projection) = projection {
7250                        for id in projection {
7251                            validate_column_id(*id, schema)?;
7252                        }
7253                    }
7254                }
7255                ProcedureStep::Put { cells, .. } => {
7256                    for cell in cells {
7257                        validate_column_id(cell.column_id, schema)?;
7258                    }
7259                }
7260                ProcedureStep::Upsert {
7261                    cells,
7262                    update_cells,
7263                    ..
7264                } => {
7265                    for cell in cells {
7266                        validate_column_id(cell.column_id, schema)?;
7267                    }
7268                    if let Some(update_cells) = update_cells {
7269                        for cell in update_cells {
7270                            validate_column_id(cell.column_id, schema)?;
7271                        }
7272                    }
7273                }
7274                ProcedureStep::DeleteByPk { .. } => {
7275                    if schema.primary_key().is_none() {
7276                        return Err(MongrelError::InvalidArgument(format!(
7277                            "procedure {:?} references DeleteByPk on table {table_name:?} without a primary key",
7278                            procedure.name
7279                        )));
7280                    }
7281                }
7282                ProcedureStep::DeleteRows { .. } | ProcedureStep::SqlQuery { .. } => {}
7283            }
7284        }
7285        Ok(())
7286    }
7287
7288    fn validate_trigger_references(&self, trigger: &StoredTrigger) -> Result<()> {
7289        let cat = self.catalog.read();
7290        let target_schema = match &trigger.target {
7291            TriggerTarget::Table(target_name) => cat
7292                .live(target_name)
7293                .ok_or_else(|| {
7294                    MongrelError::InvalidArgument(format!(
7295                        "trigger {:?} references unknown target table {target_name:?}",
7296                        trigger.name
7297                    ))
7298                })?
7299                .schema
7300                .clone(),
7301            TriggerTarget::View(_) => Schema {
7302                columns: trigger.target_columns.clone(),
7303                ..Schema::default()
7304            },
7305        };
7306        for col in &trigger.update_of {
7307            if target_schema.column(col).is_none() {
7308                return Err(MongrelError::InvalidArgument(format!(
7309                    "trigger {:?} UPDATE OF references unknown column {col:?}",
7310                    trigger.name
7311                )));
7312            }
7313        }
7314        if let Some(expr) = &trigger.when {
7315            validate_trigger_expr(expr, &target_schema, trigger.event)?;
7316        }
7317        let mut select_schemas: HashMap<String, &Schema> = HashMap::new();
7318        for step in &trigger.program.steps {
7319            if matches!(step, TriggerStep::SetNew { .. }) && trigger.timing != TriggerTiming::Before
7320            {
7321                return Err(MongrelError::InvalidArgument(
7322                    "SetNew trigger steps are only valid in BEFORE triggers".into(),
7323                ));
7324            }
7325            validate_trigger_step(
7326                step,
7327                &cat,
7328                &target_schema,
7329                trigger.event,
7330                &mut select_schemas,
7331            )?;
7332        }
7333        Ok(())
7334    }
7335
7336    /// Begin a new transaction reading at the current visible epoch.
7337    pub fn begin(&self) -> crate::txn::Transaction<'_> {
7338        self.begin_with_isolation(crate::txn::IsolationLevel::default())
7339    }
7340
7341    fn transaction_principal_snapshot(&self) -> (Option<crate::auth::Principal>, bool) {
7342        let principal = self.principal.read().clone();
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        (principal, catalog_bound)
7348    }
7349
7350    pub fn begin_as(
7351        &self,
7352        principal: Option<crate::auth::Principal>,
7353    ) -> crate::txn::Transaction<'_> {
7354        let catalog_bound = principal.as_ref().is_some_and(|principal| {
7355            let catalog = self.catalog.read();
7356            catalog.require_auth || principal.user_id != 0
7357        });
7358        let txn_id = self.alloc_txn_id();
7359        let read = Snapshot::at(self.epoch.visible());
7360        crate::txn::Transaction::new(self, txn_id, read).with_principal(principal, catalog_bound)
7361    }
7362
7363    /// Begin a transaction with a specific isolation level.
7364    pub fn begin_with_isolation(
7365        &self,
7366        level: crate::txn::IsolationLevel,
7367    ) -> crate::txn::Transaction<'_> {
7368        let txn_id = self.alloc_txn_id();
7369        let epoch = match level {
7370            crate::txn::IsolationLevel::ReadCommitted => self.epoch.visible(),
7371            _ => self.epoch.visible(),
7372        };
7373        let read = Snapshot::at(epoch);
7374        let (principal, catalog_bound) = self.transaction_principal_snapshot();
7375        crate::txn::Transaction::new(self, txn_id, read).with_principal(principal, catalog_bound)
7376    }
7377
7378    /// Begin a transaction whose trigger programs may route external-table DML
7379    /// through an application/query-layer module bridge.
7380    pub fn begin_with_external_trigger_bridge<'a>(
7381        &'a self,
7382        bridge: &'a dyn ExternalTriggerBridge,
7383    ) -> crate::txn::Transaction<'a> {
7384        let txn_id = self.alloc_txn_id();
7385        let read = Snapshot::at(self.epoch.visible());
7386        let (principal, catalog_bound) = self.transaction_principal_snapshot();
7387        crate::txn::Transaction::new(self, txn_id, read)
7388            .with_external_trigger_bridge(bridge)
7389            .with_principal(principal, catalog_bound)
7390    }
7391
7392    pub fn begin_with_external_trigger_bridge_as<'a>(
7393        &'a self,
7394        bridge: &'a dyn ExternalTriggerBridge,
7395        principal: Option<crate::auth::Principal>,
7396    ) -> crate::txn::Transaction<'a> {
7397        let catalog_bound = principal.as_ref().is_some_and(|principal| {
7398            let catalog = self.catalog.read();
7399            catalog.require_auth || principal.user_id != 0
7400        });
7401        let txn_id = self.alloc_txn_id();
7402        let read = Snapshot::at(self.epoch.visible());
7403        crate::txn::Transaction::new(self, txn_id, read)
7404            .with_external_trigger_bridge(bridge)
7405            .with_principal(principal, catalog_bound)
7406    }
7407
7408    /// Run `f` in a transaction; commit on `Ok`, rollback on `Err`.
7409    pub fn transaction<T>(
7410        &self,
7411        f: impl FnOnce(&mut crate::txn::Transaction) -> Result<T>,
7412    ) -> Result<T> {
7413        let mut tx = self.begin();
7414        match f(&mut tx) {
7415            Ok(out) => {
7416                tx.commit()?;
7417                Ok(out)
7418            }
7419            Err(e) => {
7420                tx.rollback();
7421                Err(e)
7422            }
7423        }
7424    }
7425
7426    pub fn transaction_with_row_ids<T>(
7427        &self,
7428        f: impl FnOnce(&mut crate::txn::Transaction) -> Result<T>,
7429    ) -> Result<(T, Vec<RowId>)> {
7430        let mut tx = self.begin();
7431        match f(&mut tx) {
7432            Ok(output) => {
7433                let (_, row_ids) = tx.commit_with_row_ids()?;
7434                Ok((output, row_ids))
7435            }
7436            Err(error) => {
7437                tx.rollback();
7438                Err(error)
7439            }
7440        }
7441    }
7442
7443    pub fn transaction_for_current_principal<T>(
7444        &self,
7445        f: impl FnOnce(&mut crate::txn::Transaction) -> Result<T>,
7446    ) -> Result<T> {
7447        if self.principal.read().is_some() {
7448            self.refresh_principal()?;
7449        }
7450        let mut transaction = self.begin_as(self.principal.read().clone());
7451        match f(&mut transaction) {
7452            Ok(output) => {
7453                transaction.commit()?;
7454                Ok(output)
7455            }
7456            Err(error) => {
7457                transaction.rollback();
7458                Err(error)
7459            }
7460        }
7461    }
7462
7463    pub fn transaction_for_current_principal_with_epoch<T>(
7464        &self,
7465        f: impl FnOnce(&mut crate::txn::Transaction) -> Result<T>,
7466    ) -> Result<(Epoch, T)> {
7467        if self.principal.read().is_some() {
7468            self.refresh_principal()?;
7469        }
7470        let mut transaction = self.begin_as(self.principal.read().clone());
7471        match f(&mut transaction) {
7472            Ok(output) => {
7473                let epoch = transaction.commit()?;
7474                Ok((epoch, output))
7475            }
7476            Err(error) => {
7477                transaction.rollback();
7478                Err(error)
7479            }
7480        }
7481    }
7482
7483    pub fn transaction_with_row_ids_for_current_principal<T>(
7484        &self,
7485        f: impl FnOnce(&mut crate::txn::Transaction) -> Result<T>,
7486    ) -> Result<(T, Vec<RowId>)> {
7487        if self.principal.read().is_some() {
7488            self.refresh_principal()?;
7489        }
7490        let mut transaction = self.begin_as(self.principal.read().clone());
7491        match f(&mut transaction) {
7492            Ok(output) => {
7493                let (_, row_ids) = transaction.commit_with_row_ids()?;
7494                Ok((output, row_ids))
7495            }
7496            Err(error) => {
7497                transaction.rollback();
7498                Err(error)
7499            }
7500        }
7501    }
7502
7503    /// Run `f` in a transaction with an external-trigger bridge; commit on
7504    /// `Ok`, rollback on `Err`.
7505    pub fn transaction_with_external_trigger_bridge<'a, T>(
7506        &'a self,
7507        bridge: &'a dyn ExternalTriggerBridge,
7508        f: impl FnOnce(&mut crate::txn::Transaction) -> Result<T>,
7509    ) -> Result<T> {
7510        let mut tx = self.begin_with_external_trigger_bridge(bridge);
7511        match f(&mut tx) {
7512            Ok(out) => {
7513                tx.commit()?;
7514                Ok(out)
7515            }
7516            Err(e) => {
7517                tx.rollback();
7518                Err(e)
7519            }
7520        }
7521    }
7522
7523    pub fn transaction_with_external_trigger_bridge_as<'a, T>(
7524        &'a self,
7525        bridge: &'a dyn ExternalTriggerBridge,
7526        principal: Option<crate::auth::Principal>,
7527        f: impl FnOnce(&mut crate::txn::Transaction) -> Result<T>,
7528    ) -> Result<T> {
7529        let mut tx = self.begin_with_external_trigger_bridge_as(bridge, principal);
7530        match f(&mut tx) {
7531            Ok(output) => {
7532                tx.commit()?;
7533                Ok(output)
7534            }
7535            Err(error) => {
7536                tx.rollback();
7537                Err(error)
7538            }
7539        }
7540    }
7541
7542    /// Register a txn in `ActiveTxns` (spec §9.2, review fix #12). Called from
7543    /// `Transaction::new` so registration happens **before** any read.
7544    pub(crate) fn register_active(&self, epoch: Epoch) -> crate::txn::ActiveTxnGuard<'_> {
7545        self.active_txns.register(epoch)
7546    }
7547
7548    fn fill_auto_increment_for_staging(
7549        &self,
7550        staging: &mut [(u64, crate::txn::Staged)],
7551        control: Option<&crate::ExecutionControl>,
7552    ) -> Result<()> {
7553        let mut puts_by_table: HashMap<u64, Vec<usize>> = HashMap::new();
7554        for (index, (table_id, staged)) in staging.iter().enumerate() {
7555            commit_prepare_checkpoint(control, index)?;
7556            if matches!(staged, crate::txn::Staged::Put(_)) {
7557                puts_by_table.entry(*table_id).or_default().push(index);
7558            }
7559        }
7560
7561        let tables = self.tables.read();
7562        for (table_index, (table_id, indexes)) in puts_by_table.into_iter().enumerate() {
7563            commit_prepare_checkpoint(control, table_index)?;
7564            if let Some(handle) = tables.get(&table_id) {
7565                #[cfg(test)]
7566                AUTO_INCREMENT_TABLE_LOCKS.with(|count| count.set(count.get() + 1));
7567                let mut t = handle.lock();
7568                for (fill_index, index) in indexes.into_iter().enumerate() {
7569                    commit_prepare_checkpoint(control, fill_index)?;
7570                    if let crate::txn::Staged::Put(cells) = &mut staging[index].1 {
7571                        t.fill_auto_inc(cells)?;
7572                    }
7573                }
7574            }
7575        }
7576        Ok(())
7577    }
7578
7579    fn expand_table_triggers(
7580        &self,
7581        staging: &mut Vec<(u64, crate::txn::Staged)>,
7582        read_epoch: Epoch,
7583        external_trigger_bridge: Option<&dyn ExternalTriggerBridge>,
7584        external_states: &mut Vec<(String, Vec<u8>)>,
7585        control: Option<&crate::ExecutionControl>,
7586    ) -> Result<()> {
7587        commit_prepare_checkpoint(control, 0)?;
7588        let mut external_writes = Vec::new();
7589        let config = self.trigger_config();
7590        if config.recursive_triggers {
7591            let chunk = std::mem::take(staging);
7592            let stacks = vec![Vec::new(); chunk.len()];
7593            *staging = self.expand_trigger_chunk(
7594                chunk,
7595                stacks,
7596                read_epoch,
7597                0,
7598                config.max_depth,
7599                &mut external_writes,
7600                &config,
7601                control,
7602            )?;
7603            self.apply_external_trigger_writes(
7604                external_writes,
7605                external_trigger_bridge,
7606                external_states,
7607                staging,
7608                control,
7609            )?;
7610            return Ok(());
7611        }
7612
7613        let mut expansion =
7614            self.expand_table_triggers_once(staging, read_epoch, None, &config, control)?;
7615        if !expansion.before.is_empty() {
7616            let mut final_staging = expansion.before;
7617            final_staging.extend(filter_ignored_staging(
7618                std::mem::take(staging),
7619                &expansion.ignored_indices,
7620            ));
7621            *staging = final_staging;
7622        } else if !expansion.ignored_indices.is_empty() {
7623            *staging = filter_ignored_staging(std::mem::take(staging), &expansion.ignored_indices);
7624        }
7625        staging.append(&mut expansion.after);
7626        external_writes.append(&mut expansion.before_external);
7627        external_writes.append(&mut expansion.after_external);
7628        self.apply_external_trigger_writes(
7629            external_writes,
7630            external_trigger_bridge,
7631            external_states,
7632            staging,
7633            control,
7634        )?;
7635        Ok(())
7636    }
7637
7638    #[allow(clippy::too_many_arguments)]
7639    fn expand_trigger_chunk(
7640        &self,
7641        mut chunk: Vec<(u64, crate::txn::Staged)>,
7642        stacks: Vec<Vec<String>>,
7643        read_epoch: Epoch,
7644        depth: u32,
7645        max_depth: u32,
7646        external_writes: &mut Vec<ExternalTriggerWrite>,
7647        config: &TriggerConfig,
7648        control: Option<&crate::ExecutionControl>,
7649    ) -> Result<Vec<(u64, crate::txn::Staged)>> {
7650        if chunk.is_empty() {
7651            return Ok(Vec::new());
7652        }
7653        commit_prepare_checkpoint(control, 0)?;
7654        self.fill_auto_increment_for_staging(&mut chunk, control)?;
7655        let expansion = self.expand_table_triggers_once(
7656            &mut chunk,
7657            read_epoch,
7658            Some(&stacks),
7659            config,
7660            control,
7661        )?;
7662        if depth >= max_depth && (!expansion.before.is_empty() || !expansion.after.is_empty()) {
7663            let stack = expansion
7664                .before_stacks
7665                .first()
7666                .or_else(|| expansion.after_stacks.first())
7667                .cloned()
7668                .unwrap_or_default();
7669            return Err(MongrelError::TriggerValidation(format!(
7670                "trigger recursion exceeded max depth {max_depth}; trigger stack: {}",
7671                Self::format_trigger_stack(&stack)
7672            )));
7673        }
7674
7675        let mut out = Vec::new();
7676        external_writes.extend(expansion.before_external);
7677        out.extend(self.expand_trigger_chunk(
7678            expansion.before,
7679            expansion.before_stacks,
7680            read_epoch,
7681            depth + 1,
7682            max_depth,
7683            external_writes,
7684            config,
7685            control,
7686        )?);
7687        out.extend(filter_ignored_staging(chunk, &expansion.ignored_indices));
7688        external_writes.extend(expansion.after_external);
7689        out.extend(self.expand_trigger_chunk(
7690            expansion.after,
7691            expansion.after_stacks,
7692            read_epoch,
7693            depth + 1,
7694            max_depth,
7695            external_writes,
7696            config,
7697            control,
7698        )?);
7699        Ok(out)
7700    }
7701
7702    fn apply_external_trigger_writes(
7703        &self,
7704        writes: Vec<ExternalTriggerWrite>,
7705        bridge: Option<&dyn ExternalTriggerBridge>,
7706        external_states: &mut Vec<(String, Vec<u8>)>,
7707        staging: &mut Vec<(u64, crate::txn::Staged)>,
7708        control: Option<&crate::ExecutionControl>,
7709    ) -> Result<()> {
7710        if writes.is_empty() {
7711            return Ok(());
7712        }
7713        let bridge = bridge.ok_or_else(|| {
7714            MongrelError::TriggerValidation(
7715                "trigger program wrote an external table, but this transaction has no external trigger bridge".into(),
7716            )
7717        })?;
7718        for (write_index, write) in writes.into_iter().enumerate() {
7719            commit_prepare_checkpoint(control, write_index)?;
7720            let table = write.table().to_string();
7721            let entry = self.external_table(&table).ok_or_else(|| {
7722                MongrelError::NotFound(format!("external table {table:?} not found"))
7723            })?;
7724            let base_state = current_external_state_bytes(&self.root, external_states, &table)?;
7725            let result = bridge.apply_trigger_external_write(&entry, base_state, write)?;
7726            external_states.push((table, result.state));
7727            for (base_index, base_write) in result.base_writes.into_iter().enumerate() {
7728                commit_prepare_checkpoint(control, base_index)?;
7729                match base_write {
7730                    ExternalTriggerBaseWrite::Put { table, cells } => {
7731                        let table_id = self.table_id(&table)?;
7732                        staging.push((table_id, crate::txn::Staged::Put(cells)));
7733                    }
7734                    ExternalTriggerBaseWrite::Delete { table, row_id } => {
7735                        let table_id = self.table_id(&table)?;
7736                        staging.push((table_id, crate::txn::Staged::Delete(row_id)));
7737                    }
7738                }
7739            }
7740        }
7741        dedup_external_states_in_place(external_states);
7742        Ok(())
7743    }
7744
7745    fn expand_table_triggers_once(
7746        &self,
7747        staging: &mut Vec<(u64, crate::txn::Staged)>,
7748        read_epoch: Epoch,
7749        trigger_stacks: Option<&[Vec<String>]>,
7750        config: &TriggerConfig,
7751        control: Option<&crate::ExecutionControl>,
7752    ) -> Result<TriggerExpansion> {
7753        commit_prepare_checkpoint(control, 0)?;
7754        let triggers: Vec<StoredTrigger> = self
7755            .catalog
7756            .read()
7757            .triggers
7758            .iter()
7759            .filter(|entry| {
7760                entry.trigger.enabled
7761                    && matches!(
7762                        entry.trigger.timing,
7763                        TriggerTiming::Before | TriggerTiming::After
7764                    )
7765                    && matches!(entry.trigger.target, TriggerTarget::Table(_))
7766            })
7767            .map(|entry| entry.trigger.clone())
7768            .collect();
7769        if triggers.is_empty() || staging.is_empty() {
7770            return Ok(TriggerExpansion::default());
7771        }
7772
7773        let before_triggers = triggers
7774            .iter()
7775            .filter(|trigger| trigger.timing == TriggerTiming::Before)
7776            .cloned()
7777            .collect::<Vec<_>>();
7778        let after_triggers = triggers
7779            .iter()
7780            .filter(|trigger| trigger.timing == TriggerTiming::After)
7781            .cloned()
7782            .collect::<Vec<_>>();
7783
7784        let mut before_added = Vec::new();
7785        let mut before_stacks = Vec::new();
7786        let mut before_external = Vec::new();
7787        let mut ignored_indices = std::collections::BTreeSet::new();
7788        if !before_triggers.is_empty() {
7789            let before_events =
7790                self.trigger_events_for_staging(staging, read_epoch, trigger_stacks, control)?;
7791            let mut out = TriggerProgramOutput {
7792                added: &mut before_added,
7793                added_stacks: &mut before_stacks,
7794                added_external: &mut before_external,
7795                ignored_indices: &mut ignored_indices,
7796            };
7797            self.execute_triggers_for_events(
7798                &before_triggers,
7799                &before_events,
7800                Some(staging),
7801                &mut out,
7802                config,
7803                read_epoch,
7804                control,
7805            )?;
7806        }
7807
7808        let after_events = if after_triggers.is_empty() {
7809            Vec::new()
7810        } else {
7811            self.trigger_events_for_staging(staging, read_epoch, trigger_stacks, control)?
7812                .into_iter()
7813                .filter(|event| {
7814                    !event
7815                        .op_indices
7816                        .iter()
7817                        .any(|idx| ignored_indices.contains(idx))
7818                })
7819                .collect()
7820        };
7821
7822        let mut after_added = Vec::new();
7823        let mut after_stacks = Vec::new();
7824        let mut after_external = Vec::new();
7825        let mut out = TriggerProgramOutput {
7826            added: &mut after_added,
7827            added_stacks: &mut after_stacks,
7828            added_external: &mut after_external,
7829            ignored_indices: &mut ignored_indices,
7830        };
7831        self.execute_triggers_for_events(
7832            &after_triggers,
7833            &after_events,
7834            None,
7835            &mut out,
7836            config,
7837            read_epoch,
7838            control,
7839        )?;
7840        Ok(TriggerExpansion {
7841            before: before_added,
7842            before_stacks,
7843            before_external,
7844            after: after_added,
7845            after_stacks,
7846            after_external,
7847            ignored_indices,
7848        })
7849    }
7850
7851    #[allow(clippy::too_many_arguments)]
7852    fn execute_triggers_for_events(
7853        &self,
7854        triggers: &[StoredTrigger],
7855        events: &[WriteEvent],
7856        mut staging: Option<&mut Vec<(u64, crate::txn::Staged)>>,
7857        out: &mut TriggerProgramOutput<'_>,
7858        config: &TriggerConfig,
7859        read_epoch: Epoch,
7860        control: Option<&crate::ExecutionControl>,
7861    ) -> Result<()> {
7862        let mut checkpoint_index = 0_usize;
7863        for event in events {
7864            for trigger in triggers {
7865                commit_prepare_checkpoint(control, checkpoint_index)?;
7866                checkpoint_index += 1;
7867                if event
7868                    .op_indices
7869                    .iter()
7870                    .any(|idx| out.ignored_indices.contains(idx))
7871                {
7872                    break;
7873                }
7874                let matches = {
7875                    let cat = self.catalog.read();
7876                    trigger_matches_event(trigger, event, &cat)?
7877                };
7878                if !matches {
7879                    continue;
7880                }
7881                if let Some(when) = &trigger.when {
7882                    if !eval_trigger_expr(when, event)? {
7883                        continue;
7884                    }
7885                }
7886                let trigger_stack = Self::trigger_stack_with(&event.trigger_stack, &trigger.name);
7887                if event.trigger_stack.iter().any(|name| name == &trigger.name) {
7888                    return Err(MongrelError::TriggerValidation(format!(
7889                        "trigger recursion cycle detected; trigger stack: {}",
7890                        Self::format_trigger_stack(&trigger_stack)
7891                    )));
7892                }
7893                let outcome = match staging.as_mut() {
7894                    Some(staging) => self.execute_trigger_program(
7895                        trigger,
7896                        event,
7897                        Some(&mut **staging),
7898                        out,
7899                        &trigger_stack,
7900                        config,
7901                        read_epoch,
7902                        control,
7903                    )?,
7904                    None => self.execute_trigger_program(
7905                        trigger,
7906                        event,
7907                        None,
7908                        out,
7909                        &trigger_stack,
7910                        config,
7911                        read_epoch,
7912                        control,
7913                    )?,
7914                };
7915                if outcome == TriggerProgramOutcome::Ignore {
7916                    out.ignored_indices.extend(event.op_indices.iter().copied());
7917                    break;
7918                }
7919            }
7920        }
7921        Ok(())
7922    }
7923
7924    fn trigger_events_for_staging(
7925        &self,
7926        staging: &[(u64, crate::txn::Staged)],
7927        read_epoch: Epoch,
7928        trigger_stacks: Option<&[Vec<String>]>,
7929        control: Option<&crate::ExecutionControl>,
7930    ) -> Result<Vec<WriteEvent>> {
7931        use crate::txn::Staged;
7932        use std::collections::{HashMap, VecDeque};
7933
7934        let snapshot = Snapshot::at(read_epoch);
7935        let cat = self.catalog.read();
7936        let mut table_names = HashMap::new();
7937        let mut table_schemas = HashMap::new();
7938        for entry in cat
7939            .tables
7940            .iter()
7941            .filter(|entry| matches!(entry.state, TableState::Live))
7942        {
7943            table_names.insert(entry.table_id, entry.name.clone());
7944            table_schemas.insert(entry.table_id, entry.schema.clone());
7945        }
7946        drop(cat);
7947
7948        let mut old_rows: HashMap<usize, TriggerRowImage> = HashMap::new();
7949        let mut delete_by_key: HashMap<(u64, Vec<u8>), VecDeque<usize>> = HashMap::new();
7950        let mut put_by_key: HashMap<(u64, Vec<u8>), VecDeque<usize>> = HashMap::new();
7951
7952        for (idx, (table_id, staged)) in staging.iter().enumerate() {
7953            commit_prepare_checkpoint(control, idx)?;
7954            let Some(schema) = table_schemas.get(table_id) else {
7955                continue;
7956            };
7957            let Some(pk) = schema.primary_key() else {
7958                continue;
7959            };
7960            match staged {
7961                Staged::Delete(row_id) => {
7962                    let handle = self.table_by_id(*table_id)?;
7963                    let Some(row) = handle.lock().get(*row_id, snapshot) else {
7964                        continue;
7965                    };
7966                    let Some(pk_value) = row.columns.get(&pk.id) else {
7967                        continue;
7968                    };
7969                    old_rows.insert(idx, TriggerRowImage::from_row(row.clone()));
7970                    delete_by_key
7971                        .entry((*table_id, pk_value.encode_key()))
7972                        .or_default()
7973                        .push_back(idx);
7974                }
7975                Staged::Put(cells) => {
7976                    if let Some((_, value)) = cells.iter().find(|(id, _)| *id == pk.id) {
7977                        put_by_key
7978                            .entry((*table_id, value.encode_key()))
7979                            .or_default()
7980                            .push_back(idx);
7981                    }
7982                }
7983                Staged::Update { row_id, .. } => {
7984                    let handle = self.table_by_id(*table_id)?;
7985                    let row = handle.lock().get(*row_id, snapshot);
7986                    if let Some(row) = row {
7987                        old_rows.insert(idx, TriggerRowImage::from_row(row));
7988                    }
7989                }
7990                Staged::Truncate => {}
7991            }
7992        }
7993
7994        let mut paired_delete = std::collections::HashSet::new();
7995        let mut paired_put = std::collections::HashSet::new();
7996        let mut events = Vec::new();
7997
7998        for (pair_index, (key, deletes)) in delete_by_key.iter_mut().enumerate() {
7999            commit_prepare_checkpoint(control, pair_index)?;
8000            let Some(puts) = put_by_key.get_mut(key) else {
8001                continue;
8002            };
8003            while let (Some(delete_idx), Some(put_idx)) = (deletes.pop_front(), puts.pop_front()) {
8004                paired_delete.insert(delete_idx);
8005                paired_put.insert(put_idx);
8006                let (table_id, _) = &staging[put_idx];
8007                let Some(table_name) = table_names.get(table_id).cloned() else {
8008                    continue;
8009                };
8010                let old = old_rows.get(&delete_idx).cloned();
8011                let new = match &staging[put_idx].1 {
8012                    Staged::Put(cells) => Some(TriggerRowImage::from_cells(cells)),
8013                    _ => None,
8014                };
8015                let changed_columns = changed_columns(old.as_ref(), new.as_ref());
8016                events.push(WriteEvent {
8017                    table: table_name,
8018                    kind: TriggerEvent::Update,
8019                    old,
8020                    new,
8021                    changed_columns,
8022                    op_indices: vec![delete_idx, put_idx],
8023                    put_idx: Some(put_idx),
8024                    trigger_stack: Self::trigger_stack_for_indices(
8025                        trigger_stacks,
8026                        &[delete_idx, put_idx],
8027                    ),
8028                });
8029            }
8030        }
8031
8032        for (idx, (table_id, staged)) in staging.iter().enumerate() {
8033            commit_prepare_checkpoint(control, idx)?;
8034            let Some(table_name) = table_names.get(table_id).cloned() else {
8035                continue;
8036            };
8037            match staged {
8038                Staged::Put(cells) if !paired_put.contains(&idx) => {
8039                    let new = Some(TriggerRowImage::from_cells(cells));
8040                    let changed_columns = cells.iter().map(|(id, _)| *id).collect();
8041                    events.push(WriteEvent {
8042                        table: table_name,
8043                        kind: TriggerEvent::Insert,
8044                        old: None,
8045                        new,
8046                        changed_columns,
8047                        op_indices: vec![idx],
8048                        put_idx: Some(idx),
8049                        trigger_stack: Self::trigger_stack_for_indices(trigger_stacks, &[idx]),
8050                    });
8051                }
8052                Staged::Delete(row_id) if !paired_delete.contains(&idx) => {
8053                    let old = match old_rows.get(&idx).cloned() {
8054                        Some(old) => Some(old),
8055                        None => {
8056                            let handle = self.table_by_id(*table_id)?;
8057                            let row = handle.lock().get(*row_id, snapshot);
8058                            row.map(TriggerRowImage::from_row)
8059                        }
8060                    };
8061                    let Some(old) = old else {
8062                        continue;
8063                    };
8064                    let changed_columns = old.columns.keys().copied().collect();
8065                    events.push(WriteEvent {
8066                        table: table_name,
8067                        kind: TriggerEvent::Delete,
8068                        old: Some(old),
8069                        new: None,
8070                        changed_columns,
8071                        op_indices: vec![idx],
8072                        put_idx: None,
8073                        trigger_stack: Self::trigger_stack_for_indices(trigger_stacks, &[idx]),
8074                    });
8075                }
8076                Staged::Update { new_row: cells, .. } => {
8077                    let old = old_rows.get(&idx).cloned();
8078                    let new = Some(TriggerRowImage::from_cells(cells));
8079                    let changed_columns = changed_columns(old.as_ref(), new.as_ref());
8080                    events.push(WriteEvent {
8081                        table: table_name,
8082                        kind: TriggerEvent::Update,
8083                        old,
8084                        new,
8085                        changed_columns,
8086                        op_indices: vec![idx],
8087                        put_idx: Some(idx),
8088                        trigger_stack: Self::trigger_stack_for_indices(trigger_stacks, &[idx]),
8089                    });
8090                }
8091                Staged::Truncate => {}
8092                _ => {}
8093            }
8094        }
8095
8096        Ok(events)
8097    }
8098
8099    #[allow(clippy::too_many_arguments)]
8100    fn execute_trigger_program(
8101        &self,
8102        trigger: &StoredTrigger,
8103        event: &WriteEvent,
8104        staging: Option<&mut Vec<(u64, crate::txn::Staged)>>,
8105        out: &mut TriggerProgramOutput<'_>,
8106        trigger_stack: &[String],
8107        config: &TriggerConfig,
8108        read_epoch: Epoch,
8109        control: Option<&crate::ExecutionControl>,
8110    ) -> Result<TriggerProgramOutcome> {
8111        let mut event = event.clone();
8112        let mut select_results: HashMap<String, Vec<TriggerRowImage>> = HashMap::new();
8113        self.execute_trigger_steps(
8114            trigger,
8115            &trigger.program.steps,
8116            &mut event,
8117            staging,
8118            out,
8119            trigger_stack,
8120            config,
8121            &mut select_results,
8122            0,
8123            None,
8124            read_epoch,
8125            control,
8126        )
8127    }
8128
8129    #[allow(clippy::too_many_arguments)]
8130    fn execute_trigger_steps(
8131        &self,
8132        trigger: &StoredTrigger,
8133        steps: &[TriggerStep],
8134        event: &mut WriteEvent,
8135        mut staging: Option<&mut Vec<(u64, crate::txn::Staged)>>,
8136        out: &mut TriggerProgramOutput<'_>,
8137        trigger_stack: &[String],
8138        config: &TriggerConfig,
8139        select_results: &mut HashMap<String, Vec<TriggerRowImage>>,
8140        depth: u32,
8141        selected: Option<&TriggerRowImage>,
8142        read_epoch: Epoch,
8143        control: Option<&crate::ExecutionControl>,
8144    ) -> Result<TriggerProgramOutcome> {
8145        let _ = depth;
8146        for (step_index, step) in steps.iter().enumerate() {
8147            commit_prepare_checkpoint(control, step_index)?;
8148            match step {
8149                TriggerStep::SetNew { cells } => {
8150                    if trigger.timing != TriggerTiming::Before {
8151                        return Err(MongrelError::InvalidArgument(
8152                            "SetNew trigger step is only valid in BEFORE triggers".into(),
8153                        ));
8154                    }
8155                    let put_idx = event.put_idx.ok_or_else(|| {
8156                        MongrelError::InvalidArgument(
8157                            "SetNew trigger step requires INSERT or UPDATE NEW row".into(),
8158                        )
8159                    })?;
8160                    let staging = staging.as_deref_mut().ok_or_else(|| {
8161                        MongrelError::InvalidArgument(
8162                            "SetNew trigger step requires mutable trigger staging".into(),
8163                        )
8164                    })?;
8165                    let mut update_changed_columns = None;
8166                    let row_cells = match staging.get_mut(put_idx).map(|(_, op)| op) {
8167                        Some(crate::txn::Staged::Put(cells)) => cells,
8168                        Some(crate::txn::Staged::Update {
8169                            new_row,
8170                            changed_columns,
8171                            ..
8172                        }) => {
8173                            update_changed_columns = Some(changed_columns);
8174                            new_row
8175                        }
8176                        _ => {
8177                            return Err(MongrelError::InvalidArgument(
8178                                "SetNew trigger step target row is not mutable".into(),
8179                            ))
8180                        }
8181                    };
8182                    for (column_id, value) in eval_trigger_cells(cells, event, selected)? {
8183                        row_cells.retain(|(id, _)| *id != column_id);
8184                        row_cells.push((column_id, value.clone()));
8185                        if let Some(changed_columns) = &mut update_changed_columns {
8186                            changed_columns.push(column_id);
8187                        }
8188                        if let Some(new) = &mut event.new {
8189                            new.columns.insert(column_id, value);
8190                        }
8191                    }
8192                    row_cells.sort_by_key(|(id, _)| *id);
8193                    if let Some(changed_columns) = update_changed_columns {
8194                        changed_columns.sort_unstable();
8195                        changed_columns.dedup();
8196                    }
8197                }
8198                TriggerStep::Insert { table, cells } => {
8199                    let cells = eval_trigger_cells(cells, event, selected)?;
8200                    if let Ok(table_id) = self.table_id(table) {
8201                        out.added.push((table_id, crate::txn::Staged::Put(cells)));
8202                        out.added_stacks.push(trigger_stack.to_vec());
8203                    } else if self.external_table(table).is_some() {
8204                        out.added_external.push(ExternalTriggerWrite::Insert {
8205                            table: table.clone(),
8206                            cells,
8207                        });
8208                    } else {
8209                        return Err(MongrelError::NotFound(format!(
8210                            "trigger {:?} insert target {table:?} not found",
8211                            trigger.name
8212                        )));
8213                    }
8214                }
8215                TriggerStep::UpdateByPk { table, pk, cells } => {
8216                    let pk = eval_trigger_value(pk, event, selected)?;
8217                    let cells = eval_trigger_cells(cells, event, selected)?;
8218                    if self.external_table(table).is_some() {
8219                        out.added_external.push(ExternalTriggerWrite::UpdateByPk {
8220                            table: table.clone(),
8221                            pk,
8222                            cells,
8223                        });
8224                    } else {
8225                        let row_id = self
8226                            .table(table)?
8227                            .lock()
8228                            .lookup_pk(&pk.encode_key())
8229                            .ok_or_else(|| {
8230                                MongrelError::NotFound(format!(
8231                                    "trigger {:?} update target not found",
8232                                    trigger.name
8233                                ))
8234                            })?;
8235                        let handle = self.table(table)?;
8236                        let snapshot = Snapshot::at(self.epoch.visible());
8237                        let old = handle.lock().get(row_id, snapshot).ok_or_else(|| {
8238                            MongrelError::NotFound(format!(
8239                                "trigger {:?} update target not visible",
8240                                trigger.name
8241                            ))
8242                        })?;
8243                        let mut changed_columns = cells
8244                            .iter()
8245                            .map(|(column_id, _)| *column_id)
8246                            .collect::<Vec<_>>();
8247                        changed_columns.sort_unstable();
8248                        changed_columns.dedup();
8249                        let mut merged = old.columns;
8250                        for (column_id, value) in cells {
8251                            merged.insert(column_id, value);
8252                        }
8253                        out.added.push((
8254                            self.table_id(table)?,
8255                            crate::txn::Staged::Update {
8256                                row_id,
8257                                new_row: merged.into_iter().collect(),
8258                                changed_columns,
8259                            },
8260                        ));
8261                        out.added_stacks.push(trigger_stack.to_vec());
8262                    }
8263                }
8264                TriggerStep::DeleteByPk { table, pk } => {
8265                    let pk = eval_trigger_value(pk, event, selected)?;
8266                    if self.external_table(table).is_some() {
8267                        out.added_external.push(ExternalTriggerWrite::DeleteByPk {
8268                            table: table.clone(),
8269                            pk,
8270                        });
8271                    } else {
8272                        let row_id = self
8273                            .table(table)?
8274                            .lock()
8275                            .lookup_pk(&pk.encode_key())
8276                            .ok_or_else(|| {
8277                                MongrelError::NotFound(format!(
8278                                    "trigger {:?} delete target not found",
8279                                    trigger.name
8280                                ))
8281                            })?;
8282                        out.added
8283                            .push((self.table_id(table)?, crate::txn::Staged::Delete(row_id)));
8284                        out.added_stacks.push(trigger_stack.to_vec());
8285                    }
8286                }
8287                TriggerStep::Select {
8288                    id,
8289                    table,
8290                    conditions,
8291                } => {
8292                    let schema = self.table(table)?.lock().schema().clone();
8293                    let snapshot = Snapshot::at(read_epoch);
8294                    let handle = self.table(table)?;
8295                    let rows = match control {
8296                        Some(control) => {
8297                            handle.lock().visible_rows_controlled(snapshot, control)?
8298                        }
8299                        None => handle.lock().visible_rows(snapshot)?,
8300                    };
8301                    let mut matched = Vec::new();
8302                    for (row_index, row) in rows.into_iter().enumerate() {
8303                        commit_prepare_checkpoint(control, row_index)?;
8304                        let image = TriggerRowImage::from_row(row);
8305                        let passes = conditions
8306                            .iter()
8307                            .map(|cond| eval_trigger_condition(cond, event, &image, &schema))
8308                            .collect::<Result<Vec<_>>>()?
8309                            .into_iter()
8310                            .all(|b| b);
8311                        if passes {
8312                            matched.push(image);
8313                        }
8314                    }
8315                    if let Some(pk) = schema.primary_key() {
8316                        matched.sort_by(|a, b| {
8317                            let av = a.columns.get(&pk.id).unwrap_or(&Value::Null);
8318                            let bv = b.columns.get(&pk.id).unwrap_or(&Value::Null);
8319                            value_order(av, bv).unwrap_or(std::cmp::Ordering::Equal)
8320                        });
8321                    }
8322                    select_results.insert(id.clone(), matched);
8323                }
8324                TriggerStep::Foreach { id, steps } => {
8325                    let rows = select_results.get(id).ok_or_else(|| {
8326                        MongrelError::InvalidArgument(format!(
8327                            "trigger {:?} foreach references unknown select id {id:?}",
8328                            trigger.name
8329                        ))
8330                    })?;
8331                    if rows.len() > config.max_loop_iterations as usize {
8332                        return Err(MongrelError::InvalidArgument(format!(
8333                            "trigger {:?} foreach exceeded max_loop_iterations ({})",
8334                            trigger.name, config.max_loop_iterations
8335                        )));
8336                    }
8337                    for (row_index, row) in rows.clone().into_iter().enumerate() {
8338                        commit_prepare_checkpoint(control, row_index)?;
8339                        let result = self.execute_trigger_steps(
8340                            trigger,
8341                            steps,
8342                            event,
8343                            staging.as_deref_mut(),
8344                            out,
8345                            trigger_stack,
8346                            config,
8347                            select_results,
8348                            depth + 1,
8349                            Some(&row),
8350                            read_epoch,
8351                            control,
8352                        )?;
8353                        if result == TriggerProgramOutcome::Ignore {
8354                            return Ok(TriggerProgramOutcome::Ignore);
8355                        }
8356                    }
8357                }
8358                TriggerStep::DeleteWhere { table, conditions } => {
8359                    let schema = self.table(table)?.lock().schema().clone();
8360                    let snapshot = Snapshot::at(read_epoch);
8361                    let handle = self.table(table)?;
8362                    let rows = match control {
8363                        Some(control) => {
8364                            handle.lock().visible_rows_controlled(snapshot, control)?
8365                        }
8366                        None => handle.lock().visible_rows(snapshot)?,
8367                    };
8368                    let table_id = self.table_id(table)?;
8369                    let mut to_delete = Vec::new();
8370                    for (row_index, row) in rows.into_iter().enumerate() {
8371                        commit_prepare_checkpoint(control, row_index)?;
8372                        let image = TriggerRowImage::from_row(row.clone());
8373                        let passes = conditions
8374                            .iter()
8375                            .map(|cond| eval_trigger_condition(cond, event, &image, &schema))
8376                            .collect::<Result<Vec<_>>>()?
8377                            .into_iter()
8378                            .all(|b| b);
8379                        if passes {
8380                            to_delete.push((table_id, row.row_id));
8381                        }
8382                    }
8383                    for (row_index, (table_id, row_id)) in to_delete.into_iter().enumerate() {
8384                        commit_prepare_checkpoint(control, row_index)?;
8385                        out.added
8386                            .push((table_id, crate::txn::Staged::Delete(row_id)));
8387                        out.added_stacks.push(trigger_stack.to_vec());
8388                    }
8389                }
8390                TriggerStep::UpdateWhere {
8391                    table,
8392                    conditions,
8393                    cells,
8394                } => {
8395                    let schema = self.table(table)?.lock().schema().clone();
8396                    let snapshot = Snapshot::at(read_epoch);
8397                    let handle = self.table(table)?;
8398                    let rows = match control {
8399                        Some(control) => {
8400                            handle.lock().visible_rows_controlled(snapshot, control)?
8401                        }
8402                        None => handle.lock().visible_rows(snapshot)?,
8403                    };
8404                    let table_id = self.table_id(table)?;
8405                    let mut changed_columns =
8406                        cells.iter().map(|cell| cell.column_id).collect::<Vec<_>>();
8407                    changed_columns.sort_unstable();
8408                    changed_columns.dedup();
8409                    let mut to_update = Vec::new();
8410                    for (row_index, row) in rows.into_iter().enumerate() {
8411                        commit_prepare_checkpoint(control, row_index)?;
8412                        let image = TriggerRowImage::from_row(row.clone());
8413                        let passes = conditions
8414                            .iter()
8415                            .map(|cond| eval_trigger_condition(cond, event, &image, &schema))
8416                            .collect::<Result<Vec<_>>>()?
8417                            .into_iter()
8418                            .all(|b| b);
8419                        if passes {
8420                            let new_cells = cells
8421                                .iter()
8422                                .map(|cell| {
8423                                    Ok((
8424                                        cell.column_id,
8425                                        eval_trigger_value(&cell.value, event, Some(&image))?,
8426                                    ))
8427                                })
8428                                .collect::<Result<Vec<_>>>()?;
8429                            let mut merged = row.columns.clone();
8430                            for (column_id, value) in new_cells {
8431                                merged.insert(column_id, value);
8432                            }
8433                            to_update.push((table_id, row.row_id, merged));
8434                        }
8435                    }
8436                    for (row_index, (table_id, row_id, merged)) in to_update.into_iter().enumerate()
8437                    {
8438                        commit_prepare_checkpoint(control, row_index)?;
8439                        out.added.push((
8440                            table_id,
8441                            crate::txn::Staged::Update {
8442                                row_id,
8443                                new_row: merged.into_iter().collect(),
8444                                changed_columns: changed_columns.clone(),
8445                            },
8446                        ));
8447                        out.added_stacks.push(trigger_stack.to_vec());
8448                    }
8449                }
8450                TriggerStep::Raise { action, message } => match action {
8451                    TriggerRaiseAction::Ignore => return Ok(TriggerProgramOutcome::Ignore),
8452                    TriggerRaiseAction::Abort
8453                    | TriggerRaiseAction::Fail
8454                    | TriggerRaiseAction::Rollback => {
8455                        let message = eval_trigger_value(message, event, selected)?;
8456                        return Err(MongrelError::TriggerValidation(format!(
8457                            "trigger {:?} raised: {}; trigger stack: {}",
8458                            trigger.name,
8459                            trigger_message(message),
8460                            Self::format_trigger_stack(trigger_stack)
8461                        )));
8462                    }
8463                },
8464            }
8465        }
8466        Ok(TriggerProgramOutcome::Continue)
8467    }
8468
8469    fn trigger_stack_for_indices(stacks: Option<&[Vec<String>]>, indices: &[usize]) -> Vec<String> {
8470        let Some(stacks) = stacks else {
8471            return Vec::new();
8472        };
8473        let mut out = Vec::new();
8474        for idx in indices {
8475            let Some(stack) = stacks.get(*idx) else {
8476                continue;
8477            };
8478            for name in stack {
8479                if !out.iter().any(|existing| existing == name) {
8480                    out.push(name.clone());
8481                }
8482            }
8483        }
8484        out
8485    }
8486
8487    fn trigger_stack_with(stack: &[String], trigger_name: &str) -> Vec<String> {
8488        let mut out = stack.to_vec();
8489        out.push(trigger_name.to_string());
8490        out
8491    }
8492
8493    fn format_trigger_stack(stack: &[String]) -> String {
8494        if stack.is_empty() {
8495            "<root>".into()
8496        } else {
8497            stack.join(" -> ")
8498        }
8499    }
8500
8501    /// Authoritatively validate every declared constraint on the staged write
8502    /// set under the transaction's read snapshot, AND expand ON DELETE CASCADE /
8503    /// SET NULL actions into explicit child ops. Called from
8504    /// [`Self::commit_transaction`] outside the WAL mutex. Returns the first
8505    /// violation as an `Err`, aborting the commit atomically. This is the
8506    /// server-side authority point: concurrent remote writers that each pass
8507    /// their own client-side checks still cannot both commit a violating batch.
8508    ///
8509    /// Scope: CHECK (full, three-valued), UNIQUE beyond the PK (existence scan +
8510    /// intra-transaction dedup; concurrent-txn races are additionally caught by
8511    /// `WriteKey::Unique`), and FK insert-side parent existence + ON DELETE
8512    /// {RESTRICT, CASCADE, SET NULL}. CASCADE appends child deletes (transitive
8513    /// fixpoint); SET NULL appends child updates (FK columns nulled). Truncate is
8514    /// RESTRICT-only (cascade-truncate is unsupported).
8515    fn validate_constraints(
8516        &self,
8517        staging: &mut Vec<(u64, crate::txn::Staged)>,
8518        read_epoch: Epoch,
8519        control: Option<&crate::ExecutionControl>,
8520    ) -> Result<()> {
8521        use crate::constraint::{encode_composite_key, validate_checks, FkAction};
8522        use crate::memtable::Row;
8523        use crate::txn::Staged;
8524        use std::collections::HashSet;
8525
8526        commit_prepare_checkpoint(control, 0)?;
8527        let snapshot = Snapshot::at(read_epoch);
8528        let cat = self.catalog.read();
8529
8530        // Collect live (id, name, constraints-bearing?) for staged tables.
8531        let live: Vec<(u64, &str, &crate::schema::Schema)> = cat
8532            .tables
8533            .iter()
8534            .filter(|entry| matches!(entry.state, TableState::Live | TableState::Building { .. }))
8535            .map(|e| (e.table_id, e.name.as_str(), &e.schema))
8536            .collect();
8537
8538        // Fast path: bail if no live table declares any constraints at all.
8539        let any_constraints = live.iter().any(|(_, _, s)| !s.constraints.is_empty());
8540        if !any_constraints {
8541            return Ok(());
8542        }
8543
8544        // Lazily-loaded visible rows per table, shared across checks.
8545        let mut rows_cache: HashMap<u64, Vec<Row>> = HashMap::new();
8546        let mut load_rows = |table_id: u64| -> Result<Vec<Row>> {
8547            if let Some(r) = rows_cache.get(&table_id) {
8548                return Ok(r.clone());
8549            }
8550            let handle = self.table_by_id(table_id)?;
8551            let rows = match control {
8552                Some(control) => handle.lock().visible_rows_controlled(snapshot, control)?,
8553                None => handle.lock().visible_rows(snapshot)?,
8554            };
8555            rows_cache.insert(table_id, rows.clone());
8556            Ok(rows)
8557        };
8558
8559        // ── Phase A1: expand ON UPDATE CASCADE / SET NULL while updates still
8560        // carry an explicit old RowId + full new image. This makes action choice
8561        // reliable even when the referenced key itself changes; a delete+put
8562        // heuristic cannot distinguish that from unrelated operations.
8563        let mut processed_updates = HashSet::new();
8564        type PendingUpdate = (usize, u64, crate::rowid::RowId, Vec<(u16, Value)>);
8565        let mut update_pass = 0_usize;
8566        loop {
8567            commit_prepare_checkpoint(control, update_pass)?;
8568            update_pass += 1;
8569            let updates: Vec<PendingUpdate> = staging
8570                .iter()
8571                .enumerate()
8572                .filter_map(|(index, (table_id, op))| match op {
8573                    Staged::Update {
8574                        row_id,
8575                        new_row: cells,
8576                        ..
8577                    } if !processed_updates.contains(&index) => {
8578                        Some((index, *table_id, *row_id, cells.clone()))
8579                    }
8580                    _ => None,
8581                })
8582                .collect();
8583            if updates.is_empty() {
8584                break;
8585            }
8586            let mut new_ops = Vec::new();
8587            for (update_index, (index, table_id, row_id, new_cells)) in
8588                updates.into_iter().enumerate()
8589            {
8590                commit_prepare_checkpoint(control, update_index)?;
8591                processed_updates.insert(index);
8592                let Some(tname) = live
8593                    .iter()
8594                    .find(|(id, _, _)| *id == table_id)
8595                    .map(|(_, name, _)| *name)
8596                else {
8597                    continue;
8598                };
8599                let Some(old_row) = self.table_by_id(table_id)?.lock().get(row_id, snapshot) else {
8600                    continue;
8601                };
8602                let new_map: HashMap<u16, Value> = new_cells.iter().cloned().collect();
8603                for (child_id, _child_name, child_schema) in &live {
8604                    for fk in &child_schema.constraints.foreign_keys {
8605                        if fk.ref_table != tname {
8606                            continue;
8607                        }
8608                        let Some(old_key) = encode_composite_key(&fk.ref_columns, &old_row.columns)
8609                        else {
8610                            continue;
8611                        };
8612                        if encode_composite_key(&fk.ref_columns, &new_map).as_deref()
8613                            == Some(old_key.as_slice())
8614                        {
8615                            continue;
8616                        }
8617                        if fk.on_update == FkAction::Restrict {
8618                            continue;
8619                        }
8620                        let child_rows = load_rows(*child_id)?;
8621                        for (child_index, child) in child_rows.into_iter().enumerate() {
8622                            commit_prepare_checkpoint(control, child_index)?;
8623                            if encode_composite_key(&fk.columns, &child.columns).as_deref()
8624                                != Some(old_key.as_slice())
8625                            {
8626                                continue;
8627                            }
8628                            if staging.iter().any(|(id, op)| {
8629                                *id == *child_id
8630                                    && matches!(op, Staged::Delete(id) if *id == child.row_id)
8631                            }) {
8632                                continue;
8633                            }
8634                            let mut cells: Vec<(u16, Value)> = child
8635                                .columns
8636                                .iter()
8637                                .map(|(column_id, value)| (*column_id, value.clone()))
8638                                .collect();
8639                            for (child_column, parent_column) in
8640                                fk.columns.iter().zip(&fk.ref_columns)
8641                            {
8642                                cells.retain(|(column_id, _)| column_id != child_column);
8643                                let value = match fk.on_update {
8644                                    FkAction::Cascade => {
8645                                        new_map.get(parent_column).cloned().unwrap_or(Value::Null)
8646                                    }
8647                                    FkAction::SetNull => Value::Null,
8648                                    FkAction::Restrict => {
8649                                        return Err(MongrelError::Other(
8650                                            "restricted foreign-key update reached cascade preparation"
8651                                                .into(),
8652                                        ));
8653                                    }
8654                                };
8655                                cells.push((*child_column, value));
8656                            }
8657                            cells.sort_by_key(|(column_id, _)| *column_id);
8658                            if let Some(existing_index) = staging.iter().position(|(id, op)| {
8659                                *id == *child_id
8660                                    && matches!(op, Staged::Update { row_id, .. } if *row_id == child.row_id)
8661                            }) {
8662                                if let Staged::Update {
8663                                    new_row: existing,
8664                                    changed_columns,
8665                                    ..
8666                                } = &mut staging[existing_index].1 {
8667                                    changed_columns.extend(fk.columns.iter().copied());
8668                                    changed_columns.sort_unstable();
8669                                    changed_columns.dedup();
8670                                    if *existing != cells {
8671                                        *existing = cells;
8672                                        processed_updates.remove(&existing_index);
8673                                    }
8674                                }
8675                            } else {
8676                                new_ops.push((
8677                                    *child_id,
8678                                    Staged::Update {
8679                                        row_id: child.row_id,
8680                                        new_row: cells,
8681                                        changed_columns: fk.columns.clone(),
8682                                    },
8683                                ));
8684                            }
8685                        }
8686                    }
8687                }
8688            }
8689            staging.extend(new_ops);
8690        }
8691
8692        // ── Phase A2: expand ON DELETE CASCADE / SET NULL into explicit child
8693        // ops (transitive fixpoint). RESTRICT is not expanded here — it is
8694        // enforced as a violation in Phase B. `cascaded` records every delete
8695        // we have already expanded so a self-referential CASCADE FK cannot loop.
8696        let mut cascaded: HashSet<(u64, u64)> = HashSet::new();
8697        let mut cascade_pass = 0_usize;
8698        loop {
8699            commit_prepare_checkpoint(control, cascade_pass)?;
8700            cascade_pass += 1;
8701            let mut new_ops: Vec<(u64, Staged)> = Vec::new();
8702            let deletes: Vec<(u64, crate::rowid::RowId)> = staging
8703                .iter()
8704                .filter_map(|(t, op)| match op {
8705                    Staged::Delete(rid) => Some((*t, *rid)),
8706                    _ => None,
8707                })
8708                .collect();
8709            for (delete_index, (table_id, rid)) in deletes.into_iter().enumerate() {
8710                commit_prepare_checkpoint(control, delete_index)?;
8711                if !cascaded.insert((table_id, rid.0)) {
8712                    continue;
8713                }
8714                let Some(tname) = live
8715                    .iter()
8716                    .find(|(t, _, _)| *t == table_id)
8717                    .map(|(_, n, _)| *n)
8718                else {
8719                    continue;
8720                };
8721                let parent_handle = self.table_by_id(table_id)?;
8722                let Some(parent_row) = parent_handle.lock().get(rid, snapshot) else {
8723                    continue;
8724                };
8725                for (child_id, _child_name, child_schema) in &live {
8726                    for fk in &child_schema.constraints.foreign_keys {
8727                        if fk.ref_table != tname {
8728                            continue;
8729                        }
8730                        let Some(parent_key) =
8731                            encode_composite_key(&fk.ref_columns, &parent_row.columns)
8732                        else {
8733                            continue;
8734                        };
8735                        // Suppress ON DELETE cascade/set-null when this "delete"
8736                        // is actually half of an UPDATE encoded as Delete(old)+
8737                        // Put(new): if a staged Put in the SAME table still
8738                        // provides the referenced parent key, the parent still
8739                        // exists (its non-key columns changed) and the children
8740                        // must be left alone. A genuine delete, or an update
8741                        // that CHANGES the referenced key, has no preserving Put
8742                        // → cascade fires as before.
8743                        let key_preserved = staging.iter().any(|(t, op)| {
8744                            if *t != table_id {
8745                                return false;
8746                            }
8747                            let Staged::Put(cells) = op else {
8748                                return false;
8749                            };
8750                            let map: HashMap<u16, crate::memtable::Value> =
8751                                cells.iter().cloned().collect();
8752                            encode_composite_key(&fk.ref_columns, &map).as_deref()
8753                                == Some(parent_key.as_slice())
8754                        });
8755                        if key_preserved {
8756                            continue;
8757                        }
8758                        match fk.on_delete {
8759                            FkAction::Restrict => continue,
8760                            FkAction::Cascade => {
8761                                let child_rows = load_rows(*child_id)?;
8762                                for (child_index, cr) in child_rows.iter().enumerate() {
8763                                    commit_prepare_checkpoint(control, child_index)?;
8764                                    if !cascaded.contains(&(*child_id, cr.row_id.0))
8765                                        && encode_composite_key(&fk.columns, &cr.columns).as_deref()
8766                                            == Some(parent_key.as_slice())
8767                                    {
8768                                        new_ops.push((*child_id, Staged::Delete(cr.row_id)));
8769                                    }
8770                                }
8771                            }
8772                            FkAction::SetNull => {
8773                                let child_rows = load_rows(*child_id)?;
8774                                for (child_index, cr) in child_rows.iter().enumerate() {
8775                                    commit_prepare_checkpoint(control, child_index)?;
8776                                    if !cascaded.contains(&(*child_id, cr.row_id.0))
8777                                        && encode_composite_key(&fk.columns, &cr.columns).as_deref()
8778                                            == Some(parent_key.as_slice())
8779                                    {
8780                                        // Re-emit the child row with the FK
8781                                        // columns set to NULL (delete + put).
8782                                        let mut cells: Vec<(u16, crate::memtable::Value)> = cr
8783                                            .columns
8784                                            .iter()
8785                                            .map(|(k, v)| (*k, v.clone()))
8786                                            .collect();
8787                                        for cid in &fk.columns {
8788                                            cells.retain(|(k, _)| k != cid);
8789                                            cells.push((*cid, crate::memtable::Value::Null));
8790                                        }
8791                                        new_ops.push((
8792                                            *child_id,
8793                                            Staged::Update {
8794                                                row_id: cr.row_id,
8795                                                new_row: cells,
8796                                                changed_columns: fk.columns.clone(),
8797                                            },
8798                                        ));
8799                                    }
8800                                }
8801                            }
8802                        }
8803                    }
8804                }
8805            }
8806            if new_ops.is_empty() {
8807                break;
8808            }
8809            staging.extend(new_ops);
8810        }
8811
8812        // Rows staged for deletion in THIS transaction (now including cascaded
8813        // deletes). Used to exclude the old version of an updated row from
8814        // unique-existence scans.
8815        let staged_deletes: HashSet<(u64, u64)> = staging
8816            .iter()
8817            .filter_map(|(t, op)| match op {
8818                Staged::Delete(rid) | Staged::Update { row_id: rid, .. } => Some((*t, rid.0)),
8819                _ => None,
8820            })
8821            .collect();
8822
8823        // Intra-transaction unique-key dedup: (table_id, uc_id, key).
8824        let mut seen_unique: HashSet<(u64, u16, Vec<u8>)> = HashSet::new();
8825
8826        // ── Phase B: validate the fully-expanded staging set.
8827        for (operation_index, (table_id, op)) in staging.iter().enumerate() {
8828            commit_prepare_checkpoint(control, operation_index)?;
8829            let Some((_, tname, schema)) = live.iter().find(|(t, _, _)| t == table_id).copied()
8830            else {
8831                continue;
8832            };
8833            let cells_map: HashMap<u16, crate::memtable::Value>;
8834            match op {
8835                Staged::Put(cells) | Staged::Update { new_row: cells, .. } => {
8836                    cells_map = cells.iter().cloned().collect();
8837
8838                    // CHECK constraints.
8839                    if !schema.constraints.checks.is_empty() {
8840                        validate_checks(&schema.constraints.checks, &cells_map)?;
8841                    }
8842
8843                    // UNIQUE (non-PK) constraints.
8844                    for uc in &schema.constraints.uniques {
8845                        let Some(key) = encode_composite_key(&uc.columns, &cells_map) else {
8846                            continue; // NULL in a constrained column → skip (SQL).
8847                        };
8848                        let marker = (*table_id, uc.id, key.clone());
8849                        if !seen_unique.insert(marker) {
8850                            return Err(MongrelError::Conflict(format!(
8851                                "UNIQUE constraint '{}' on table '{tname}' violated within batch",
8852                                uc.name
8853                            )));
8854                        }
8855                        let rows = load_rows(*table_id)?;
8856                        for (row_index, r) in rows.iter().enumerate() {
8857                            commit_prepare_checkpoint(control, row_index)?;
8858                            // Skip rows this same transaction is deleting (the
8859                            // old version of an updated/cascade-deleted row).
8860                            if staged_deletes.contains(&(*table_id, r.row_id.0)) {
8861                                continue;
8862                            }
8863                            if let Some(theirs) = encode_composite_key(&uc.columns, &r.columns) {
8864                                if theirs == key {
8865                                    return Err(MongrelError::Conflict(format!(
8866                                        "UNIQUE constraint '{}' on table '{tname}' violated",
8867                                        uc.name
8868                                    )));
8869                                }
8870                            }
8871                        }
8872                    }
8873
8874                    // FK insert-side: parent must exist.
8875                    for fk in &schema.constraints.foreign_keys {
8876                        let Some(child_key) = encode_composite_key(&fk.columns, &cells_map) else {
8877                            continue; // NULL FK component → not checked (SQL).
8878                        };
8879                        let Some(parent_id) = cat
8880                            .tables
8881                            .iter()
8882                            .find(|t| t.name == fk.ref_table)
8883                            .map(|t| t.table_id)
8884                        else {
8885                            return Err(MongrelError::InvalidArgument(format!(
8886                                "FOREIGN KEY '{}' references unknown table '{}'",
8887                                fk.name, fk.ref_table
8888                            )));
8889                        };
8890                        let parent_rows = load_rows(parent_id)?;
8891                        let mut found = false;
8892                        for (row_index, r) in parent_rows.iter().enumerate() {
8893                            commit_prepare_checkpoint(control, row_index)?;
8894                            if staged_deletes.contains(&(parent_id, r.row_id.0)) {
8895                                continue;
8896                            }
8897                            if let Some(pkey) = encode_composite_key(&fk.ref_columns, &r.columns) {
8898                                if pkey == child_key {
8899                                    found = true;
8900                                    break;
8901                                }
8902                            }
8903                        }
8904                        // Final-write-set FK validation: a parent inserted in
8905                        // THIS transaction also satisfies the FK. This enables
8906                        // atomic parent+child batches and cyclical/mutual FK
8907                        // inserts within a single transaction — the child sees
8908                        // the staged parent put even though it is not committed
8909                        // yet.
8910                        if !found {
8911                            for (staged_index, (st_table, st_op)) in staging.iter().enumerate() {
8912                                commit_prepare_checkpoint(control, staged_index)?;
8913                                if *st_table != parent_id {
8914                                    continue;
8915                                }
8916                                if let Staged::Put(pcells)
8917                                | Staged::Update {
8918                                    new_row: pcells, ..
8919                                } = st_op
8920                                {
8921                                    let pmap: HashMap<u16, crate::memtable::Value> =
8922                                        pcells.iter().cloned().collect();
8923                                    if let Some(pkey) = encode_composite_key(&fk.ref_columns, &pmap)
8924                                    {
8925                                        if pkey == child_key {
8926                                            found = true;
8927                                            break;
8928                                        }
8929                                    }
8930                                }
8931                            }
8932                        }
8933                        if !found {
8934                            return Err(MongrelError::Conflict(format!(
8935                                "FOREIGN KEY '{}' on table '{tname}' has no matching parent in '{}'",
8936                                fk.name, fk.ref_table
8937                            )));
8938                        }
8939                    }
8940
8941                    // Parent-side ON UPDATE RESTRICT. CASCADE/SET NULL were
8942                    // expanded in Phase A; here the final child write set is
8943                    // known, so a child explicitly moved/deleted by this same
8944                    // transaction does not cause a false violation.
8945                    if let Staged::Update { row_id, .. } = op {
8946                        let parent_handle = self.table_by_id(*table_id)?;
8947                        let Some(old_parent) = parent_handle.lock().get(*row_id, snapshot) else {
8948                            continue;
8949                        };
8950                        for (child_id, child_name, child_schema) in &live {
8951                            for fk in &child_schema.constraints.foreign_keys {
8952                                if fk.ref_table != tname || fk.on_update != FkAction::Restrict {
8953                                    continue;
8954                                }
8955                                let Some(old_key) =
8956                                    encode_composite_key(&fk.ref_columns, &old_parent.columns)
8957                                else {
8958                                    continue;
8959                                };
8960                                if encode_composite_key(&fk.ref_columns, &cells_map).as_deref()
8961                                    == Some(old_key.as_slice())
8962                                {
8963                                    continue;
8964                                }
8965                                for (child_index, child) in
8966                                    load_rows(*child_id)?.into_iter().enumerate()
8967                                {
8968                                    commit_prepare_checkpoint(control, child_index)?;
8969                                    if encode_composite_key(&fk.columns, &child.columns).as_deref()
8970                                        != Some(old_key.as_slice())
8971                                    {
8972                                        continue;
8973                                    }
8974                                    let replacement = staging.iter().find_map(|(id, op)| {
8975                                        if *id != *child_id {
8976                                            return None;
8977                                        }
8978                                        match op {
8979                                            Staged::Delete(id) if *id == child.row_id => Some(None),
8980                                            Staged::Update {
8981                                                row_id,
8982                                                new_row: cells,
8983                                                ..
8984                                            } if *row_id == child.row_id => {
8985                                                let map: HashMap<u16, Value> =
8986                                                    cells.iter().cloned().collect();
8987                                                Some(encode_composite_key(&fk.columns, &map))
8988                                            }
8989                                            _ => None,
8990                                        }
8991                                    });
8992                                    if replacement.is_some_and(|key| {
8993                                        key.as_deref() != Some(old_key.as_slice())
8994                                    }) {
8995                                        continue;
8996                                    }
8997                                    return Err(MongrelError::Conflict(format!(
8998                                        "FOREIGN KEY '{}' on table '{child_name}' restricts update (parent key referenced)",
8999                                        fk.name
9000                                    )));
9001                                }
9002                            }
9003                        }
9004                    }
9005                }
9006                Staged::Delete(rid) => {
9007                    // FK ON DELETE RESTRICT: a child row (whose FK action is
9008                    // RESTRICT) referencing this parent blocks the delete.
9009                    // CASCADE/SET NULL children were expanded in Phase A.
9010                    let parent_handle = self.table_by_id(*table_id)?;
9011                    let Some(parent_row) = parent_handle.lock().get(*rid, snapshot) else {
9012                        continue;
9013                    };
9014                    for (child_id, child_name, child_schema) in &live {
9015                        for fk in &child_schema.constraints.foreign_keys {
9016                            if fk.ref_table != tname || fk.on_delete != FkAction::Restrict {
9017                                continue;
9018                            }
9019                            let Some(parent_key) =
9020                                encode_composite_key(&fk.ref_columns, &parent_row.columns)
9021                            else {
9022                                continue;
9023                            };
9024                            let child_rows = load_rows(*child_id)?;
9025                            for (row_index, r) in child_rows.iter().enumerate() {
9026                                commit_prepare_checkpoint(control, row_index)?;
9027                                // A child already being deleted by this txn
9028                                // (cascade/inline) is not a restrict violation.
9029                                if staged_deletes.contains(&(*child_id, r.row_id.0)) {
9030                                    continue;
9031                                }
9032                                if let Some(ck) = encode_composite_key(&fk.columns, &r.columns) {
9033                                    if ck == parent_key {
9034                                        return Err(MongrelError::Conflict(format!(
9035                                            "FOREIGN KEY '{}' on table '{child_name}' restricts delete (parent referenced)",
9036                                            fk.name
9037                                        )));
9038                                    }
9039                                }
9040                            }
9041                        }
9042                    }
9043                }
9044                Staged::Truncate => {
9045                    // Truncate is RESTRICT-only: reject if any child references
9046                    // this table (any FK action), since cascade-truncate is
9047                    // unsupported.
9048                    for (child_id, child_name, child_schema) in &live {
9049                        for fk in &child_schema.constraints.foreign_keys {
9050                            if fk.ref_table != tname {
9051                                continue;
9052                            }
9053                            let child_rows = load_rows(*child_id)?;
9054                            if child_rows
9055                                .iter()
9056                                .any(|r| encode_composite_key(&fk.columns, &r.columns).is_some())
9057                            {
9058                                return Err(MongrelError::Conflict(format!(
9059                                    "FOREIGN KEY '{}' on table '{child_name}' restricts truncate of '{tname}'",
9060                                    fk.name
9061                                )));
9062                            }
9063                        }
9064                    }
9065                }
9066            }
9067        }
9068        Ok(())
9069    }
9070
9071    fn validate_write_permissions(
9072        &self,
9073        staging: &[(u64, crate::txn::Staged)],
9074        principal: Option<&crate::auth::Principal>,
9075        control: Option<&crate::ExecutionControl>,
9076    ) -> Result<()> {
9077        commit_prepare_checkpoint(control, 0)?;
9078        if principal.is_none() && !self.auth_state.require_auth() {
9079            return Ok(());
9080        }
9081        let principal = principal.ok_or(MongrelError::AuthRequired)?;
9082        let needs = summarize_write_permissions(staging);
9083        let catalog = self.catalog.read();
9084
9085        if needs.values().any(|need| need.truncate) {
9086            self.require_for(Some(principal), &crate::auth::Permission::Admin)?;
9087        }
9088        for (need_index, (table_id, need)) in needs.into_iter().enumerate() {
9089            commit_prepare_checkpoint(control, need_index)?;
9090            let entry = catalog
9091                .tables
9092                .iter()
9093                .find(|entry| {
9094                    entry.table_id == table_id
9095                        && matches!(entry.state, TableState::Live | TableState::Building { .. })
9096                })
9097                .ok_or_else(|| {
9098                    MongrelError::NotFound(format!(
9099                        "live table {table_id} not found during write validation"
9100                    ))
9101                })?;
9102            if matches!(entry.state, TableState::Building { .. }) {
9103                self.require_for(Some(principal), &crate::auth::Permission::Ddl)?;
9104                continue;
9105            }
9106            if need.insert {
9107                Self::require_columns_for_principal(
9108                    &entry.name,
9109                    &entry.schema,
9110                    crate::auth::ColumnOperation::Insert,
9111                    &need.insert_columns,
9112                    principal,
9113                )?;
9114            }
9115            if need.update {
9116                Self::require_columns_for_principal(
9117                    &entry.name,
9118                    &entry.schema,
9119                    crate::auth::ColumnOperation::Update,
9120                    &need.update_columns,
9121                    principal,
9122                )?;
9123            }
9124            if need.delete {
9125                self.require_for(
9126                    Some(principal),
9127                    &crate::auth::Permission::Delete {
9128                        table: entry.name.clone(),
9129                    },
9130                )?;
9131            }
9132        }
9133        Ok(())
9134    }
9135
9136    fn validate_security_writes(
9137        &self,
9138        staging: &[(u64, crate::txn::Staged)],
9139        read_epoch: Epoch,
9140        explicit_principal: Option<&crate::auth::Principal>,
9141        control: Option<&crate::ExecutionControl>,
9142    ) -> Result<()> {
9143        commit_prepare_checkpoint(control, 0)?;
9144        use crate::security::PolicyCommand;
9145        use crate::txn::Staged;
9146
9147        let catalog = self.catalog.read();
9148        if catalog.security.rls_tables.is_empty() {
9149            return Ok(());
9150        }
9151        let security = catalog.security.clone();
9152        let table_names = catalog
9153            .tables
9154            .iter()
9155            .filter(|entry| matches!(entry.state, TableState::Live))
9156            .map(|entry| (entry.table_id, entry.name.clone()))
9157            .collect::<HashMap<_, _>>();
9158        drop(catalog);
9159        if !staging.iter().any(|(table_id, _)| {
9160            table_names
9161                .get(table_id)
9162                .is_some_and(|table| security.rls_enabled(table))
9163        }) {
9164            return Ok(());
9165        }
9166        let principal = explicit_principal.ok_or(MongrelError::AuthRequired)?;
9167
9168        for (operation_index, (table_id, operation)) in staging.iter().enumerate() {
9169            commit_prepare_checkpoint(control, operation_index)?;
9170            let Some(table) = table_names.get(table_id) else {
9171                continue;
9172            };
9173            if !security.rls_enabled(table) || principal.is_admin {
9174                continue;
9175            }
9176            let denied = |command| MongrelError::PermissionDenied {
9177                required: match command {
9178                    PolicyCommand::Insert => crate::auth::Permission::Insert {
9179                        table: table.clone(),
9180                    },
9181                    PolicyCommand::Update => crate::auth::Permission::Update {
9182                        table: table.clone(),
9183                    },
9184                    PolicyCommand::Delete | PolicyCommand::All | PolicyCommand::Select => {
9185                        crate::auth::Permission::Delete {
9186                            table: table.clone(),
9187                        }
9188                    }
9189                },
9190                principal: principal.username.clone(),
9191            };
9192            match operation {
9193                Staged::Put(cells) => {
9194                    let mut row = crate::memtable::Row::new(RowId(0), Epoch(read_epoch.0));
9195                    row.columns.extend(cells.iter().cloned());
9196                    if !security.row_allowed(table, PolicyCommand::Insert, &row, principal, true) {
9197                        return Err(denied(PolicyCommand::Insert));
9198                    }
9199                }
9200                Staged::Update {
9201                    row_id,
9202                    new_row: cells,
9203                    ..
9204                } => {
9205                    let old = self
9206                        .table_by_id(*table_id)?
9207                        .lock()
9208                        .get(*row_id, Snapshot::at(read_epoch))
9209                        .ok_or_else(|| {
9210                            MongrelError::NotFound(format!("row {} not found", row_id.0))
9211                        })?;
9212                    if !security.row_allowed(table, PolicyCommand::Update, &old, principal, false) {
9213                        return Err(denied(PolicyCommand::Update));
9214                    }
9215                    let mut new = crate::memtable::Row::new(*row_id, Epoch(read_epoch.0));
9216                    new.columns.extend(cells.iter().cloned());
9217                    if !security.row_allowed(table, PolicyCommand::Update, &new, principal, true) {
9218                        return Err(denied(PolicyCommand::Update));
9219                    }
9220                }
9221                Staged::Delete(row_id) => {
9222                    let old = self
9223                        .table_by_id(*table_id)?
9224                        .lock()
9225                        .get(*row_id, Snapshot::at(read_epoch))
9226                        .ok_or_else(|| {
9227                            MongrelError::NotFound(format!("row {} not found", row_id.0))
9228                        })?;
9229                    if !security.row_allowed(table, PolicyCommand::Delete, &old, principal, false) {
9230                        return Err(denied(PolicyCommand::Delete));
9231                    }
9232                }
9233                Staged::Truncate => return Err(denied(PolicyCommand::Delete)),
9234            }
9235        }
9236        Ok(())
9237    }
9238
9239    /// Seal a transaction (spec §9.3):
9240    /// 1. Prepare — derive write keys, allocate row ids (brief table locks).
9241    /// 2. Sequencer — validate-first under the WAL mutex; abort on conflict
9242    ///    with no epoch consumed; assign epoch, append data records + TxnCommit,
9243    ///    group-sync, record conflict keys.
9244    /// 3. Publish — apply to tables, advance visible in-order.
9245    #[allow(clippy::too_many_arguments)]
9246    pub(crate) fn commit_transaction_with_external_states(
9247        &self,
9248        txn_id: u64,
9249        read_epoch: Epoch,
9250        staging: Vec<(u64, crate::txn::Staged)>,
9251        external_states: Vec<(String, Vec<u8>)>,
9252        materialized_view_updates: Vec<crate::catalog::MaterializedViewEntry>,
9253        security_principal: Option<crate::auth::Principal>,
9254        principal_catalog_bound: bool,
9255        external_trigger_bridge: Option<&dyn ExternalTriggerBridge>,
9256    ) -> Result<(Epoch, Vec<RowId>)> {
9257        self.commit_transaction_with_external_states_inner(
9258            txn_id,
9259            read_epoch,
9260            staging,
9261            external_states,
9262            materialized_view_updates,
9263            security_principal,
9264            principal_catalog_bound,
9265            external_trigger_bridge,
9266            None,
9267            None,
9268        )
9269    }
9270
9271    #[allow(clippy::too_many_arguments)]
9272    pub(crate) fn commit_transaction_with_external_states_controlled(
9273        &self,
9274        txn_id: u64,
9275        read_epoch: Epoch,
9276        staging: Vec<(u64, crate::txn::Staged)>,
9277        external_states: Vec<(String, Vec<u8>)>,
9278        materialized_view_updates: Vec<crate::catalog::MaterializedViewEntry>,
9279        security_principal: Option<crate::auth::Principal>,
9280        principal_catalog_bound: bool,
9281        external_trigger_bridge: Option<&dyn ExternalTriggerBridge>,
9282        control: &crate::ExecutionControl,
9283        before_commit: &mut dyn FnMut() -> Result<()>,
9284    ) -> Result<(Epoch, Vec<RowId>)> {
9285        self.commit_transaction_with_external_states_inner(
9286            txn_id,
9287            read_epoch,
9288            staging,
9289            external_states,
9290            materialized_view_updates,
9291            security_principal,
9292            principal_catalog_bound,
9293            external_trigger_bridge,
9294            Some(control),
9295            Some(before_commit),
9296        )
9297    }
9298
9299    #[allow(clippy::too_many_arguments)]
9300    fn commit_transaction_with_external_states_inner(
9301        &self,
9302        txn_id: u64,
9303        read_epoch: Epoch,
9304        mut staging: Vec<(u64, crate::txn::Staged)>,
9305        external_states: Vec<(String, Vec<u8>)>,
9306        materialized_view_updates: Vec<crate::catalog::MaterializedViewEntry>,
9307        mut security_principal: Option<crate::auth::Principal>,
9308        principal_catalog_bound: bool,
9309        external_trigger_bridge: Option<&dyn ExternalTriggerBridge>,
9310        control: Option<&crate::ExecutionControl>,
9311        mut before_commit: Option<&mut dyn FnMut() -> Result<()>>,
9312    ) -> Result<(Epoch, Vec<RowId>)> {
9313        use crate::memtable::Row;
9314        use crate::txn::{Staged, StagedOp, WriteKey};
9315        use crate::wal::Op;
9316        use std::collections::hash_map::DefaultHasher;
9317        use std::hash::{Hash, Hasher};
9318        use std::sync::atomic::Ordering;
9319
9320        if txn_id == crate::wal::SYSTEM_TXN_ID {
9321            return Err(MongrelError::Full(
9322                "per-open transaction id namespace exhausted; reopen the database".into(),
9323            ));
9324        }
9325        if self.read_only {
9326            return Err(MongrelError::ReadOnlyReplica);
9327        }
9328        commit_prepare_checkpoint(control, 0)?;
9329        let observed_security_version = self.security_coordinator.version.load(Ordering::Acquire);
9330        self.refresh_security_catalog_if_stale(observed_security_version)?;
9331        let trigger_binding = trigger_catalog_binding(&self.catalog.read());
9332        if self.auth_state.require_auth() && security_principal.is_none() {
9333            return Err(MongrelError::AuthRequired);
9334        }
9335        {
9336            let catalog = self.catalog.read();
9337            if catalog.require_auth
9338                || principal_catalog_bound
9339                || security_principal
9340                    .as_ref()
9341                    .is_some_and(|principal| principal.user_id != 0)
9342            {
9343                let principal = security_principal
9344                    .as_ref()
9345                    .ok_or(MongrelError::AuthRequired)?;
9346                security_principal =
9347                    Self::resolve_bound_principal_from_catalog(&catalog, principal);
9348                if security_principal.is_none() {
9349                    return Err(MongrelError::AuthRequired);
9350                }
9351            }
9352        }
9353        let _replication_guard = self.replication_barrier.read();
9354        if self.poisoned.load(Ordering::Relaxed) {
9355            return Err(MongrelError::Other(
9356                "database poisoned by fsync error".into(),
9357            ));
9358        }
9359        let mut external_states = dedup_external_states(external_states);
9360        if !external_states.is_empty() {
9361            let cat = self.catalog.read();
9362            for (name, _) in &external_states {
9363                if !cat.external_tables.iter().any(|entry| entry.name == *name) {
9364                    return Err(MongrelError::NotFound(format!(
9365                        "external table {name:?} not found"
9366                    )));
9367                }
9368            }
9369        }
9370        let prepared_materialized_views = {
9371            let mut deduplicated = HashMap::new();
9372            for (definition_index, definition) in materialized_view_updates.into_iter().enumerate()
9373            {
9374                commit_prepare_checkpoint(control, definition_index)?;
9375                if definition.name.is_empty() || definition.query.trim().is_empty() {
9376                    return Err(MongrelError::InvalidArgument(
9377                        "materialized view name and query must not be empty".into(),
9378                    ));
9379                }
9380                deduplicated.insert(definition.name.clone(), definition);
9381            }
9382            let catalog = self.catalog.read();
9383            let mut prepared = Vec::with_capacity(deduplicated.len());
9384            for (definition_index, definition) in deduplicated.into_values().enumerate() {
9385                commit_prepare_checkpoint(control, definition_index)?;
9386                let table_id = catalog
9387                    .live(&definition.name)
9388                    .ok_or_else(|| {
9389                        MongrelError::NotFound(format!(
9390                            "materialized view table {:?} not found",
9391                            definition.name
9392                        ))
9393                    })?
9394                    .table_id;
9395                prepared.push((table_id, definition));
9396            }
9397            prepared.sort_by(|left, right| left.1.name.cmp(&right.1.name));
9398            prepared
9399        };
9400
9401        // ── 1. Prepare: fill generated values, expand triggers, validate, then
9402        // derive write keys from the final atomic write set.
9403        self.fill_auto_increment_for_staging(&mut staging, control)?;
9404        self.expand_table_triggers(
9405            &mut staging,
9406            read_epoch,
9407            external_trigger_bridge,
9408            &mut external_states,
9409            control,
9410        )?;
9411        self.fill_auto_increment_for_staging(&mut staging, control)?;
9412        external_states = dedup_external_states(external_states);
9413        let expected_external_generations = {
9414            let catalog = self.catalog.read();
9415            let mut generations = HashMap::with_capacity(external_states.len());
9416            for (name, _) in &external_states {
9417                let entry = catalog
9418                    .external_tables
9419                    .iter()
9420                    .find(|entry| entry.name == *name)
9421                    .ok_or_else(|| {
9422                        MongrelError::NotFound(format!("external table {name:?} not found"))
9423                    })?;
9424                generations.insert(name.clone(), entry.created_epoch);
9425            }
9426            generations
9427        };
9428
9429        // Validate declarative constraints (unique / FK / check) under the read
9430        // snapshot, outside the WAL mutex. Trigger-produced writes are included
9431        // here, so the batch either satisfies every declared constraint or is
9432        // rejected atomically.
9433        self.validate_constraints(&mut staging, read_epoch, control)?;
9434        self.validate_write_permissions(&staging, security_principal.as_ref(), control)?;
9435        self.validate_security_writes(&staging, read_epoch, security_principal.as_ref(), control)?;
9436        let mut normalized = Vec::with_capacity(staging.len() * 2);
9437        for (staged_index, (table_id, op)) in staging.into_iter().enumerate() {
9438            commit_prepare_checkpoint(control, staged_index)?;
9439            match op {
9440                crate::txn::Staged::Update {
9441                    row_id,
9442                    new_row: cells,
9443                    ..
9444                } => {
9445                    normalized.push((table_id, crate::txn::Staged::Delete(row_id)));
9446                    normalized.push((table_id, crate::txn::Staged::Put(cells)));
9447                }
9448                op => normalized.push((table_id, op)),
9449            }
9450        }
9451        staging = normalized;
9452        let has_changes = !staging.is_empty()
9453            || !external_states.is_empty()
9454            || !prepared_materialized_views.is_empty();
9455        let truncated_tables: HashSet<u64> = staging
9456            .iter()
9457            .filter_map(|(table_id, op)| matches!(op, Staged::Truncate).then_some(*table_id))
9458            .collect();
9459
9460        let write_keys = {
9461            let cat = self.catalog.read();
9462            let mut keys: Vec<WriteKey> = Vec::new();
9463            for (staged_index, (table_id, staged)) in staging.iter().enumerate() {
9464                commit_prepare_checkpoint(control, staged_index)?;
9465                match staged {
9466                    Staged::Put(cells) => {
9467                        if let Some(entry) = cat.tables.iter().find(|t| t.table_id == *table_id) {
9468                            for col in &entry.schema.columns {
9469                                if col.flags.contains(crate::schema::ColumnFlags::PRIMARY_KEY) {
9470                                    if let Some((_, val)) =
9471                                        cells.iter().find(|(id, _)| *id == col.id)
9472                                    {
9473                                        let mut h = DefaultHasher::new();
9474                                        val.encode_key().hash(&mut h);
9475                                        keys.push(WriteKey::Unique {
9476                                            table_id: *table_id,
9477                                            index_id: 0,
9478                                            key_hash: h.finish(),
9479                                        });
9480                                    }
9481                                }
9482                            }
9483                            // Declared non-PK unique constraints register a
9484                            // `WriteKey::Unique` (namespace-separated from the
9485                            // PK's index_id==0 by setting the high bit) so two
9486                            // concurrent transactions inserting the same key
9487                            // cannot both commit. Rows with any NULL constrained
9488                            // column are skipped (SQL semantics).
9489                            for uc in &entry.schema.constraints.uniques {
9490                                if let Some(key_bytes) = crate::constraint::encode_composite_key(
9491                                    &uc.columns,
9492                                    &cells.iter().cloned().collect(),
9493                                ) {
9494                                    let mut h = DefaultHasher::new();
9495                                    key_bytes.hash(&mut h);
9496                                    keys.push(WriteKey::Unique {
9497                                        table_id: *table_id,
9498                                        index_id: uc.id | 0x8000,
9499                                        key_hash: h.finish(),
9500                                    });
9501                                }
9502                            }
9503                        }
9504                    }
9505                    Staged::Delete(rid) => keys.push(WriteKey::Row {
9506                        table_id: *table_id,
9507                        row_id: rid.0,
9508                    }),
9509                    Staged::Truncate => keys.push(WriteKey::Table {
9510                        table_id: *table_id,
9511                    }),
9512                    Staged::Update { .. } => {
9513                        return Err(MongrelError::Other(
9514                            "transaction contains an unnormalized update during preparation".into(),
9515                        ));
9516                    }
9517                }
9518            }
9519            for (external_index, (name, _)) in external_states.iter().enumerate() {
9520                commit_prepare_checkpoint(control, external_index)?;
9521                let mut h = DefaultHasher::new();
9522                name.hash(&mut h);
9523                keys.push(WriteKey::Unique {
9524                    table_id: EXTERNAL_TABLE_ID,
9525                    index_id: 0,
9526                    key_hash: h.finish(),
9527                });
9528            }
9529            keys
9530        };
9531
9532        // Opportunistic pruning.
9533        let min_active = self.active_txns.min_read_epoch();
9534        if min_active < u64::MAX {
9535            self.conflicts.prune_below(Epoch(min_active));
9536        }
9537
9538        // ── 1a. Pre-validate the full write-set OUTSIDE the sequencer (spec
9539        // §8.5, review fix #17). Snapshot the conflict-index version so the
9540        // sequencer only re-checks if new commits arrived in the interim.
9541        if self.conflicts.conflicts(&write_keys, read_epoch) {
9542            return Err(MongrelError::Conflict(
9543                "write-write conflict (pre-validate, first-committer-wins)".into(),
9544            ));
9545        }
9546        let pre_validate_version = self.conflicts.version();
9547
9548        // ── 1b. Spill: if a table's staged puts exceed the threshold, write a
9549        // uniform-epoch pending run (spec §8.5). Rows in the run are NOT
9550        // streamed as Put records; they are linked at publish time.
9551        let mut spilled: Vec<SpilledRun> = Vec::new();
9552        let mut spilled_tables: std::collections::HashSet<u64> = std::collections::HashSet::new();
9553        // Protect this txn's `_txn/<id>/` dir from a concurrent `gc()` for as long
9554        // as the spill runs are live (registered on first spill, dropped at the
9555        // end of this function on commit/abort/error).
9556        let mut spill_guard: Option<crate::retention::SpillGuard> = None;
9557        {
9558            let mut table_bytes: HashMap<u64, u64> = HashMap::new();
9559            let mut put_indexes: HashMap<u64, Vec<usize>> = HashMap::new();
9560            for (staged_index, (table_id, staged)) in staging.iter().enumerate() {
9561                commit_prepare_checkpoint(control, staged_index)?;
9562                if let Staged::Put(cells) = staged {
9563                    let bytes = cells.iter().fold(32_u64, |bytes, (_, value)| {
9564                        bytes.saturating_add(value.estimated_bytes())
9565                    });
9566                    let table_bytes = table_bytes.entry(*table_id).or_default();
9567                    *table_bytes = table_bytes.saturating_add(bytes);
9568                    put_indexes.entry(*table_id).or_default().push(staged_index);
9569                }
9570            }
9571            let tables = self.tables.read();
9572            for (table_index, (&table_id, &bytes)) in table_bytes.iter().enumerate() {
9573                commit_prepare_checkpoint(control, table_index)?;
9574                if bytes
9575                    <= self
9576                        .spill_threshold
9577                        .load(std::sync::atomic::Ordering::Relaxed)
9578                {
9579                    continue;
9580                }
9581                let Some(handle) = tables.get(&table_id) else {
9582                    continue;
9583                };
9584                spill_guard.get_or_insert_with(|| self.active_spills.register(txn_id));
9585                let mut t = handle.lock();
9586                let tdir = t.table_dir().to_path_buf();
9587                let txn_dir = tdir.join("_txn").join(txn_id.to_string());
9588                std::fs::create_dir_all(&txn_dir)?;
9589                let run_id = t.alloc_run_id()? as u128;
9590                let pending_path = txn_dir.join(format!("r-{run_id}.sr"));
9591                let final_path = t.run_path(run_id as u64);
9592
9593                let mut rows: Vec<Row> = Vec::new();
9594                for (put_index, staged_index) in put_indexes[&table_id].iter().enumerate() {
9595                    commit_prepare_checkpoint(control, put_index)?;
9596                    let Staged::Put(cells) = &mut staging[*staged_index].1 else {
9597                        return Err(MongrelError::Other(
9598                            "transaction put index no longer references a put".into(),
9599                        ));
9600                    };
9601                    t.validate_cells_not_null(cells)?;
9602                    let row_id = t.alloc_row_id()?;
9603                    let mut row = Row::new(row_id, Epoch(0));
9604                    row.columns.extend(std::mem::take(cells));
9605                    rows.push(row);
9606                }
9607                let schema = t.schema_ref().clone();
9608                let kek = t.kek_ref().cloned();
9609                let specs = t.indexable_column_specs();
9610                drop(t);
9611
9612                let mut writer = crate::sorted_run::RunWriter::new(&schema, run_id, Epoch(0), 0)
9613                    .uniform_epoch(true);
9614                if let Some(ref kek) = kek {
9615                    writer = writer.with_encryption(kek.as_ref(), specs);
9616                }
9617                commit_prepare_checkpoint(control, 0)?;
9618                let header = writer.write(&pending_path, &rows)?;
9619                commit_prepare_checkpoint(control, 0)?;
9620                let row_count = header.row_count;
9621                let min_rid = rows.first().map(|r| r.row_id.0).unwrap_or(0);
9622                let max_rid = rows.last().map(|r| r.row_id.0).unwrap_or(0);
9623
9624                spilled.push(SpilledRun {
9625                    table_id,
9626                    run_id,
9627                    pending_path,
9628                    final_path,
9629                    rows,
9630                    row_count,
9631                    min_rid,
9632                    max_rid,
9633                    content_hash: header.content_hash,
9634                });
9635                spilled_tables.insert(table_id);
9636            }
9637        }
9638
9639        // Test seam: let a test race `gc()` against this in-flight spill.
9640        if spill_guard.is_some() {
9641            if let Some(hook) = self.spill_hook.lock().as_ref() {
9642                hook();
9643            }
9644        }
9645
9646        // ── 1c. Pre-build non-spilled put rows OUTSIDE the WAL critical section.
9647        // Allocating row ids + building the rows here (lock order: table handle →
9648        // nothing) means the sequencer never locks a table handle while holding
9649        // the shared-WAL mutex. That matters because `Table::commit`/`flush` lock
9650        // the table handle THEN the shared WAL; if the sequencer did the reverse
9651        // (WAL then handle) the two paths would deadlock (review fix: B1).
9652        // Aligned 1:1 with `staging`; `None` for deletes and spilled puts.
9653        // Row ids are allocated here, before the sequencer's delta conflict
9654        // re-check, so a losing txn leaks the ids it reserved — harmless, the
9655        // u64 row-id space is monotonic and gaps are expected (spills do the same).
9656        let mut prebuilt: Vec<Option<Row>> = std::iter::repeat_with(|| None)
9657            .take(staging.len())
9658            .collect();
9659        let mut delete_images: Vec<Option<Row>> = std::iter::repeat_with(|| None)
9660            .take(staging.len())
9661            .collect();
9662        {
9663            let mut indexes_by_table: HashMap<u64, Vec<usize>> = HashMap::new();
9664            for (index, (table_id, staged)) in staging.iter().enumerate() {
9665                commit_prepare_checkpoint(control, index)?;
9666                if matches!(staged, Staged::Delete(_))
9667                    || matches!(staged, Staged::Put(_) if !spilled_tables.contains(table_id))
9668                {
9669                    indexes_by_table.entry(*table_id).or_default().push(index);
9670                }
9671            }
9672            let tables = self.tables.read();
9673            for (table_index, (table_id, indexes)) in indexes_by_table.into_iter().enumerate() {
9674                commit_prepare_checkpoint(control, table_index)?;
9675                let handle = tables.get(&table_id).ok_or_else(|| {
9676                    MongrelError::NotFound(format!("table {table_id} not mounted"))
9677                })?;
9678                #[cfg(test)]
9679                PREBUILD_TABLE_LOCKS.with(|count| count.set(count.get() + 1));
9680                let mut t = handle.lock();
9681                for (prepare_index, index) in indexes.into_iter().enumerate() {
9682                    commit_prepare_checkpoint(control, prepare_index)?;
9683                    match &staging[index].1 {
9684                        Staged::Put(cells) if !spilled_tables.contains(&table_id) => {
9685                            t.validate_cells_not_null(cells)?;
9686                            let mut row = Row::new(t.alloc_row_id()?, Epoch(0));
9687                            for (column, value) in cells {
9688                                row.columns.insert(*column, value.clone());
9689                            }
9690                            prebuilt[index] = Some(row);
9691                        }
9692                        Staged::Delete(row_id) => {
9693                            delete_images[index] = t.get(*row_id, Snapshot::at(read_epoch));
9694                        }
9695                        Staged::Put(_) | Staged::Truncate => {}
9696                        Staged::Update { .. } => {
9697                            return Err(MongrelError::Other(
9698                                "transaction contains an unnormalized update during row preparation"
9699                                    .into(),
9700                            ));
9701                        }
9702                    }
9703                }
9704            }
9705        }
9706
9707        // Finish every fallible index read before the commit marker can become
9708        // durable. Post-durable row/run metadata application is then entirely
9709        // in-memory and cannot stop halfway through a multi-table publish.
9710        let prepared_table_handles = {
9711            let table_ids: HashSet<u64> = staging.iter().map(|(table_id, _)| *table_id).collect();
9712            let put_table_ids: HashSet<u64> = staging
9713                .iter()
9714                .filter_map(|(table_id, staged)| {
9715                    matches!(staged, Staged::Put(_)).then_some(*table_id)
9716                })
9717                .collect();
9718            let tables = self.tables.read();
9719            let mut handles = HashMap::with_capacity(table_ids.len());
9720            for (table_index, table_id) in table_ids.into_iter().enumerate() {
9721                commit_prepare_checkpoint(control, table_index)?;
9722                let handle = tables.get(&table_id).ok_or_else(|| {
9723                    MongrelError::NotFound(format!("table {table_id} not mounted"))
9724                })?;
9725                if put_table_ids.contains(&table_id) {
9726                    match control {
9727                        Some(control) => {
9728                            handle.lock().prepare_durable_publish_controlled(control)?
9729                        }
9730                        None => handle.lock().prepare_durable_publish()?,
9731                    }
9732                }
9733                handles.insert(table_id, handle.clone());
9734            }
9735            handles
9736        };
9737
9738        // Link large-transaction spill files before WAL durability. The guard
9739        // restores their pending names on every error before WAL append begins;
9740        // publication only attaches already-present files in memory.
9741        let mut prepared_run_links = PreparedRunLinks::prepare(&spilled)?;
9742
9743        let mut spilled_row_ids: HashMap<u64, VecDeque<RowId>> = spilled
9744            .iter()
9745            .map(|run| {
9746                (
9747                    run.table_id,
9748                    run.rows.iter().map(|row| row.row_id).collect(),
9749                )
9750            })
9751            .collect();
9752        let committed_row_ids = staging
9753            .iter()
9754            .enumerate()
9755            .filter_map(|(index, (table_id, staged))| {
9756                if !matches!(staged, Staged::Put(_)) {
9757                    return None;
9758                }
9759                prebuilt[index].as_ref().map(|row| row.row_id).or_else(|| {
9760                    spilled_row_ids
9761                        .get_mut(table_id)
9762                        .and_then(VecDeque::pop_front)
9763                })
9764            })
9765            .collect();
9766
9767        let mut prepared_external = Vec::with_capacity(external_states.len());
9768        for (external_index, (name, state)) in external_states.iter().enumerate() {
9769            commit_prepare_checkpoint(control, external_index)?;
9770            let pending = prepare_external_state_file(&self.root, name, state, txn_id)?;
9771            prepared_external.push((name.clone(), state.clone(), pending));
9772        }
9773
9774        // ── 2. Sequencer: validate-first → assign → append → sync → record ──
9775        let added_runs: Vec<crate::wal::AddedRun> = spilled
9776            .iter()
9777            .map(|s| crate::wal::AddedRun {
9778                table_id: s.table_id,
9779                run_id: s.run_id,
9780                row_count: s.row_count,
9781                level: 0,
9782                min_row_id: s.min_rid,
9783                max_row_id: s.max_rid,
9784                content_hash: s.content_hash,
9785            })
9786            .collect();
9787        if let Some(hook) = self.catalog_commit_hook.lock().as_ref() {
9788            hook();
9789        }
9790        // Lock order: security gate -> commit lock -> shared WAL -> table locks.
9791        // Security mutations cannot overtake an authorized commit before its
9792        // commit marker is durable.
9793        let security_guard = self.security_coordinator.gate.read();
9794        if self.security_coordinator.version.load(Ordering::Acquire) != observed_security_version {
9795            return Err(MongrelError::Conflict(
9796                "security policy changed during write".into(),
9797            ));
9798        }
9799        if spill_guard.is_some() {
9800            if let Some(hook) = self.security_commit_hook.lock().as_ref() {
9801                hook();
9802            }
9803        }
9804        let commit_guard = self.commit_lock.lock();
9805        let catalog_generation_result = (|| {
9806            {
9807                let catalog = self.catalog.read();
9808                for table_id in prepared_table_handles.keys() {
9809                    let is_current = catalog.tables.iter().any(|entry| {
9810                        entry.table_id == *table_id
9811                            && matches!(entry.state, TableState::Live | TableState::Building { .. })
9812                    });
9813                    if !is_current {
9814                        return Err(MongrelError::Conflict(format!(
9815                            "table {table_id} changed during transaction preparation"
9816                        )));
9817                    }
9818                }
9819                for (name, created_epoch) in &expected_external_generations {
9820                    let current = catalog
9821                        .external_tables
9822                        .iter()
9823                        .find(|entry| entry.name == *name)
9824                        .map(|entry| entry.created_epoch);
9825                    if current != Some(*created_epoch) {
9826                        return Err(MongrelError::Conflict(format!(
9827                            "external table {name:?} changed during transaction preparation"
9828                        )));
9829                    }
9830                }
9831                for (table_id, definition) in &prepared_materialized_views {
9832                    let current = catalog.live(&definition.name).map(|entry| entry.table_id);
9833                    if current != Some(*table_id) {
9834                        return Err(MongrelError::Conflict(format!(
9835                            "materialized view {:?} changed during transaction preparation",
9836                            definition.name
9837                        )));
9838                    }
9839                }
9840                if trigger_catalog_binding(&catalog) != trigger_binding {
9841                    return Err(MongrelError::Conflict(
9842                        "trigger or referenced table generation changed during transaction preparation"
9843                            .into(),
9844                    ));
9845                }
9846            }
9847            let tables = self.tables.read();
9848            for (table_id, prepared) in &prepared_table_handles {
9849                if !tables
9850                    .get(table_id)
9851                    .is_some_and(|current| current.ptr_eq(prepared))
9852                {
9853                    return Err(MongrelError::Conflict(format!(
9854                        "table {table_id} mount changed during transaction preparation"
9855                    )));
9856                }
9857            }
9858            Ok(())
9859        })();
9860        if let Err(error) = catalog_generation_result {
9861            drop(commit_guard);
9862            for (_, _, pending) in &prepared_external {
9863                let _ = std::fs::remove_file(pending);
9864            }
9865            return Err(error);
9866        }
9867        // The commit lock keeps the next epoch stable while logical spill
9868        // records are serialized. Build them before taking the shared WAL
9869        // lock, and cap their aggregate memory/WAL footprint.
9870        let new_epoch = self.epoch.assigned().next();
9871        let mut spilled_wal_bytes = 0;
9872        let mut spilled_wal_records = Vec::<(u64, Op)>::new();
9873        let spill_prepare = (|| {
9874            for run in &mut spilled {
9875                for row in &mut run.rows {
9876                    row.committed_epoch = new_epoch;
9877                }
9878                for rows in encode_spilled_row_chunks(
9879                    &run.rows,
9880                    &mut spilled_wal_bytes,
9881                    SPILLED_WAL_TOTAL_MAX_BYTES,
9882                    control,
9883                )? {
9884                    spilled_wal_records.push((
9885                        run.table_id,
9886                        Op::SpilledRows {
9887                            table_id: run.table_id,
9888                            rows,
9889                        },
9890                    ));
9891                }
9892            }
9893            Result::<()>::Ok(())
9894        })();
9895        if let Err(error) = spill_prepare {
9896            for (_, _, pending) in &prepared_external {
9897                let _ = std::fs::remove_file(pending);
9898            }
9899            return Err(error);
9900        }
9901        let (new_epoch, mut _epoch_guard, applies, committed_materialized_views, commit_seq) = {
9902            let mut wal = self.shared_wal.lock();
9903
9904            // Re-check only if the conflict index advanced since pre-validation
9905            // (bounded delta — spec §8.5, review fix #17). If the version is
9906            // unchanged, the pre-check result is still valid and the sequencer
9907            // does O(1) work regardless of write-set size.
9908            if self.conflicts.version() != pre_validate_version
9909                && self.conflicts.conflicts(&write_keys, read_epoch)
9910            {
9911                // Abort: this txn assigned no epoch yet. The prepared-run guard
9912                // restores final run names to their pending paths on return.
9913                drop(wal);
9914                for (_, _, pending) in &prepared_external {
9915                    let _ = std::fs::remove_file(pending);
9916                }
9917                return Err(MongrelError::Conflict(
9918                    "write-write conflict (sequencer delta re-check)".into(),
9919                ));
9920            }
9921
9922            if let Some(control) = control {
9923                if let Err(error) = control.checkpoint() {
9924                    drop(wal);
9925                    for (_, _, pending) in &prepared_external {
9926                        let _ = std::fs::remove_file(pending);
9927                    }
9928                    return Err(error);
9929                }
9930            }
9931            let mut applies = Vec::<TableApplyBatch>::new();
9932            let mut apply_indexes = HashMap::<u64, usize>::new();
9933            let mut committed_materialized_views = Vec::new();
9934            let mut wal_records = spilled_wal_records;
9935
9936            let mut index = 0;
9937            while index < staging.len() {
9938                let table_id = staging[index].0;
9939                let handle = prepared_table_handles
9940                    .get(&table_id)
9941                    .cloned()
9942                    .ok_or_else(|| {
9943                        MongrelError::NotFound(format!("table {table_id} not prepared"))
9944                    })?;
9945                let batch_index = *apply_indexes.entry(table_id).or_insert_with(|| {
9946                    let index = applies.len();
9947                    applies.push(TableApplyBatch {
9948                        table_id,
9949                        handle,
9950                        ops: Vec::new(),
9951                    });
9952                    index
9953                });
9954
9955                // Skip puts for tables that were spilled — their data is in a
9956                // pending run, not in streamed Put records.
9957                if spilled_tables.contains(&table_id) && matches!(&staging[index].1, Staged::Put(_))
9958                {
9959                    index += 1;
9960                    continue;
9961                }
9962
9963                match &staging[index].1 {
9964                    Staged::Put(_) => {
9965                        let mut rows = Vec::new();
9966                        while index < staging.len()
9967                            && staging[index].0 == table_id
9968                            && matches!(&staging[index].1, Staged::Put(_))
9969                        {
9970                            let mut row = prebuilt[index].take().ok_or_else(|| {
9971                                MongrelError::Other(
9972                                    "transaction prepare lost a prebuilt put row".into(),
9973                                )
9974                            })?;
9975                            row.committed_epoch = new_epoch;
9976                            rows.push(row);
9977                            index += 1;
9978                        }
9979                        let payload = bincode::serialize(&rows)
9980                            .map_err(|e| MongrelError::Other(format!("row serialize: {e}")))?;
9981                        wal_records.push((
9982                            table_id,
9983                            Op::Put {
9984                                table_id,
9985                                rows: payload,
9986                            },
9987                        ));
9988                        applies[batch_index].ops.push(StagedOp::Put(rows));
9989                    }
9990                    Staged::Delete(_) => {
9991                        let mut row_ids = Vec::new();
9992                        while index < staging.len()
9993                            && staging[index].0 == table_id
9994                            && matches!(&staging[index].1, Staged::Delete(_))
9995                        {
9996                            let Staged::Delete(row_id) = &staging[index].1 else {
9997                                return Err(MongrelError::Other(
9998                                    "transaction delete batch changed during WAL preparation"
9999                                        .into(),
10000                                ));
10001                            };
10002                            if let Some(before) = &delete_images[index] {
10003                                wal_records.push((
10004                                    table_id,
10005                                    Op::BeforeImage {
10006                                        table_id,
10007                                        row_id: *row_id,
10008                                        row: bincode::serialize(before).map_err(|error| {
10009                                            MongrelError::Other(format!(
10010                                                "before-image serialize: {error}"
10011                                            ))
10012                                        })?,
10013                                    },
10014                                ));
10015                            }
10016                            row_ids.push(*row_id);
10017                            index += 1;
10018                        }
10019                        wal_records.push((
10020                            table_id,
10021                            Op::Delete {
10022                                table_id,
10023                                row_ids: row_ids.clone(),
10024                            },
10025                        ));
10026                        applies[batch_index].ops.push(StagedOp::Delete(row_ids));
10027                    }
10028                    Staged::Truncate => {
10029                        wal_records.push((table_id, Op::TruncateTable { table_id }));
10030                        applies[batch_index].ops.push(StagedOp::Truncate);
10031                        index += 1;
10032                    }
10033                    Staged::Update { .. } => {
10034                        return Err(MongrelError::Other(
10035                            "transaction contains an unnormalized update at the sequencer".into(),
10036                        ));
10037                    }
10038                }
10039            }
10040
10041            for (name, state, _) in &prepared_external {
10042                wal_records.push((
10043                    EXTERNAL_TABLE_ID,
10044                    Op::ExternalTableState {
10045                        name: name.clone(),
10046                        state: state.clone(),
10047                    },
10048                ));
10049            }
10050
10051            for (table_id, definition) in &prepared_materialized_views {
10052                let mut definition = definition.clone();
10053                definition.last_refresh_epoch = new_epoch.0;
10054                wal_records.push((
10055                    *table_id,
10056                    Op::Ddl(crate::wal::DdlOp::SetMaterializedView {
10057                        name: definition.name.clone(),
10058                        definition_json: crate::wal::DdlOp::encode_materialized_view(&definition)?,
10059                    }),
10060                ));
10061                committed_materialized_views.push(definition);
10062            }
10063            if !committed_materialized_views.is_empty() {
10064                let mut next_catalog = self.catalog.read().clone();
10065                for definition in &committed_materialized_views {
10066                    if let Some(existing) = next_catalog
10067                        .materialized_views
10068                        .iter_mut()
10069                        .find(|existing| existing.name == definition.name)
10070                    {
10071                        *existing = definition.clone();
10072                    } else {
10073                        next_catalog.materialized_views.push(definition.clone());
10074                    }
10075                }
10076                next_catalog.db_epoch = next_catalog.db_epoch.max(new_epoch.0);
10077                wal_records.push((
10078                    WAL_TABLE_ID,
10079                    Op::Ddl(crate::wal::DdlOp::CatalogSnapshot {
10080                        catalog_json: crate::wal::DdlOp::encode_catalog(&next_catalog)?,
10081                    }),
10082                ));
10083            }
10084
10085            if let Some(control) = control {
10086                if let Err(error) = control.checkpoint() {
10087                    drop(wal);
10088                    for (_, _, pending) in &prepared_external {
10089                        let _ = std::fs::remove_file(pending);
10090                    }
10091                    return Err(error);
10092                }
10093            }
10094            if let Some(before_commit) = before_commit.as_mut() {
10095                if let Err(error) = before_commit() {
10096                    drop(wal);
10097                    for (_, _, pending) in &prepared_external {
10098                        let _ = std::fs::remove_file(pending);
10099                    }
10100                    return Err(error);
10101                }
10102            }
10103
10104            let assigned_epoch = self.epoch.bump_assigned();
10105            let _epoch_guard = EpochGuard::new(self.epoch.as_ref(), assigned_epoch);
10106            if assigned_epoch != new_epoch {
10107                for (_, _, pending) in &prepared_external {
10108                    let _ = std::fs::remove_file(pending);
10109                }
10110                return Err(MongrelError::Conflict(
10111                    "commit epoch changed while sequencer lock was held".into(),
10112                ));
10113            }
10114
10115            // From this point the outcome can become ambiguous. Keep prepared
10116            // spill files at the final names referenced by a possibly durable
10117            // commit marker; orphan cleanup is safe when the append did fail.
10118            prepared_run_links.disarm();
10119
10120            let append: Result<u64> = (|| {
10121                for (table_id, op) in wal_records {
10122                    wal.append(txn_id, table_id, op)?;
10123                }
10124                wal.append_commit(txn_id, new_epoch, &added_runs)
10125            })();
10126            let commit_seq =
10127                append.map_err(|error| self.commit_outcome_unknown(new_epoch, error))?;
10128
10129            // Record the conflict + assign the epoch under the WAL lock so commit
10130            // order == WAL append order, but DO NOT fsync here (P3.2): the fsync
10131            // moves out of this critical section to the group-commit coordinator
10132            // so concurrent committers share a single leader fsync.
10133            self.conflicts.record(&write_keys, new_epoch);
10134            (
10135                new_epoch,
10136                _epoch_guard,
10137                applies,
10138                committed_materialized_views,
10139                commit_seq,
10140            )
10141        };
10142        drop(commit_guard);
10143
10144        // ── 2b. Durability: one leader fsync serves this whole batch (P3.2). ──
10145        self.await_durable_commit(commit_seq, new_epoch)?;
10146        drop(security_guard);
10147
10148        // ── 3. Publish: apply non-spilled ops + link spilled runs ──
10149        let publish_result: Result<()> = {
10150            let mut first_error = None;
10151            let mut spilled_by_table: HashMap<u64, Vec<&SpilledRun>> = HashMap::new();
10152            for run in &spilled {
10153                spilled_by_table.entry(run.table_id).or_default().push(run);
10154            }
10155            let mut modified_tables = Vec::with_capacity(applies.len());
10156            // Apply every table completely before any fallible manifest write.
10157            // The visible epoch remains unchanged until all tables are coherent.
10158            for batch in applies {
10159                #[cfg(test)]
10160                PUBLISH_TABLE_LOCKS.with(|count| count.set(count.get() + 1));
10161                let mut t = batch.handle.lock();
10162                for op in batch.ops {
10163                    match op {
10164                        StagedOp::Put(rows) => t.apply_put_rows_prepared(rows),
10165                        StagedOp::Delete(row_ids) => {
10166                            for row_id in row_ids {
10167                                t.apply_delete(row_id, new_epoch);
10168                            }
10169                        }
10170                        StagedOp::Truncate => t.apply_truncate(new_epoch),
10171                    }
10172                }
10173                if let Some(runs) = spilled_by_table.remove(&batch.table_id) {
10174                    for run in runs {
10175                        t.link_run(crate::manifest::RunRef {
10176                            run_id: run.run_id,
10177                            level: 0,
10178                            epoch_created: new_epoch.0,
10179                            row_count: run.row_count,
10180                        });
10181                        t.apply_run_metadata_prepared(&run.rows)?;
10182                        if truncated_tables.contains(&batch.table_id) {
10183                            // TRUNCATE + spilled puts fully describe this table at
10184                            // the commit epoch. Endorse the epoch so clean-reopen
10185                            // recovery does not replay the truncate over the
10186                            // already-linked replacement run.
10187                            t.set_flushed_epoch(new_epoch);
10188                        }
10189                    }
10190                }
10191                t.invalidate_pending_cache();
10192                drop(t);
10193                modified_tables.push(batch.handle);
10194            }
10195
10196            // Checkpoint only after every live table carries the durable state.
10197            // Continue after one checkpoint failure so runtime publication stays
10198            // all-or-nothing; WAL recovery repairs failed files on reopen.
10199            for handle in modified_tables {
10200                #[cfg(test)]
10201                COMMIT_MANIFEST_WRITES.with(|count| count.set(count.get() + 1));
10202                if let Err(error) = handle.lock().persist_manifest(new_epoch) {
10203                    first_error.get_or_insert(error);
10204                }
10205            }
10206            for (name, _, pending) in &prepared_external {
10207                if let Err(error) = publish_external_state_file(&self.root, name, pending) {
10208                    first_error.get_or_insert(error);
10209                }
10210            }
10211            if !committed_materialized_views.is_empty() {
10212                let mut next_catalog = self.catalog.read().clone();
10213                for definition in committed_materialized_views {
10214                    if let Some(existing) = next_catalog
10215                        .materialized_views
10216                        .iter_mut()
10217                        .find(|existing| existing.name == definition.name)
10218                    {
10219                        *existing = definition;
10220                    } else {
10221                        next_catalog.materialized_views.push(definition);
10222                    }
10223                }
10224                next_catalog.db_epoch = next_catalog.db_epoch.max(new_epoch.0);
10225                if let Err(error) = self.checkpoint_catalog_after_durable(next_catalog) {
10226                    first_error.get_or_insert(error);
10227                }
10228            }
10229            match first_error {
10230                Some(error) => Err(error),
10231                None => Ok(()),
10232            }
10233        };
10234
10235        if has_changes {
10236            let _ = self.change_wake.send(());
10237        }
10238        self.finish_durable_publish(new_epoch, &mut _epoch_guard, publish_result)?;
10239        Ok((new_epoch, committed_row_ids))
10240    }
10241
10242    /// Register a read snapshot at the current visible epoch and return it with
10243    /// a guard that retains it for GC until dropped.
10244    pub fn snapshot(&self) -> (Snapshot, SnapshotGuard<'_>) {
10245        let e = self.epoch.visible();
10246        let g = self.snapshots.register(e);
10247        (Snapshot::at(e), g)
10248    }
10249
10250    /// Owned (clonable-handle) variant of [`Self::snapshot`] for cross-thread
10251    /// retention.
10252    pub fn snapshot_owned(&self) -> (Snapshot, OwnedSnapshotGuard) {
10253        let e = self.epoch.visible();
10254        let g = self.snapshots.register_owned(e);
10255        (Snapshot::at(e), g)
10256    }
10257
10258    /// Configure a rolling history window measured in prior commit epochs.
10259    /// The first enable starts at the current epoch because earlier versions
10260    /// may already have been compacted. Increasing the window likewise cannot
10261    /// recreate history that fell outside the previous guarantee.
10262    pub fn set_history_retention_epochs(&self, epochs: u64) -> Result<()> {
10263        let _guard = self.ddl_lock.lock();
10264        let current = self.epoch.visible();
10265        let (old_epochs, old_start) = self.snapshots.history_config();
10266        let earliest_already_guaranteed = if old_epochs == 0 {
10267            current
10268        } else {
10269            Epoch(old_start.0.max(current.0.saturating_sub(old_epochs)))
10270        };
10271        let start = if epochs == 0 {
10272            current
10273        } else {
10274            earliest_already_guaranteed
10275        };
10276        let published = std::cell::Cell::new(false);
10277        let result = write_history_retention(&self.root, epochs, start, || {
10278            self.snapshots.configure_history(epochs, start);
10279            published.set(true);
10280        });
10281        match result {
10282            Err(error) if published.get() => Err(MongrelError::CommitOutcomeUnknown {
10283                epoch: current.0,
10284                message: format!("history-retention publication was not durable: {error}"),
10285            }),
10286            result => result,
10287        }
10288    }
10289
10290    pub fn history_retention_epochs(&self) -> u64 {
10291        self.snapshots.history_config().0
10292    }
10293
10294    pub fn earliest_retained_epoch(&self) -> Epoch {
10295        let current = self.epoch.visible();
10296        self.snapshots.history_floor(current).unwrap_or(current)
10297    }
10298
10299    /// Pin a guaranteed historical epoch for the lifetime of the returned
10300    /// guard. Rejects future epochs and epochs outside the configured window.
10301    pub fn snapshot_at_owned(&self, epoch: Epoch) -> Result<(Snapshot, OwnedSnapshotGuard)> {
10302        let current = self.epoch.visible();
10303        if epoch > current {
10304            return Err(MongrelError::InvalidArgument(format!(
10305                "epoch {} is in the future; current epoch is {}",
10306                epoch.0, current.0
10307            )));
10308        }
10309        let earliest = self.earliest_retained_epoch();
10310        if epoch < earliest {
10311            return Err(MongrelError::InvalidArgument(format!(
10312                "epoch {} is no longer retained; earliest available epoch is {}",
10313                epoch.0, earliest.0
10314            )));
10315        }
10316        let guard = self.snapshots.register_owned(epoch);
10317        Ok((Snapshot::at(epoch), guard))
10318    }
10319
10320    /// Names of all live tables.
10321    pub fn table_names(&self) -> Vec<String> {
10322        self.catalog
10323            .read()
10324            .tables
10325            .iter()
10326            .filter(|t| matches!(t.state, TableState::Live))
10327            .map(|t| t.name.clone())
10328            .collect()
10329    }
10330
10331    /// Best-effort flush-on-close (§4.4): force-flush every mounted table
10332    /// that has pending writes to a `.sr` sorted run, so WAL segments can be
10333    /// reaped on the next open. Call this as the last action before a
10334    /// short-lived process (CLI, one-shot script) exits. The daemon does not
10335    /// need this — its background auto-compactor handles run management.
10336    pub fn close(&self) -> Result<()> {
10337        for name in self.table_names() {
10338            if let Ok(handle) = self.table(&name) {
10339                if let Err(e) = handle.lock().close() {
10340                    eprintln!("[close] flush failed for {name}: {e}");
10341                }
10342            }
10343        }
10344        Ok(())
10345    }
10346
10347    /// Compact every mounted table: merge all sorted runs into one clean run
10348    /// so query cost stays flat (single-run fast path) instead of growing
10349    /// with run count. Tables with < 2 runs are skipped unless TTL has expired
10350    /// rows to reclaim. Each table
10351    /// is locked individually for its own compaction; snapshot retention is
10352    /// honored by `Table::compact`. Returns `(tables_compacted, tables_skipped)`.
10353    pub fn compact(&self) -> Result<(usize, usize)> {
10354        self.require(&crate::auth::Permission::Ddl)?;
10355        let mut compacted = 0;
10356        let mut skipped = 0;
10357        for name in self.table_names() {
10358            let Ok(handle) = self.table(&name) else {
10359                continue;
10360            };
10361            {
10362                let mut t = handle.lock();
10363                let before = t.run_count();
10364                if before < 2 && !t.should_compact() {
10365                    skipped += 1;
10366                    continue;
10367                }
10368                match t.compact() {
10369                    Ok(()) => {
10370                        let after = t.run_count();
10371                        compacted += 1;
10372                        eprintln!("[compact] {name}: {before} -> {after} runs");
10373                    }
10374                    Err(e) => {
10375                        eprintln!("[compact] {name}: compaction failed: {e}");
10376                        skipped += 1;
10377                    }
10378                }
10379            }
10380        }
10381        Ok((compacted, skipped))
10382    }
10383
10384    /// Compact a single table by name. Returns `Ok(true)` if it was
10385    /// compacted, `Ok(false)` if skipped (< 2 runs).
10386    pub fn compact_table(&self, name: &str) -> Result<bool> {
10387        self.require(&crate::auth::Permission::Ddl)?;
10388        let handle = self.table(name)?;
10389        let mut t = handle.lock();
10390        let before = t.run_count();
10391        if before < 2 {
10392            return Ok(false);
10393        }
10394        t.compact()?;
10395        Ok(t.run_count() < before)
10396    }
10397
10398    /// Look up a live table by name.
10399    pub fn table(&self, name: &str) -> Result<TableHandle> {
10400        let cat = self.catalog.read();
10401        let entry = cat
10402            .live(name)
10403            .ok_or_else(|| MongrelError::NotFound(format!("table {name:?} not found")))?;
10404        let id = entry.table_id;
10405        drop(cat);
10406        self.tables
10407            .read()
10408            .get(&id)
10409            .cloned()
10410            .ok_or_else(|| MongrelError::NotFound(format!("table {name:?} not mounted")))
10411    }
10412
10413    /// Whether any mounted table has wall-clock TTL retention. SQL sessions
10414    /// use this to avoid epoch-keyed result caches that can outlive a cutoff.
10415    pub fn has_ttl_tables(&self) -> bool {
10416        self.tables
10417            .read()
10418            .values()
10419            .any(|table| table.lock().ttl().is_some())
10420    }
10421
10422    /// Resolve a live table id → mounted handle (used by the constraint
10423    /// validation pass and other id-qualified internal paths).
10424    pub(crate) fn table_by_id(&self, id: u64) -> Result<TableHandle> {
10425        self.tables
10426            .read()
10427            .get(&id)
10428            .cloned()
10429            .ok_or_else(|| MongrelError::NotFound(format!("table id {id} not mounted")))
10430    }
10431
10432    /// Create a new table. The DDL is first logged to the shared WAL
10433    /// (`Op::Ddl(CreateTable)` + `TxnCommit`) and group-synced so it is durable
10434    /// BEFORE the in-memory catalog and table map are mutated; the catalog
10435    /// checkpoint is rewritten afterwards (spec §15, review fix #16). A reopen
10436    /// that sees a stale catalog still recovers the table by replaying the Ddl.
10437    pub fn create_table(&self, name: &str, schema: Schema) -> Result<u64> {
10438        if name.starts_with(CTAS_BUILD_TABLE_PREFIX) {
10439            return Err(MongrelError::InvalidArgument(format!(
10440                "table names beginning with {CTAS_BUILD_TABLE_PREFIX:?} are reserved"
10441            )));
10442        }
10443        self.create_table_with_state(name, schema, TableState::Live)
10444    }
10445
10446    /// Create a durable but non-queryable CTAS build table.
10447    #[doc(hidden)]
10448    pub fn create_building_table(
10449        &self,
10450        build_name: &str,
10451        intended_name: &str,
10452        query_id: &str,
10453        schema: Schema,
10454    ) -> Result<u64> {
10455        if !build_name.starts_with(CTAS_BUILD_TABLE_PREFIX)
10456            || intended_name.is_empty()
10457            || intended_name.starts_with(CTAS_BUILD_TABLE_PREFIX)
10458            || query_id.is_empty()
10459        {
10460            return Err(MongrelError::InvalidArgument(
10461                "invalid CTAS building-table identity".into(),
10462            ));
10463        }
10464        self.create_table_with_state(
10465            build_name,
10466            schema,
10467            TableState::Building {
10468                intended_name: intended_name.to_string(),
10469                query_id: query_id.to_string(),
10470                created_at_unix_nanos: current_unix_nanos(),
10471                replaces_table_id: None,
10472            },
10473        )
10474    }
10475
10476    /// Create a hidden schema-rebuild table while the intended target remains
10477    /// live. Publication later validates that the same target is still live.
10478    #[doc(hidden)]
10479    pub fn create_rebuilding_table(
10480        &self,
10481        build_name: &str,
10482        intended_name: &str,
10483        query_id: &str,
10484        schema: Schema,
10485    ) -> Result<u64> {
10486        if !build_name.starts_with(CTAS_BUILD_TABLE_PREFIX)
10487            || intended_name.is_empty()
10488            || intended_name.starts_with(CTAS_BUILD_TABLE_PREFIX)
10489            || query_id.is_empty()
10490        {
10491            return Err(MongrelError::InvalidArgument(
10492                "invalid rebuilding-table identity".into(),
10493            ));
10494        }
10495        let replaces_table_id = self
10496            .catalog
10497            .read()
10498            .live(intended_name)
10499            .ok_or_else(|| MongrelError::NotFound(format!("table {intended_name:?} not found")))?
10500            .table_id;
10501        self.create_table_with_state(
10502            build_name,
10503            schema,
10504            TableState::Building {
10505                intended_name: intended_name.to_string(),
10506                query_id: query_id.to_string(),
10507                created_at_unix_nanos: current_unix_nanos(),
10508                replaces_table_id: Some(replaces_table_id),
10509            },
10510        )
10511    }
10512
10513    fn create_table_with_state(
10514        &self,
10515        name: &str,
10516        schema: Schema,
10517        state: TableState,
10518    ) -> Result<u64> {
10519        use crate::wal::DdlOp;
10520        use std::sync::atomic::Ordering;
10521
10522        self.require(&crate::auth::Permission::Ddl)?;
10523        if self.poisoned.load(Ordering::Relaxed) {
10524            return Err(MongrelError::Other(
10525                "database poisoned by fsync error".into(),
10526            ));
10527        }
10528
10529        let _g = self.ddl_lock.lock();
10530        let _security_write = self.security_write()?;
10531        self.require(&crate::auth::Permission::Ddl)?;
10532        {
10533            let cat = self.catalog.read();
10534            match &state {
10535                TableState::Live => {
10536                    if cat.live(name).is_some() || cat.building_for(name).is_some() {
10537                        return Err(MongrelError::InvalidArgument(format!(
10538                            "table {name:?} already exists or is being built"
10539                        )));
10540                    }
10541                }
10542                TableState::Building {
10543                    intended_name,
10544                    replaces_table_id,
10545                    ..
10546                } => {
10547                    let target_matches = match replaces_table_id {
10548                        Some(table_id) => cat
10549                            .live(intended_name)
10550                            .is_some_and(|entry| entry.table_id == *table_id),
10551                        None => cat.live(intended_name).is_none(),
10552                    };
10553                    if !target_matches || cat.building_for(intended_name).is_some() {
10554                        return Err(MongrelError::InvalidArgument(format!(
10555                            "table {intended_name:?} changed or is already being built"
10556                        )));
10557                    }
10558                    if cat.building(name).is_some() {
10559                        return Err(MongrelError::InvalidArgument(format!(
10560                            "building table {name:?} already exists"
10561                        )));
10562                    }
10563                }
10564                TableState::Dropped { .. } => {
10565                    return Err(MongrelError::InvalidArgument(
10566                        "cannot create a dropped table".into(),
10567                    ));
10568                }
10569            }
10570        }
10571
10572        // Allocate id + epoch + txn id under the commit lock so the DDL commit
10573        // is serialized with data commits (in-order publish).
10574        let commit_lock = Arc::clone(&self.commit_lock);
10575        let _c = commit_lock.lock();
10576        let table_id = {
10577            let mut cat = self.catalog.write();
10578            let id = cat.next_table_id;
10579            cat.next_table_id = id
10580                .checked_add(1)
10581                .ok_or_else(|| MongrelError::InvalidArgument("table id space exhausted".into()))?;
10582            Result::<u64>::Ok(id)
10583        }?;
10584        let epoch = self.epoch.bump_assigned();
10585        let mut _epoch_guard = EpochGuard::new(self.epoch.as_ref(), epoch);
10586        let txn_id = self.alloc_txn_id()?;
10587
10588        // Stamp the schema_id with the unique table_id so every table in the
10589        // database has a distinct schema_id (caller-provided values are
10590        // ignored to prevent collisions).
10591        let mut schema = schema;
10592        schema.schema_id = table_id;
10593        // Defense in depth: reject an invalid schema BEFORE any durable
10594        // side-effect. `Table::create_in` re-validates, but by then the DDL has
10595        // already been appended to the shared WAL; a failing create_in would
10596        // leave a dangling entry that `recover_ddl_from_wal` replays without
10597        // re-validating, corrupting the catalog on reopen. Validating here
10598        // keeps the WAL free of schemas that can never be opened.
10599        schema.validate_auto_increment()?;
10600        schema.validate_defaults()?;
10601        schema.validate_ai()?;
10602        for index in &schema.indexes {
10603            index.validate_options()?;
10604        }
10605        for constraint in &schema.constraints.checks {
10606            constraint.expr.validate()?;
10607        }
10608
10609        // Build the complete mounted table before its DDL can become durable.
10610        // Any failure removes the unpublished directory and abandons the epoch.
10611        let table_relative = Path::new(TABLES_DIR).join(table_id.to_string());
10612        let canonical_tdir = self.root.join(&table_relative);
10613        let table_root = Arc::new(
10614            self.durable_root
10615                .create_directory_all_pinned(&table_relative)?,
10616        );
10617        let tdir = table_root.io_path()?;
10618        let mut pending_table_dir = PendingTableDir::new(canonical_tdir);
10619        let ctx = SharedCtx {
10620            root_guard: Some(table_root),
10621            epoch: Arc::clone(&self.epoch),
10622            page_cache: Arc::clone(&self.page_cache),
10623            decoded_cache: Arc::clone(&self.decoded_cache),
10624            snapshots: Arc::clone(&self.snapshots),
10625            kek: self.kek.clone(),
10626            commit_lock: Arc::clone(&self.commit_lock),
10627            shared: Some(crate::engine::SharedWalCtx {
10628                wal: Arc::clone(&self.shared_wal),
10629                group: Arc::clone(&self.group),
10630                poisoned: Arc::clone(&self.poisoned),
10631                txn_ids: Arc::clone(&self.next_txn_id),
10632                change_wake: self.change_wake.clone(),
10633            }),
10634            table_name: Some(name.to_string()),
10635            auth: self.table_auth_checker(),
10636            read_only: self.read_only,
10637        };
10638        let table = Table::create_in(&tdir, schema.clone(), table_id, ctx)?;
10639
10640        // 1. Log the DDL + commit marker to the shared WAL, then make it durable
10641        //    via the group-commit coordinator (no fsync under the WAL lock — P3.2).
10642        let schema_json = DdlOp::encode_schema(&schema)?;
10643        let ddl = match &state {
10644            TableState::Live => DdlOp::CreateTable {
10645                table_id,
10646                name: name.to_string(),
10647                schema_json,
10648            },
10649            TableState::Building {
10650                intended_name,
10651                query_id,
10652                created_at_unix_nanos,
10653                replaces_table_id,
10654            } => match replaces_table_id {
10655                Some(replaces_table_id) => DdlOp::CreateRebuildingTable {
10656                    table_id,
10657                    build_name: name.to_string(),
10658                    intended_name: intended_name.clone(),
10659                    query_id: query_id.clone(),
10660                    created_at_unix_nanos: *created_at_unix_nanos,
10661                    replaces_table_id: *replaces_table_id,
10662                    schema_json,
10663                },
10664                None => DdlOp::CreateBuildingTable {
10665                    table_id,
10666                    build_name: name.to_string(),
10667                    intended_name: intended_name.clone(),
10668                    query_id: query_id.clone(),
10669                    created_at_unix_nanos: *created_at_unix_nanos,
10670                    schema_json,
10671                },
10672            },
10673            TableState::Dropped { .. } => {
10674                return Err(MongrelError::InvalidArgument(
10675                    "cannot create a table in dropped state".into(),
10676                ));
10677            }
10678        };
10679        let mut next_catalog = self.catalog.read().clone();
10680        next_catalog.tables.push(CatalogEntry {
10681            table_id,
10682            name: name.to_string(),
10683            schema: schema.clone(),
10684            state: state.clone(),
10685            created_epoch: epoch.0,
10686        });
10687        next_catalog.db_epoch = next_catalog.db_epoch.max(epoch.0);
10688        let commit_seq = {
10689            let mut wal = self.shared_wal.lock();
10690            let append: Result<u64> = (|| {
10691                wal.append(txn_id, table_id, crate::wal::Op::Ddl(ddl))?;
10692                append_catalog_snapshot(&mut wal, txn_id, &next_catalog)?;
10693                wal.append_commit(txn_id, epoch, &[])
10694            })();
10695            append.map_err(|error| self.commit_outcome_unknown(epoch, error))?
10696        };
10697        self.await_durable_commit(commit_seq, epoch)?;
10698        pending_table_dir.disarm();
10699
10700        // Publish the mounted table and catalog in memory even if the catalog
10701        // checkpoint fails after the WAL commit.
10702        self.tables
10703            .write()
10704            .insert(table_id, TableHandle::new(table));
10705        let checkpoint = self.checkpoint_catalog_after_durable(next_catalog);
10706        self.finish_durable_publish(epoch, &mut _epoch_guard, checkpoint)?;
10707        Ok(table_id)
10708    }
10709
10710    /// Logically drop a table, logging the DDL through the shared WAL first.
10711    pub fn drop_table(&self, name: &str) -> Result<()> {
10712        self.drop_table_with_epoch(name).map(|_| ())
10713    }
10714
10715    /// Logically drop a table and return the exact publication epoch.
10716    pub fn drop_table_with_epoch(&self, name: &str) -> Result<Epoch> {
10717        self.drop_table_with_state(name, false, None)
10718    }
10719
10720    pub fn drop_table_with_epoch_controlled<F>(
10721        &self,
10722        name: &str,
10723        mut before_commit: F,
10724    ) -> Result<Epoch>
10725    where
10726        F: FnMut() -> Result<()>,
10727    {
10728        self.drop_table_with_state(name, false, Some(&mut before_commit))
10729    }
10730
10731    /// Discard an unpublished CTAS build.
10732    #[doc(hidden)]
10733    pub fn discard_building_table(&self, name: &str) -> Result<()> {
10734        if !name.starts_with(CTAS_BUILD_TABLE_PREFIX) {
10735            return Err(MongrelError::InvalidArgument(
10736                "not a CTAS building table".into(),
10737            ));
10738        }
10739        self.drop_table_with_state(name, true, None).map(|_| ())
10740    }
10741
10742    fn drop_table_with_state(
10743        &self,
10744        name: &str,
10745        building: bool,
10746        before_commit: Option<&mut dyn FnMut() -> Result<()>>,
10747    ) -> Result<Epoch> {
10748        use crate::wal::DdlOp;
10749        use std::sync::atomic::Ordering;
10750
10751        self.require(&crate::auth::Permission::Ddl)?;
10752        if self.poisoned.load(Ordering::Relaxed) {
10753            return Err(MongrelError::Other(
10754                "database poisoned by fsync error".into(),
10755            ));
10756        }
10757
10758        let _g = self.ddl_lock.lock();
10759        let _security_write = self.security_write()?;
10760        self.require(&crate::auth::Permission::Ddl)?;
10761        let table_id = {
10762            let cat = self.catalog.read();
10763            if building {
10764                cat.building(name)
10765            } else {
10766                cat.live(name)
10767            }
10768            .ok_or_else(|| MongrelError::NotFound(format!("table {name:?} not found")))?
10769            .table_id
10770        };
10771
10772        let commit_lock = Arc::clone(&self.commit_lock);
10773        let _c = commit_lock.lock();
10774        let epoch = self.epoch.bump_assigned();
10775        let mut _epoch_guard = EpochGuard::new(self.epoch.as_ref(), epoch);
10776        let txn_id = self.alloc_txn_id()?;
10777        let mut next_catalog = self.catalog.read().clone();
10778        let entry = next_catalog
10779            .tables
10780            .iter_mut()
10781            .find(|t| t.table_id == table_id)
10782            .ok_or_else(|| MongrelError::NotFound(format!("table {name:?} not found")))?;
10783        entry.state = TableState::Dropped { at_epoch: epoch.0 };
10784        next_catalog.triggers.retain(|trigger| {
10785            !matches!(
10786                &trigger.trigger.target,
10787                TriggerTarget::Table(target) if target == name
10788            )
10789        });
10790        next_catalog
10791            .materialized_views
10792            .retain(|definition| definition.name != name);
10793        next_catalog
10794            .security
10795            .rls_tables
10796            .retain(|table| table != name);
10797        next_catalog
10798            .security
10799            .policies
10800            .retain(|policy| policy.table != name);
10801        next_catalog
10802            .security
10803            .masks
10804            .retain(|mask| mask.table != name);
10805        for role in &mut next_catalog.roles {
10806            role.permissions
10807                .retain(|permission| permission_table(permission) != Some(name));
10808        }
10809        advance_security_version(&mut next_catalog)?;
10810        next_catalog.db_epoch = next_catalog.db_epoch.max(epoch.0);
10811        let commit_seq = {
10812            let mut wal = self.shared_wal.lock();
10813            if let Some(before_commit) = before_commit {
10814                before_commit()?;
10815            }
10816            let append: Result<u64> = (|| {
10817                wal.append(
10818                    txn_id,
10819                    table_id,
10820                    crate::wal::Op::Ddl(DdlOp::DropTable { table_id }),
10821                )?;
10822                append_catalog_snapshot(&mut wal, txn_id, &next_catalog)?;
10823                wal.append_commit(txn_id, epoch, &[])
10824            })();
10825            append.map_err(|error| self.commit_outcome_unknown(epoch, error))?
10826        };
10827        self.await_durable_commit(commit_seq, epoch)?;
10828
10829        let checkpoint = self.checkpoint_catalog_after_durable(next_catalog);
10830        self.tables.write().remove(&table_id);
10831        self.finish_durable_publish(epoch, &mut _epoch_guard, checkpoint)?;
10832        Ok(epoch)
10833    }
10834
10835    /// Rename a live table. `name` must exist and `new_name` must not collide
10836    /// with any live table; both checks run under `ddl_lock` so they are atomic
10837    /// with the rename and with concurrent `create_table` existence checks (no
10838    /// TOCTOU window). A no-op rename (`name == new_name`) succeeds without
10839    /// side-effects. The rename is logged to the shared WAL as
10840    /// `DdlOp::RenameTable` and recovered on reopen; the `table_id`, schema,
10841    /// and on-disk layout are unchanged (the table is keyed by `table_id`, so
10842    /// the in-memory object does not move — only the catalog name changes).
10843    pub fn rename_table(&self, name: &str, new_name: &str) -> Result<()> {
10844        self.rename_table_with_epoch(name, new_name).map(|_| ())
10845    }
10846
10847    /// Rename a table and return its exact publication epoch.
10848    pub fn rename_table_with_epoch(&self, name: &str, new_name: &str) -> Result<Epoch> {
10849        self.rename_table_with_epoch_inner(name, new_name, None)
10850    }
10851
10852    pub fn rename_table_with_epoch_controlled<F>(
10853        &self,
10854        name: &str,
10855        new_name: &str,
10856        mut before_commit: F,
10857    ) -> Result<Epoch>
10858    where
10859        F: FnMut() -> Result<()>,
10860    {
10861        self.rename_table_with_epoch_inner(name, new_name, Some(&mut before_commit))
10862    }
10863
10864    fn rename_table_with_epoch_inner(
10865        &self,
10866        name: &str,
10867        new_name: &str,
10868        before_commit: Option<&mut dyn FnMut() -> Result<()>>,
10869    ) -> Result<Epoch> {
10870        if name.starts_with(CTAS_BUILD_TABLE_PREFIX)
10871            || new_name.starts_with(CTAS_BUILD_TABLE_PREFIX)
10872        {
10873            return Err(MongrelError::InvalidArgument(
10874                "the CTAS building-table namespace is reserved".into(),
10875            ));
10876        }
10877        self.rename_table_with_state(name, new_name, false, None, before_commit)
10878    }
10879
10880    /// Atomically publish a hidden CTAS build under its intended live name.
10881    #[doc(hidden)]
10882    pub fn publish_building_table(&self, build_name: &str, new_name: &str) -> Result<Epoch> {
10883        self.publish_building_table_inner(build_name, new_name, None)
10884    }
10885
10886    #[doc(hidden)]
10887    pub fn publish_building_table_controlled<F>(
10888        &self,
10889        build_name: &str,
10890        new_name: &str,
10891        mut before_commit: F,
10892    ) -> Result<Epoch>
10893    where
10894        F: FnMut() -> Result<()>,
10895    {
10896        self.publish_building_table_inner(build_name, new_name, Some(&mut before_commit))
10897    }
10898
10899    fn publish_building_table_inner(
10900        &self,
10901        build_name: &str,
10902        new_name: &str,
10903        before_commit: Option<&mut dyn FnMut() -> Result<()>>,
10904    ) -> Result<Epoch> {
10905        if !build_name.starts_with(CTAS_BUILD_TABLE_PREFIX)
10906            || new_name.starts_with(CTAS_BUILD_TABLE_PREFIX)
10907        {
10908            return Err(MongrelError::InvalidArgument(
10909                "invalid CTAS publish identity".into(),
10910            ));
10911        }
10912        self.rename_table_with_state(build_name, new_name, true, None, before_commit)
10913    }
10914
10915    /// Atomically publish a hidden build and its materialized-view definition.
10916    #[doc(hidden)]
10917    pub fn publish_materialized_building_table(
10918        &self,
10919        build_name: &str,
10920        new_name: &str,
10921        definition: crate::catalog::MaterializedViewEntry,
10922    ) -> Result<Epoch> {
10923        self.publish_materialized_building_table_inner(build_name, new_name, definition, None)
10924    }
10925
10926    #[doc(hidden)]
10927    pub fn publish_materialized_building_table_controlled<F>(
10928        &self,
10929        build_name: &str,
10930        new_name: &str,
10931        definition: crate::catalog::MaterializedViewEntry,
10932        mut before_commit: F,
10933    ) -> Result<Epoch>
10934    where
10935        F: FnMut() -> Result<()>,
10936    {
10937        self.publish_materialized_building_table_inner(
10938            build_name,
10939            new_name,
10940            definition,
10941            Some(&mut before_commit),
10942        )
10943    }
10944
10945    fn publish_materialized_building_table_inner(
10946        &self,
10947        build_name: &str,
10948        new_name: &str,
10949        definition: crate::catalog::MaterializedViewEntry,
10950        before_commit: Option<&mut dyn FnMut() -> Result<()>>,
10951    ) -> Result<Epoch> {
10952        if definition.name != new_name || definition.query.trim().is_empty() {
10953            return Err(MongrelError::InvalidArgument(
10954                "invalid materialized-view publication".into(),
10955            ));
10956        }
10957        self.rename_table_with_state(build_name, new_name, true, Some(definition), before_commit)
10958    }
10959
10960    /// Atomically replace a still-live table with its completed hidden rebuild.
10961    #[doc(hidden)]
10962    pub fn publish_rebuilding_table(&self, build_name: &str, new_name: &str) -> Result<Epoch> {
10963        self.publish_rebuilding_table_inner(build_name, new_name, None, None)
10964    }
10965
10966    #[doc(hidden)]
10967    pub fn publish_rebuilding_table_controlled<F>(
10968        &self,
10969        build_name: &str,
10970        new_name: &str,
10971        mut before_commit: F,
10972    ) -> Result<Epoch>
10973    where
10974        F: FnMut() -> Result<()>,
10975    {
10976        self.publish_rebuilding_table_inner(build_name, new_name, None, Some(&mut before_commit))
10977    }
10978
10979    /// Atomically replace a live materialized-view table and its definition.
10980    #[doc(hidden)]
10981    pub fn publish_materialized_rebuilding_table_controlled<F>(
10982        &self,
10983        build_name: &str,
10984        new_name: &str,
10985        definition: crate::catalog::MaterializedViewEntry,
10986        mut before_commit: F,
10987    ) -> Result<Epoch>
10988    where
10989        F: FnMut() -> Result<()>,
10990    {
10991        self.publish_rebuilding_table_inner(
10992            build_name,
10993            new_name,
10994            Some(definition),
10995            Some(&mut before_commit),
10996        )
10997    }
10998
10999    fn publish_rebuilding_table_inner(
11000        &self,
11001        build_name: &str,
11002        new_name: &str,
11003        mut materialized_view: Option<crate::catalog::MaterializedViewEntry>,
11004        before_commit: Option<&mut dyn FnMut() -> Result<()>>,
11005    ) -> Result<Epoch> {
11006        use crate::wal::DdlOp;
11007
11008        if !build_name.starts_with(CTAS_BUILD_TABLE_PREFIX)
11009            || new_name.is_empty()
11010            || new_name.starts_with(CTAS_BUILD_TABLE_PREFIX)
11011        {
11012            return Err(MongrelError::InvalidArgument(
11013                "invalid rebuilding-table publish identity".into(),
11014            ));
11015        }
11016        if materialized_view.as_ref().is_some_and(|definition| {
11017            definition.name != new_name || definition.query.trim().is_empty()
11018        }) {
11019            return Err(MongrelError::InvalidArgument(
11020                "invalid materialized-view replacement".into(),
11021            ));
11022        }
11023        self.require(&crate::auth::Permission::Ddl)?;
11024        if self.poisoned.load(Ordering::Relaxed) {
11025            return Err(MongrelError::Other(
11026                "database poisoned by fsync error".into(),
11027            ));
11028        }
11029
11030        let _ddl = self.ddl_lock.lock();
11031        let _security_write = self.security_write()?;
11032        let (table_id, replaced_table_id) = {
11033            let catalog = self.catalog.read();
11034            let build = catalog.building(build_name).ok_or_else(|| {
11035                MongrelError::NotFound(format!("building table {build_name:?} not found"))
11036            })?;
11037            let replaced_table_id = match &build.state {
11038                TableState::Building {
11039                    intended_name,
11040                    replaces_table_id: Some(replaced_table_id),
11041                    ..
11042                } if intended_name == new_name => *replaced_table_id,
11043                _ => {
11044                    return Err(MongrelError::InvalidArgument(format!(
11045                        "building table {build_name:?} is not a replacement for {new_name:?}"
11046                    )))
11047                }
11048            };
11049            if catalog
11050                .live(new_name)
11051                .is_none_or(|entry| entry.table_id != replaced_table_id)
11052            {
11053                return Err(MongrelError::Conflict(format!(
11054                    "table {new_name:?} changed while its replacement was built"
11055                )));
11056            }
11057            (build.table_id, replaced_table_id)
11058        };
11059
11060        let _commit = self.commit_lock.lock();
11061        let epoch = self.epoch.assigned().next();
11062        let txn_id = self.alloc_txn_id()?;
11063        let mut next_catalog = self.catalog.read().clone();
11064        apply_rebuilding_publish(
11065            &mut next_catalog,
11066            table_id,
11067            replaced_table_id,
11068            new_name,
11069            epoch.0,
11070        )?;
11071        if let Some(definition) = materialized_view.as_mut() {
11072            definition.last_refresh_epoch = epoch.0;
11073        }
11074        let materialized_view_json = materialized_view
11075            .as_ref()
11076            .map(DdlOp::encode_materialized_view)
11077            .transpose()?;
11078        if let Some(definition) = materialized_view {
11079            if let Some(existing) = next_catalog
11080                .materialized_views
11081                .iter_mut()
11082                .find(|existing| existing.name == definition.name)
11083            {
11084                *existing = definition;
11085            } else {
11086                next_catalog.materialized_views.push(definition);
11087            }
11088        }
11089        next_catalog.db_epoch = next_catalog.db_epoch.max(epoch.0);
11090        if let Some(before_commit) = before_commit {
11091            before_commit()?;
11092        }
11093        let assigned_epoch = self.epoch.bump_assigned();
11094        let mut epoch_guard = EpochGuard::new(self.epoch.as_ref(), assigned_epoch);
11095        if assigned_epoch != epoch {
11096            return Err(MongrelError::Conflict(
11097                "commit epoch changed while sequencer lock was held".into(),
11098            ));
11099        }
11100        let commit_seq = {
11101            let mut wal = self.shared_wal.lock();
11102            let append: Result<u64> = (|| {
11103                wal.append(
11104                    txn_id,
11105                    table_id,
11106                    crate::wal::Op::Ddl(DdlOp::ReplaceBuildingTable {
11107                        table_id,
11108                        replaced_table_id,
11109                        new_name: new_name.to_string(),
11110                    }),
11111                )?;
11112                if let Some(definition_json) = materialized_view_json {
11113                    wal.append(
11114                        txn_id,
11115                        table_id,
11116                        crate::wal::Op::Ddl(DdlOp::SetMaterializedView {
11117                            name: new_name.to_string(),
11118                            definition_json,
11119                        }),
11120                    )?;
11121                }
11122                append_catalog_snapshot(&mut wal, txn_id, &next_catalog)?;
11123                wal.append_commit(txn_id, epoch, &[])
11124            })();
11125            append.map_err(|error| self.commit_outcome_unknown(epoch, error))?
11126        };
11127        self.await_durable_commit(commit_seq, epoch)?;
11128
11129        let checkpoint = self.checkpoint_catalog_after_durable(next_catalog);
11130        self.tables.write().remove(&replaced_table_id);
11131        if let Some(table) = self.tables.read().get(&table_id) {
11132            table.lock().set_catalog_name(new_name.to_string());
11133        }
11134        self.finish_durable_publish(epoch, &mut epoch_guard, checkpoint)?;
11135        Ok(epoch)
11136    }
11137
11138    fn rename_table_with_state(
11139        &self,
11140        name: &str,
11141        new_name: &str,
11142        building: bool,
11143        mut materialized_view: Option<crate::catalog::MaterializedViewEntry>,
11144        before_commit: Option<&mut dyn FnMut() -> Result<()>>,
11145    ) -> Result<Epoch> {
11146        use crate::wal::DdlOp;
11147        use std::sync::atomic::Ordering;
11148
11149        self.require(&crate::auth::Permission::Ddl)?;
11150        if self.poisoned.load(Ordering::Relaxed) {
11151            return Err(MongrelError::Other(
11152                "database poisoned by fsync error".into(),
11153            ));
11154        }
11155
11156        // A no-op rename short-circuits before any locking, so it can never
11157        // trip the "target already exists" check (the source *is* that name).
11158        if name == new_name {
11159            return Ok(self.visible_epoch());
11160        }
11161        if new_name.is_empty() {
11162            return Err(MongrelError::InvalidArgument(
11163                "rename_table: new name must not be empty".into(),
11164            ));
11165        }
11166
11167        let _g = self.ddl_lock.lock();
11168        let _security_write = self.security_write()?;
11169        self.require(&crate::auth::Permission::Ddl)?;
11170        let table_id = {
11171            let cat = self.catalog.read();
11172            let src = if building {
11173                cat.building(name)
11174            } else {
11175                cat.live(name)
11176            }
11177            .ok_or_else(|| MongrelError::NotFound(format!("table {name:?} not found")))?;
11178            if building
11179                && !matches!(
11180                    &src.state,
11181                    TableState::Building { intended_name, .. } if intended_name == new_name
11182                )
11183            {
11184                return Err(MongrelError::InvalidArgument(format!(
11185                    "building table {name:?} is not reserved for {new_name:?}"
11186                )));
11187            }
11188            // Target must be free. Checked under ddl_lock, which every other
11189            // DDL (create/rename/drop) also holds, so a concurrent operation
11190            // cannot claim `new_name` between this check and the catalog write.
11191            if cat.live(new_name).is_some() || (!building && cat.building_for(new_name).is_some()) {
11192                return Err(MongrelError::InvalidArgument(format!(
11193                    "rename_table: a table named {new_name:?} already exists"
11194                )));
11195            }
11196            src.table_id
11197        };
11198
11199        let commit_lock = Arc::clone(&self.commit_lock);
11200        let _c = commit_lock.lock();
11201        let epoch = self.epoch.bump_assigned();
11202        let mut _epoch_guard = EpochGuard::new(self.epoch.as_ref(), epoch);
11203        let txn_id = self.alloc_txn_id()?;
11204        if let Some(definition) = materialized_view.as_mut() {
11205            definition.last_refresh_epoch = epoch.0;
11206        }
11207        let materialized_view_json = materialized_view
11208            .as_ref()
11209            .map(DdlOp::encode_materialized_view)
11210            .transpose()?;
11211        let mut next_catalog = self.catalog.read().clone();
11212        let entry = next_catalog
11213            .tables
11214            .iter_mut()
11215            .find(|t| t.table_id == table_id)
11216            .ok_or_else(|| MongrelError::NotFound(format!("table {name:?} not found")))?;
11217        entry.name = new_name.to_string();
11218        if building {
11219            entry.state = TableState::Live;
11220        }
11221        for trigger in &mut next_catalog.triggers {
11222            if matches!(
11223                &trigger.trigger.target,
11224                TriggerTarget::Table(target) if target == name
11225            ) {
11226                trigger.trigger = trigger.trigger.retarget_table(new_name, epoch.0)?;
11227            }
11228        }
11229        if let Some(definition) = next_catalog
11230            .materialized_views
11231            .iter_mut()
11232            .find(|definition| definition.name == name)
11233        {
11234            definition.name = new_name.to_string();
11235        }
11236        if let Some(definition) = materialized_view.take() {
11237            next_catalog.materialized_views.push(definition);
11238        }
11239        next_catalog.db_epoch = next_catalog.db_epoch.max(epoch.0);
11240        for table in &mut next_catalog.security.rls_tables {
11241            if table == name {
11242                *table = new_name.to_string();
11243            }
11244        }
11245        for policy in &mut next_catalog.security.policies {
11246            if policy.table == name {
11247                policy.table = new_name.to_string();
11248            }
11249        }
11250        for mask in &mut next_catalog.security.masks {
11251            if mask.table == name {
11252                mask.table = new_name.to_string();
11253            }
11254        }
11255        for role in &mut next_catalog.roles {
11256            for permission in &mut role.permissions {
11257                rename_permission_table(permission, name, new_name);
11258            }
11259        }
11260        advance_security_version(&mut next_catalog)?;
11261        let ddl = if building {
11262            DdlOp::PublishBuildingTable {
11263                table_id,
11264                new_name: new_name.to_string(),
11265            }
11266        } else {
11267            DdlOp::RenameTable {
11268                table_id,
11269                new_name: new_name.to_string(),
11270            }
11271        };
11272        let commit_seq = {
11273            let mut wal = self.shared_wal.lock();
11274            if let Some(before_commit) = before_commit {
11275                before_commit()?;
11276            }
11277            let append: Result<u64> = (|| {
11278                wal.append(txn_id, table_id, crate::wal::Op::Ddl(ddl))?;
11279                if let Some(definition_json) = materialized_view_json {
11280                    wal.append(
11281                        txn_id,
11282                        table_id,
11283                        crate::wal::Op::Ddl(DdlOp::SetMaterializedView {
11284                            name: new_name.to_string(),
11285                            definition_json,
11286                        }),
11287                    )?;
11288                }
11289                append_catalog_snapshot(&mut wal, txn_id, &next_catalog)?;
11290                wal.append_commit(txn_id, epoch, &[])
11291            })();
11292            append.map_err(|error| self.commit_outcome_unknown(epoch, error))?
11293        };
11294        self.await_durable_commit(commit_seq, epoch)?;
11295
11296        let checkpoint = self.checkpoint_catalog_after_durable(next_catalog);
11297        // The in-memory table object is keyed by table_id, not name, so it does
11298        // not move and live TableHandles remain valid.
11299        if let Some(table) = self.tables.read().get(&table_id) {
11300            table.lock().set_catalog_name(new_name.to_string());
11301        }
11302        self.finish_durable_publish(epoch, &mut _epoch_guard, checkpoint)?;
11303        Ok(epoch)
11304    }
11305
11306    pub fn alter_column(
11307        &self,
11308        table_name: &str,
11309        column_name: &str,
11310        change: AlterColumn,
11311    ) -> Result<ColumnDef> {
11312        self.alter_column_with_epoch(table_name, column_name, change)
11313            .map(|(column, _)| column)
11314    }
11315
11316    pub fn alter_column_with_epoch(
11317        &self,
11318        table_name: &str,
11319        column_name: &str,
11320        change: AlterColumn,
11321    ) -> Result<(ColumnDef, Option<Epoch>)> {
11322        self.alter_column_with_epoch_inner(table_name, column_name, change, None, None, None)
11323    }
11324
11325    /// Cooperatively prepare an ALTER and fence each durable commit separately.
11326    /// `after_commit(Some(epoch))` follows an exact durable outcome;
11327    /// `after_commit(None)` follows an uncertain WAL attempt. It is called once
11328    /// for every successful `before_commit` callback.
11329    pub fn alter_column_with_epoch_controlled<B, A>(
11330        &self,
11331        table_name: &str,
11332        column_name: &str,
11333        change: AlterColumn,
11334        control: &crate::ExecutionControl,
11335        mut before_commit: B,
11336        mut after_commit: A,
11337    ) -> Result<(ColumnDef, Option<Epoch>)>
11338    where
11339        B: FnMut() -> Result<()>,
11340        A: FnMut(Option<Epoch>) -> Result<()>,
11341    {
11342        self.alter_column_with_epoch_inner(
11343            table_name,
11344            column_name,
11345            change,
11346            Some(control),
11347            Some(&mut before_commit),
11348            Some(&mut after_commit),
11349        )
11350    }
11351
11352    #[allow(clippy::too_many_arguments)]
11353    fn alter_column_with_epoch_inner(
11354        &self,
11355        table_name: &str,
11356        column_name: &str,
11357        change: AlterColumn,
11358        control: Option<&crate::ExecutionControl>,
11359        mut before_commit: Option<&mut dyn FnMut() -> Result<()>>,
11360        mut after_commit: Option<&mut dyn FnMut(Option<Epoch>) -> Result<()>>,
11361    ) -> Result<(ColumnDef, Option<Epoch>)> {
11362        use crate::wal::DdlOp;
11363        use std::sync::atomic::Ordering;
11364
11365        self.require(&crate::auth::Permission::Ddl)?;
11366        commit_prepare_checkpoint(control, 0)?;
11367        if self.poisoned.load(Ordering::Relaxed) {
11368            return Err(MongrelError::Other(
11369                "database poisoned by fsync error".into(),
11370            ));
11371        }
11372
11373        let _g = self.ddl_lock.lock();
11374        let table_id = {
11375            let cat = self.catalog.read();
11376            cat.live(table_name)
11377                .ok_or_else(|| MongrelError::NotFound(format!("table {table_name:?} not found")))?
11378                .table_id
11379        };
11380        let handle =
11381            self.tables.read().get(&table_id).cloned().ok_or_else(|| {
11382                MongrelError::NotFound(format!("table {table_name:?} not mounted"))
11383            })?;
11384
11385        // Legitimate online-ALTER slice: when nullable -> NOT NULL has a
11386        // declared default, backfill existing NULL/absent cells as one durable
11387        // transaction before logging the metadata change. A crash between the
11388        // two commits leaves a harmless nullable-but-filled column; retry is
11389        // idempotent because only remaining NULLs are touched.
11390        let backfill = {
11391            let table = handle.lock();
11392            let old = table
11393                .schema()
11394                .column(column_name)
11395                .cloned()
11396                .ok_or_else(|| MongrelError::Schema(format!("unknown column {column_name}")))?;
11397            let next_flags = change.flags.unwrap_or(old.flags);
11398            if old.flags.contains(crate::schema::ColumnFlags::NULLABLE)
11399                && !next_flags.contains(crate::schema::ColumnFlags::NULLABLE)
11400                && old.default_value.is_some()
11401            {
11402                let snapshot = Snapshot::at(self.epoch.visible());
11403                let mut updates = Vec::new();
11404                let rows = match control {
11405                    Some(control) => table.visible_rows_controlled(snapshot, control)?,
11406                    None => table.visible_rows(snapshot)?,
11407                };
11408                for (row_index, row) in rows.into_iter().enumerate() {
11409                    commit_prepare_checkpoint(control, row_index)?;
11410                    if row
11411                        .columns
11412                        .get(&old.id)
11413                        .is_some_and(|value| !matches!(value, Value::Null))
11414                    {
11415                        continue;
11416                    }
11417                    let mut cells: Vec<(u16, Value)> = row.columns.into_iter().collect();
11418                    table.apply_defaults(&mut cells)?;
11419                    updates.push((
11420                        table_id,
11421                        crate::txn::Staged::Update {
11422                            row_id: row.row_id,
11423                            new_row: cells,
11424                            changed_columns: vec![old.id],
11425                        },
11426                    ));
11427                }
11428                updates
11429            } else {
11430                Vec::new()
11431            }
11432        };
11433        let durable_epoch = std::cell::Cell::new(None);
11434        let backfill_epoch = if backfill.is_empty() {
11435            None
11436        } else {
11437            let (principal, catalog_bound) = self.transaction_principal_snapshot();
11438            let txn_id = self.alloc_txn_id()?;
11439            let mut entered_fence = false;
11440            let commit_result = match (control, before_commit.as_deref_mut()) {
11441                (Some(control), Some(before_commit)) => self
11442                    .commit_transaction_with_external_states_controlled(
11443                        txn_id,
11444                        self.epoch.visible(),
11445                        backfill,
11446                        Vec::new(),
11447                        Vec::new(),
11448                        principal.clone(),
11449                        catalog_bound,
11450                        None,
11451                        control,
11452                        &mut || {
11453                            before_commit()?;
11454                            entered_fence = true;
11455                            Ok(())
11456                        },
11457                    )
11458                    .map(|(epoch, _)| epoch),
11459                _ => self
11460                    .commit_transaction_with_external_states(
11461                        txn_id,
11462                        self.epoch.visible(),
11463                        backfill,
11464                        Vec::new(),
11465                        Vec::new(),
11466                        principal,
11467                        catalog_bound,
11468                        None,
11469                    )
11470                    .map(|(epoch, _)| epoch),
11471            };
11472            let commit_result = if entered_fence {
11473                finish_controlled_commit_attempt(commit_result, &mut after_commit)
11474            } else {
11475                commit_result
11476            };
11477            match &commit_result {
11478                Ok(epoch) => durable_epoch.set(Some(*epoch)),
11479                Err(MongrelError::DurableCommit { epoch, .. }) => {
11480                    durable_epoch.set(Some(Epoch(*epoch)));
11481                }
11482                Err(_) => {}
11483            }
11484            Some(commit_result?)
11485        };
11486        let result: Result<(ColumnDef, Option<Epoch>)> = (|| {
11487            let _security_write = self.security_write()?;
11488            self.require(&crate::auth::Permission::Ddl)?;
11489            if self
11490                .catalog
11491                .read()
11492                .live(table_name)
11493                .is_none_or(|entry| entry.table_id != table_id)
11494            {
11495                return Err(MongrelError::Conflict(format!(
11496                    "table {table_name:?} changed during ALTER"
11497                )));
11498            }
11499            let mut table = handle.lock();
11500            let (column, prepared_schema) = table.prepare_alter_column(column_name, &change)?;
11501            let renamed_column = (column.name != column_name).then(|| column.name.clone());
11502            let Some(prepared_schema) = prepared_schema else {
11503                return Ok((column, backfill_epoch));
11504            };
11505
11506            let commit_lock = Arc::clone(&self.commit_lock);
11507            let _c = commit_lock.lock();
11508            let epoch = self.epoch.bump_assigned();
11509            let mut _epoch_guard = EpochGuard::new(self.epoch.as_ref(), epoch);
11510            let txn_id = self.alloc_txn_id()?;
11511            let column_json = DdlOp::encode_column(&column)?;
11512            let mut next_catalog = self.catalog.read().clone();
11513            let catalog_entry_index = next_catalog
11514                .tables
11515                .iter()
11516                .position(|entry| entry.table_id == table_id)
11517                .ok_or_else(|| MongrelError::NotFound(format!("table {table_name:?} not found")))?;
11518            if let Some(new_column_name) = &renamed_column {
11519                for (trigger_index, trigger) in next_catalog.triggers.iter_mut().enumerate() {
11520                    commit_prepare_checkpoint(control, trigger_index)?;
11521                    if matches!(
11522                        &trigger.trigger.target,
11523                        TriggerTarget::Table(target) if target == table_name
11524                    ) {
11525                        trigger.trigger = trigger.trigger.renamed_update_column(
11526                            column_name,
11527                            new_column_name.clone(),
11528                            epoch.0,
11529                        )?;
11530                    }
11531                }
11532                for (role_index, role) in next_catalog.roles.iter_mut().enumerate() {
11533                    commit_prepare_checkpoint(control, role_index)?;
11534                    for (permission_index, permission) in role.permissions.iter_mut().enumerate() {
11535                        commit_prepare_checkpoint(control, permission_index)?;
11536                        rename_permission_column(
11537                            permission,
11538                            table_name,
11539                            column_name,
11540                            new_column_name,
11541                        );
11542                    }
11543                }
11544                advance_security_version(&mut next_catalog)?;
11545            }
11546            next_catalog.tables[catalog_entry_index].schema = prepared_schema.clone();
11547            next_catalog.db_epoch = next_catalog.db_epoch.max(epoch.0);
11548            commit_prepare_checkpoint(control, 0)?;
11549            let mut entered_fence = false;
11550            if let Some(before_commit) = before_commit.as_deref_mut() {
11551                before_commit()?;
11552                entered_fence = true;
11553            }
11554            let commit_result: Result<Epoch> = (|| {
11555                let commit_seq = {
11556                    let mut wal = self.shared_wal.lock();
11557                    let append: Result<u64> = (|| {
11558                        wal.append(
11559                            txn_id,
11560                            table_id,
11561                            crate::wal::Op::Ddl(DdlOp::AlterTable {
11562                                table_id,
11563                                column_json,
11564                            }),
11565                        )?;
11566                        append_catalog_snapshot(&mut wal, txn_id, &next_catalog)?;
11567                        wal.append_commit(txn_id, epoch, &[])
11568                    })();
11569                    append.map_err(|error| self.commit_outcome_unknown(epoch, error))?
11570                };
11571                self.await_durable_commit(commit_seq, epoch)?;
11572                durable_epoch.set(Some(epoch));
11573
11574                table.apply_altered_schema_prepared(prepared_schema);
11575                let schema = table.schema().clone();
11576                let table_checkpoint = table.checkpoint_altered_schema();
11577                drop(table);
11578                next_catalog.tables[catalog_entry_index].schema = schema;
11579                next_catalog.db_epoch = next_catalog.db_epoch.max(epoch.0);
11580                let catalog_result =
11581                    catalog::write_atomic(&self.root, &next_catalog, self.meta_dek.as_ref());
11582                let security_version = next_catalog.security_version;
11583                *self.catalog.write() = next_catalog;
11584                if renamed_column.is_some() {
11585                    self.security_coordinator
11586                        .version
11587                        .store(security_version, Ordering::Release);
11588                }
11589                self.epoch.publish_in_order(epoch);
11590                _epoch_guard.disarm();
11591                if let Err(error) = table_checkpoint.and(catalog_result) {
11592                    self.poisoned.store(true, Ordering::Relaxed);
11593                    return Err(MongrelError::DurableCommit {
11594                        epoch: epoch.0,
11595                        message: error.to_string(),
11596                    });
11597                }
11598                Ok(epoch)
11599            })();
11600            let commit_result = if entered_fence {
11601                finish_controlled_commit_attempt(commit_result, &mut after_commit)
11602            } else {
11603                commit_result
11604            };
11605            let epoch = commit_result?;
11606            Ok((column, Some(epoch)))
11607        })();
11608        result.map_err(|error| match (durable_epoch.get(), error) {
11609            (_, error @ MongrelError::DurableCommit { .. }) => error,
11610            (Some(epoch), error) => MongrelError::DurableCommit {
11611                epoch: epoch.0,
11612                message: error.to_string(),
11613            },
11614            (None, error) => error,
11615        })
11616    }
11617
11618    /// Set a timestamp-column TTL policy and WAL-log it for crash recovery and
11619    /// replication. Duration is in nanoseconds.
11620    pub fn set_table_ttl(
11621        &self,
11622        table_name: &str,
11623        column_name: &str,
11624        duration_nanos: u64,
11625    ) -> Result<crate::manifest::TtlPolicy> {
11626        let policy = self.replace_table_ttl(table_name, Some((column_name, duration_nanos)))?;
11627        policy.ok_or_else(|| MongrelError::Other("set TTL produced no policy".into()))
11628    }
11629
11630    /// Set TTL metadata on a hidden build before it is published.
11631    #[doc(hidden)]
11632    pub fn set_building_table_ttl(
11633        &self,
11634        table_name: &str,
11635        column_name: &str,
11636        duration_nanos: u64,
11637    ) -> Result<crate::manifest::TtlPolicy> {
11638        let policy = self.replace_table_ttl_with_state(
11639            table_name,
11640            Some((column_name, duration_nanos)),
11641            true,
11642        )?;
11643        policy
11644            .ok_or_else(|| MongrelError::Other("set building-table TTL produced no policy".into()))
11645    }
11646
11647    pub fn clear_table_ttl(&self, table_name: &str) -> Result<()> {
11648        self.replace_table_ttl(table_name, None)?;
11649        Ok(())
11650    }
11651
11652    fn replace_table_ttl(
11653        &self,
11654        table_name: &str,
11655        requested: Option<(&str, u64)>,
11656    ) -> Result<Option<crate::manifest::TtlPolicy>> {
11657        self.replace_table_ttl_with_state(table_name, requested, false)
11658    }
11659
11660    fn replace_table_ttl_with_state(
11661        &self,
11662        table_name: &str,
11663        requested: Option<(&str, u64)>,
11664        building: bool,
11665    ) -> Result<Option<crate::manifest::TtlPolicy>> {
11666        use crate::wal::DdlOp;
11667        use std::sync::atomic::Ordering;
11668
11669        self.require(&crate::auth::Permission::Ddl)?;
11670        if self.poisoned.load(Ordering::Relaxed) {
11671            return Err(MongrelError::Other(
11672                "database poisoned by fsync error".into(),
11673            ));
11674        }
11675
11676        let _g = self.ddl_lock.lock();
11677        let _security_write = self.security_write()?;
11678        self.require(&crate::auth::Permission::Ddl)?;
11679        let table_id = {
11680            let cat = self.catalog.read();
11681            if building {
11682                cat.building(table_name)
11683            } else {
11684                cat.live(table_name)
11685            }
11686            .ok_or_else(|| MongrelError::NotFound(format!("table {table_name:?} not found")))?
11687            .table_id
11688        };
11689        let handle =
11690            self.tables.read().get(&table_id).cloned().ok_or_else(|| {
11691                MongrelError::NotFound(format!("table {table_name:?} not mounted"))
11692            })?;
11693        let mut table = handle.lock();
11694        let policy = match requested {
11695            Some((column, duration)) => Some(table.prepare_ttl_policy(column, duration)?),
11696            None => None,
11697        };
11698        if table.ttl() == policy {
11699            return Ok(policy);
11700        }
11701
11702        let commit_lock = Arc::clone(&self.commit_lock);
11703        let _c = commit_lock.lock();
11704        let epoch = self.epoch.bump_assigned();
11705        let mut _epoch_guard = EpochGuard::new(self.epoch.as_ref(), epoch);
11706        let txn_id = self.alloc_txn_id()?;
11707        let policy_json = DdlOp::encode_ttl(policy)?;
11708        let mut next_catalog = self.catalog.read().clone();
11709        next_catalog.db_epoch = next_catalog.db_epoch.max(epoch.0);
11710        let commit_seq = {
11711            let mut wal = self.shared_wal.lock();
11712            let append: Result<u64> = (|| {
11713                wal.append(
11714                    txn_id,
11715                    table_id,
11716                    crate::wal::Op::Ddl(DdlOp::SetTtl {
11717                        table_id,
11718                        policy_json,
11719                    }),
11720                )?;
11721                append_catalog_snapshot(&mut wal, txn_id, &next_catalog)?;
11722                wal.append_commit(txn_id, epoch, &[])
11723            })();
11724            append.map_err(|error| self.commit_outcome_unknown(epoch, error))?
11725        };
11726        self.await_durable_commit(commit_seq, epoch)?;
11727
11728        let mut publish_error = table.apply_ttl_policy_at(policy, epoch).err();
11729        drop(table);
11730        if let Err(error) = self.checkpoint_catalog_after_durable(next_catalog) {
11731            publish_error.get_or_insert(error);
11732        }
11733        self.finish_durable_publish(epoch, &mut _epoch_guard, publish_error.map_or(Ok(()), Err))?;
11734        Ok(policy)
11735    }
11736
11737    /// Retention-gated garbage collection (spec §6.4, §7.4, §16). Deletes:
11738    /// - Dropped-table subdirs whose `at_epoch < min_active_snapshot`.
11739    /// - Stale `_txn/` dirs (aborted/crashed large-txn pending runs).
11740    ///
11741    /// Returns the number of items reclaimed.
11742    pub fn gc(&self) -> Result<usize> {
11743        let control = crate::ExecutionControl::new(None);
11744        self.gc_controlled(&control, || true)
11745    }
11746
11747    /// Discover reclaimable state cooperatively, then cross one publication
11748    /// boundary immediately before the first irreversible deletion.
11749    #[doc(hidden)]
11750    pub fn gc_controlled<F>(
11751        &self,
11752        control: &crate::ExecutionControl,
11753        before_publish: F,
11754    ) -> Result<usize>
11755    where
11756        F: FnOnce() -> bool,
11757    {
11758        self.gc_controlled_with_receipt(control, before_publish)
11759            .map(|(reclaimed, _)| reclaimed)
11760    }
11761
11762    /// Discover reclaimable state from one exact catalog/epoch snapshot, then
11763    /// return that snapshot if an irreversible deletion was attempted.
11764    #[doc(hidden)]
11765    pub fn gc_controlled_with_receipt<F>(
11766        &self,
11767        control: &crate::ExecutionControl,
11768        before_publish: F,
11769    ) -> Result<(usize, Option<MaintenanceReceipt>)>
11770    where
11771        F: FnOnce() -> bool,
11772    {
11773        enum Candidate {
11774            Directory(PathBuf),
11775            File(PathBuf),
11776        }
11777
11778        self.require(&crate::auth::Permission::Ddl)?;
11779        let _ddl = self.ddl_lock.lock();
11780        self.require(&crate::auth::Permission::Ddl)?;
11781        control.checkpoint()?;
11782        let maintenance_epoch = self.epoch.visible();
11783        let min_active = self.snapshots.min_active(maintenance_epoch).0;
11784        let mut candidates = Vec::new();
11785
11786        // Reclaim dropped-table dirs where no pinned snapshot still needs them.
11787        let cat = self.catalog.read();
11788        for (entry_index, entry) in cat.tables.iter().enumerate() {
11789            if entry_index % 256 == 0 {
11790                control.checkpoint()?;
11791            }
11792            if let TableState::Dropped { at_epoch } = entry.state {
11793                if at_epoch <= min_active {
11794                    let tdir = self.root.join(TABLES_DIR).join(entry.table_id.to_string());
11795                    if tdir.exists() {
11796                        candidates.push(Candidate::Directory(tdir));
11797                    }
11798                }
11799            }
11800        }
11801        drop(cat);
11802
11803        // Sweep stale _txn/<id>/ dirs on remaining live tables — but NEVER an
11804        // in-flight spill's dir (deleting it would lose the pending run and fail
11805        // the commit, review fix #14). Each `_txn/` subdir is named by its txn id;
11806        // skip any id still registered in `active_spills`.
11807        let cat = self.catalog.read();
11808        for (entry_index, entry) in cat.tables.iter().enumerate() {
11809            if entry_index % 256 == 0 {
11810                control.checkpoint()?;
11811            }
11812            if !matches!(entry.state, TableState::Live) {
11813                continue;
11814            }
11815            let txn_dir = self
11816                .root
11817                .join(TABLES_DIR)
11818                .join(entry.table_id.to_string())
11819                .join("_txn");
11820            if !txn_dir.exists() {
11821                continue;
11822            }
11823            for (sub_index, sub) in std::fs::read_dir(&txn_dir)?.enumerate() {
11824                if sub_index % 256 == 0 {
11825                    control.checkpoint()?;
11826                }
11827                let sub = sub?;
11828                let name = sub.file_name();
11829                let Some(name) = name.to_str() else { continue };
11830                // A non-numeric entry can't belong to a live txn — sweep it.
11831                let is_active = name
11832                    .parse::<u64>()
11833                    .map(|id| self.active_spills.is_active(id))
11834                    .unwrap_or(false);
11835                if is_active {
11836                    continue;
11837                }
11838                candidates.push(Candidate::Directory(sub.path()));
11839            }
11840        }
11841        drop(cat);
11842
11843        let external_names = {
11844            let cat = self.catalog.read();
11845            cat.external_tables
11846                .iter()
11847                .map(|entry| entry.name.clone())
11848                .collect::<std::collections::HashSet<_>>()
11849        };
11850        let vtab_dir = self.root.join(VTAB_DIR);
11851        if vtab_dir.exists() {
11852            for (entry_index, entry) in std::fs::read_dir(&vtab_dir)?.enumerate() {
11853                if entry_index % 256 == 0 {
11854                    control.checkpoint()?;
11855                }
11856                let entry = entry?;
11857                let name = entry.file_name();
11858                let Some(name) = name.to_str() else { continue };
11859                if external_names.contains(name) {
11860                    continue;
11861                }
11862                let path = entry.path();
11863                if path.is_dir() {
11864                    candidates.push(Candidate::Directory(path));
11865                } else {
11866                    candidates.push(Candidate::File(path));
11867                }
11868            }
11869        }
11870
11871        // Reap compaction-superseded runs whose retire epoch no pinned snapshot
11872        // can still need (spec §6.4). Each table deletes its own retired files
11873        // gated on `min_active` and persists its manifest.
11874        let tables = self
11875            .tables
11876            .read()
11877            .iter()
11878            .map(|(table_id, handle)| (*table_id, handle.clone()))
11879            .collect::<Vec<_>>();
11880        let mut retiring = Vec::new();
11881        for (table_index, (table_id, handle)) in tables.iter().enumerate() {
11882            if table_index % 256 == 0 {
11883                control.checkpoint()?;
11884            }
11885            let backup_pinned: HashSet<u128> = self
11886                .backup_pins
11887                .lock()
11888                .keys()
11889                .filter_map(|(pinned_table, run_id)| {
11890                    (*pinned_table == *table_id).then_some(*run_id)
11891                })
11892                .collect();
11893            if handle
11894                .lock()
11895                .has_reapable_retiring(Epoch(min_active), &backup_pinned)
11896            {
11897                retiring.push((handle.clone(), backup_pinned));
11898            }
11899        }
11900
11901        // WAL-segment GC (spec §6.4/§16). `SharedWal::open` mints a fresh active
11902        // segment on every reopen without truncating the prior ones, so rotated
11903        // segments accumulate. Once every live table's committed data is durable
11904        // in runs (no in-memory rows) and no in-flight spill is open, all rotated
11905        // (non-active) segments are redundant for recovery and safe to delete —
11906        // an in-flight txn only ever appends to the active segment, which is
11907        // never deleted.
11908        let all_durable = self.active_spills.is_idle()
11909            && tables.iter().all(|(_, handle)| {
11910                let g = handle.lock();
11911                g.memtable_len() == 0 && g.mutable_run_len() == 0
11912            });
11913        let retain = self
11914            .replication_wal_retention_segments
11915            .load(std::sync::atomic::Ordering::Relaxed);
11916        let reap_wal = all_durable
11917            && self
11918                .shared_wal
11919                .lock()
11920                .has_gc_segments_retain_recent(retain)?;
11921
11922        if candidates.is_empty() && retiring.is_empty() && !reap_wal {
11923            return Ok((0, None));
11924        }
11925        control.checkpoint()?;
11926        if !before_publish() {
11927            return Err(MongrelError::Cancelled);
11928        }
11929
11930        let mut reclaimed = 0;
11931        for candidate in candidates {
11932            match candidate {
11933                Candidate::Directory(path) => std::fs::remove_dir_all(path)?,
11934                Candidate::File(path) => std::fs::remove_file(path)?,
11935            }
11936            reclaimed += 1;
11937        }
11938        for (handle, backup_pinned) in retiring {
11939            reclaimed += handle
11940                .lock()
11941                .reap_retiring(Epoch(min_active), &backup_pinned)?;
11942        }
11943        if reap_wal {
11944            reclaimed += self
11945                .shared_wal
11946                .lock()
11947                .gc_segments_retain_recent(u64::MAX, retain)?;
11948        }
11949
11950        Ok((
11951            reclaimed,
11952            Some(MaintenanceReceipt {
11953                epoch: maintenance_epoch,
11954            }),
11955        ))
11956    }
11957
11958    /// Produce a deterministic-stable byte image of the database directory.
11959    ///
11960    /// After `checkpoint()`:
11961    ///   - All pending writes are flushed to sorted runs (no memtable data).
11962    ///   - Each table is compacted to a single sorted run (no run fragmentation).
11963    ///   - All non-active WAL segments are deleted (data is durable in runs).
11964    ///   - The active WAL segment is rotated to a fresh empty segment.
11965    ///   - Dropped-table directories are removed.
11966    ///   - All manifests + catalog are persisted.
11967    ///
11968    /// The resulting directory is byte-stable: `git add` captures a snapshot
11969    /// that `git checkout` restores deterministically. No stale WAL tail bytes,
11970    /// no unbounded segment growth, no mutable-run spill files.
11971    ///
11972    /// This is the engine primitive behind `mongreldb snapshot <dir>` (CLI).
11973    /// It does NOT clear the exclusive lock — the caller still owns the
11974    /// database handle.
11975    pub fn checkpoint(&self) -> Result<()> {
11976        self.checkpoint_controlled(|| Ok(()))
11977    }
11978
11979    /// Strict checkpoint with a deterministic test hook after every table is
11980    /// flushed/compacted but before WAL replacement.
11981    #[doc(hidden)]
11982    pub fn checkpoint_controlled<F>(&self, before_wal_reset: F) -> Result<()>
11983    where
11984        F: FnOnce() -> Result<()>,
11985    {
11986        self.require(&crate::auth::Permission::Ddl)?;
11987        // Block cross-table commits and DDL for the full operation. Locking all
11988        // mounted handles also excludes direct `Table` commits, which do not
11989        // enter the database replication barrier.
11990        let _replication = self.replication_barrier.write();
11991        let _ddl = self.ddl_lock.lock();
11992        let _security = self.security_coordinator.gate.read();
11993        self.require(&crate::auth::Permission::Ddl)?;
11994
11995        let mut handles = self
11996            .tables
11997            .read()
11998            .iter()
11999            .map(|(table_id, handle)| (*table_id, handle.clone()))
12000            .collect::<Vec<_>>();
12001        handles.sort_by_key(|(table_id, _)| *table_id);
12002        let mut tables = handles
12003            .iter()
12004            .map(|(table_id, handle)| (*table_id, handle.lock()))
12005            .collect::<Vec<_>>();
12006
12007        // Strict flush. Any error leaves the old WAL recovery source intact.
12008        for (_, table) in &mut tables {
12009            if table.has_pending_writes() || table.memtable_len() > 0 || table.mutable_run_len() > 0
12010            {
12011                table.force_flush()?;
12012            }
12013        }
12014
12015        // Strict compaction. Checkpoint never reports a stable image after a
12016        // skipped failure.
12017        for (_, table) in &mut tables {
12018            if table.run_count() >= 2 || table.should_compact() {
12019                table.compact()?;
12020            }
12021        }
12022
12023        before_wal_reset()?;
12024
12025        // Reap table-local retired runs while every table remains quiesced.
12026        let maintenance_epoch = self.epoch.visible();
12027        let min_active = self.snapshots.min_active(maintenance_epoch);
12028        for (table_id, table) in &mut tables {
12029            let backup_pinned: HashSet<u128> = self
12030                .backup_pins
12031                .lock()
12032                .keys()
12033                .filter_map(|(pinned_table, run_id)| {
12034                    (*pinned_table == *table_id).then_some(*run_id)
12035                })
12036                .collect();
12037            table.reap_retiring(min_active, &backup_pinned)?;
12038        }
12039
12040        // Publish a fresh synced active WAL, then durably reap every older
12041        // segment. This point is reached only after every strict flush succeeds.
12042        self.shared_wal.lock().reset_after_checkpoint()?;
12043
12044        // Remove catalog-unreachable directories and stale transaction state.
12045        let catalog_snapshot = self.catalog.read().clone();
12046        for entry in &catalog_snapshot.tables {
12047            if matches!(entry.state, TableState::Dropped { at_epoch } if at_epoch <= min_active.0) {
12048                crate::durable_file::remove_directory_all(
12049                    &self.root.join(TABLES_DIR).join(entry.table_id.to_string()),
12050                )?;
12051            }
12052            if !matches!(entry.state, TableState::Live) {
12053                continue;
12054            }
12055            let transaction_dir = self
12056                .root
12057                .join(TABLES_DIR)
12058                .join(entry.table_id.to_string())
12059                .join("_txn");
12060            if transaction_dir.is_dir() {
12061                for child in std::fs::read_dir(&transaction_dir)? {
12062                    let child = child?;
12063                    let active = child
12064                        .file_name()
12065                        .to_str()
12066                        .and_then(|name| name.parse::<u64>().ok())
12067                        .is_some_and(|txn_id| self.active_spills.is_active(txn_id));
12068                    if !active {
12069                        crate::durable_file::remove_directory_all(&child.path())?;
12070                    }
12071                }
12072            }
12073        }
12074        let external_names = catalog_snapshot
12075            .external_tables
12076            .iter()
12077            .map(|entry| entry.name.as_str())
12078            .collect::<HashSet<_>>();
12079        let external_root = self.root.join(VTAB_DIR);
12080        if external_root.is_dir() {
12081            for entry in std::fs::read_dir(&external_root)? {
12082                let entry = entry?;
12083                let name = entry.file_name();
12084                if name
12085                    .to_str()
12086                    .is_some_and(|name| external_names.contains(name))
12087                {
12088                    continue;
12089                }
12090                if entry.file_type()?.is_dir() {
12091                    crate::durable_file::remove_directory_all(&entry.path())?;
12092                } else {
12093                    std::fs::remove_file(entry.path())?;
12094                    crate::durable_file::sync_directory(&external_root)?;
12095                }
12096            }
12097        }
12098
12099        // Final authoritative metadata checkpoint while all writers remain
12100        // excluded.
12101        catalog::write_atomic(&self.root, &catalog_snapshot, self.meta_dek.as_ref())?;
12102        let visible = self.epoch.visible();
12103        for (_, table) in &tables {
12104            table.persist_manifest(visible)?;
12105        }
12106
12107        Ok(())
12108    }
12109    fn alloc_txn_id(&self) -> Result<u64> {
12110        crate::txn::allocate_txn_id(&self.next_txn_id)
12111    }
12112
12113    /// Set the per-table spill threshold (bytes). When a transaction's staged
12114    /// bytes for a single table exceed this, the rows are written as a
12115    /// uniform-epoch pending run instead of streamed Put records (spec §8.5).
12116    pub fn set_spill_threshold(&self, bytes: u64) {
12117        self.spill_threshold
12118            .store(bytes, std::sync::atomic::Ordering::Relaxed);
12119    }
12120
12121    /// Test-only: install a hook invoked after a transaction writes its spill
12122    /// runs but before the sequencer, so a test can race `gc()` against an
12123    /// in-flight spill. Not part of the stable API.
12124    #[doc(hidden)]
12125    pub fn __set_spill_hook(&self, f: impl Fn() + Send + Sync + 'static) {
12126        *self.spill_hook.lock() = Some(Box::new(f));
12127    }
12128
12129    /// Test-only: install a hook invoked while a spilled commit holds the
12130    /// security read gate and before it appends to the WAL.
12131    #[doc(hidden)]
12132    pub fn __set_security_commit_hook(&self, f: impl Fn() + Send + Sync + 'static) {
12133        *self.security_commit_hook.lock() = Some(Box::new(f));
12134    }
12135
12136    /// Test-only: install a hook after transaction preparation and before the
12137    /// commit sequencer validates catalog generations.
12138    #[doc(hidden)]
12139    pub fn __set_catalog_commit_hook(&self, f: impl Fn() + Send + Sync + 'static) {
12140        *self.catalog_commit_hook.lock() = Some(Box::new(f));
12141    }
12142
12143    /// Test-only: pause an online backup after its consistent boundary is
12144    /// captured but before the pinned immutable runs are copied.
12145    #[doc(hidden)]
12146    pub fn __set_backup_hook(&self, f: impl Fn() + Send + Sync + 'static) {
12147        *self.backup_hook.lock() = Some(Box::new(f));
12148    }
12149
12150    /// Test-only: pause WAL extraction before its final principal recheck.
12151    #[doc(hidden)]
12152    pub fn __set_replication_hook(&self, f: impl Fn() + Send + Sync + 'static) {
12153        *self.replication_hook.lock() = Some(Box::new(f));
12154    }
12155
12156    /// Number of WAL fsyncs issued so far (test/diagnostic). With group commit
12157    /// this stays well below the number of committed transactions when commits
12158    /// are concurrent (one leader fsync covers a whole batch — spec §9.3).
12159    #[doc(hidden)]
12160    pub fn __wal_group_sync_count(&self) -> u64 {
12161        self.shared_wal.lock().group_sync_count()
12162    }
12163
12164    /// Force the poisoned state (test-only) to verify the §9.3e fail-fast
12165    /// contract that an fsync error would trigger in production.
12166    #[doc(hidden)]
12167    pub fn __poison(&self) {
12168        self.poisoned
12169            .store(true, std::sync::atomic::Ordering::Relaxed);
12170    }
12171
12172    /// Verify multi-table integrity (spec §16). For every live table this:
12173    /// authenticates the manifest; opens each `RunRef`'s file through
12174    /// [`RunReader`](crate::sorted_run::RunReader), which verifies the run footer
12175    /// checksum and — for encrypted DBs — the keyed run-metadata MAC; checks each
12176    /// run's physical row count against its `RunRef`; flags `RunRef`s whose file
12177    /// is missing (dangling) and `.sr` files on disk that no `RunRef` references
12178    /// (orphan); and verifies `flushed_epoch <= current_epoch`. Returns the list
12179    /// of issues found (empty = healthy). Orphans are `warning`-severity; all
12180    /// other findings are `error`-severity (so [`Self::doctor`] quarantines them).
12181    ///
12182    /// Cost: O(total run bytes) — the footer checksum is verified over each run's
12183    /// full body, so this is an integrity tool, not a hot path.
12184    pub fn check(&self) -> Vec<CheckIssue> {
12185        match self.check_inner(None) {
12186            Ok(issues) => issues,
12187            Err(error) => vec![CheckIssue {
12188                table_id: WAL_TABLE_ID,
12189                table_name: "shared WAL".into(),
12190                severity: "error".into(),
12191                description: error.to_string(),
12192            }],
12193        }
12194    }
12195
12196    /// Integrity check with cooperative cancellation between tables and runs.
12197    #[doc(hidden)]
12198    pub fn check_controlled(&self, control: &crate::ExecutionControl) -> Result<Vec<CheckIssue>> {
12199        self.check_inner(Some(control))
12200    }
12201
12202    fn check_inner(&self, control: Option<&crate::ExecutionControl>) -> Result<Vec<CheckIssue>> {
12203        let mut issues = Vec::new();
12204        let cat = self.catalog.read();
12205        let manifest_meta_dek = crate::encryption::meta_dek_for(self.kek.as_deref());
12206        for (table_index, entry) in cat.tables.iter().enumerate() {
12207            if table_index % 256 == 0 {
12208                if let Some(control) = control {
12209                    control.checkpoint()?;
12210                }
12211            }
12212            if !matches!(entry.state, TableState::Live) {
12213                continue;
12214            }
12215            let tdir = self.root.join(TABLES_DIR).join(entry.table_id.to_string());
12216            let mut err = |sev: &str, desc: String| {
12217                issues.push(CheckIssue {
12218                    table_id: entry.table_id,
12219                    table_name: entry.name.clone(),
12220                    severity: sev.into(),
12221                    description: desc,
12222                });
12223            };
12224            let m = match crate::manifest::read(&tdir, manifest_meta_dek.as_ref()) {
12225                Ok(m) => m,
12226                Err(e) => {
12227                    err("error", format!("manifest read failed: {e}"));
12228                    continue;
12229                }
12230            };
12231            if m.flushed_epoch > m.current_epoch {
12232                err(
12233                    "error",
12234                    format!(
12235                        "flushed_epoch {} exceeds current_epoch {} (impossible)",
12236                        m.flushed_epoch, m.current_epoch
12237                    ),
12238                );
12239            }
12240
12241            let runs_dir = tdir.join(crate::engine::RUNS_DIR);
12242            let mut referenced: std::collections::HashSet<u128> = std::collections::HashSet::new();
12243            for (run_index, rr) in m.runs.iter().enumerate() {
12244                if run_index % 256 == 0 {
12245                    if let Some(control) = control {
12246                        control.checkpoint()?;
12247                    }
12248                }
12249                referenced.insert(rr.run_id);
12250                let run_path = runs_dir.join(format!("r-{}.sr", rr.run_id));
12251                if !run_path.exists() {
12252                    err("error", format!("missing run file: r-{}.sr", rr.run_id));
12253                    continue;
12254                }
12255                match crate::sorted_run::RunReader::open(
12256                    &run_path,
12257                    entry.schema.clone(),
12258                    self.kek.clone(),
12259                ) {
12260                    Ok(reader) => {
12261                        if reader.row_count() as u64 != rr.row_count {
12262                            err(
12263                                "error",
12264                                format!(
12265                                    "run r-{} row count mismatch: manifest {} vs run {}",
12266                                    rr.run_id,
12267                                    rr.row_count,
12268                                    reader.row_count()
12269                                ),
12270                            );
12271                        }
12272                    }
12273                    Err(e) => {
12274                        err(
12275                            "error",
12276                            format!("run r-{} integrity check failed: {e}", rr.run_id),
12277                        );
12278                    }
12279                }
12280            }
12281
12282            // Compaction-superseded runs awaiting retention-gated deletion are
12283            // tracked in `retiring`; their files are expected on disk, so they
12284            // are not orphans.
12285            for r in &m.retiring {
12286                referenced.insert(r.run_id);
12287            }
12288
12289            // Orphan `.sr` files present on disk but absent from the manifest.
12290            if let Ok(rd) = std::fs::read_dir(&runs_dir) {
12291                for (entry_index, ent) in rd.flatten().enumerate() {
12292                    if entry_index % 256 == 0 {
12293                        if let Some(control) = control {
12294                            control.checkpoint()?;
12295                        }
12296                    }
12297                    let p = ent.path();
12298                    if p.extension().and_then(|s| s.to_str()) != Some("sr") {
12299                        continue;
12300                    }
12301                    let run_id = p
12302                        .file_stem()
12303                        .and_then(|s| s.to_str())
12304                        .and_then(|s| s.strip_prefix("r-"))
12305                        .and_then(|s| s.parse::<u128>().ok());
12306                    if let Some(id) = run_id {
12307                        if !referenced.contains(&id) {
12308                            err(
12309                                "warning",
12310                                format!("orphan run file r-{id}.sr not referenced by the manifest"),
12311                            );
12312                        }
12313                    }
12314                }
12315            }
12316        }
12317
12318        let external_names = cat
12319            .external_tables
12320            .iter()
12321            .map(|entry| entry.name.clone())
12322            .collect::<std::collections::HashSet<_>>();
12323        let vtab_dir = self.root.join(VTAB_DIR);
12324        if let Ok(entries) = std::fs::read_dir(&vtab_dir) {
12325            for (entry_index, entry) in entries.flatten().enumerate() {
12326                if entry_index % 256 == 0 {
12327                    if let Some(control) = control {
12328                        control.checkpoint()?;
12329                    }
12330                }
12331                let name = entry.file_name();
12332                let Some(name) = name.to_str() else { continue };
12333                if !external_names.contains(name) {
12334                    issues.push(CheckIssue {
12335                        table_id: EXTERNAL_TABLE_ID,
12336                        table_name: name.to_string(),
12337                        severity: "warning".into(),
12338                        description: format!(
12339                            "orphan external table state entry {:?} not referenced by the catalog",
12340                            entry.path()
12341                        ),
12342                    });
12343                }
12344            }
12345        }
12346
12347        // WAL retention / integrity invariant (spec §16): every on-disk WAL
12348        // segment must open (header magic + version, and the frame cipher must
12349        // be derivable for an encrypted WAL). A segment that won't open is
12350        // corrupt or truncated and would break crash recovery. `table_id` is
12351        // the reserved `WAL_TABLE_ID` sentinel (u64::MAX) so [`Self::doctor`]
12352        // never confuses a WAL issue with a real table.
12353        if let Some(control) = control {
12354            control.checkpoint()?;
12355        }
12356        for (seg, msg) in self.shared_wal.lock().verify_segments() {
12357            issues.push(CheckIssue {
12358                table_id: WAL_TABLE_ID,
12359                table_name: "<wal>".into(),
12360                severity: "error".into(),
12361                description: format!("WAL segment seg-{seg:06}.wal failed integrity check: {msg}"),
12362            });
12363        }
12364        Ok(issues)
12365    }
12366
12367    /// Quarantine unreadable tables (spec §16). Moves corrupt table dirs to
12368    /// `_quarantine/<table_id>/`, marks them dropped in the catalog, and
12369    /// unmounts them from the live table map so the DB still opens.
12370    pub fn doctor(&self) -> Result<Vec<u64>> {
12371        let control = crate::ExecutionControl::new(None);
12372        self.doctor_controlled(&control, || true)
12373    }
12374
12375    /// Check cancellably, then fence immediately before the first quarantine
12376    /// mutation. Returning `false` from `before_publish` leaves the database
12377    /// untouched.
12378    #[doc(hidden)]
12379    pub fn doctor_controlled<F>(
12380        &self,
12381        control: &crate::ExecutionControl,
12382        before_publish: F,
12383    ) -> Result<Vec<u64>>
12384    where
12385        F: FnOnce() -> bool,
12386    {
12387        self.doctor_controlled_with_receipt(control, before_publish)
12388            .map(|(quarantined, _)| quarantined)
12389    }
12390
12391    /// Check cancellably and return the exact catalog epoch used for a
12392    /// quarantine publication. No receipt is returned when nothing changes.
12393    #[doc(hidden)]
12394    pub fn doctor_controlled_with_receipt<F>(
12395        &self,
12396        control: &crate::ExecutionControl,
12397        before_publish: F,
12398    ) -> Result<(Vec<u64>, Option<MaintenanceReceipt>)>
12399    where
12400        F: FnOnce() -> bool,
12401    {
12402        // Hold the DDL lock for the whole operation to prevent concurrent
12403        // create_table/drop_table from racing the catalog/dir mutation.
12404        let _ddl = self.ddl_lock.lock();
12405        let _security_write = self.security_write()?;
12406        let issues = self.check_inner(Some(control))?;
12407        // A corrupt WAL segment is reported as an error but is NOT a table
12408        // problem — quarantining an innocent table cannot fix it (and the first
12409        // real table is id 0, so the WAL sentinel WAL_TABLE_ID = u64::MAX keeps
12410        // them disjoint). The admin must address WAL corruption manually.
12411        let bad_tables: std::collections::HashSet<u64> = issues
12412            .iter()
12413            .filter(|i| {
12414                i.severity == "error"
12415                    && i.table_id != WAL_TABLE_ID
12416                    && i.table_id != EXTERNAL_TABLE_ID
12417            })
12418            .map(|i| i.table_id)
12419            .collect();
12420        if bad_tables.is_empty() {
12421            return Ok((Vec::new(), None));
12422        }
12423        let _commit = self.commit_lock.lock();
12424        control.checkpoint()?;
12425        if !before_publish() {
12426            return Err(MongrelError::Cancelled);
12427        }
12428        let maintenance_epoch = self.epoch.bump_assigned();
12429        let mut epoch_guard = EpochGuard::new(self.epoch.as_ref(), maintenance_epoch);
12430
12431        let qdir = self.root.join("_quarantine");
12432        crate::durable_file::create_directory(&qdir)?;
12433        let mut bad_tables = bad_tables.into_iter().collect::<Vec<_>>();
12434        bad_tables.sort_unstable();
12435
12436        // Quiesce every mounted target before catalog publication. Existing
12437        // handle clones are marked unavailable in the publication callback so
12438        // they cannot append to the shared WAL after their catalog entry drops.
12439        let mut handles = self
12440            .tables
12441            .read()
12442            .iter()
12443            .filter(|(table_id, _)| bad_tables.binary_search(table_id).is_ok())
12444            .map(|(table_id, handle)| (*table_id, handle.clone()))
12445            .collect::<Vec<_>>();
12446        handles.sort_by_key(|(table_id, _)| *table_id);
12447        let mut table_guards = handles
12448            .iter()
12449            .map(|(table_id, handle)| (*table_id, handle.lock()))
12450            .collect::<Vec<_>>();
12451
12452        let mut next_catalog = self.catalog.read().clone();
12453        for table_id in &bad_tables {
12454            if let Some(entry) = next_catalog
12455                .tables
12456                .iter_mut()
12457                .find(|entry| entry.table_id == *table_id)
12458            {
12459                entry.state = TableState::Dropped {
12460                    at_epoch: maintenance_epoch.0,
12461                };
12462            }
12463        }
12464        next_catalog.db_epoch = next_catalog.db_epoch.max(maintenance_epoch.0);
12465
12466        let txn_id = self.alloc_txn_id()?;
12467        let commit_seq = {
12468            let mut wal = self.shared_wal.lock();
12469            let append: Result<u64> = (|| {
12470                for table_id in &bad_tables {
12471                    wal.append(
12472                        txn_id,
12473                        *table_id,
12474                        crate::wal::Op::Ddl(crate::wal::DdlOp::DropTable {
12475                            table_id: *table_id,
12476                        }),
12477                    )?;
12478                }
12479                append_catalog_snapshot(&mut wal, txn_id, &next_catalog)?;
12480                wal.append_commit(txn_id, maintenance_epoch, &[])
12481            })();
12482            append.map_err(|error| self.commit_outcome_unknown(maintenance_epoch, error))?
12483        };
12484        self.await_durable_commit(commit_seq, maintenance_epoch)?;
12485        for (_, table) in &mut table_guards {
12486            table.mark_unavailable_after_quarantine();
12487        }
12488        {
12489            let mut live_tables = self.tables.write();
12490            for table_id in &bad_tables {
12491                live_tables.remove(table_id);
12492            }
12493        }
12494        let checkpoint = self.checkpoint_catalog_after_durable(next_catalog);
12495        self.finish_durable_publish(maintenance_epoch, &mut epoch_guard, checkpoint)?;
12496
12497        // The catalog drop is durable. Directory placement is secondary but
12498        // still uses a write-through rename. A failure reports the known
12499        // catalog outcome and leaves a harmless orphan under `tables/`.
12500        for table_id in &bad_tables {
12501            let source = self.root.join(TABLES_DIR).join(table_id.to_string());
12502            if source.exists() {
12503                let destination = qdir.join(table_id.to_string());
12504                if let Err(error) = crate::durable_file::rename(&source, &destination) {
12505                    return Err(MongrelError::DurableCommit {
12506                        epoch: maintenance_epoch.0,
12507                        message: format!(
12508                            "DOCTOR dropped table {table_id} but quarantine move failed: {error}"
12509                        ),
12510                    });
12511                }
12512            }
12513        }
12514        Ok((
12515            bad_tables,
12516            Some(MaintenanceReceipt {
12517                epoch: maintenance_epoch,
12518            }),
12519        ))
12520    }
12521
12522    /// The DB-wide KEK (if encrypted).
12523    #[allow(dead_code)]
12524    pub(crate) fn kek(&self) -> Option<&Arc<crate::encryption::Kek>> {
12525        self.kek.as_ref()
12526    }
12527
12528    /// Shared epoch authority (used by the transaction layer in P2).
12529    #[allow(dead_code)]
12530    pub(crate) fn epoch_authority(&self) -> &Arc<EpochAuthority> {
12531        &self.epoch
12532    }
12533
12534    /// Shared snapshot registry (used by GC in P3.6).
12535    #[allow(dead_code)]
12536    pub(crate) fn snapshots(&self) -> &Arc<SnapshotRegistry> {
12537        &self.snapshots
12538    }
12539}
12540
12541fn external_state_dir(root: &Path, name: &str) -> PathBuf {
12542    root.join(VTAB_DIR).join(name)
12543}
12544
12545fn append_catalog_snapshot(
12546    wal: &mut crate::wal::SharedWal,
12547    txn_id: u64,
12548    catalog: &Catalog,
12549) -> Result<()> {
12550    let catalog_json = crate::wal::DdlOp::encode_catalog(catalog)?;
12551    wal.append(
12552        txn_id,
12553        WAL_TABLE_ID,
12554        crate::wal::Op::Ddl(crate::wal::DdlOp::CatalogSnapshot { catalog_json }),
12555    )?;
12556    Ok(())
12557}
12558
12559fn filter_ignored_staging(
12560    staging: Vec<(u64, crate::txn::Staged)>,
12561    ignored_indices: &std::collections::BTreeSet<usize>,
12562) -> Vec<(u64, crate::txn::Staged)> {
12563    if ignored_indices.is_empty() {
12564        return staging;
12565    }
12566    staging
12567        .into_iter()
12568        .enumerate()
12569        .filter_map(|(idx, staged)| (!ignored_indices.contains(&idx)).then_some(staged))
12570        .collect()
12571}
12572
12573fn external_state_file(root: &Path, name: &str) -> PathBuf {
12574    external_state_dir(root, name).join("state.json")
12575}
12576
12577fn read_external_state_file(root: &Path, name: &str) -> Result<Vec<u8>> {
12578    let path = external_state_file(root, name);
12579    match std::fs::read(path) {
12580        Ok(bytes) => Ok(bytes),
12581        Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(Vec::new()),
12582        Err(e) => Err(e.into()),
12583    }
12584}
12585
12586fn current_external_state_bytes(
12587    root: &Path,
12588    external_states: &[(String, Vec<u8>)],
12589    name: &str,
12590) -> Result<Vec<u8>> {
12591    for (table, state) in external_states.iter().rev() {
12592        if table == name {
12593            return Ok(state.clone());
12594        }
12595    }
12596    read_external_state_file(root, name)
12597}
12598
12599fn dedup_external_states(external_states: Vec<(String, Vec<u8>)>) -> Vec<(String, Vec<u8>)> {
12600    let mut out = external_states;
12601    dedup_external_states_in_place(&mut out);
12602    out
12603}
12604
12605fn dedup_external_states_in_place(external_states: &mut Vec<(String, Vec<u8>)>) {
12606    let mut seen = std::collections::HashSet::new();
12607    let mut out = Vec::with_capacity(external_states.len());
12608    for (name, state) in std::mem::take(external_states).into_iter().rev() {
12609        if seen.insert(name.clone()) {
12610            out.push((name, state));
12611        }
12612    }
12613    out.reverse();
12614    *external_states = out;
12615}
12616
12617fn prepare_external_state_file(
12618    root: &Path,
12619    name: &str,
12620    state: &[u8],
12621    txn_id: u64,
12622) -> Result<PathBuf> {
12623    crate::durable_file::create_directory(&root.join(VTAB_DIR))?;
12624    let dir = external_state_dir(root, name);
12625    crate::durable_file::create_directory(&dir)?;
12626    let pending = dir.join(format!("state.json.{txn_id}.tmp"));
12627    {
12628        let mut file = std::fs::OpenOptions::new()
12629            .create_new(true)
12630            .write(true)
12631            .open(&pending)?;
12632        file.write_all(state)?;
12633        file.sync_all()?;
12634    }
12635    Ok(pending)
12636}
12637
12638fn publish_external_state_file(root: &Path, name: &str, pending: &Path) -> Result<()> {
12639    let path = external_state_file(root, name);
12640    crate::durable_file::replace(pending, &path)?;
12641    Ok(())
12642}
12643
12644fn write_external_state_file(
12645    durable: &crate::durable_file::DurableRoot,
12646    name: &str,
12647    state: &[u8],
12648) -> Result<()> {
12649    let directory = Path::new(VTAB_DIR).join(name);
12650    durable.create_directory_all(&directory)?;
12651    durable.write_atomic(directory.join("state.json"), state)?;
12652    Ok(())
12653}
12654
12655fn validate_recovered_data_table(
12656    catalog: &Catalog,
12657    tables: &HashMap<u64, TableHandle>,
12658    table_id: u64,
12659    commit_epoch: u64,
12660    offset: u64,
12661) -> Result<bool> {
12662    let entry = catalog
12663        .tables
12664        .iter()
12665        .find(|entry| entry.table_id == table_id)
12666        .ok_or_else(|| MongrelError::CorruptWal {
12667            offset,
12668            reason: format!("committed record references unknown table {table_id}"),
12669        })?;
12670    if commit_epoch < entry.created_epoch {
12671        return Err(MongrelError::CorruptWal {
12672            offset,
12673            reason: format!(
12674                "table {table_id} record epoch {commit_epoch} precedes creation epoch {}",
12675                entry.created_epoch
12676            ),
12677        });
12678    }
12679    match entry.state {
12680        TableState::Dropped { at_epoch } => {
12681            // Abandoned hidden builds are marked dropped at the last durable
12682            // boundary during open, so their final build commit may equal the
12683            // cleanup epoch. Ordinary table drops consume a new epoch and must
12684            // remain strictly later than every data commit.
12685            let abandoned_build_boundary =
12686                entry.name.starts_with(CTAS_BUILD_TABLE_PREFIX) && commit_epoch == at_epoch;
12687            if commit_epoch >= at_epoch && !abandoned_build_boundary {
12688                Err(MongrelError::CorruptWal {
12689                    offset,
12690                    reason: format!(
12691                        "table {table_id} record epoch {commit_epoch} is not before drop epoch {at_epoch}"
12692                    ),
12693                })
12694            } else {
12695                Ok(false)
12696            }
12697        }
12698        TableState::Live | TableState::Building { .. } => {
12699            if tables.contains_key(&table_id) {
12700                Ok(true)
12701            } else {
12702                Err(MongrelError::CorruptWal {
12703                    offset,
12704                    reason: format!("live table {table_id} has no mounted recovery handle"),
12705                })
12706            }
12707        }
12708    }
12709}
12710
12711type RecoveryTableStage = (
12712    Vec<crate::memtable::Row>,
12713    Vec<(crate::rowid::RowId, Epoch)>,
12714    Option<Epoch>,
12715    Epoch,
12716);
12717
12718#[derive(Clone)]
12719struct RecoveryValidationTable {
12720    schema: Schema,
12721    flushed_epoch: u64,
12722}
12723
12724fn validate_shared_wal_recovery_plan(
12725    durable_root: &crate::durable_file::DurableRoot,
12726    catalog: &Catalog,
12727    recovered_table_ids: &HashSet<u64>,
12728    reconciled_table_ids: &HashSet<u64>,
12729    meta_dek: Option<&[u8; META_DEK_LEN]>,
12730    kek: Option<Arc<crate::encryption::Kek>>,
12731    records: &[crate::wal::Record],
12732) -> Result<()> {
12733    use crate::wal::{DdlOp, Op};
12734
12735    let mut tables = HashMap::<u64, RecoveryValidationTable>::new();
12736    for entry in &catalog.tables {
12737        if !matches!(entry.state, TableState::Live) {
12738            continue;
12739        }
12740        let relative_dir = Path::new(TABLES_DIR).join(entry.table_id.to_string());
12741        let manifest = match crate::manifest::read_durable(durable_root, &relative_dir, meta_dek) {
12742            Ok(manifest) => Some(manifest),
12743            Err(MongrelError::Io(error)) if error.kind() == std::io::ErrorKind::NotFound => None,
12744            Err(error) => return Err(error),
12745        };
12746        let flushed_epoch = if let Some(manifest) = manifest {
12747            if manifest.table_id != entry.table_id {
12748                return Err(MongrelError::Conflict(format!(
12749                    "catalog table {} storage identity mismatch",
12750                    entry.table_id
12751                )));
12752            }
12753            if (manifest.schema_id != entry.schema.schema_id
12754                && !reconciled_table_ids.contains(&entry.table_id))
12755                || manifest.flushed_epoch > manifest.current_epoch
12756                || manifest.global_idx_epoch > manifest.current_epoch
12757                || manifest.next_row_id == u64::MAX
12758                || manifest.auto_inc_next < 0
12759                || manifest.auto_inc_next == i64::MAX
12760                || (entry.schema.auto_increment_column().is_none() && manifest.auto_inc_next != 0)
12761            {
12762                return Err(MongrelError::InvalidArgument(format!(
12763                    "table {} manifest counters or schema identity are invalid",
12764                    entry.table_id
12765                )));
12766            }
12767            #[cfg(feature = "encryption")]
12768            let idx_dek = kek.as_ref().map(|key| key.derive_idx_key());
12769            #[cfg(not(feature = "encryption"))]
12770            let idx_dek: Option<zeroize::Zeroizing<[u8; 32]>> = None;
12771            crate::global_idx::read_durable_for(
12772                durable_root,
12773                &relative_dir,
12774                entry.table_id,
12775                &entry.schema,
12776                idx_dek.as_deref(),
12777            )?;
12778            let mut run_ids = HashSet::new();
12779            let mut maximum_row_id = None::<u64>;
12780            for run in &manifest.runs {
12781                if run.run_id >= u64::MAX as u128
12782                    || run.epoch_created > manifest.current_epoch
12783                    || !run_ids.insert(run.run_id)
12784                {
12785                    return Err(MongrelError::InvalidArgument(format!(
12786                        "table {} manifest contains an invalid or duplicate run id",
12787                        entry.table_id
12788                    )));
12789                }
12790                let relative = relative_dir
12791                    .join(crate::engine::RUNS_DIR)
12792                    .join(format!("r-{}.sr", run.run_id as u64));
12793                let file = durable_root.open_regular(&relative)?;
12794                let mut reader = crate::sorted_run::RunReader::open_file(
12795                    file,
12796                    entry.schema.clone(),
12797                    kek.clone(),
12798                )?;
12799                let header = reader.header();
12800                if header.run_id != run.run_id
12801                    || header.level != run.level
12802                    || header.row_count != run.row_count
12803                    || !header.is_uniform_epoch() && header.epoch_created != run.epoch_created
12804                    || header.is_uniform_epoch() && header.epoch_created != 0
12805                    || header.schema_id > entry.schema.schema_id
12806                {
12807                    return Err(MongrelError::InvalidArgument(format!(
12808                        "table {} run {} differs from its manifest: header=(id {}, level {}, rows {}, epoch {}, schema {}), manifest=(id {}, level {}, rows {}, epoch {}, schema <= {})",
12809                        entry.table_id,
12810                        run.run_id,
12811                        header.run_id,
12812                        header.level,
12813                        header.row_count,
12814                        header.epoch_created,
12815                        header.schema_id,
12816                        run.run_id,
12817                        run.level,
12818                        run.row_count,
12819                        run.epoch_created,
12820                        entry.schema.schema_id,
12821                    )));
12822                }
12823                if header.row_count != 0 {
12824                    maximum_row_id = Some(
12825                        maximum_row_id
12826                            .map_or(header.max_row_id, |value| value.max(header.max_row_id)),
12827                    );
12828                }
12829                reader.validate_all_pages()?;
12830            }
12831            if maximum_row_id.is_some_and(|maximum| manifest.next_row_id <= maximum) {
12832                return Err(MongrelError::InvalidArgument(format!(
12833                    "table {} next_row_id does not advance beyond persisted rows",
12834                    entry.table_id
12835                )));
12836            }
12837            for run in &manifest.retiring {
12838                if run.run_id >= u64::MAX as u128
12839                    || run.retire_epoch > manifest.current_epoch
12840                    || !run_ids.insert(run.run_id)
12841                {
12842                    return Err(MongrelError::InvalidArgument(format!(
12843                        "table {} manifest contains an invalid or aliased retired run",
12844                        entry.table_id
12845                    )));
12846                }
12847            }
12848            manifest.flushed_epoch
12849        } else {
12850            if !recovered_table_ids.contains(&entry.table_id) {
12851                return Err(MongrelError::NotFound(format!(
12852                    "live table {} manifest is missing",
12853                    entry.table_id
12854                )));
12855            }
12856            0
12857        };
12858        tables.insert(
12859            entry.table_id,
12860            RecoveryValidationTable {
12861                schema: entry.schema.clone(),
12862                flushed_epoch,
12863            },
12864        );
12865    }
12866
12867    let committed = records
12868        .iter()
12869        .filter_map(|record| match record.op {
12870            Op::TxnCommit { epoch, .. } => Some((record.txn_id, epoch)),
12871            _ => None,
12872        })
12873        .collect::<HashMap<_, _>>();
12874    let mut run_ids = HashSet::new();
12875    let mut recovered_row_ids = HashMap::<u64, HashSet<u64>>::new();
12876    for record in records {
12877        let Some(&commit_epoch) = committed.get(&record.txn_id) else {
12878            continue;
12879        };
12880        match &record.op {
12881            Op::Put { table_id, rows } => {
12882                let table = validate_recovery_data_table_plan(
12883                    catalog,
12884                    &tables,
12885                    *table_id,
12886                    commit_epoch,
12887                    record.seq.0,
12888                )?;
12889                let decoded: Vec<crate::memtable::Row> =
12890                    bincode::deserialize(rows).map_err(|error| MongrelError::CorruptWal {
12891                        offset: record.seq.0,
12892                        reason: format!(
12893                            "committed Put payload for transaction {} could not be decoded: {error}",
12894                            record.txn_id
12895                        ),
12896                    })?;
12897                if let Some(table) = table {
12898                    for row in &decoded {
12899                        if !recovered_row_ids
12900                            .entry(*table_id)
12901                            .or_default()
12902                            .insert(row.row_id.0)
12903                        {
12904                            return Err(MongrelError::CorruptWal {
12905                                offset: record.seq.0,
12906                                reason: format!(
12907                                    "committed WAL repeats recovered row id {} for table {table_id}",
12908                                    row.row_id.0
12909                                ),
12910                            });
12911                        }
12912                        validate_recovered_row(&table.schema, row)?;
12913                    }
12914                }
12915            }
12916            Op::Delete { table_id, .. } | Op::TruncateTable { table_id } => {
12917                validate_recovery_data_table_plan(
12918                    catalog,
12919                    &tables,
12920                    *table_id,
12921                    commit_epoch,
12922                    record.seq.0,
12923                )?;
12924            }
12925            Op::ExternalTableState { name, .. } => validate_recovered_external_name(name)?,
12926            Op::Ddl(DdlOp::ResetExternalTableState {
12927                name,
12928                generation_epoch,
12929            }) => {
12930                if *generation_epoch != commit_epoch {
12931                    return Err(MongrelError::CorruptWal {
12932                        offset: record.seq.0,
12933                        reason: format!(
12934                            "external state reset epoch {generation_epoch} does not match WAL commit epoch {commit_epoch}"
12935                        ),
12936                    });
12937                }
12938                validate_recovered_external_name(name)?;
12939            }
12940            Op::TxnCommit { added_runs, .. } => {
12941                for added in added_runs {
12942                    let Some(table) = validate_recovery_data_table_plan(
12943                        catalog,
12944                        &tables,
12945                        added.table_id,
12946                        commit_epoch,
12947                        record.seq.0,
12948                    )?
12949                    else {
12950                        continue;
12951                    };
12952                    if added.run_id >= u64::MAX as u128
12953                        || !run_ids.insert((added.table_id, added.run_id))
12954                    {
12955                        return Err(MongrelError::CorruptWal {
12956                            offset: record.seq.0,
12957                            reason: format!(
12958                                "duplicate or invalid recovered run {} for table {}",
12959                                added.run_id, added.table_id
12960                            ),
12961                        });
12962                    }
12963                    if commit_epoch <= table.flushed_epoch {
12964                        continue;
12965                    }
12966                    validate_planned_spilled_run(
12967                        durable_root,
12968                        record.txn_id,
12969                        commit_epoch,
12970                        added,
12971                        &table.schema,
12972                        kek.clone(),
12973                    )?;
12974                }
12975            }
12976            _ => {}
12977        }
12978    }
12979    Ok(())
12980}
12981
12982fn validate_recovery_data_table_plan<'a>(
12983    catalog: &Catalog,
12984    tables: &'a HashMap<u64, RecoveryValidationTable>,
12985    table_id: u64,
12986    commit_epoch: u64,
12987    offset: u64,
12988) -> Result<Option<&'a RecoveryValidationTable>> {
12989    let entry = catalog
12990        .tables
12991        .iter()
12992        .find(|entry| entry.table_id == table_id)
12993        .ok_or_else(|| MongrelError::CorruptWal {
12994            offset,
12995            reason: format!("committed record references unknown table {table_id}"),
12996        })?;
12997    if commit_epoch < entry.created_epoch {
12998        return Err(MongrelError::CorruptWal {
12999            offset,
13000            reason: format!(
13001                "table {table_id} record epoch {commit_epoch} precedes creation epoch {}",
13002                entry.created_epoch
13003            ),
13004        });
13005    }
13006    match entry.state {
13007        TableState::Dropped { at_epoch } => {
13008            let abandoned =
13009                entry.name.starts_with(CTAS_BUILD_TABLE_PREFIX) && commit_epoch == at_epoch;
13010            if commit_epoch >= at_epoch && !abandoned {
13011                return Err(MongrelError::CorruptWal {
13012                    offset,
13013                    reason: format!(
13014                        "table {table_id} record epoch {commit_epoch} is not before drop epoch {at_epoch}"
13015                    ),
13016                });
13017            }
13018            Ok(None)
13019        }
13020        TableState::Live => {
13021            tables
13022                .get(&table_id)
13023                .map(Some)
13024                .ok_or_else(|| MongrelError::CorruptWal {
13025                    offset,
13026                    reason: format!("live table {table_id} has no recovery plan"),
13027                })
13028        }
13029        TableState::Building { .. } => Err(MongrelError::CorruptWal {
13030            offset,
13031            reason: format!("building table {table_id} was not normalized before recovery"),
13032        }),
13033    }
13034}
13035
13036fn validate_planned_spilled_run(
13037    root: &crate::durable_file::DurableRoot,
13038    txn_id: u64,
13039    commit_epoch: u64,
13040    added: &crate::wal::AddedRun,
13041    schema: &Schema,
13042    kek: Option<Arc<crate::encryption::Kek>>,
13043) -> Result<()> {
13044    let table = Path::new(TABLES_DIR).join(added.table_id.to_string());
13045    let destination = table
13046        .join(crate::engine::RUNS_DIR)
13047        .join(format!("r-{}.sr", added.run_id as u64));
13048    let pending = table
13049        .join("_txn")
13050        .join(txn_id.to_string())
13051        .join(format!("r-{}.sr", added.run_id as u64));
13052    let file = match root.open_regular(&destination) {
13053        Ok(file) => file,
13054        Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
13055            root.open_regular(&pending).map_err(|pending_error| {
13056                if pending_error.kind() == std::io::ErrorKind::NotFound {
13057                    MongrelError::CorruptWal {
13058                        offset: commit_epoch,
13059                        reason: format!(
13060                            "committed spilled run {} for transaction {txn_id} is missing",
13061                            added.run_id
13062                        ),
13063                    }
13064                } else {
13065                    pending_error.into()
13066                }
13067            })?
13068        }
13069        Err(error) => return Err(error.into()),
13070    };
13071    let mut reader = crate::sorted_run::RunReader::open_file(file, schema.clone(), kek)?;
13072    let header = reader.header();
13073    if header.run_id != added.run_id
13074        || header.content_hash != added.content_hash
13075        || header.row_count != added.row_count
13076        || header.level != added.level
13077        || header.min_row_id != added.min_row_id
13078        || header.max_row_id != added.max_row_id
13079        || header.schema_id != schema.schema_id
13080        || !header.is_uniform_epoch()
13081        || header.epoch_created != 0
13082    {
13083        return Err(MongrelError::CorruptWal {
13084            offset: commit_epoch,
13085            reason: format!(
13086                "committed spilled run {} metadata differs from WAL",
13087                added.run_id
13088            ),
13089        });
13090    }
13091    reader.validate_all_pages()?;
13092    Ok(())
13093}
13094
13095/// Two-pass, `flushed_epoch`-gated recovery of the shared WAL (spec §15).
13096///
13097/// Pass 1 scans every `TxnCommit` marker and records `txn_id → commit_epoch`
13098/// (the per-txn outcome; aborted / in-flight / torn-tail txns are absent). Pass
13099/// 2 applies each committed data record (Put/Delete) to its table at the commit
13100/// epoch, skipping records whose `commit_epoch <= table.flushed_epoch` (already
13101/// durable in a sorted run). Finally the shared epoch authority is raised to the
13102/// max committed epoch so the next commit continues monotonically.
13103fn recover_shared_wal(
13104    durable_root: &crate::durable_file::DurableRoot,
13105    tables: &HashMap<u64, TableHandle>,
13106    catalog: &Catalog,
13107    epoch: &EpochAuthority,
13108    records: &[crate::wal::Record],
13109) -> Result<()> {
13110    use crate::memtable::Row;
13111    use crate::wal::{DdlOp, Op};
13112
13113    // Pass 1: committed-txn outcomes + collect spilled-run info.
13114    let mut committed: HashMap<u64, u64> = HashMap::new();
13115    let mut spilled_to_link: Vec<(
13116        u64, /*txn_id*/
13117        u64, /*epoch*/
13118        Vec<crate::wal::AddedRun>,
13119    )> = Vec::new();
13120    for r in records {
13121        if let Op::TxnCommit {
13122            epoch: ce,
13123            ref added_runs,
13124        } = r.op
13125        {
13126            committed.insert(r.txn_id, ce);
13127            if !added_runs.is_empty() {
13128                spilled_to_link.push((r.txn_id, ce, added_runs.clone()));
13129            }
13130        }
13131    }
13132    for record in records {
13133        let Some(&commit_epoch) = committed.get(&record.txn_id) else {
13134            continue;
13135        };
13136        match &record.op {
13137            Op::Put { table_id, .. }
13138            | Op::Delete { table_id, .. }
13139            | Op::TruncateTable { table_id } => {
13140                validate_recovered_data_table(
13141                    catalog,
13142                    tables,
13143                    *table_id,
13144                    commit_epoch,
13145                    record.seq.0,
13146                )?;
13147            }
13148            Op::TxnCommit { added_runs, .. } => {
13149                for run in added_runs {
13150                    validate_recovered_data_table(
13151                        catalog,
13152                        tables,
13153                        run.table_id,
13154                        commit_epoch,
13155                        record.seq.0,
13156                    )?;
13157                }
13158            }
13159            _ => {}
13160        }
13161    }
13162    let truncated_transactions: HashSet<(u64, u64)> = records
13163        .iter()
13164        .filter_map(|record| {
13165            committed.get(&record.txn_id)?;
13166            match record.op {
13167                Op::TruncateTable { table_id } => Some((record.txn_id, table_id)),
13168                _ => None,
13169            }
13170        })
13171        .collect();
13172
13173    // Pass 2: stage data per table, gated by flushed_epoch.
13174    enum ExternalRecoveryAction {
13175        Write { name: String, state: Vec<u8> },
13176        Reset { name: String },
13177    }
13178    let mut stage: HashMap<u64, RecoveryTableStage> = HashMap::new();
13179    let mut external_actions = Vec::new();
13180    let mut max_epoch = epoch.visible().0;
13181    for r in records.iter().cloned() {
13182        let Some(&ce) = committed.get(&r.txn_id) else {
13183            continue; // aborted / in-flight — discard
13184        };
13185        let commit_epoch = Epoch(ce);
13186        max_epoch = max_epoch.max(ce);
13187        match r.op {
13188            Op::Put { table_id, rows } => {
13189                // Skip if this table already flushed past the commit epoch.
13190                let skip = tables
13191                    .get(&table_id)
13192                    .map(|h| h.lock().flushed_epoch() >= ce)
13193                    .unwrap_or(true);
13194                if skip {
13195                    continue;
13196                }
13197                let rows: Vec<Row> = bincode::deserialize(&rows).map_err(|error| {
13198                    MongrelError::CorruptWal {
13199                        offset: r.seq.0,
13200                        reason: format!(
13201                            "committed Put payload for transaction {} could not be decoded: {error}",
13202                            r.txn_id
13203                        ),
13204                    }
13205                })?;
13206                // Re-stamp each row at the txn commit epoch (rows are pre-stamped
13207                // at pending_epoch which equals the commit epoch, but be robust).
13208                let rows: Vec<Row> = rows
13209                    .into_iter()
13210                    .map(|mut row| {
13211                        row.committed_epoch = commit_epoch;
13212                        row
13213                    })
13214                    .collect();
13215                let entry = stage
13216                    .entry(table_id)
13217                    .or_insert_with(|| (Vec::new(), Vec::new(), None, commit_epoch));
13218                entry.0.extend(rows);
13219                entry.3 = commit_epoch;
13220            }
13221            Op::Delete { table_id, row_ids } => {
13222                let skip = tables
13223                    .get(&table_id)
13224                    .map(|h| h.lock().flushed_epoch() >= ce)
13225                    .unwrap_or(true);
13226                if skip {
13227                    continue;
13228                }
13229                let dels = row_ids.into_iter().map(|rid| (rid, commit_epoch));
13230                let entry = stage
13231                    .entry(table_id)
13232                    .or_insert_with(|| (Vec::new(), Vec::new(), None, commit_epoch));
13233                entry.1.extend(dels);
13234                entry.3 = commit_epoch;
13235            }
13236            Op::TruncateTable { table_id } => {
13237                let skip = tables
13238                    .get(&table_id)
13239                    .map(|h| h.lock().flushed_epoch() >= ce)
13240                    .unwrap_or(true);
13241                if skip {
13242                    continue;
13243                }
13244                stage.insert(
13245                    table_id,
13246                    (Vec::new(), Vec::new(), Some(commit_epoch), commit_epoch),
13247                );
13248            }
13249            Op::ExternalTableState { name, state } => {
13250                let current_generation = catalog
13251                    .external_tables
13252                    .iter()
13253                    .find(|entry| entry.name == name)
13254                    .map(|entry| entry.created_epoch);
13255                if current_generation.is_some_and(|created_epoch| ce >= created_epoch) {
13256                    validate_recovered_external_name(&name)?;
13257                    external_actions.push(ExternalRecoveryAction::Write { name, state });
13258                }
13259            }
13260            Op::Ddl(DdlOp::ResetExternalTableState {
13261                name,
13262                generation_epoch,
13263            }) => {
13264                if generation_epoch != ce {
13265                    return Err(MongrelError::CorruptWal {
13266                        offset: r.seq.0,
13267                        reason: format!(
13268                        "external state reset epoch {generation_epoch} does not match WAL commit epoch {ce}"
13269                    ),
13270                    });
13271                }
13272                validate_recovered_external_name(&name)?;
13273                external_actions.push(ExternalRecoveryAction::Reset { name });
13274            }
13275            Op::Flush { .. }
13276            | Op::TxnCommit { .. }
13277            | Op::TxnAbort
13278            | Op::Ddl(_)
13279            | Op::BeforeImage { .. }
13280            | Op::CommitTimestamp { .. }
13281            | Op::SpilledRows { .. } => {}
13282        }
13283    }
13284    for (_, commit_epoch, added_runs) in &mut spilled_to_link {
13285        added_runs.retain(|added| {
13286            tables
13287                .get(&added.table_id)
13288                .is_some_and(|table| table.lock().flushed_epoch() < *commit_epoch)
13289        });
13290    }
13291    spilled_to_link.retain(|(_, _, added_runs)| !added_runs.is_empty());
13292    validate_recovery_table_stages(tables, &stage)?;
13293    validate_recovery_spilled_runs(durable_root, tables, &spilled_to_link)?;
13294
13295    // All WAL payloads, catalog generations, table stages, and immutable run
13296    // identities have now been validated. Only this application phase mutates
13297    // the database tree.
13298    for action in external_actions {
13299        match action {
13300            ExternalRecoveryAction::Write { name, state } => {
13301                write_external_state_file(durable_root, &name, &state)?;
13302            }
13303            ExternalRecoveryAction::Reset { name } => {
13304                durable_root.create_directory_all(VTAB_DIR)?;
13305                durable_root.remove_directory_all(Path::new(VTAB_DIR).join(name))?;
13306            }
13307        }
13308    }
13309    for (table_id, (rows, deletes, truncate_epoch, table_epoch)) in stage {
13310        let Some(handle) = tables.get(&table_id) else {
13311            continue;
13312        };
13313        let mut t = handle.lock();
13314        if let Some(epoch) = truncate_epoch {
13315            t.apply_truncate(epoch);
13316        }
13317        t.recover_apply(rows, deletes)?;
13318        // The WAL can be newer than the copied/persisted manifest after a
13319        // crash or replication apply. Rebuild O(1) count metadata from the
13320        // recovered state before endorsing the commit epoch in the manifest.
13321        let rows = t.visible_rows(Snapshot::at(Epoch(u64::MAX)))?;
13322        t.live_count = rows.len() as u64;
13323        // Recovery can replay older row commits while a newer spilled run is
13324        // already linked by the copied manifest. Never move that manifest's
13325        // epoch behind its existing run references.
13326        t.persist_manifest(table_epoch.max(epoch.visible()))?;
13327    }
13328
13329    // Pass 3: link spilled runs from committed txns (spec §8.5). A crash
13330    // between TxnCommit sync and the publish phase leaves the run in
13331    // `_txn/<txn_id>/`. Move it to `_runs/` and add the RunRef.
13332    for (txn_id, ce, added_runs) in &spilled_to_link {
13333        for ar in added_runs {
13334            let Some(handle) = tables.get(&ar.table_id) else {
13335                continue;
13336            };
13337            let mut t = handle.lock();
13338            let table_dir = Path::new(TABLES_DIR).join(ar.table_id.to_string());
13339            let destination = table_dir
13340                .join(crate::engine::RUNS_DIR)
13341                .join(format!("r-{}.sr", ar.run_id));
13342            match durable_root.open_regular(&destination) {
13343                Ok(_) => {}
13344                Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
13345                    let pending = table_dir
13346                        .join("_txn")
13347                        .join(txn_id.to_string())
13348                        .join(format!("r-{}.sr", ar.run_id));
13349                    durable_root.rename_file_new(&pending, &destination)?;
13350                }
13351                Err(error) => return Err(error.into()),
13352            }
13353            // Only link a run whose file is actually present, and never re-link
13354            // one the publish phase already persisted into the manifest (which is
13355            // the common clean-reopen case, since the `TxnCommit` lives in the WAL
13356            // until segment GC). `recover_spilled_run` is idempotent + reconciles
13357            // `live_count`/indexes only when the run is genuinely new.
13358            let linked = t.recover_spilled_run(crate::manifest::RunRef {
13359                run_id: ar.run_id,
13360                level: ar.level,
13361                epoch_created: *ce,
13362                row_count: ar.row_count,
13363            });
13364            let replaced = truncated_transactions.contains(&(*txn_id, ar.table_id));
13365            if replaced {
13366                t.set_flushed_epoch(Epoch(*ce));
13367            }
13368            if linked || replaced {
13369                t.persist_manifest(Epoch(*ce).max(epoch.visible()))?;
13370            }
13371        }
13372    }
13373
13374    epoch.advance_recovered(Epoch(max_epoch));
13375    Ok(())
13376}
13377
13378fn reconcile_recovered_table_metadata(
13379    tables: &HashMap<u64, TableHandle>,
13380    epoch: Epoch,
13381) -> Result<()> {
13382    let mut table_ids = tables.keys().copied().collect::<Vec<_>>();
13383    table_ids.sort_unstable();
13384    let mut plans = Vec::with_capacity(table_ids.len());
13385    for table_id in &table_ids {
13386        let handle = tables.get(table_id).ok_or_else(|| {
13387            MongrelError::Other(format!("mounted table {table_id} vanished during recovery"))
13388        })?;
13389        plans.push((*table_id, handle.lock().plan_recovered_metadata()?));
13390    }
13391    // Every table's data and metadata have been decoded successfully. Publish
13392    // repairs only after the complete database-wide plan is known valid.
13393    for (table_id, plan) in plans {
13394        let handle = tables.get(&table_id).ok_or_else(|| {
13395            MongrelError::Other(format!("mounted table {table_id} vanished during recovery"))
13396        })?;
13397        handle.lock().apply_recovered_metadata(plan, epoch)?;
13398    }
13399    Ok(())
13400}
13401
13402fn validate_recovered_external_name(name: &str) -> Result<()> {
13403    if name.is_empty()
13404        || !name.chars().all(|character| {
13405            character.is_ascii_alphanumeric() || character == '_' || character == '-'
13406        })
13407    {
13408        return Err(MongrelError::CorruptWal {
13409            offset: 0,
13410            reason: format!("unsafe recovered external-table name {name:?}"),
13411        });
13412    }
13413    Ok(())
13414}
13415
13416fn validate_recovery_table_stages(
13417    tables: &HashMap<u64, TableHandle>,
13418    stages: &HashMap<u64, RecoveryTableStage>,
13419) -> Result<()> {
13420    for (table_id, (rows, _, _, _)) in stages {
13421        let handle = tables
13422            .get(table_id)
13423            .ok_or_else(|| MongrelError::CorruptWal {
13424                offset: *table_id,
13425                reason: format!("recovery stage references unmounted table {table_id}"),
13426            })?;
13427        let table = handle.lock();
13428        // Force all existing immutable runs through their integrity/decode path
13429        // before any other table manifest can be changed.
13430        table.visible_rows(Snapshot::at(Epoch(u64::MAX)))?;
13431        for row in rows {
13432            validate_recovered_row(table.schema(), row)?;
13433        }
13434    }
13435    Ok(())
13436}
13437
13438fn validate_recovered_row(schema: &Schema, row: &crate::memtable::Row) -> Result<()> {
13439    if row.deleted || row.row_id.0 == u64::MAX {
13440        return Err(MongrelError::CorruptWal {
13441            offset: row.row_id.0,
13442            reason: "committed Put payload contains a tombstone or exhausted row id".into(),
13443        });
13444    }
13445    let cells = row
13446        .columns
13447        .iter()
13448        .map(|(column, value)| (*column, value.clone()))
13449        .collect::<Vec<_>>();
13450    schema
13451        .validate_persisted_values(&cells)
13452        .map_err(|error| MongrelError::CorruptWal {
13453            offset: row.row_id.0,
13454            reason: format!("recovered row violates table schema: {error}"),
13455        })?;
13456    if schema.auto_increment_column().is_some_and(|column| {
13457        matches!(row.columns.get(&column.id), Some(Value::Int64(value)) if *value == i64::MAX)
13458    }) {
13459        return Err(MongrelError::CorruptWal {
13460            offset: row.row_id.0,
13461            reason: "recovered AUTO_INCREMENT value exhausts i64".into(),
13462        });
13463    }
13464    Ok(())
13465}
13466
13467fn validate_recovery_spilled_runs(
13468    root: &crate::durable_file::DurableRoot,
13469    tables: &HashMap<u64, TableHandle>,
13470    spilled: &[(u64, u64, Vec<crate::wal::AddedRun>)],
13471) -> Result<()> {
13472    let mut identities = HashSet::new();
13473    for (txn_id, commit_epoch, added_runs) in spilled {
13474        for added in added_runs {
13475            if added.run_id >= u64::MAX as u128 {
13476                return Err(MongrelError::CorruptWal {
13477                    offset: *commit_epoch,
13478                    reason: format!(
13479                        "recovered run id {} exceeds the on-disk namespace",
13480                        added.run_id
13481                    ),
13482                });
13483            }
13484            let Some(handle) = tables.get(&added.table_id) else {
13485                continue;
13486            };
13487            if !identities.insert((added.table_id, added.run_id)) {
13488                return Err(MongrelError::CorruptWal {
13489                    offset: *commit_epoch,
13490                    reason: format!(
13491                        "duplicate recovered run {} for table {}",
13492                        added.run_id, added.table_id
13493                    ),
13494                });
13495            }
13496            let table = handle.lock();
13497            validate_planned_spilled_run(
13498                root,
13499                *txn_id,
13500                *commit_epoch,
13501                added,
13502                table.schema(),
13503                table.kek(),
13504            )?;
13505        }
13506    }
13507    Ok(())
13508}
13509
13510fn validate_condition_columns(condition: &ProcedureCondition, schema: &Schema) -> Result<()> {
13511    match condition {
13512        ProcedureCondition::Pk { .. } => {
13513            if schema.primary_key().is_none() {
13514                return Err(MongrelError::InvalidArgument(
13515                    "procedure condition Pk references a table without a primary key".into(),
13516                ));
13517            }
13518        }
13519        ProcedureCondition::BitmapEq { column_id, .. }
13520        | ProcedureCondition::BitmapIn { column_id, .. }
13521        | ProcedureCondition::Range { column_id, .. }
13522        | ProcedureCondition::RangeF64 { column_id, .. }
13523        | ProcedureCondition::IsNull { column_id }
13524        | ProcedureCondition::IsNotNull { column_id }
13525        | ProcedureCondition::FmContains { column_id, .. } => {
13526            validate_column_id(*column_id, schema)?;
13527        }
13528    }
13529    Ok(())
13530}
13531
13532fn bind_procedure_args(
13533    procedure: &StoredProcedure,
13534    mut args: HashMap<String, crate::Value>,
13535) -> Result<HashMap<String, crate::Value>> {
13536    let mut out = HashMap::new();
13537    for param in &procedure.params {
13538        let value = match args.remove(&param.name) {
13539            Some(value) => value,
13540            None => param.default.clone().ok_or_else(|| {
13541                MongrelError::InvalidArgument(format!(
13542                    "missing required procedure parameter {:?}",
13543                    param.name
13544                ))
13545            })?,
13546        };
13547        if !param.nullable && matches!(value, crate::Value::Null) {
13548            return Err(MongrelError::InvalidArgument(format!(
13549                "procedure parameter {:?} must not be NULL",
13550                param.name
13551            )));
13552        }
13553        if !matches!(value, crate::Value::Null) && !value_matches_type(&value, param.ty.clone()) {
13554            return Err(MongrelError::InvalidArgument(format!(
13555                "procedure parameter {:?} has wrong type",
13556                param.name
13557            )));
13558        }
13559        out.insert(param.name.clone(), value);
13560    }
13561    if let Some(extra) = args.keys().next() {
13562        return Err(MongrelError::InvalidArgument(format!(
13563            "unknown procedure parameter {extra:?}"
13564        )));
13565    }
13566    Ok(out)
13567}
13568
13569fn value_matches_type(value: &crate::Value, ty: crate::TypeId) -> bool {
13570    matches!(
13571        (value, ty),
13572        (crate::Value::Bool(_), crate::TypeId::Bool)
13573            | (crate::Value::Int64(_), crate::TypeId::Int8)
13574            | (crate::Value::Int64(_), crate::TypeId::Int16)
13575            | (crate::Value::Int64(_), crate::TypeId::Int32)
13576            | (crate::Value::Int64(_), crate::TypeId::Int64)
13577            | (crate::Value::Int64(_), crate::TypeId::UInt8)
13578            | (crate::Value::Int64(_), crate::TypeId::UInt16)
13579            | (crate::Value::Int64(_), crate::TypeId::UInt32)
13580            | (crate::Value::Int64(_), crate::TypeId::UInt64)
13581            | (crate::Value::Int64(_), crate::TypeId::TimestampNanos)
13582            | (crate::Value::Int64(_), crate::TypeId::Date32)
13583            | (crate::Value::Float64(_), crate::TypeId::Float32)
13584            | (crate::Value::Float64(_), crate::TypeId::Float64)
13585            | (crate::Value::Bytes(_), crate::TypeId::Bytes)
13586            | (crate::Value::Embedding(_), crate::TypeId::Embedding { .. })
13587    )
13588}
13589
13590fn eval_cells(
13591    cells: &[crate::procedure::ProcedureCell],
13592    args: &HashMap<String, crate::Value>,
13593    outputs: &HashMap<String, ProcedureCallOutput>,
13594) -> Result<Vec<(u16, crate::Value)>> {
13595    cells
13596        .iter()
13597        .map(|cell| Ok((cell.column_id, eval_value(&cell.value, args, outputs)?)))
13598        .collect()
13599}
13600
13601fn eval_condition(
13602    condition: &ProcedureCondition,
13603    args: &HashMap<String, crate::Value>,
13604    outputs: &HashMap<String, ProcedureCallOutput>,
13605) -> Result<crate::Condition> {
13606    Ok(match condition {
13607        ProcedureCondition::Pk { value } => {
13608            crate::Condition::Pk(eval_value(value, args, outputs)?.encode_key())
13609        }
13610        ProcedureCondition::BitmapEq { column_id, value } => crate::Condition::BitmapEq {
13611            column_id: *column_id,
13612            value: eval_value(value, args, outputs)?.encode_key(),
13613        },
13614        ProcedureCondition::BitmapIn { column_id, values } => crate::Condition::BitmapIn {
13615            column_id: *column_id,
13616            values: values
13617                .iter()
13618                .map(|value| Ok(eval_value(value, args, outputs)?.encode_key()))
13619                .collect::<Result<Vec<_>>>()?,
13620        },
13621        ProcedureCondition::Range { column_id, lo, hi } => crate::Condition::Range {
13622            column_id: *column_id,
13623            lo: expect_i64(eval_value(lo, args, outputs)?)?,
13624            hi: expect_i64(eval_value(hi, args, outputs)?)?,
13625        },
13626        ProcedureCondition::RangeF64 {
13627            column_id,
13628            lo,
13629            lo_inclusive,
13630            hi,
13631            hi_inclusive,
13632        } => crate::Condition::RangeF64 {
13633            column_id: *column_id,
13634            lo: expect_f64(eval_value(lo, args, outputs)?)?,
13635            lo_inclusive: *lo_inclusive,
13636            hi: expect_f64(eval_value(hi, args, outputs)?)?,
13637            hi_inclusive: *hi_inclusive,
13638        },
13639        ProcedureCondition::IsNull { column_id } => crate::Condition::IsNull {
13640            column_id: *column_id,
13641        },
13642        ProcedureCondition::IsNotNull { column_id } => crate::Condition::IsNotNull {
13643            column_id: *column_id,
13644        },
13645        ProcedureCondition::FmContains { column_id, pattern } => crate::Condition::FmContains {
13646            column_id: *column_id,
13647            pattern: expect_bytes(eval_value(pattern, args, outputs)?)?,
13648        },
13649    })
13650}
13651
13652fn eval_value(
13653    value: &ProcedureValue,
13654    args: &HashMap<String, crate::Value>,
13655    outputs: &HashMap<String, ProcedureCallOutput>,
13656) -> Result<crate::Value> {
13657    match value {
13658        ProcedureValue::Literal(value) => Ok(value.clone()),
13659        ProcedureValue::Param(name) => args.get(name).cloned().ok_or_else(|| {
13660            MongrelError::InvalidArgument(format!("unknown procedure parameter {name:?}"))
13661        }),
13662        ProcedureValue::StepScalar(id) => match outputs.get(id) {
13663            Some(ProcedureCallOutput::Scalar(value)) => Ok(value.clone()),
13664            _ => Err(MongrelError::InvalidArgument(format!(
13665                "procedure step {id:?} did not return a scalar"
13666            ))),
13667        },
13668        ProcedureValue::StepRows(_) | ProcedureValue::StepRow(_) => {
13669            Err(MongrelError::InvalidArgument(
13670                "row-valued procedure reference cannot be used as a scalar".into(),
13671            ))
13672        }
13673        ProcedureValue::Object(_) | ProcedureValue::Array(_) => Err(MongrelError::InvalidArgument(
13674            "structured procedure value cannot be used as a scalar cell".into(),
13675        )),
13676    }
13677}
13678
13679fn eval_return_output(
13680    value: &ProcedureValue,
13681    args: &HashMap<String, crate::Value>,
13682    outputs: &HashMap<String, ProcedureCallOutput>,
13683) -> Result<ProcedureCallOutput> {
13684    match value {
13685        ProcedureValue::Literal(value) => Ok(ProcedureCallOutput::Scalar(value.clone())),
13686        ProcedureValue::Param(name) => Ok(ProcedureCallOutput::Scalar(
13687            args.get(name).cloned().ok_or_else(|| {
13688                MongrelError::InvalidArgument(format!("unknown procedure parameter {name:?}"))
13689            })?,
13690        )),
13691        ProcedureValue::StepRows(id)
13692        | ProcedureValue::StepRow(id)
13693        | ProcedureValue::StepScalar(id) => outputs.get(id).cloned().ok_or_else(|| {
13694            MongrelError::InvalidArgument(format!("unknown procedure step output {id:?}"))
13695        }),
13696        ProcedureValue::Object(fields) => {
13697            let mut out = Vec::with_capacity(fields.len());
13698            for (name, value) in fields {
13699                out.push((name.clone(), eval_return_output(value, args, outputs)?));
13700            }
13701            Ok(ProcedureCallOutput::Object(out))
13702        }
13703        ProcedureValue::Array(values) => {
13704            let mut out = Vec::with_capacity(values.len());
13705            for value in values {
13706                out.push(eval_return_output(value, args, outputs)?);
13707            }
13708            Ok(ProcedureCallOutput::Array(out))
13709        }
13710    }
13711}
13712
13713fn expect_i64(value: crate::Value) -> Result<i64> {
13714    match value {
13715        crate::Value::Int64(value) => Ok(value),
13716        _ => Err(MongrelError::InvalidArgument(
13717            "procedure value must be Int64".into(),
13718        )),
13719    }
13720}
13721
13722fn expect_f64(value: crate::Value) -> Result<f64> {
13723    match value {
13724        crate::Value::Float64(value) => Ok(value),
13725        _ => Err(MongrelError::InvalidArgument(
13726            "procedure value must be Float64".into(),
13727        )),
13728    }
13729}
13730
13731fn expect_bytes(value: crate::Value) -> Result<Vec<u8>> {
13732    match value {
13733        crate::Value::Bytes(value) => Ok(value),
13734        _ => Err(MongrelError::InvalidArgument(
13735            "procedure value must be Bytes".into(),
13736        )),
13737    }
13738}
13739
13740fn validate_column_id(column_id: u16, schema: &Schema) -> Result<()> {
13741    if schema.columns.iter().any(|c| c.id == column_id) {
13742        Ok(())
13743    } else {
13744        Err(MongrelError::InvalidArgument(format!(
13745            "unknown column id {column_id}"
13746        )))
13747    }
13748}
13749
13750fn trigger_matches_event(
13751    trigger: &StoredTrigger,
13752    event: &WriteEvent,
13753    cat: &Catalog,
13754) -> Result<bool> {
13755    if trigger.event != event.kind {
13756        return Ok(false);
13757    }
13758    let TriggerTarget::Table(target) = &trigger.target else {
13759        return Ok(false);
13760    };
13761    if target != &event.table {
13762        return Ok(false);
13763    }
13764    if trigger.event == TriggerEvent::Update && !trigger.update_of.is_empty() {
13765        let schema = &cat
13766            .live(target)
13767            .ok_or_else(|| {
13768                MongrelError::InvalidArgument(format!(
13769                    "trigger {:?} references unknown table {target:?}",
13770                    trigger.name
13771                ))
13772            })?
13773            .schema;
13774        let mut watched = Vec::with_capacity(trigger.update_of.len());
13775        for name in &trigger.update_of {
13776            let col = schema.column(name).ok_or_else(|| {
13777                MongrelError::InvalidArgument(format!(
13778                    "trigger {:?} references unknown UPDATE OF column {name:?}",
13779                    trigger.name
13780                ))
13781            })?;
13782            watched.push(col.id);
13783        }
13784        if !event
13785            .changed_columns
13786            .iter()
13787            .any(|column_id| watched.contains(column_id))
13788        {
13789            return Ok(false);
13790        }
13791    }
13792    Ok(true)
13793}
13794
13795fn changed_columns(old: Option<&TriggerRowImage>, new: Option<&TriggerRowImage>) -> Vec<u16> {
13796    let mut ids = std::collections::BTreeSet::new();
13797    if let Some(old) = old {
13798        ids.extend(old.columns.keys().copied());
13799    }
13800    if let Some(new) = new {
13801        ids.extend(new.columns.keys().copied());
13802    }
13803    ids.into_iter()
13804        .filter(|id| {
13805            old.and_then(|row| row.columns.get(id)) != new.and_then(|row| row.columns.get(id))
13806        })
13807        .collect()
13808}
13809
13810fn eval_trigger_cells(
13811    cells: &[crate::trigger::TriggerCell],
13812    event: &WriteEvent,
13813    selected: Option<&TriggerRowImage>,
13814) -> Result<Vec<(u16, Value)>> {
13815    cells
13816        .iter()
13817        .map(|cell| {
13818            Ok((
13819                cell.column_id,
13820                eval_trigger_value(&cell.value, event, selected)?,
13821            ))
13822        })
13823        .collect()
13824}
13825
13826fn eval_trigger_expr(expr: &TriggerExpr, event: &WriteEvent) -> Result<bool> {
13827    match expr {
13828        TriggerExpr::Value(value) => match eval_trigger_value(value, event, None)? {
13829            Value::Bool(value) => Ok(value),
13830            Value::Null => Ok(false),
13831            other => Err(MongrelError::InvalidArgument(format!(
13832                "trigger WHEN value must be boolean, got {other:?}"
13833            ))),
13834        },
13835        TriggerExpr::Eq { left, right } => Ok(values_equal(
13836            &eval_trigger_value(left, event, None)?,
13837            &eval_trigger_value(right, event, None)?,
13838        )),
13839        TriggerExpr::NotEq { left, right } => Ok(!values_equal(
13840            &eval_trigger_value(left, event, None)?,
13841            &eval_trigger_value(right, event, None)?,
13842        )),
13843        TriggerExpr::Lt { left, right } => match value_order(
13844            &eval_trigger_value(left, event, None)?,
13845            &eval_trigger_value(right, event, None)?,
13846        ) {
13847            Some(ordering) => Ok(ordering == std::cmp::Ordering::Less),
13848            None => Ok(false),
13849        },
13850        TriggerExpr::Lte { left, right } => match value_order(
13851            &eval_trigger_value(left, event, None)?,
13852            &eval_trigger_value(right, event, None)?,
13853        ) {
13854            Some(ordering) => Ok(ordering != std::cmp::Ordering::Greater),
13855            None => Ok(false),
13856        },
13857        TriggerExpr::Gt { left, right } => match value_order(
13858            &eval_trigger_value(left, event, None)?,
13859            &eval_trigger_value(right, event, None)?,
13860        ) {
13861            Some(ordering) => Ok(ordering == std::cmp::Ordering::Greater),
13862            None => Ok(false),
13863        },
13864        TriggerExpr::Gte { left, right } => match value_order(
13865            &eval_trigger_value(left, event, None)?,
13866            &eval_trigger_value(right, event, None)?,
13867        ) {
13868            Some(ordering) => Ok(ordering != std::cmp::Ordering::Less),
13869            None => Ok(false),
13870        },
13871        TriggerExpr::IsNull(value) => Ok(matches!(
13872            eval_trigger_value(value, event, None)?,
13873            Value::Null
13874        )),
13875        TriggerExpr::IsNotNull(value) => Ok(!matches!(
13876            eval_trigger_value(value, event, None)?,
13877            Value::Null
13878        )),
13879        TriggerExpr::And { left, right } => {
13880            if !eval_trigger_expr(left, event)? {
13881                Ok(false)
13882            } else {
13883                Ok(eval_trigger_expr(right, event)?)
13884            }
13885        }
13886        TriggerExpr::Or { left, right } => {
13887            if eval_trigger_expr(left, event)? {
13888                Ok(true)
13889            } else {
13890                Ok(eval_trigger_expr(right, event)?)
13891            }
13892        }
13893        TriggerExpr::Not(expr) => Ok(!eval_trigger_expr(expr, event)?),
13894    }
13895}
13896
13897fn eval_trigger_condition(
13898    condition: &TriggerCondition,
13899    event: &WriteEvent,
13900    selected: &TriggerRowImage,
13901    schema: &Schema,
13902) -> Result<bool> {
13903    match condition {
13904        TriggerCondition::Pk { value } => {
13905            let pk = schema.primary_key().ok_or_else(|| {
13906                MongrelError::InvalidArgument(
13907                    "trigger condition Pk references a table without a primary key".into(),
13908                )
13909            })?;
13910            let lhs = eval_trigger_value(value, event, Some(selected))?;
13911            Ok(values_equal(
13912                &lhs,
13913                selected.columns.get(&pk.id).unwrap_or(&Value::Null),
13914            ))
13915        }
13916        TriggerCondition::Eq { column_id, value } => Ok(values_equal(
13917            selected.columns.get(column_id).unwrap_or(&Value::Null),
13918            &eval_trigger_value(value, event, Some(selected))?,
13919        )),
13920        TriggerCondition::NotEq { column_id, value } => Ok(!values_equal(
13921            selected.columns.get(column_id).unwrap_or(&Value::Null),
13922            &eval_trigger_value(value, event, Some(selected))?,
13923        )),
13924        TriggerCondition::Lt { column_id, value } => match value_order(
13925            selected.columns.get(column_id).unwrap_or(&Value::Null),
13926            &eval_trigger_value(value, event, Some(selected))?,
13927        ) {
13928            Some(ordering) => Ok(ordering == std::cmp::Ordering::Less),
13929            None => Ok(false),
13930        },
13931        TriggerCondition::Lte { column_id, value } => match value_order(
13932            selected.columns.get(column_id).unwrap_or(&Value::Null),
13933            &eval_trigger_value(value, event, Some(selected))?,
13934        ) {
13935            Some(ordering) => Ok(ordering != std::cmp::Ordering::Greater),
13936            None => Ok(false),
13937        },
13938        TriggerCondition::Gt { column_id, value } => match value_order(
13939            selected.columns.get(column_id).unwrap_or(&Value::Null),
13940            &eval_trigger_value(value, event, Some(selected))?,
13941        ) {
13942            Some(ordering) => Ok(ordering == std::cmp::Ordering::Greater),
13943            None => Ok(false),
13944        },
13945        TriggerCondition::Gte { column_id, value } => match value_order(
13946            selected.columns.get(column_id).unwrap_or(&Value::Null),
13947            &eval_trigger_value(value, event, Some(selected))?,
13948        ) {
13949            Some(ordering) => Ok(ordering != std::cmp::Ordering::Less),
13950            None => Ok(false),
13951        },
13952        TriggerCondition::IsNull { column_id } => Ok(matches!(
13953            selected.columns.get(column_id),
13954            None | Some(Value::Null)
13955        )),
13956        TriggerCondition::IsNotNull { column_id } => Ok(!matches!(
13957            selected.columns.get(column_id),
13958            None | Some(Value::Null)
13959        )),
13960        TriggerCondition::And { left, right } => {
13961            if !eval_trigger_condition(left, event, selected, schema)? {
13962                Ok(false)
13963            } else {
13964                Ok(eval_trigger_condition(right, event, selected, schema)?)
13965            }
13966        }
13967        TriggerCondition::Or { left, right } => {
13968            if eval_trigger_condition(left, event, selected, schema)? {
13969                Ok(true)
13970            } else {
13971                Ok(eval_trigger_condition(right, event, selected, schema)?)
13972            }
13973        }
13974        TriggerCondition::Not(condition) => {
13975            Ok(!eval_trigger_condition(condition, event, selected, schema)?)
13976        }
13977    }
13978}
13979
13980fn eval_trigger_value(
13981    value: &TriggerValue,
13982    event: &WriteEvent,
13983    selected: Option<&TriggerRowImage>,
13984) -> Result<Value> {
13985    match value {
13986        TriggerValue::Literal(value) => Ok(value.clone()),
13987        TriggerValue::NewColumn(column_id) => event
13988            .new
13989            .as_ref()
13990            .and_then(|row| row.columns.get(column_id))
13991            .cloned()
13992            .ok_or_else(|| MongrelError::InvalidArgument("NEW column is not available".into())),
13993        TriggerValue::OldColumn(column_id) => event
13994            .old
13995            .as_ref()
13996            .and_then(|row| row.columns.get(column_id))
13997            .cloned()
13998            .ok_or_else(|| MongrelError::InvalidArgument("OLD column is not available".into())),
13999        TriggerValue::SelectedColumn(column_id) => selected
14000            .and_then(|row| row.columns.get(column_id))
14001            .cloned()
14002            .ok_or_else(|| {
14003                MongrelError::InvalidArgument("SELECTED column is not available".into())
14004            }),
14005    }
14006}
14007
14008fn values_equal(left: &Value, right: &Value) -> bool {
14009    match (left, right) {
14010        (Value::Null, Value::Null) => true,
14011        (Value::Bool(a), Value::Bool(b)) => a == b,
14012        (Value::Int64(a), Value::Int64(b)) => a == b,
14013        (Value::Float64(a), Value::Float64(b)) => a.to_bits() == b.to_bits(),
14014        (Value::Bytes(a), Value::Bytes(b)) => a == b,
14015        (Value::Embedding(a), Value::Embedding(b)) => {
14016            a.len() == b.len()
14017                && a.iter()
14018                    .zip(b.iter())
14019                    .all(|(a, b)| a.to_bits() == b.to_bits())
14020        }
14021        _ => false,
14022    }
14023}
14024
14025fn value_order(left: &Value, right: &Value) -> Option<std::cmp::Ordering> {
14026    match (left, right) {
14027        (Value::Null, _) | (_, Value::Null) => None,
14028        (Value::Bool(a), Value::Bool(b)) => Some(a.cmp(b)),
14029        (Value::Int64(a), Value::Int64(b)) => Some(a.cmp(b)),
14030        // Cross-type Int64/Float64 comparison coerces the integer to f64.
14031        // This matches the spec but can lose precision for i64 values above 2^53.
14032        (Value::Int64(a), Value::Float64(b)) => {
14033            let af = *a as f64;
14034            Some(af.total_cmp(b))
14035        }
14036        // Cross-type Int64/Float64 comparison coerces the integer to f64.
14037        // This matches the spec but can lose precision for i64 values above 2^53.
14038        (Value::Float64(a), Value::Int64(b)) => {
14039            let bf = *b as f64;
14040            Some(a.total_cmp(&bf))
14041        }
14042        (Value::Float64(a), Value::Float64(b)) => Some(a.total_cmp(b)),
14043        (Value::Bytes(a), Value::Bytes(b)) => Some(a.cmp(b)),
14044        (Value::Embedding(_), Value::Embedding(_)) => None,
14045        _ => None,
14046    }
14047}
14048
14049fn trigger_message(value: Value) -> String {
14050    match value {
14051        Value::Null => "NULL".into(),
14052        Value::Bool(value) => value.to_string(),
14053        Value::Int64(value) => value.to_string(),
14054        Value::Float64(value) => value.to_string(),
14055        Value::Bytes(value) => String::from_utf8_lossy(&value).into_owned(),
14056        Value::Embedding(value) => format!("{value:?}"),
14057        Value::Decimal(value) => value.to_string(),
14058        Value::Interval {
14059            months,
14060            days,
14061            nanos,
14062        } => format!("{months}m {days}d {nanos}ns"),
14063        Value::Uuid(b) => b.iter().map(|x| format!("{x:02x}")).collect(),
14064        Value::Json(b) => String::from_utf8_lossy(&b).into_owned(),
14065    }
14066}
14067
14068fn validate_trigger_step<'a>(
14069    step: &TriggerStep,
14070    cat: &'a Catalog,
14071    target_schema: &Schema,
14072    event: TriggerEvent,
14073    select_schemas: &mut HashMap<String, &'a Schema>,
14074) -> Result<()> {
14075    match step {
14076        TriggerStep::SetNew { cells } => {
14077            if event == TriggerEvent::Delete {
14078                return Err(MongrelError::InvalidArgument(
14079                    "SetNew trigger step is not valid for DELETE triggers".into(),
14080                ));
14081            }
14082            for cell in cells {
14083                validate_column_id(cell.column_id, target_schema)?;
14084                validate_trigger_value(&cell.value, target_schema, event)?;
14085            }
14086        }
14087        TriggerStep::Insert { table, cells } => {
14088            let schema = trigger_write_schema(cat, table, "insert")?;
14089            for cell in cells {
14090                validate_column_id(cell.column_id, schema)?;
14091                validate_trigger_value(&cell.value, target_schema, event)?;
14092            }
14093        }
14094        TriggerStep::UpdateByPk { table, pk, cells } => {
14095            let schema = trigger_write_schema(cat, table, "update")?;
14096            if schema.primary_key().is_none() {
14097                return Err(MongrelError::InvalidArgument(format!(
14098                    "trigger update_by_pk references table {table:?} without a primary key"
14099                )));
14100            }
14101            validate_trigger_value(pk, target_schema, event)?;
14102            for cell in cells {
14103                validate_column_id(cell.column_id, schema)?;
14104                validate_trigger_value(&cell.value, target_schema, event)?;
14105            }
14106        }
14107        TriggerStep::DeleteByPk { table, pk } => {
14108            let schema = trigger_write_schema(cat, table, "delete")?;
14109            if schema.primary_key().is_none() {
14110                return Err(MongrelError::InvalidArgument(format!(
14111                    "trigger delete_by_pk references table {table:?} without a primary key"
14112                )));
14113            }
14114            validate_trigger_value(pk, target_schema, event)?;
14115        }
14116        TriggerStep::Select {
14117            id,
14118            table,
14119            conditions,
14120        } => {
14121            let schema = trigger_read_schema(cat, table)?;
14122            for condition in conditions {
14123                validate_trigger_condition(condition, schema, target_schema, event)?;
14124            }
14125            if select_schemas.contains_key(id) {
14126                return Err(MongrelError::InvalidArgument(format!(
14127                    "duplicate select id {id:?} in trigger program"
14128                )));
14129            }
14130            select_schemas.insert(id.clone(), schema);
14131        }
14132        TriggerStep::Foreach { id, steps } => {
14133            if !select_schemas.contains_key(id) {
14134                return Err(MongrelError::InvalidArgument(format!(
14135                    "foreach references unknown select id {id:?}"
14136                )));
14137            }
14138            let mut inner_select_schemas = select_schemas.clone();
14139            for step in steps {
14140                validate_trigger_step(step, cat, target_schema, event, &mut inner_select_schemas)?;
14141            }
14142        }
14143        TriggerStep::DeleteWhere { table, conditions } => {
14144            let schema = trigger_write_schema(cat, table, "delete")?;
14145            for condition in conditions {
14146                validate_trigger_condition(condition, schema, target_schema, event)?;
14147            }
14148        }
14149        TriggerStep::UpdateWhere {
14150            table,
14151            conditions,
14152            cells,
14153        } => {
14154            let schema = trigger_write_schema(cat, table, "update")?;
14155            for condition in conditions {
14156                validate_trigger_condition(condition, schema, target_schema, event)?;
14157            }
14158            for cell in cells {
14159                validate_column_id(cell.column_id, schema)?;
14160                validate_trigger_value(&cell.value, target_schema, event)?;
14161            }
14162        }
14163        TriggerStep::Raise { message, .. } => {
14164            validate_trigger_value(message, target_schema, event)?
14165        }
14166    }
14167    Ok(())
14168}
14169
14170fn trigger_validation_error(error: MongrelError) -> MongrelError {
14171    match error {
14172        MongrelError::TriggerValidation(_) => error,
14173        MongrelError::InvalidArgument(message)
14174        | MongrelError::Conflict(message)
14175        | MongrelError::NotFound(message) => MongrelError::TriggerValidation(message),
14176        error => error,
14177    }
14178}
14179
14180fn trigger_write_schema<'a>(cat: &'a Catalog, table: &str, op: &str) -> Result<&'a Schema> {
14181    if let Some(entry) = cat.live(table) {
14182        return Ok(&entry.schema);
14183    }
14184    if let Some(entry) = cat.external_tables.iter().find(|entry| entry.name == table) {
14185        let allowed = match op {
14186            "insert" => entry.capabilities.writable || entry.capabilities.insert_only,
14187            "update" | "delete" => entry.capabilities.writable,
14188            _ => false,
14189        };
14190        if !allowed {
14191            return Err(MongrelError::InvalidArgument(format!(
14192                "trigger {op} references external table {table:?}, but module {:?} is not writable for that operation",
14193                entry.module
14194            )));
14195        }
14196        if !entry.capabilities.transaction_safe {
14197            return Err(MongrelError::InvalidArgument(format!(
14198                "trigger {op} references external table {table:?}, but module {:?} is not transaction-safe",
14199                entry.module
14200            )));
14201        }
14202        return Ok(&entry.declared_schema);
14203    }
14204    Err(MongrelError::InvalidArgument(format!(
14205        "trigger references unknown table {table:?}"
14206    )))
14207}
14208
14209fn trigger_read_schema<'a>(cat: &'a Catalog, table: &str) -> Result<&'a Schema> {
14210    if let Some(entry) = cat.live(table) {
14211        return Ok(&entry.schema);
14212    }
14213    if let Some(entry) = cat.external_tables.iter().find(|entry| entry.name == table) {
14214        if entry.capabilities.trigger_safe {
14215            return Ok(&entry.declared_schema);
14216        }
14217        return Err(MongrelError::InvalidArgument(format!(
14218            "trigger reads external table {table:?}, but module {:?} is not trigger-safe",
14219            entry.module
14220        )));
14221    }
14222    Err(MongrelError::InvalidArgument(format!(
14223        "trigger references unknown table {table:?}"
14224    )))
14225}
14226
14227fn validate_trigger_condition(
14228    condition: &TriggerCondition,
14229    schema: &Schema,
14230    target_schema: &Schema,
14231    event: TriggerEvent,
14232) -> Result<()> {
14233    match condition {
14234        TriggerCondition::Pk { value } => {
14235            if schema.primary_key().is_none() {
14236                return Err(MongrelError::InvalidArgument(
14237                    "trigger condition Pk references a table without a primary key".into(),
14238                ));
14239            }
14240            validate_trigger_value(value, target_schema, event)
14241        }
14242        TriggerCondition::Eq { column_id, value }
14243        | TriggerCondition::NotEq { column_id, value }
14244        | TriggerCondition::Lt { column_id, value }
14245        | TriggerCondition::Lte { column_id, value }
14246        | TriggerCondition::Gt { column_id, value }
14247        | TriggerCondition::Gte { column_id, value } => {
14248            validate_column_id(*column_id, schema)?;
14249            validate_trigger_value(value, target_schema, event)
14250        }
14251        TriggerCondition::IsNull { column_id } | TriggerCondition::IsNotNull { column_id } => {
14252            validate_column_id(*column_id, schema)
14253        }
14254        TriggerCondition::And { left, right } | TriggerCondition::Or { left, right } => {
14255            validate_trigger_condition(left, schema, target_schema, event)?;
14256            validate_trigger_condition(right, schema, target_schema, event)
14257        }
14258        TriggerCondition::Not(condition) => {
14259            validate_trigger_condition(condition, schema, target_schema, event)
14260        }
14261    }
14262}
14263
14264fn validate_trigger_expr(expr: &TriggerExpr, schema: &Schema, event: TriggerEvent) -> Result<()> {
14265    match expr {
14266        TriggerExpr::Value(value) | TriggerExpr::IsNull(value) | TriggerExpr::IsNotNull(value) => {
14267            validate_trigger_value(value, schema, event)
14268        }
14269        TriggerExpr::Eq { left, right }
14270        | TriggerExpr::NotEq { left, right }
14271        | TriggerExpr::Lt { left, right }
14272        | TriggerExpr::Lte { left, right }
14273        | TriggerExpr::Gt { left, right }
14274        | TriggerExpr::Gte { left, right } => {
14275            validate_trigger_value(left, schema, event)?;
14276            validate_trigger_value(right, schema, event)
14277        }
14278        TriggerExpr::And { left, right } | TriggerExpr::Or { left, right } => {
14279            validate_trigger_expr(left, schema, event)?;
14280            validate_trigger_expr(right, schema, event)
14281        }
14282        TriggerExpr::Not(expr) => validate_trigger_expr(expr, schema, event),
14283    }
14284}
14285
14286fn validate_trigger_value(
14287    value: &TriggerValue,
14288    schema: &Schema,
14289    event: TriggerEvent,
14290) -> Result<()> {
14291    match value {
14292        TriggerValue::Literal(_) => Ok(()),
14293        TriggerValue::NewColumn(id) => {
14294            if event == TriggerEvent::Delete {
14295                return Err(MongrelError::InvalidArgument(
14296                    "DELETE triggers cannot reference NEW".into(),
14297                ));
14298            }
14299            validate_column_id(*id, schema)
14300        }
14301        TriggerValue::OldColumn(id) => {
14302            if event == TriggerEvent::Insert {
14303                return Err(MongrelError::InvalidArgument(
14304                    "INSERT triggers cannot reference OLD".into(),
14305                ));
14306            }
14307            validate_column_id(*id, schema)
14308        }
14309        // SELECTED column references are only meaningful inside a foreach loop.
14310        // Strict loop-scope validation is deferred to runtime; the executor raises
14311        // an error if a selected row is not available.
14312        TriggerValue::SelectedColumn(_) => Ok(()),
14313    }
14314}
14315
14316/// Replay committed `Op::Ddl` records from the shared WAL into the catalog
14317/// (spec §15, review fix #16). A crash between WAL group-sync and the catalog
14318/// checkpoint leaves DDL durable in the WAL but absent from the on-disk
14319/// catalog. This pass closes that window by reconstructing missing entries
14320/// (and marking committed drops) before tables are mounted.
14321fn recover_ddl_from_wal(
14322    root: &Path,
14323    durable_root: Option<&crate::durable_file::DurableRoot>,
14324    target_catalog: &mut Catalog,
14325    meta_dek: Option<&[u8; META_DEK_LEN]>,
14326    wal_dek: Option<&zeroize::Zeroizing<[u8; 32]>>,
14327    apply: bool,
14328    table_roots: Option<&HashMap<u64, Arc<crate::durable_file::DurableRoot>>>,
14329) -> Result<()> {
14330    use crate::wal::SharedWal;
14331    let records = match durable_root {
14332        Some(root) => SharedWal::replay_durable_with_dek(root, wal_dek)?,
14333        None => SharedWal::replay_with_dek(root, wal_dek)?,
14334    };
14335    recover_ddl_from_records(
14336        root,
14337        durable_root,
14338        target_catalog,
14339        meta_dek,
14340        apply,
14341        table_roots,
14342        &records,
14343    )
14344}
14345
14346fn recover_ddl_from_records(
14347    root: &Path,
14348    durable_root: Option<&crate::durable_file::DurableRoot>,
14349    target_catalog: &mut Catalog,
14350    meta_dek: Option<&[u8; META_DEK_LEN]>,
14351    apply: bool,
14352    table_roots: Option<&HashMap<u64, Arc<crate::durable_file::DurableRoot>>>,
14353    records: &[crate::wal::Record],
14354) -> Result<()> {
14355    use crate::wal::{DdlOp, Op};
14356
14357    let original_catalog = target_catalog.clone();
14358    let mut recovered_catalog = original_catalog.clone();
14359    let cat = &mut recovered_catalog;
14360    let mut created_table_ids = HashSet::<u64>::new();
14361    let mut ttl_updates = HashMap::<u64, (Option<crate::manifest::TtlPolicy>, u64)>::new();
14362
14363    let mut committed: HashMap<u64, u64> = HashMap::new();
14364    for r in records {
14365        if let Op::TxnCommit { epoch: ce, .. } = r.op {
14366            committed.insert(r.txn_id, ce);
14367        }
14368    }
14369    let catalog_snapshot_txns = records
14370        .iter()
14371        .filter_map(|record| {
14372            (committed.contains_key(&record.txn_id)
14373                && matches!(&record.op, Op::Ddl(DdlOp::CatalogSnapshot { .. })))
14374            .then_some(record.txn_id)
14375        })
14376        .collect::<HashSet<_>>();
14377
14378    let mut changed = false;
14379    let mut applied_catalog_epoch = cat.db_epoch;
14380    let max_committed_epoch = committed.values().copied().max().unwrap_or(cat.db_epoch);
14381    for r in records.iter().cloned() {
14382        let Some(&ce) = committed.get(&r.txn_id) else {
14383            continue;
14384        };
14385        let txn_id = r.txn_id;
14386        match r.op {
14387            Op::Ddl(DdlOp::CreateTable {
14388                table_id,
14389                ref name,
14390                ref schema_json,
14391            }) => {
14392                if cat.tables.iter().any(|t| t.table_id == table_id) {
14393                    continue;
14394                }
14395                let schema = DdlOp::decode_schema(schema_json)?;
14396                validate_recovered_schema(&schema)?;
14397                created_table_ids.insert(table_id);
14398                cat.tables.push(CatalogEntry {
14399                    table_id,
14400                    name: name.clone(),
14401                    schema,
14402                    state: TableState::Live,
14403                    created_epoch: ce,
14404                });
14405                cat.next_table_id =
14406                    cat.next_table_id
14407                        .max(table_id.checked_add(1).ok_or_else(|| {
14408                            MongrelError::Full("table id namespace exhausted".into())
14409                        })?);
14410                changed = true;
14411            }
14412            Op::Ddl(DdlOp::CreateBuildingTable {
14413                table_id,
14414                ref build_name,
14415                ref intended_name,
14416                ref query_id,
14417                created_at_unix_nanos,
14418                ref schema_json,
14419            }) => {
14420                if cat.tables.iter().any(|table| table.table_id == table_id) {
14421                    continue;
14422                }
14423                let schema = DdlOp::decode_schema(schema_json)?;
14424                validate_recovered_schema(&schema)?;
14425                created_table_ids.insert(table_id);
14426                cat.tables.push(CatalogEntry {
14427                    table_id,
14428                    name: build_name.clone(),
14429                    schema,
14430                    state: TableState::Building {
14431                        intended_name: intended_name.clone(),
14432                        query_id: query_id.clone(),
14433                        created_at_unix_nanos,
14434                        replaces_table_id: None,
14435                    },
14436                    created_epoch: ce,
14437                });
14438                cat.next_table_id =
14439                    cat.next_table_id
14440                        .max(table_id.checked_add(1).ok_or_else(|| {
14441                            MongrelError::Full("table id namespace exhausted".into())
14442                        })?);
14443                changed = true;
14444            }
14445            Op::Ddl(DdlOp::CreateRebuildingTable {
14446                table_id,
14447                ref build_name,
14448                ref intended_name,
14449                ref query_id,
14450                created_at_unix_nanos,
14451                replaces_table_id,
14452                ref schema_json,
14453            }) => {
14454                if cat.tables.iter().any(|table| table.table_id == table_id) {
14455                    continue;
14456                }
14457                let schema = DdlOp::decode_schema(schema_json)?;
14458                validate_recovered_schema(&schema)?;
14459                created_table_ids.insert(table_id);
14460                cat.tables.push(CatalogEntry {
14461                    table_id,
14462                    name: build_name.clone(),
14463                    schema,
14464                    state: TableState::Building {
14465                        intended_name: intended_name.clone(),
14466                        query_id: query_id.clone(),
14467                        created_at_unix_nanos,
14468                        replaces_table_id: Some(replaces_table_id),
14469                    },
14470                    created_epoch: ce,
14471                });
14472                cat.next_table_id =
14473                    cat.next_table_id
14474                        .max(table_id.checked_add(1).ok_or_else(|| {
14475                            MongrelError::Full("table id namespace exhausted".into())
14476                        })?);
14477                changed = true;
14478            }
14479            Op::Ddl(DdlOp::DropTable { table_id }) => {
14480                let mut dropped_name = None;
14481                if let Some(entry) = cat.tables.iter_mut().find(|t| t.table_id == table_id) {
14482                    if matches!(entry.state, TableState::Live | TableState::Building { .. }) {
14483                        dropped_name = Some(entry.name.clone());
14484                        entry.state = TableState::Dropped { at_epoch: ce };
14485                        changed = true;
14486                    }
14487                }
14488                if let Some(name) = dropped_name {
14489                    let before = cat.materialized_views.len();
14490                    cat.materialized_views
14491                        .retain(|definition| definition.name != name);
14492                    changed |= before != cat.materialized_views.len();
14493                    cat.security.rls_tables.retain(|table| table != &name);
14494                    cat.security.policies.retain(|policy| policy.table != name);
14495                    cat.security.masks.retain(|mask| mask.table != name);
14496                    for role in &mut cat.roles {
14497                        role.permissions
14498                            .retain(|permission| permission_table(permission) != Some(&name));
14499                    }
14500                    if !catalog_snapshot_txns.contains(&txn_id) {
14501                        advance_security_version(cat)?;
14502                    }
14503                }
14504            }
14505            Op::Ddl(DdlOp::PublishBuildingTable {
14506                table_id,
14507                ref new_name,
14508            }) => {
14509                if let Some(entry) = cat
14510                    .tables
14511                    .iter_mut()
14512                    .find(|table| table.table_id == table_id)
14513                {
14514                    if entry.name != *new_name || !matches!(entry.state, TableState::Live) {
14515                        entry.name = new_name.clone();
14516                        entry.state = TableState::Live;
14517                        changed = true;
14518                    }
14519                }
14520            }
14521            Op::Ddl(DdlOp::ReplaceBuildingTable {
14522                table_id,
14523                replaced_table_id,
14524                ref new_name,
14525            }) => {
14526                changed |=
14527                    apply_rebuilding_publish(cat, table_id, replaced_table_id, new_name, ce)?;
14528            }
14529            Op::Ddl(DdlOp::RenameTable {
14530                table_id,
14531                ref new_name,
14532            }) => {
14533                let mut old_name = None;
14534                if let Some(entry) = cat.tables.iter_mut().find(|t| t.table_id == table_id) {
14535                    if entry.name != *new_name {
14536                        old_name = Some(entry.name.clone());
14537                        entry.name = new_name.clone();
14538                        changed = true;
14539                    }
14540                }
14541                if let Some(old_name) = old_name {
14542                    if let Some(definition) = cat
14543                        .materialized_views
14544                        .iter_mut()
14545                        .find(|definition| definition.name == old_name)
14546                    {
14547                        definition.name = new_name.clone();
14548                    }
14549                    for table in &mut cat.security.rls_tables {
14550                        if *table == old_name {
14551                            *table = new_name.clone();
14552                        }
14553                    }
14554                    for policy in &mut cat.security.policies {
14555                        if policy.table == old_name {
14556                            policy.table = new_name.clone();
14557                        }
14558                    }
14559                    for mask in &mut cat.security.masks {
14560                        if mask.table == old_name {
14561                            mask.table = new_name.clone();
14562                        }
14563                    }
14564                    for role in &mut cat.roles {
14565                        for permission in &mut role.permissions {
14566                            rename_permission_table(permission, &old_name, new_name);
14567                        }
14568                    }
14569                    if !catalog_snapshot_txns.contains(&txn_id) {
14570                        advance_security_version(cat)?;
14571                    }
14572                }
14573                // If the entry is absent, its CreateTable was already
14574                // checkpointed carrying the post-rename name, so there is
14575                // nothing to apply — a no-op, not an error.
14576            }
14577            Op::Ddl(DdlOp::AlterTable {
14578                table_id,
14579                ref column_json,
14580            }) => {
14581                let column = DdlOp::decode_column(column_json)?;
14582                let mut renamed = None;
14583                if let Some(entry) = cat.tables.iter_mut().find(|t| t.table_id == table_id) {
14584                    renamed = entry
14585                        .schema
14586                        .columns
14587                        .iter()
14588                        .find(|existing| existing.id == column.id && existing.name != column.name)
14589                        .map(|existing| {
14590                            (
14591                                entry.name.clone(),
14592                                existing.name.clone(),
14593                                column.name.clone(),
14594                            )
14595                        });
14596                    if apply_recovered_column_def(&mut entry.schema, column)? {
14597                        validate_recovered_schema(&entry.schema)?;
14598                        changed = true;
14599                    }
14600                }
14601                if let Some((table, old_name, new_name)) = renamed {
14602                    for role in &mut cat.roles {
14603                        for permission in &mut role.permissions {
14604                            rename_permission_column(permission, &table, &old_name, &new_name);
14605                        }
14606                    }
14607                    if !catalog_snapshot_txns.contains(&txn_id) {
14608                        advance_security_version(cat)?;
14609                    }
14610                }
14611            }
14612            Op::Ddl(DdlOp::SetTtl {
14613                table_id,
14614                ref policy_json,
14615            }) => {
14616                let policy = DdlOp::decode_ttl(policy_json)?;
14617                let entry = cat
14618                    .tables
14619                    .iter()
14620                    .find(|entry| entry.table_id == table_id)
14621                    .ok_or_else(|| {
14622                        MongrelError::Schema(format!(
14623                            "recovered TTL references unknown table id {table_id}"
14624                        ))
14625                    })?;
14626                if let Some(policy) = policy {
14627                    let valid = entry
14628                        .schema
14629                        .columns
14630                        .iter()
14631                        .find(|column| column.id == policy.column_id)
14632                        .is_some_and(|column| {
14633                            column.ty == TypeId::TimestampNanos
14634                                && policy.duration_nanos > 0
14635                                && policy.duration_nanos <= i64::MAX as u64
14636                        });
14637                    if !valid {
14638                        return Err(MongrelError::Schema(format!(
14639                            "invalid recovered TTL policy for table id {table_id}"
14640                        )));
14641                    }
14642                }
14643                ttl_updates.insert(table_id, (policy, ce));
14644            }
14645            Op::Ddl(DdlOp::SetMaterializedView {
14646                ref name,
14647                ref definition_json,
14648            }) => {
14649                let definition = DdlOp::decode_materialized_view(definition_json)?;
14650                if definition.name != *name {
14651                    return Err(MongrelError::Schema(format!(
14652                        "materialized view WAL name mismatch: {name:?}"
14653                    )));
14654                }
14655                if cat.live(name).is_some() {
14656                    if let Some(existing) = cat
14657                        .materialized_views
14658                        .iter_mut()
14659                        .find(|existing| existing.name == *name)
14660                    {
14661                        if *existing != definition {
14662                            *existing = definition;
14663                            changed = true;
14664                        }
14665                    } else {
14666                        cat.materialized_views.push(definition);
14667                        changed = true;
14668                    }
14669                }
14670            }
14671            Op::Ddl(DdlOp::SetSecurityCatalog { ref security_json }) => {
14672                let security = DdlOp::decode_security(security_json)?;
14673                validate_security_catalog(cat, &security)?;
14674                if cat.security != security {
14675                    cat.security = security;
14676                    if !catalog_snapshot_txns.contains(&txn_id) {
14677                        advance_security_version(cat)?;
14678                    }
14679                    changed = true;
14680                }
14681            }
14682            Op::Ddl(DdlOp::SetSqlPragma { ref key, value }) => {
14683                let target = match key.as_str() {
14684                    "user_version" => &mut cat.user_version,
14685                    "application_id" => &mut cat.application_id,
14686                    _ => {
14687                        return Err(MongrelError::InvalidArgument(format!(
14688                            "unsupported recovered SQL pragma {key:?}"
14689                        )))
14690                    }
14691                };
14692                if *target != Some(value) {
14693                    *target = Some(value);
14694                    cat.db_epoch = cat.db_epoch.max(ce);
14695                    changed = true;
14696                }
14697            }
14698            Op::Ddl(DdlOp::CatalogSnapshot { ref catalog_json }) => {
14699                if ce <= applied_catalog_epoch {
14700                    continue;
14701                }
14702                let snapshot = DdlOp::decode_catalog(catalog_json)?;
14703                if snapshot.db_epoch != ce {
14704                    return Err(MongrelError::Schema(format!(
14705                        "catalog snapshot epoch {} does not match WAL commit epoch {ce}",
14706                        snapshot.db_epoch
14707                    )));
14708                }
14709                validate_recovered_catalog(&snapshot)?;
14710                validate_catalog_transition(cat, &snapshot)?;
14711                *cat = snapshot;
14712                applied_catalog_epoch = ce;
14713                changed = true;
14714            }
14715            _ => {}
14716        }
14717    }
14718
14719    if cat.db_epoch < max_committed_epoch {
14720        cat.db_epoch = max_committed_epoch;
14721        changed = true;
14722    }
14723    changed |= repair_catalog_allocator_counters(cat)?;
14724
14725    validate_recovered_catalog(cat)?;
14726    let storage_reconciliation = validate_recovered_storage_plan(
14727        root,
14728        durable_root,
14729        cat,
14730        &created_table_ids,
14731        &ttl_updates,
14732        meta_dek,
14733    )?;
14734
14735    let needs_storage_apply = !storage_reconciliation.is_empty() || !ttl_updates.is_empty();
14736    if apply && (changed || needs_storage_apply) {
14737        for table_id in storage_reconciliation {
14738            let entry = cat
14739                .tables
14740                .iter()
14741                .find(|entry| entry.table_id == table_id)
14742                .ok_or_else(|| MongrelError::CorruptWal {
14743                    offset: table_id,
14744                    reason: "recovery storage plan lost its catalog table".into(),
14745                })?;
14746            ensure_recovered_table_storage(
14747                table_roots
14748                    .and_then(|roots| roots.get(&table_id))
14749                    .map(Arc::as_ref),
14750                durable_root,
14751                &root.join(TABLES_DIR).join(table_id.to_string()),
14752                table_id,
14753                &entry.schema,
14754                meta_dek,
14755            )?;
14756        }
14757        for (table_id, (policy, ttl_epoch)) in ttl_updates {
14758            let Some(entry) = cat.tables.iter().find(|entry| {
14759                entry.table_id == table_id
14760                    && matches!(entry.state, TableState::Live | TableState::Building { .. })
14761            }) else {
14762                continue;
14763            };
14764            let table_root = if let Some(root) = table_roots.and_then(|roots| roots.get(&table_id))
14765            {
14766                root.try_clone()?
14767            } else if let Some(root) = durable_root {
14768                root.open_directory(Path::new(TABLES_DIR).join(table_id.to_string()))?
14769            } else {
14770                crate::durable_file::DurableRoot::open(
14771                    root.join(TABLES_DIR).join(table_id.to_string()),
14772                )?
14773            };
14774            let table_dir = table_root.io_path()?;
14775            let mut manifest = crate::manifest::read_durable(&table_root, "", meta_dek)?;
14776            if manifest.ttl != policy || manifest.current_epoch < ttl_epoch {
14777                manifest.ttl = policy;
14778                manifest.current_epoch = manifest.current_epoch.max(ttl_epoch);
14779                manifest.schema_id = entry.schema.schema_id;
14780                crate::manifest::write_atomic(&table_dir, &mut manifest, meta_dek)?;
14781            }
14782        }
14783        if changed {
14784            match durable_root {
14785                Some(root) => catalog::write_durable(root, cat, meta_dek)?,
14786                None => catalog::write_atomic(root, cat, meta_dek)?,
14787            }
14788        }
14789    }
14790    *target_catalog = recovered_catalog;
14791    Ok(())
14792}
14793
14794fn ensure_recovered_table_storage(
14795    pinned_table: Option<&crate::durable_file::DurableRoot>,
14796    durable_root: Option<&crate::durable_file::DurableRoot>,
14797    fallback_table_dir: &Path,
14798    table_id: u64,
14799    schema: &Schema,
14800    meta_dek: Option<&[u8; META_DEK_LEN]>,
14801) -> Result<()> {
14802    let table_root = if let Some(root) = pinned_table {
14803        root.try_clone()?
14804    } else if let Some(root) = durable_root {
14805        let relative = Path::new(TABLES_DIR).join(table_id.to_string());
14806        match root.open_directory(&relative) {
14807            Ok(table) => table,
14808            Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
14809                root.create_directory_all_pinned(relative)?
14810            }
14811            Err(error) => return Err(error.into()),
14812        }
14813    } else {
14814        crate::durable_file::create_directory_all(fallback_table_dir)?;
14815        crate::durable_file::DurableRoot::open(fallback_table_dir)?
14816    };
14817    let table_dir = table_root.io_path()?;
14818    let mut existing_manifest = match crate::manifest::read_durable(&table_root, "", meta_dek) {
14819        Ok(manifest) => {
14820            if manifest.table_id != table_id {
14821                return Err(MongrelError::Conflict(format!(
14822                    "recovered table directory id mismatch: expected {table_id}, found {}",
14823                    manifest.table_id
14824                )));
14825            }
14826            Some(manifest)
14827        }
14828        Err(MongrelError::Io(error)) if error.kind() == std::io::ErrorKind::NotFound => None,
14829        Err(error) => return Err(error),
14830    };
14831
14832    table_root.create_directory_all(crate::engine::WAL_DIR)?;
14833    table_root.create_directory_all(crate::engine::RUNS_DIR)?;
14834    crate::engine::write_schema(&table_dir, schema)?;
14835
14836    if let Some(mut manifest) = existing_manifest.take() {
14837        if manifest.schema_id != schema.schema_id {
14838            manifest.schema_id = schema.schema_id;
14839            crate::manifest::write_atomic(&table_dir, &mut manifest, meta_dek)?;
14840        }
14841    } else {
14842        // The DB-wide meta DEK is also the per-table manifest meta DEK.
14843        let mut manifest = crate::manifest::Manifest::new(table_id, schema.schema_id);
14844        crate::manifest::write_atomic(&table_dir, &mut manifest, meta_dek)?;
14845    }
14846    Ok(())
14847}
14848
14849fn validate_recovered_schema(schema: &Schema) -> Result<()> {
14850    schema.validate_auto_increment()?;
14851    schema.validate_defaults()?;
14852    schema.validate_ai()?;
14853    let mut column_ids = HashSet::new();
14854    let mut column_names = HashSet::new();
14855    for column in &schema.columns {
14856        if !column_ids.insert(column.id) || !column_names.insert(column.name.as_str()) {
14857            return Err(MongrelError::Schema(
14858                "recovered schema contains duplicate columns".into(),
14859            ));
14860        }
14861        match &column.ty {
14862            TypeId::Decimal128 { precision, scale }
14863                if *precision == 0 || *precision > 38 || scale.unsigned_abs() > *precision =>
14864            {
14865                return Err(MongrelError::Schema(format!(
14866                    "column {:?} has invalid decimal precision or scale",
14867                    column.name
14868                )));
14869            }
14870            TypeId::Enum { variants }
14871                if variants.is_empty()
14872                    || variants.iter().any(String::is_empty)
14873                    || variants.iter().collect::<HashSet<_>>().len() != variants.len() =>
14874            {
14875                return Err(MongrelError::Schema(format!(
14876                    "column {:?} has invalid enum variants",
14877                    column.name
14878                )));
14879            }
14880            _ => {}
14881        }
14882    }
14883    let mut index_names = HashSet::new();
14884    for index in &schema.indexes {
14885        index.validate_options()?;
14886        if index.name.is_empty()
14887            || !index_names.insert(index.name.as_str())
14888            || schema
14889                .columns
14890                .iter()
14891                .all(|column| column.id != index.column_id)
14892        {
14893            return Err(MongrelError::Schema(format!(
14894                "recovered index {:?} references missing column {}",
14895                index.name, index.column_id
14896            )));
14897        }
14898    }
14899    let mut colocated = HashSet::new();
14900    for group in &schema.colocation {
14901        if group.is_empty()
14902            || group.iter().any(|id| !column_ids.contains(id))
14903            || group.iter().any(|id| !colocated.insert(*id))
14904        {
14905            return Err(MongrelError::Schema(
14906                "recovered schema contains invalid column co-location groups".into(),
14907            ));
14908        }
14909    }
14910
14911    let mut constraint_ids = HashSet::new();
14912    let mut constraint_names = HashSet::<String>::new();
14913    let mut validate_constraint_identity = |id: u16, name: &str| -> Result<()> {
14914        if name.is_empty()
14915            || !constraint_ids.insert(id)
14916            || !constraint_names.insert(name.to_owned())
14917        {
14918            return Err(MongrelError::Schema(
14919                "recovered schema contains duplicate or empty constraint identities".into(),
14920            ));
14921        }
14922        Ok(())
14923    };
14924    for unique in &schema.constraints.uniques {
14925        validate_constraint_identity(unique.id, &unique.name)?;
14926        if unique.columns.is_empty()
14927            || unique.columns.iter().any(|id| !column_ids.contains(id))
14928            || unique.columns.iter().collect::<HashSet<_>>().len() != unique.columns.len()
14929        {
14930            return Err(MongrelError::Schema(format!(
14931                "unique constraint {:?} has invalid columns",
14932                unique.name
14933            )));
14934        }
14935    }
14936    for foreign_key in &schema.constraints.foreign_keys {
14937        validate_constraint_identity(foreign_key.id, &foreign_key.name)?;
14938        if foreign_key.ref_table.is_empty()
14939            || foreign_key.columns.is_empty()
14940            || foreign_key.columns.len() != foreign_key.ref_columns.len()
14941            || foreign_key
14942                .columns
14943                .iter()
14944                .any(|id| !column_ids.contains(id))
14945            || foreign_key.columns.iter().collect::<HashSet<_>>().len() != foreign_key.columns.len()
14946            || foreign_key.ref_columns.iter().collect::<HashSet<_>>().len()
14947                != foreign_key.ref_columns.len()
14948        {
14949            return Err(MongrelError::Schema(format!(
14950                "foreign key {:?} has invalid columns",
14951                foreign_key.name
14952            )));
14953        }
14954        if (matches!(foreign_key.on_delete, crate::constraint::FkAction::SetNull)
14955            || matches!(foreign_key.on_update, crate::constraint::FkAction::SetNull))
14956            && foreign_key.columns.iter().any(|id| {
14957                schema
14958                    .columns
14959                    .iter()
14960                    .find(|column| column.id == *id)
14961                    .is_none_or(|column| {
14962                        !column.flags.contains(crate::schema::ColumnFlags::NULLABLE)
14963                    })
14964            })
14965        {
14966            return Err(MongrelError::Schema(format!(
14967                "foreign key {:?} uses SET NULL on a non-nullable column",
14968                foreign_key.name
14969            )));
14970        }
14971    }
14972    for check in &schema.constraints.checks {
14973        validate_constraint_identity(check.id, &check.name)?;
14974        check.expr.validate()?;
14975        validate_check_columns(&check.expr, &column_ids)?;
14976    }
14977    Ok(())
14978}
14979
14980fn validate_check_columns(
14981    expression: &crate::constraint::CheckExpr,
14982    column_ids: &HashSet<u16>,
14983) -> Result<()> {
14984    use crate::constraint::CheckExpr;
14985    match expression {
14986        CheckExpr::Col(id) | CheckExpr::IsNull(id) | CheckExpr::IsNotNull(id) => {
14987            if column_ids.contains(id) {
14988                Ok(())
14989            } else {
14990                Err(MongrelError::Schema(format!(
14991                    "check constraint references unknown column {id}"
14992                )))
14993            }
14994        }
14995        CheckExpr::Regex { col, .. } => {
14996            if column_ids.contains(col) {
14997                Ok(())
14998            } else {
14999                Err(MongrelError::Schema(format!(
15000                    "check constraint references unknown column {col}"
15001                )))
15002            }
15003        }
15004        CheckExpr::Add(left, right)
15005        | CheckExpr::Sub(left, right)
15006        | CheckExpr::Mul(left, right)
15007        | CheckExpr::Div(left, right)
15008        | CheckExpr::Mod(left, right)
15009        | CheckExpr::Eq(left, right)
15010        | CheckExpr::Ne(left, right)
15011        | CheckExpr::Lt(left, right)
15012        | CheckExpr::Le(left, right)
15013        | CheckExpr::Gt(left, right)
15014        | CheckExpr::Ge(left, right)
15015        | CheckExpr::And(left, right)
15016        | CheckExpr::Or(left, right) => {
15017            validate_check_columns(left, column_ids)?;
15018            validate_check_columns(right, column_ids)
15019        }
15020        CheckExpr::Not(inner) => validate_check_columns(inner, column_ids),
15021        CheckExpr::True | CheckExpr::Lit(_) => Ok(()),
15022    }
15023}
15024
15025fn validate_catalog_transition(current: &Catalog, next: &Catalog) -> Result<()> {
15026    for (name, prior, candidate) in [
15027        ("db_epoch", current.db_epoch, next.db_epoch),
15028        ("next_table_id", current.next_table_id, next.next_table_id),
15029        (
15030            "next_segment_no",
15031            current.next_segment_no,
15032            next.next_segment_no,
15033        ),
15034        ("next_user_id", current.next_user_id, next.next_user_id),
15035        (
15036            "security_version",
15037            current.security_version,
15038            next.security_version,
15039        ),
15040    ] {
15041        if candidate < prior {
15042            return Err(MongrelError::Schema(format!(
15043                "catalog snapshot rolls back {name} from {prior} to {candidate}"
15044            )));
15045        }
15046    }
15047    for prior in &current.tables {
15048        let Some(candidate) = next
15049            .tables
15050            .iter()
15051            .find(|entry| entry.table_id == prior.table_id)
15052        else {
15053            return Err(MongrelError::Schema(format!(
15054                "catalog snapshot removes table identity {}",
15055                prior.table_id
15056            )));
15057        };
15058        if candidate.created_epoch != prior.created_epoch
15059            || candidate.schema.schema_id < prior.schema.schema_id
15060            || matches!(prior.state, TableState::Dropped { .. })
15061                && !matches!(candidate.state, TableState::Dropped { .. })
15062        {
15063            return Err(MongrelError::Schema(format!(
15064                "catalog snapshot rolls back table identity {}",
15065                prior.table_id
15066            )));
15067        }
15068    }
15069    for prior in &current.users {
15070        if let Some(candidate) = next.users.iter().find(|user| user.id == prior.id) {
15071            if candidate.username != prior.username
15072                || candidate.created_epoch != prior.created_epoch
15073            {
15074                return Err(MongrelError::Schema(format!(
15075                    "catalog snapshot reuses user identity {}",
15076                    prior.id
15077                )));
15078            }
15079        }
15080    }
15081    Ok(())
15082}
15083
15084fn validate_recovered_catalog(catalog: &Catalog) -> Result<()> {
15085    let mut table_ids = HashSet::new();
15086    let mut active_names = HashSet::new();
15087    let mut max_table_id = None::<u64>;
15088    for entry in &catalog.tables {
15089        if !table_ids.insert(entry.table_id) {
15090            return Err(MongrelError::Schema(format!(
15091                "catalog contains duplicate table id {}",
15092                entry.table_id
15093            )));
15094        }
15095        max_table_id = Some(max_table_id.map_or(entry.table_id, |value| value.max(entry.table_id)));
15096        if entry.name.is_empty() || entry.created_epoch > catalog.db_epoch {
15097            return Err(MongrelError::Schema(format!(
15098                "catalog table {} has invalid name or creation epoch",
15099                entry.table_id
15100            )));
15101        }
15102        validate_recovered_schema(&entry.schema)?;
15103        match &entry.state {
15104            TableState::Live => {
15105                if !active_names.insert(entry.name.as_str()) {
15106                    return Err(MongrelError::Schema(format!(
15107                        "catalog contains duplicate active table name {:?}",
15108                        entry.name
15109                    )));
15110                }
15111            }
15112            TableState::Dropped { at_epoch } => {
15113                if *at_epoch < entry.created_epoch || *at_epoch > catalog.db_epoch {
15114                    return Err(MongrelError::Schema(format!(
15115                        "catalog table {} has invalid drop epoch {at_epoch}",
15116                        entry.table_id
15117                    )));
15118                }
15119            }
15120            TableState::Building {
15121                intended_name,
15122                query_id,
15123                replaces_table_id,
15124                ..
15125            } => {
15126                if intended_name.is_empty() || query_id.is_empty() {
15127                    return Err(MongrelError::Schema(format!(
15128                        "building table {} has empty identity fields",
15129                        entry.table_id
15130                    )));
15131                }
15132                if !active_names.insert(entry.name.as_str()) {
15133                    return Err(MongrelError::Schema(format!(
15134                        "catalog contains duplicate active/building table name {:?}",
15135                        entry.name
15136                    )));
15137                }
15138                if replaces_table_id.is_some_and(|id| id == entry.table_id) {
15139                    return Err(MongrelError::Schema(
15140                        "building table cannot replace itself".into(),
15141                    ));
15142                }
15143            }
15144        }
15145    }
15146    if let Some(maximum) = max_table_id {
15147        let required = maximum
15148            .checked_add(1)
15149            .ok_or_else(|| MongrelError::Full("table id namespace exhausted".into()))?;
15150        if catalog.next_table_id < required {
15151            return Err(MongrelError::Schema(format!(
15152                "catalog next_table_id {} precedes required {required}",
15153                catalog.next_table_id
15154            )));
15155        }
15156    }
15157    for entry in &catalog.tables {
15158        if let TableState::Building {
15159            replaces_table_id: Some(replaced),
15160            ..
15161        } = entry.state
15162        {
15163            if !table_ids.contains(&replaced) {
15164                return Err(MongrelError::Schema(format!(
15165                    "building table {} replaces unknown table {replaced}",
15166                    entry.table_id
15167                )));
15168            }
15169        }
15170    }
15171    for entry in &catalog.tables {
15172        if matches!(entry.state, TableState::Live | TableState::Building { .. }) {
15173            validate_foreign_key_targets(catalog, &entry.schema)?;
15174        }
15175    }
15176
15177    let mut external_names = HashSet::new();
15178    for entry in &catalog.external_tables {
15179        entry.validate()?;
15180        validate_recovered_schema(&entry.declared_schema)?;
15181        if !entry.declared_schema.constraints.is_empty() {
15182            return Err(MongrelError::Schema(format!(
15183                "external table {:?} cannot carry engine-enforced constraints",
15184                entry.name
15185            )));
15186        }
15187        if entry.created_epoch > catalog.db_epoch
15188            || !external_names.insert(entry.name.as_str())
15189            || active_names.contains(entry.name.as_str())
15190        {
15191            return Err(MongrelError::Schema(format!(
15192                "invalid or duplicate external table {:?}",
15193                entry.name
15194            )));
15195        }
15196    }
15197
15198    let mut procedure_names = HashSet::new();
15199    for entry in &catalog.procedures {
15200        entry.procedure.validate()?;
15201        if entry.procedure.created_epoch > entry.procedure.updated_epoch
15202            || entry.procedure.updated_epoch > catalog.db_epoch
15203            || !procedure_names.insert(entry.procedure.name.as_str())
15204        {
15205            return Err(MongrelError::Schema(format!(
15206                "invalid or duplicate procedure {:?}",
15207                entry.procedure.name
15208            )));
15209        }
15210        validate_recovered_procedure_references(catalog, &entry.procedure)?;
15211    }
15212
15213    let mut trigger_names = HashSet::new();
15214    for entry in &catalog.triggers {
15215        entry.trigger.validate()?;
15216        if entry.trigger.created_epoch > entry.trigger.updated_epoch
15217            || entry.trigger.updated_epoch > catalog.db_epoch
15218            || !trigger_names.insert(entry.trigger.name.as_str())
15219        {
15220            return Err(MongrelError::Schema(format!(
15221                "invalid or duplicate trigger {:?}",
15222                entry.trigger.name
15223            )));
15224        }
15225        validate_recovered_trigger_references(catalog, &entry.trigger)?;
15226    }
15227
15228    let mut views = HashSet::new();
15229    for view in &catalog.materialized_views {
15230        let target = catalog.live(&view.name).ok_or_else(|| {
15231            MongrelError::Schema(format!(
15232                "materialized view {:?} has no live table",
15233                view.name
15234            ))
15235        })?;
15236        if view.name.is_empty()
15237            || view.query.trim().is_empty()
15238            || view.last_refresh_epoch > catalog.db_epoch
15239            || !views.insert(view.name.as_str())
15240        {
15241            return Err(MongrelError::Schema(format!(
15242                "materialized view {:?} has no unique live table",
15243                view.name
15244            )));
15245        }
15246        if let Some(incremental) = &view.incremental {
15247            let source = catalog.live(&incremental.source_table).ok_or_else(|| {
15248                MongrelError::Schema(format!(
15249                    "materialized view {:?} references missing source {:?}",
15250                    view.name, incremental.source_table
15251                ))
15252            })?;
15253            if source.table_id != incremental.source_table_id
15254                || source
15255                    .schema
15256                    .columns
15257                    .iter()
15258                    .all(|column| column.id != incremental.group_column)
15259            {
15260                return Err(MongrelError::Schema(format!(
15261                    "materialized view {:?} has invalid incremental source",
15262                    view.name
15263                )));
15264            }
15265            let target_ids = target
15266                .schema
15267                .columns
15268                .iter()
15269                .map(|column| column.id)
15270                .collect::<HashSet<_>>();
15271            let mut output_ids = HashSet::new();
15272            let count_outputs = incremental
15273                .outputs
15274                .iter()
15275                .filter(|output| {
15276                    matches!(output.kind, crate::catalog::IncrementalAggregateKind::Count)
15277                })
15278                .count();
15279            if incremental.checkpoint_event_id.is_empty()
15280                || !target_ids.contains(&incremental.group_output_column)
15281                || !target_ids.contains(&incremental.count_output_column)
15282                || incremental.outputs.is_empty()
15283                || count_outputs != 1
15284                || incremental.outputs.iter().any(|output| {
15285                    !target_ids.contains(&output.output_column)
15286                        || output.output_column == incremental.group_output_column
15287                        || !output_ids.insert(output.output_column)
15288                        || matches!(output.kind, crate::catalog::IncrementalAggregateKind::Count)
15289                            && output.output_column != incremental.count_output_column
15290                        || match output.kind {
15291                            crate::catalog::IncrementalAggregateKind::Sum { source_column } => {
15292                                source
15293                                    .schema
15294                                    .columns
15295                                    .iter()
15296                                    .all(|column| column.id != source_column)
15297                            }
15298                            crate::catalog::IncrementalAggregateKind::Count => false,
15299                        }
15300                })
15301            {
15302                return Err(MongrelError::Schema(format!(
15303                    "materialized view {:?} has invalid incremental outputs",
15304                    view.name
15305                )));
15306            }
15307        }
15308    }
15309
15310    validate_security_catalog(catalog, &catalog.security)?;
15311    validate_recovered_auth_catalog(catalog)?;
15312    Ok(())
15313}
15314
15315fn repair_catalog_allocator_counters(catalog: &mut Catalog) -> Result<bool> {
15316    let mut changed = false;
15317    if let Some(maximum) = catalog.tables.iter().map(|entry| entry.table_id).max() {
15318        let required = maximum
15319            .checked_add(1)
15320            .ok_or_else(|| MongrelError::Full("table id namespace exhausted".into()))?;
15321        if catalog.next_table_id < required {
15322            catalog.next_table_id = required;
15323            changed = true;
15324        }
15325    }
15326    if let Some(maximum) = catalog.users.iter().map(|user| user.id).max() {
15327        let required = maximum
15328            .checked_add(1)
15329            .ok_or_else(|| MongrelError::Full("user id namespace exhausted".into()))?;
15330        if catalog.next_user_id < required {
15331            catalog.next_user_id = required;
15332            changed = true;
15333        }
15334    }
15335    Ok(changed)
15336}
15337
15338fn validate_foreign_key_targets(catalog: &Catalog, schema: &Schema) -> Result<()> {
15339    for foreign_key in &schema.constraints.foreign_keys {
15340        let parent = catalog.live(&foreign_key.ref_table).ok_or_else(|| {
15341            MongrelError::Schema(format!(
15342                "foreign key {:?} references unknown live table {:?}",
15343                foreign_key.name, foreign_key.ref_table
15344            ))
15345        })?;
15346        let referenced_unique = parent
15347            .schema
15348            .constraints
15349            .uniques
15350            .iter()
15351            .any(|unique| unique.columns == foreign_key.ref_columns)
15352            || foreign_key.ref_columns.len() == 1
15353                && parent
15354                    .schema
15355                    .primary_key()
15356                    .is_some_and(|column| column.id == foreign_key.ref_columns[0]);
15357        if !referenced_unique {
15358            return Err(MongrelError::Schema(format!(
15359                "foreign key {:?} does not reference a unique key",
15360                foreign_key.name
15361            )));
15362        }
15363        for (local_id, parent_id) in foreign_key.columns.iter().zip(&foreign_key.ref_columns) {
15364            let local = schema.columns.iter().find(|column| column.id == *local_id);
15365            let referenced = parent
15366                .schema
15367                .columns
15368                .iter()
15369                .find(|column| column.id == *parent_id);
15370            if local
15371                .zip(referenced)
15372                .is_none_or(|(local, referenced)| local.ty != referenced.ty)
15373            {
15374                return Err(MongrelError::Schema(format!(
15375                    "foreign key {:?} has missing or incompatible columns",
15376                    foreign_key.name
15377                )));
15378            }
15379        }
15380    }
15381    Ok(())
15382}
15383
15384fn validate_recovered_procedure_references(
15385    catalog: &Catalog,
15386    procedure: &StoredProcedure,
15387) -> Result<()> {
15388    for step in &procedure.body.steps {
15389        let Some(table_name) = step.table() else {
15390            continue;
15391        };
15392        let schema = &catalog
15393            .live(table_name)
15394            .ok_or_else(|| {
15395                MongrelError::Schema(format!(
15396                    "procedure {:?} references unknown table {table_name:?}",
15397                    procedure.name
15398                ))
15399            })?
15400            .schema;
15401        match step {
15402            ProcedureStep::NativeQuery {
15403                conditions,
15404                projection,
15405                ..
15406            } => {
15407                for condition in conditions {
15408                    validate_condition_columns(condition, schema)?;
15409                }
15410                for id in projection.iter().flatten() {
15411                    validate_column_id(*id, schema)?;
15412                }
15413            }
15414            ProcedureStep::Put { cells, .. } => {
15415                for cell in cells {
15416                    validate_column_id(cell.column_id, schema)?;
15417                }
15418            }
15419            ProcedureStep::Upsert {
15420                cells,
15421                update_cells,
15422                ..
15423            } => {
15424                for cell in cells.iter().chain(update_cells.iter().flatten()) {
15425                    validate_column_id(cell.column_id, schema)?;
15426                }
15427            }
15428            ProcedureStep::DeleteByPk { .. } if schema.primary_key().is_none() => {
15429                return Err(MongrelError::Schema(format!(
15430                    "procedure {:?} deletes by primary key on table without one",
15431                    procedure.name
15432                )));
15433            }
15434            ProcedureStep::DeleteByPk { .. }
15435            | ProcedureStep::DeleteRows { .. }
15436            | ProcedureStep::SqlQuery { .. } => {}
15437        }
15438    }
15439    Ok(())
15440}
15441
15442fn validate_recovered_trigger_references(catalog: &Catalog, trigger: &StoredTrigger) -> Result<()> {
15443    let target_schema = match &trigger.target {
15444        TriggerTarget::Table(name) => catalog
15445            .live(name)
15446            .ok_or_else(|| {
15447                MongrelError::Schema(format!(
15448                    "trigger {:?} references unknown table {name:?}",
15449                    trigger.name
15450                ))
15451            })?
15452            .schema
15453            .clone(),
15454        TriggerTarget::View(_) => Schema {
15455            columns: trigger.target_columns.clone(),
15456            ..Schema::default()
15457        },
15458    };
15459    for column in &trigger.update_of {
15460        if target_schema.column(column).is_none() {
15461            return Err(MongrelError::Schema(format!(
15462                "trigger {:?} references unknown UPDATE OF column {column:?}",
15463                trigger.name
15464            )));
15465        }
15466    }
15467    if let Some(expr) = &trigger.when {
15468        validate_trigger_expr(expr, &target_schema, trigger.event)?;
15469    }
15470    let mut selects = HashMap::new();
15471    for step in &trigger.program.steps {
15472        if matches!(step, TriggerStep::SetNew { .. }) && trigger.timing != TriggerTiming::Before {
15473            return Err(MongrelError::Schema(
15474                "SetNew is only valid in BEFORE triggers".into(),
15475            ));
15476        }
15477        validate_trigger_step(step, catalog, &target_schema, trigger.event, &mut selects)?;
15478    }
15479    Ok(())
15480}
15481
15482fn validate_recovered_auth_catalog(catalog: &Catalog) -> Result<()> {
15483    let mut role_names = HashSet::new();
15484    for role in &catalog.roles {
15485        if role.name.is_empty()
15486            || role.created_epoch > catalog.db_epoch
15487            || !role_names.insert(role.name.as_str())
15488        {
15489            return Err(MongrelError::Schema(format!(
15490                "invalid or duplicate role {:?}",
15491                role.name
15492            )));
15493        }
15494        for permission in &role.permissions {
15495            if let Some(table) = permission_table(permission) {
15496                let schema = catalog
15497                    .live(table)
15498                    .map(|entry| &entry.schema)
15499                    .or_else(|| {
15500                        catalog
15501                            .external_tables
15502                            .iter()
15503                            .find(|entry| entry.name == table)
15504                            .map(|entry| &entry.declared_schema)
15505                    })
15506                    .ok_or_else(|| {
15507                        MongrelError::Schema(format!(
15508                            "role {:?} references unknown table {table:?}",
15509                            role.name
15510                        ))
15511                    })?;
15512                let columns = match permission {
15513                    crate::auth::Permission::SelectColumns { columns, .. }
15514                    | crate::auth::Permission::InsertColumns { columns, .. }
15515                    | crate::auth::Permission::UpdateColumns { columns, .. } => Some(columns),
15516                    _ => None,
15517                };
15518                if columns.is_some_and(|columns| {
15519                    columns.is_empty()
15520                        || columns.iter().any(|column| schema.column(column).is_none())
15521                }) {
15522                    return Err(MongrelError::Schema(format!(
15523                        "role {:?} contains invalid column permissions",
15524                        role.name
15525                    )));
15526                }
15527            }
15528        }
15529    }
15530    let mut user_ids = HashSet::new();
15531    let mut usernames = HashSet::new();
15532    let mut maximum_user_id = 0;
15533    for user in &catalog.users {
15534        maximum_user_id = maximum_user_id.max(user.id);
15535        if user.id == 0
15536            || user.username.is_empty()
15537            || user.password_hash.is_empty()
15538            || user.created_epoch > catalog.db_epoch
15539            || !user_ids.insert(user.id)
15540            || !usernames.insert(user.username.as_str())
15541            || user
15542                .roles
15543                .iter()
15544                .any(|role| !role_names.contains(role.as_str()))
15545        {
15546            return Err(MongrelError::Schema(format!(
15547                "invalid or duplicate user {:?}",
15548                user.username
15549            )));
15550        }
15551    }
15552    if !catalog.users.is_empty() && catalog.next_user_id <= maximum_user_id {
15553        return Err(MongrelError::Schema(
15554            "catalog next_user_id does not advance beyond existing user ids".into(),
15555        ));
15556    }
15557    if catalog.require_auth && !catalog.users.iter().any(|user| user.is_admin) {
15558        return Err(MongrelError::Schema(
15559            "authenticated catalog has no administrator".into(),
15560        ));
15561    }
15562    Ok(())
15563}
15564
15565fn validate_recovered_storage_plan(
15566    root: &Path,
15567    durable_root: Option<&crate::durable_file::DurableRoot>,
15568    catalog: &Catalog,
15569    created_table_ids: &HashSet<u64>,
15570    ttl_updates: &HashMap<u64, (Option<crate::manifest::TtlPolicy>, u64)>,
15571    meta_dek: Option<&[u8; META_DEK_LEN]>,
15572) -> Result<Vec<u64>> {
15573    const MAX_SCHEMA_BYTES: u64 = 16 * 1024 * 1024;
15574    let mut reconcile = Vec::new();
15575    for entry in &catalog.tables {
15576        if !matches!(entry.state, TableState::Live | TableState::Building { .. }) {
15577            continue;
15578        }
15579        let relative_dir = Path::new(TABLES_DIR).join(entry.table_id.to_string());
15580        let table_dir = root.join(TABLES_DIR).join(entry.table_id.to_string());
15581        let table_exists = match durable_root {
15582            Some(root) => match root.open_directory(&relative_dir) {
15583                Ok(_) => true,
15584                Err(error) if error.kind() == std::io::ErrorKind::NotFound => false,
15585                Err(error) => return Err(error.into()),
15586            },
15587            None => table_dir.is_dir(),
15588        };
15589        if !table_exists {
15590            if created_table_ids.contains(&entry.table_id) {
15591                reconcile.push(entry.table_id);
15592                continue;
15593            }
15594            return Err(MongrelError::NotFound(format!(
15595                "catalog table {} storage is missing",
15596                entry.table_id
15597            )));
15598        }
15599        let manifest_result = match durable_root {
15600            Some(root) => crate::manifest::read_durable(root, &relative_dir, meta_dek),
15601            None => crate::manifest::read(&table_dir, meta_dek),
15602        };
15603        let manifest = match manifest_result {
15604            Ok(manifest) => manifest,
15605            Err(MongrelError::Io(error))
15606                if created_table_ids.contains(&entry.table_id)
15607                    && error.kind() == std::io::ErrorKind::NotFound =>
15608            {
15609                reconcile.push(entry.table_id);
15610                continue;
15611            }
15612            Err(error) => return Err(error),
15613        };
15614        if manifest.table_id != entry.table_id {
15615            return Err(MongrelError::Conflict(format!(
15616                "catalog table {} storage identity mismatch",
15617                entry.table_id
15618            )));
15619        }
15620        let schema_result = match durable_root {
15621            Some(root) => root
15622                .open_regular(relative_dir.join(crate::engine::SCHEMA_FILENAME))
15623                .map_err(MongrelError::from),
15624            None => crate::durable_file::open_regular_nofollow(
15625                &table_dir.join(crate::engine::SCHEMA_FILENAME),
15626            ),
15627        };
15628        let file = match schema_result {
15629            Ok(file) => file,
15630            Err(MongrelError::Io(error))
15631                if created_table_ids.contains(&entry.table_id)
15632                    && error.kind() == std::io::ErrorKind::NotFound =>
15633            {
15634                reconcile.push(entry.table_id);
15635                continue;
15636            }
15637            Err(error) => return Err(error),
15638        };
15639        let length = file.metadata()?.len();
15640        if length > MAX_SCHEMA_BYTES {
15641            return Err(MongrelError::ResourceLimitExceeded {
15642                resource: "recovered schema bytes",
15643                requested: usize::try_from(length).unwrap_or(usize::MAX),
15644                limit: MAX_SCHEMA_BYTES as usize,
15645            });
15646        }
15647        let disk_schema: Schema = serde_json::from_reader(file.take(MAX_SCHEMA_BYTES + 1))
15648            .map_err(|error| MongrelError::Schema(format!("decode recovered schema: {error}")))?;
15649        if manifest.schema_id != entry.schema.schema_id
15650            || crate::wal::DdlOp::encode_schema(&disk_schema)?
15651                != crate::wal::DdlOp::encode_schema(&entry.schema)?
15652        {
15653            reconcile.push(entry.table_id);
15654        }
15655    }
15656    for table_id in ttl_updates.keys() {
15657        if !catalog.tables.iter().any(|entry| {
15658            entry.table_id == *table_id
15659                && matches!(entry.state, TableState::Live | TableState::Building { .. })
15660        }) {
15661            continue;
15662        }
15663        let relative_dir = Path::new(TABLES_DIR).join(table_id.to_string());
15664        let table_exists = match durable_root {
15665            Some(root) => match root.open_directory(&relative_dir) {
15666                Ok(_) => true,
15667                Err(error) if error.kind() == std::io::ErrorKind::NotFound => false,
15668                Err(error) => return Err(error.into()),
15669            },
15670            None => root.join(&relative_dir).is_dir(),
15671        };
15672        if !table_exists && !created_table_ids.contains(table_id) {
15673            return Err(MongrelError::NotFound(format!(
15674                "TTL recovery table {table_id} storage is missing"
15675            )));
15676        }
15677    }
15678    reconcile.sort_unstable();
15679    reconcile.dedup();
15680    Ok(reconcile)
15681}
15682
15683fn validate_catalog_table_storage(
15684    root: &crate::durable_file::DurableRoot,
15685    catalog: &Catalog,
15686    meta_dek: Option<&[u8; META_DEK_LEN]>,
15687) -> Result<()> {
15688    for entry in &catalog.tables {
15689        if !matches!(entry.state, TableState::Live | TableState::Building { .. }) {
15690            continue;
15691        }
15692        let table_dir = Path::new(TABLES_DIR).join(entry.table_id.to_string());
15693        let manifest = crate::manifest::read_durable(root, &table_dir, meta_dek)?;
15694        if manifest.table_id != entry.table_id || manifest.schema_id != entry.schema.schema_id {
15695            return Err(MongrelError::Conflict(format!(
15696                "catalog table {} storage identity mismatch",
15697                entry.table_id
15698            )));
15699        }
15700        root.open_regular(table_dir.join(crate::engine::SCHEMA_FILENAME))?;
15701    }
15702    Ok(())
15703}
15704
15705fn apply_recovered_column_def(schema: &mut Schema, column: ColumnDef) -> Result<bool> {
15706    match schema.columns.iter_mut().find(|c| c.id == column.id) {
15707        Some(existing) if *existing == column => Ok(false),
15708        Some(existing) => {
15709            *existing = column;
15710            schema.schema_id = schema
15711                .schema_id
15712                .checked_add(1)
15713                .ok_or_else(|| MongrelError::Schema("schema id space exhausted".into()))?;
15714            Ok(true)
15715        }
15716        None => {
15717            schema.columns.push(column);
15718            schema.schema_id = schema
15719                .schema_id
15720                .checked_add(1)
15721                .ok_or_else(|| MongrelError::Schema("schema id space exhausted".into()))?;
15722            Ok(true)
15723        }
15724    }
15725}
15726
15727fn permission_table(permission: &crate::auth::Permission) -> Option<&str> {
15728    use crate::auth::Permission;
15729    match permission {
15730        Permission::Select { table }
15731        | Permission::Insert { table }
15732        | Permission::Update { table }
15733        | Permission::Delete { table }
15734        | Permission::SelectColumns { table, .. }
15735        | Permission::InsertColumns { table, .. }
15736        | Permission::UpdateColumns { table, .. } => Some(table),
15737        Permission::All | Permission::Ddl | Permission::Admin => None,
15738    }
15739}
15740
15741fn apply_rebuilding_publish(
15742    catalog: &mut Catalog,
15743    table_id: u64,
15744    replaced_table_id: u64,
15745    new_name: &str,
15746    epoch: u64,
15747) -> Result<bool> {
15748    let already_published = catalog.tables.iter().any(|entry| {
15749        entry.table_id == table_id
15750            && entry.name == new_name
15751            && matches!(entry.state, TableState::Live)
15752    }) && catalog.tables.iter().any(|entry| {
15753        entry.table_id == replaced_table_id && matches!(entry.state, TableState::Dropped { .. })
15754    });
15755    if already_published {
15756        return Ok(false);
15757    }
15758    let schema = catalog
15759        .tables
15760        .iter()
15761        .find(|entry| entry.table_id == table_id)
15762        .ok_or_else(|| MongrelError::NotFound(format!("table id {table_id} not found")))?
15763        .schema
15764        .clone();
15765    let replaced = catalog
15766        .tables
15767        .iter_mut()
15768        .find(|entry| entry.table_id == replaced_table_id)
15769        .ok_or_else(|| MongrelError::NotFound(format!("table id {replaced_table_id} not found")))?;
15770    replaced.state = TableState::Dropped { at_epoch: epoch };
15771    let replacement = catalog
15772        .tables
15773        .iter_mut()
15774        .find(|entry| entry.table_id == table_id)
15775        .ok_or_else(|| MongrelError::NotFound(format!("table id {table_id} not found")))?;
15776    replacement.name = new_name.to_string();
15777    replacement.state = TableState::Live;
15778
15779    for role in &mut catalog.roles {
15780        role.permissions.retain_mut(|permission| {
15781            retain_rebuilt_permission_columns(permission, new_name, &schema)
15782        });
15783    }
15784    for definition in &mut catalog.materialized_views {
15785        if let Some(incremental) = definition.incremental.as_mut() {
15786            if incremental.source_table == new_name
15787                && incremental.source_table_id == replaced_table_id
15788            {
15789                incremental.source_table_id = table_id;
15790            }
15791        }
15792    }
15793    advance_security_version(catalog)?;
15794    Ok(true)
15795}
15796
15797fn retain_rebuilt_permission_columns(
15798    permission: &mut crate::auth::Permission,
15799    target_table: &str,
15800    schema: &Schema,
15801) -> bool {
15802    use crate::auth::Permission;
15803    let columns = match permission {
15804        Permission::SelectColumns { table, columns }
15805        | Permission::InsertColumns { table, columns }
15806        | Permission::UpdateColumns { table, columns }
15807            if table == target_table =>
15808        {
15809            Some(columns)
15810        }
15811        _ => None,
15812    };
15813    if let Some(columns) = columns {
15814        columns.retain(|column| schema.column(column).is_some());
15815        !columns.is_empty()
15816    } else {
15817        true
15818    }
15819}
15820
15821fn rename_permission_table(permission: &mut crate::auth::Permission, old: &str, new: &str) {
15822    use crate::auth::Permission;
15823    let table = match permission {
15824        Permission::Select { table }
15825        | Permission::Insert { table }
15826        | Permission::Update { table }
15827        | Permission::Delete { table }
15828        | Permission::SelectColumns { table, .. }
15829        | Permission::InsertColumns { table, .. }
15830        | Permission::UpdateColumns { table, .. } => Some(table),
15831        Permission::All | Permission::Ddl | Permission::Admin => None,
15832    };
15833    if let Some(table) = table.filter(|table| table.as_str() == old) {
15834        *table = new.to_string();
15835    }
15836}
15837
15838fn rename_permission_column(
15839    permission: &mut crate::auth::Permission,
15840    target_table: &str,
15841    old: &str,
15842    new: &str,
15843) {
15844    use crate::auth::Permission;
15845    let columns = match permission {
15846        Permission::SelectColumns { table, columns }
15847        | Permission::InsertColumns { table, columns }
15848        | Permission::UpdateColumns { table, columns }
15849            if table == target_table =>
15850        {
15851            Some(columns)
15852        }
15853        _ => None,
15854    };
15855    if let Some(column) = columns
15856        .into_iter()
15857        .flatten()
15858        .find(|column| column.as_str() == old)
15859    {
15860        *column = new.to_string();
15861    }
15862}
15863
15864fn merge_permission(
15865    permissions: &mut Vec<crate::auth::Permission>,
15866    permission: crate::auth::Permission,
15867) {
15868    use crate::auth::Permission;
15869    let (kind, table, mut columns) = match permission {
15870        Permission::SelectColumns { table, columns } => (0, table, columns),
15871        Permission::InsertColumns { table, columns } => (1, table, columns),
15872        Permission::UpdateColumns { table, columns } => (2, table, columns),
15873        permission if !permissions.contains(&permission) => {
15874            permissions.push(permission);
15875            return;
15876        }
15877        _ => return,
15878    };
15879    for permission in permissions.iter_mut() {
15880        let existing = match permission {
15881            Permission::SelectColumns {
15882                table: existing_table,
15883                columns,
15884            } if kind == 0 && existing_table == &table => Some(columns),
15885            Permission::InsertColumns {
15886                table: existing_table,
15887                columns,
15888            } if kind == 1 && existing_table == &table => Some(columns),
15889            Permission::UpdateColumns {
15890                table: existing_table,
15891                columns,
15892            } if kind == 2 && existing_table == &table => Some(columns),
15893            _ => None,
15894        };
15895        if let Some(existing) = existing {
15896            existing.append(&mut columns);
15897            existing.sort();
15898            existing.dedup();
15899            return;
15900        }
15901    }
15902    columns.sort();
15903    columns.dedup();
15904    let permission = if kind == 0 {
15905        Permission::SelectColumns { table, columns }
15906    } else if kind == 1 {
15907        Permission::InsertColumns { table, columns }
15908    } else {
15909        Permission::UpdateColumns { table, columns }
15910    };
15911    permissions.push(permission);
15912}
15913
15914fn revoke_permission_from(
15915    permissions: &mut Vec<crate::auth::Permission>,
15916    revoked: &crate::auth::Permission,
15917) {
15918    use crate::auth::Permission;
15919    let revoked_columns = match revoked {
15920        Permission::SelectColumns { table, columns } => Some((0, table, columns)),
15921        Permission::InsertColumns { table, columns } => Some((1, table, columns)),
15922        Permission::UpdateColumns { table, columns } => Some((2, table, columns)),
15923        _ => None,
15924    };
15925    let Some((kind, table, columns)) = revoked_columns else {
15926        permissions.retain(|permission| permission != revoked);
15927        return;
15928    };
15929    for permission in permissions.iter_mut() {
15930        let current = match permission {
15931            Permission::SelectColumns {
15932                table: current_table,
15933                columns,
15934            } if kind == 0 && current_table == table => Some(columns),
15935            Permission::InsertColumns {
15936                table: current_table,
15937                columns,
15938            } if kind == 1 && current_table == table => Some(columns),
15939            Permission::UpdateColumns {
15940                table: current_table,
15941                columns,
15942            } if kind == 2 && current_table == table => Some(columns),
15943            _ => None,
15944        };
15945        if let Some(current) = current {
15946            current.retain(|column| !columns.contains(column));
15947        }
15948    }
15949    permissions.retain(|permission| match permission {
15950        Permission::SelectColumns { columns, .. }
15951        | Permission::InsertColumns { columns, .. }
15952        | Permission::UpdateColumns { columns, .. } => !columns.is_empty(),
15953        _ => true,
15954    });
15955}
15956
15957fn validate_security_catalog(
15958    catalog: &Catalog,
15959    security: &crate::security::SecurityCatalog,
15960) -> Result<()> {
15961    let mut policy_names = HashSet::new();
15962    for table in &security.rls_tables {
15963        if catalog.live(table).is_none() {
15964            return Err(MongrelError::NotFound(format!(
15965                "RLS table {table:?} not found"
15966            )));
15967        }
15968    }
15969    for policy in &security.policies {
15970        if !policy_names.insert((policy.table.clone(), policy.name.clone())) {
15971            return Err(MongrelError::InvalidArgument(format!(
15972                "duplicate policy {:?} on {:?}",
15973                policy.name, policy.table
15974            )));
15975        }
15976        let schema = &catalog
15977            .live(&policy.table)
15978            .ok_or_else(|| {
15979                MongrelError::NotFound(format!("policy table {:?} not found", policy.table))
15980            })?
15981            .schema;
15982        if let Some(expression) = &policy.using {
15983            validate_security_expression(expression, schema)?;
15984        }
15985        if let Some(expression) = &policy.with_check {
15986            validate_security_expression(expression, schema)?;
15987        }
15988    }
15989    let mut mask_names = HashSet::new();
15990    for mask in &security.masks {
15991        if !mask_names.insert((mask.table.clone(), mask.name.clone())) {
15992            return Err(MongrelError::InvalidArgument(format!(
15993                "duplicate mask {:?} on {:?}",
15994                mask.name, mask.table
15995            )));
15996        }
15997        let column = catalog
15998            .live(&mask.table)
15999            .and_then(|entry| {
16000                entry
16001                    .schema
16002                    .columns
16003                    .iter()
16004                    .find(|column| column.id == mask.column)
16005            })
16006            .ok_or_else(|| {
16007                MongrelError::NotFound(format!(
16008                    "mask column {} on {:?} not found",
16009                    mask.column, mask.table
16010                ))
16011            })?;
16012        if matches!(
16013            mask.strategy,
16014            crate::security::MaskStrategy::Redact { .. } | crate::security::MaskStrategy::Sha256
16015        ) && !matches!(column.ty, TypeId::Bytes | TypeId::Enum { .. })
16016        {
16017            return Err(MongrelError::InvalidArgument(format!(
16018                "mask {:?} requires a string/bytes column",
16019                mask.name
16020            )));
16021        }
16022    }
16023    Ok(())
16024}
16025
16026fn validate_security_expression(
16027    expression: &crate::security::SecurityExpr,
16028    schema: &Schema,
16029) -> Result<()> {
16030    use crate::security::SecurityExpr;
16031    match expression {
16032        SecurityExpr::True => Ok(()),
16033        SecurityExpr::ColumnEqCurrentUser { column }
16034        | SecurityExpr::ColumnEqValue { column, .. } => {
16035            if schema
16036                .columns
16037                .iter()
16038                .any(|candidate| candidate.id == *column)
16039            {
16040                Ok(())
16041            } else {
16042                Err(MongrelError::InvalidArgument(format!(
16043                    "security expression references unknown column id {column}"
16044                )))
16045            }
16046        }
16047        SecurityExpr::And { left, right } | SecurityExpr::Or { left, right } => {
16048            validate_security_expression(left, schema)?;
16049            validate_security_expression(right, schema)
16050        }
16051        SecurityExpr::Not { expression } => validate_security_expression(expression, schema),
16052    }
16053}
16054
16055/// Remove canonical numeric table directories that no catalog generation owns.
16056fn sweep_unreferenced_table_dirs(root: &Path, cat: &Catalog) -> Result<()> {
16057    let referenced = cat
16058        .tables
16059        .iter()
16060        .filter(|entry| matches!(entry.state, TableState::Live | TableState::Building { .. }))
16061        .map(|entry| entry.table_id)
16062        .collect::<HashSet<_>>();
16063    let tables_dir = root.join(TABLES_DIR);
16064    let entries = match std::fs::read_dir(&tables_dir) {
16065        Ok(entries) => entries,
16066        Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(()),
16067        Err(error) => return Err(error.into()),
16068    };
16069    for entry in entries {
16070        let entry = entry?;
16071        if !entry.file_type()?.is_dir() {
16072            continue;
16073        }
16074        let file_name = entry.file_name();
16075        let Some(name) = file_name.to_str() else {
16076            continue;
16077        };
16078        let Ok(table_id) = name.parse::<u64>() else {
16079            continue;
16080        };
16081        if name != table_id.to_string() {
16082            continue;
16083        }
16084        if !referenced.contains(&table_id) {
16085            crate::durable_file::remove_directory_all(&entry.path())?;
16086        }
16087    }
16088    Ok(())
16089}
16090
16091/// Sweep stale `_txn/<txn_id>/` dirs from every table (spec §8.5, review fix
16092/// #14). These dirs hold pending uniform-epoch runs from large transactions
16093/// that were aborted or crashed before commit. On open, all such dirs are safe
16094/// to remove because committed txns moved their runs to `_runs/` at publish.
16095fn sweep_pending_txn_dirs(root: &Path, cat: &Catalog) {
16096    for entry in &cat.tables {
16097        let txn_dir = root
16098            .join(TABLES_DIR)
16099            .join(entry.table_id.to_string())
16100            .join("_txn");
16101        if txn_dir.exists() {
16102            let _ = std::fs::remove_dir_all(&txn_dir);
16103        }
16104    }
16105}
16106
16107#[cfg(test)]
16108mod write_permission_tests {
16109    use super::*;
16110    use crate::txn::Staged;
16111
16112    struct NoopExternalBridge;
16113
16114    impl ExternalTriggerBridge for NoopExternalBridge {
16115        fn apply_trigger_external_write(
16116            &self,
16117            _entry: &ExternalTableEntry,
16118            base_state: Vec<u8>,
16119            _op: ExternalTriggerWrite,
16120        ) -> Result<ExternalTriggerWriteResult> {
16121            Ok(ExternalTriggerWriteResult::new(base_state))
16122        }
16123    }
16124
16125    fn assert_txn_namespace_full<T>(result: Result<T>) {
16126        assert!(matches!(result, Err(MongrelError::Full(_))));
16127    }
16128
16129    #[test]
16130    fn every_begin_api_preserves_transaction_id_exhaustion_without_wal_mutation() {
16131        let directory = tempfile::tempdir().unwrap();
16132        let database = Database::create(directory.path()).unwrap();
16133        let generation = (*database.next_txn_id.lock() >> 32).saturating_add(1);
16134        *database.next_txn_id.lock() = generation << 32;
16135        let before = crate::wal::SharedWal::replay(directory.path())
16136            .unwrap()
16137            .len();
16138        let bridge = NoopExternalBridge;
16139
16140        assert_txn_namespace_full(database.begin().commit());
16141        assert_txn_namespace_full(database.begin_as(None).commit_with_row_ids());
16142        assert_txn_namespace_full(
16143            database
16144                .begin_with_isolation(crate::txn::IsolationLevel::Serializable)
16145                .commit(),
16146        );
16147        assert_txn_namespace_full(
16148            database
16149                .begin_with_external_trigger_bridge(&bridge)
16150                .commit(),
16151        );
16152        assert_txn_namespace_full(
16153            database
16154                .begin_with_external_trigger_bridge_as(&bridge, None)
16155                .commit_controlled(&crate::ExecutionControl::new(None), || Ok(())),
16156        );
16157
16158        assert_eq!(
16159            crate::wal::SharedWal::replay(directory.path())
16160                .unwrap()
16161                .len(),
16162            before
16163        );
16164        drop(database);
16165        Database::open(directory.path()).unwrap();
16166    }
16167
16168    #[test]
16169    fn recovered_storage_identity_mismatch_does_not_mutate_directory() {
16170        let directory = tempfile::tempdir().unwrap();
16171        let table_dir = directory.path().join("7");
16172        crate::durable_file::create_directory_all(&table_dir).unwrap();
16173        let original_schema = test_schema();
16174        crate::engine::write_schema(&table_dir, &original_schema).unwrap();
16175        let mut manifest = crate::manifest::Manifest::new(8, original_schema.schema_id);
16176        crate::manifest::write_atomic(&table_dir, &mut manifest, None).unwrap();
16177        let schema_path = table_dir.join(crate::engine::SCHEMA_FILENAME);
16178        let original_bytes = std::fs::read(&schema_path).unwrap();
16179
16180        let mut replacement_schema = original_schema;
16181        replacement_schema.schema_id += 1;
16182        assert!(matches!(
16183            ensure_recovered_table_storage(None, None, &table_dir, 7, &replacement_schema, None,),
16184            Err(MongrelError::Conflict(_))
16185        ));
16186
16187        assert_eq!(std::fs::read(schema_path).unwrap(), original_bytes);
16188        assert!(!table_dir.join(crate::engine::WAL_DIR).exists());
16189        assert!(!table_dir.join(crate::engine::RUNS_DIR).exists());
16190        assert_eq!(crate::manifest::read(&table_dir, None).unwrap().table_id, 8);
16191    }
16192
16193    #[test]
16194    fn catalog_table_missing_storage_fails_without_recreating_it() {
16195        let directory = tempfile::tempdir().unwrap();
16196        let table_dir = {
16197            let database = Database::create(directory.path()).unwrap();
16198            database.create_table("docs", test_schema()).unwrap();
16199            directory
16200                .path()
16201                .join(TABLES_DIR)
16202                .join(database.table_id("docs").unwrap().to_string())
16203        };
16204        std::fs::remove_dir_all(&table_dir).unwrap();
16205
16206        assert!(matches!(
16207            Database::open(directory.path()),
16208            Err(MongrelError::NotFound(_))
16209        ));
16210        assert!(!table_dir.exists());
16211    }
16212
16213    #[test]
16214    fn authentication_and_principal_resolution_share_one_catalog_snapshot() {
16215        let directory = tempfile::tempdir().unwrap();
16216        let database = std::sync::Arc::new(
16217            Database::create_with_credentials(directory.path(), "admin", "admin-password").unwrap(),
16218        );
16219        database.create_user("alice", "old-password").unwrap();
16220        let old_identity = database.user_identity("alice").unwrap();
16221        let (verified_tx, verified_rx) = std::sync::mpsc::channel();
16222        let (resume_tx, resume_rx) = std::sync::mpsc::channel();
16223        let (mutation_started_tx, mutation_started_rx) = std::sync::mpsc::channel();
16224        let (mutation_done_tx, mutation_done_rx) = std::sync::mpsc::channel();
16225
16226        std::thread::scope(|scope| {
16227            let authenticate = {
16228                let database = std::sync::Arc::clone(&database);
16229                scope.spawn(move || {
16230                    database.authenticate_principal_inner("alice", "old-password", || {
16231                        verified_tx.send(()).unwrap();
16232                        resume_rx.recv().unwrap();
16233                    })
16234                })
16235            };
16236            verified_rx.recv().unwrap();
16237            let mutate = {
16238                let database = std::sync::Arc::clone(&database);
16239                scope.spawn(move || {
16240                    mutation_started_tx.send(()).unwrap();
16241                    database.drop_user("alice").unwrap();
16242                    database.create_user("alice", "new-password").unwrap();
16243                    mutation_done_tx.send(()).unwrap();
16244                })
16245            };
16246            mutation_started_rx.recv().unwrap();
16247            assert!(mutation_done_rx
16248                .recv_timeout(std::time::Duration::from_millis(50))
16249                .is_err());
16250            resume_tx.send(()).unwrap();
16251            let principal = authenticate.join().unwrap().unwrap().unwrap();
16252            assert_eq!((principal.user_id, principal.created_epoch), old_identity);
16253            mutate.join().unwrap();
16254        });
16255
16256        assert_ne!(database.user_identity("alice").unwrap(), old_identity);
16257        assert!(database
16258            .authenticate_principal("alice", "old-password")
16259            .unwrap()
16260            .is_none());
16261        assert!(database
16262            .authenticate_principal("alice", "new-password")
16263            .unwrap()
16264            .is_some());
16265    }
16266
16267    #[test]
16268    fn homogeneous_batch_summarizes_to_one_permission_decision() {
16269        let staging = (0..10_050)
16270            .map(|_| {
16271                (
16272                    7,
16273                    Staged::Put(vec![(2, Value::Int64(2)), (1, Value::Int64(1))]),
16274                )
16275            })
16276            .collect::<Vec<_>>();
16277
16278        let needs = summarize_write_permissions(&staging);
16279        let table = needs.get(&7).unwrap();
16280        assert_eq!(needs.len(), 1);
16281        assert!(table.insert);
16282        assert_eq!(table.insert_columns, [1, 2]);
16283        assert!(!table.update);
16284        assert!(!table.delete);
16285        assert!(!table.truncate);
16286    }
16287
16288    #[test]
16289    fn mixed_writes_union_columns_and_preserve_empty_operations() {
16290        let staging = vec![
16291            (7, Staged::Put(vec![(2, Value::Int64(2))])),
16292            (7, Staged::Put(vec![(1, Value::Int64(1))])),
16293            (
16294                7,
16295                Staged::Update {
16296                    row_id: RowId(1),
16297                    new_row: vec![(1, Value::Int64(1)), (2, Value::Int64(2))],
16298                    changed_columns: vec![2],
16299                },
16300            ),
16301            (7, Staged::Delete(RowId(2))),
16302            (8, Staged::Truncate),
16303        ];
16304
16305        let needs = summarize_write_permissions(&staging);
16306        let table = needs.get(&7).unwrap();
16307        assert_eq!(table.insert_columns, [1, 2]);
16308        assert!(table.update);
16309        assert_eq!(table.update_columns, [2]);
16310        assert!(table.delete);
16311        assert!(needs.get(&8).unwrap().truncate);
16312    }
16313
16314    #[test]
16315    fn final_permission_decisions_do_not_scale_with_rows() {
16316        let credentialless_dir = tempfile::tempdir().unwrap();
16317        let credentialless = Database::create(credentialless_dir.path()).unwrap();
16318        credentialless.create_table("docs", test_schema()).unwrap();
16319        WRITE_PERMISSION_DECISIONS.with(|decisions| decisions.set(0));
16320        credentialless
16321            .validate_write_permissions(&puts(credentialless.table_id("docs").unwrap()), None, None)
16322            .unwrap();
16323        WRITE_PERMISSION_DECISIONS.with(|decisions| assert_eq!(decisions.get(), 0));
16324
16325        let authenticated_dir = tempfile::tempdir().unwrap();
16326        let authenticated =
16327            Database::create_with_credentials(authenticated_dir.path(), "admin", "admin-password")
16328                .unwrap();
16329        authenticated.create_table("docs", test_schema()).unwrap();
16330        let admin = authenticated.resolve_principal("admin").unwrap();
16331        WRITE_PERMISSION_DECISIONS.with(|decisions| decisions.set(0));
16332        authenticated
16333            .validate_write_permissions(
16334                &puts(authenticated.table_id("docs").unwrap()),
16335                Some(&admin),
16336                None,
16337            )
16338            .unwrap();
16339        WRITE_PERMISSION_DECISIONS.with(|decisions| assert_eq!(decisions.get(), 1));
16340    }
16341
16342    #[test]
16343    fn delete_batch_checks_permission_once_when_staged_and_once_when_committed() {
16344        let dir = tempfile::tempdir().unwrap();
16345        let db = Database::create_with_credentials(dir.path(), "admin", "admin-password").unwrap();
16346        db.create_table("docs", test_schema()).unwrap();
16347        let admin = db.resolve_principal("admin").unwrap();
16348        TABLE_PERMISSION_DECISIONS.with(|decisions| decisions.set(0));
16349
16350        let mut transaction = db.begin_as(Some(admin));
16351        transaction
16352            .delete_batch("docs", (0..100).map(RowId).collect())
16353            .unwrap();
16354        transaction.commit().unwrap();
16355
16356        TABLE_PERMISSION_DECISIONS.with(|decisions| assert_eq!(decisions.get(), 2));
16357    }
16358
16359    #[test]
16360    fn truncate_validation_checks_admin_once_for_all_tables() {
16361        let dir = tempfile::tempdir().unwrap();
16362        let db = Database::create_with_credentials(dir.path(), "admin", "admin-password").unwrap();
16363        db.create_table("first", test_schema()).unwrap();
16364        db.create_table("second", test_schema()).unwrap();
16365        let admin = db.resolve_principal("admin").unwrap();
16366        let staging = vec![
16367            (db.table_id("first").unwrap(), Staged::Truncate),
16368            (db.table_id("second").unwrap(), Staged::Truncate),
16369        ];
16370
16371        TABLE_PERMISSION_DECISIONS.with(|decisions| decisions.set(0));
16372        db.validate_write_permissions(&staging, Some(&admin), None)
16373            .unwrap();
16374        TABLE_PERMISSION_DECISIONS.with(|decisions| assert_eq!(decisions.get(), 1));
16375    }
16376
16377    #[test]
16378    fn one_table_commit_batches_structural_work() {
16379        let dir = tempfile::tempdir().unwrap();
16380        let db = Database::create(dir.path()).unwrap();
16381        db.create_table("docs", test_schema()).unwrap();
16382        let table_id = db.table_id("docs").unwrap();
16383
16384        AUTO_INCREMENT_TABLE_LOCKS.with(|count| count.set(0));
16385        PREBUILD_TABLE_LOCKS.with(|count| count.set(0));
16386        PUBLISH_TABLE_LOCKS.with(|count| count.set(0));
16387        COMMIT_MANIFEST_WRITES.with(|count| count.set(0));
16388        db.transaction(|transaction| {
16389            for id in 0..100 {
16390                transaction.put("docs", vec![(1, Value::Int64(id))])?;
16391            }
16392            Ok(())
16393        })
16394        .unwrap();
16395
16396        AUTO_INCREMENT_TABLE_LOCKS.with(|count| assert_eq!(count.get(), 2));
16397        PREBUILD_TABLE_LOCKS.with(|count| assert_eq!(count.get(), 1));
16398        PUBLISH_TABLE_LOCKS.with(|count| assert_eq!(count.get(), 1));
16399        COMMIT_MANIFEST_WRITES.with(|count| assert_eq!(count.get(), 1));
16400
16401        let puts = crate::wal::SharedWal::replay(dir.path())
16402            .unwrap()
16403            .into_iter()
16404            .filter_map(|record| match record.op {
16405                crate::wal::Op::Put { table_id: id, rows } if id == table_id => Some(
16406                    bincode::deserialize::<Vec<crate::memtable::Row>>(&rows)
16407                        .unwrap()
16408                        .len(),
16409                ),
16410                _ => None,
16411            })
16412            .collect::<Vec<_>>();
16413        assert_eq!(puts, [100]);
16414
16415        let row_ids = db
16416            .table("docs")
16417            .unwrap()
16418            .lock()
16419            .visible_rows(db.snapshot().0)
16420            .unwrap()
16421            .into_iter()
16422            .take(2)
16423            .map(|row| row.row_id)
16424            .collect::<Vec<_>>();
16425        PREBUILD_TABLE_LOCKS.with(|count| count.set(0));
16426        PUBLISH_TABLE_LOCKS.with(|count| count.set(0));
16427        COMMIT_MANIFEST_WRITES.with(|count| count.set(0));
16428        db.transaction(|transaction| {
16429            for row_id in row_ids {
16430                transaction.delete("docs", row_id)?;
16431            }
16432            Ok(())
16433        })
16434        .unwrap();
16435        PREBUILD_TABLE_LOCKS.with(|count| assert_eq!(count.get(), 1));
16436        PUBLISH_TABLE_LOCKS.with(|count| assert_eq!(count.get(), 1));
16437        COMMIT_MANIFEST_WRITES.with(|count| assert_eq!(count.get(), 1));
16438
16439        let deletes = crate::wal::SharedWal::replay(dir.path())
16440            .unwrap()
16441            .into_iter()
16442            .filter_map(|record| match record.op {
16443                crate::wal::Op::Delete {
16444                    table_id: id,
16445                    row_ids,
16446                } if id == table_id => Some(row_ids.len()),
16447                _ => None,
16448            })
16449            .collect::<Vec<_>>();
16450        assert_eq!(deletes, [2]);
16451    }
16452
16453    fn puts(table_id: u64) -> Vec<(u64, Staged)> {
16454        (0..10_050)
16455            .map(|id| (table_id, Staged::Put(vec![(1, Value::Int64(id))])))
16456            .collect()
16457    }
16458
16459    fn test_schema() -> Schema {
16460        Schema {
16461            columns: vec![ColumnDef {
16462                id: 1,
16463                name: "id".into(),
16464                ty: TypeId::Int64,
16465                flags: crate::schema::ColumnFlags::empty()
16466                    .with(crate::schema::ColumnFlags::PRIMARY_KEY),
16467                default_value: None,
16468            }],
16469            ..Schema::default()
16470        }
16471    }
16472}
16473
16474#[cfg(test)]
16475mod cdc_bounds_tests {
16476    use super::*;
16477
16478    #[test]
16479    fn retained_byte_limit_rejects_without_allocating_payload() {
16480        let mut retained = 0;
16481        let error = charge_cdc_bytes(
16482            &mut retained,
16483            CDC_MAX_RETAINED_BYTES.saturating_add(1),
16484            "CDC retained bytes",
16485        )
16486        .unwrap_err();
16487        assert!(matches!(
16488            error,
16489            MongrelError::ResourceLimitExceeded {
16490                resource: "CDC retained bytes",
16491                ..
16492            }
16493        ));
16494    }
16495
16496    #[test]
16497    fn row_json_estimate_accounts_for_byte_array_expansion() {
16498        let row = crate::memtable::Row::new(RowId(1), Epoch(1))
16499            .with_column(1, Value::Bytes(vec![0; 1024]));
16500        assert!(cdc_row_json_bytes(&row) >= 1024 * std::mem::size_of::<serde_json::Value>());
16501    }
16502}
16503
16504#[cfg(test)]
16505mod generation_metrics_tests {
16506    use super::*;
16507    use crate::schema::{ColumnDef, ColumnFlags, Schema, TypeId};
16508
16509    #[test]
16510    fn legacy_cow_fallback_is_measured() {
16511        let dir = tempfile::tempdir().unwrap();
16512        let table = Table::create(
16513            dir.path(),
16514            Schema {
16515                columns: vec![ColumnDef {
16516                    id: 1,
16517                    name: "id".into(),
16518                    ty: TypeId::Int64,
16519                    flags: ColumnFlags::empty().with(ColumnFlags::PRIMARY_KEY),
16520                    default_value: None,
16521                }],
16522                ..Schema::default()
16523            },
16524            1,
16525        )
16526        .unwrap();
16527        let handle = TableHandle::from_table(table);
16528        let held = match &handle.inner {
16529            TableHandleInner::CopyOnWrite(slot) => Arc::clone(&slot.read()),
16530            TableHandleInner::Direct(_) => unreachable!(),
16531        };
16532
16533        handle.lock().set_sync_byte_threshold(1);
16534
16535        let stats = handle.generation_stats();
16536        assert_eq!(stats.cow_clone_count, 1);
16537        assert!(stats.estimated_cow_clone_bytes > 0);
16538        drop(held);
16539    }
16540}
16541
16542#[cfg(test)]
16543mod trigger_engine_tests {
16544    use super::*;
16545
16546    fn event_with(new_cells: &[(u16, Value)], old_cells: &[(u16, Value)]) -> WriteEvent {
16547        WriteEvent {
16548            table: "test".into(),
16549            kind: TriggerEvent::Insert,
16550            new: Some(TriggerRowImage {
16551                columns: new_cells.iter().cloned().collect(),
16552            }),
16553            old: Some(TriggerRowImage {
16554                columns: old_cells.iter().cloned().collect(),
16555            }),
16556            changed_columns: Vec::new(),
16557            op_indices: Vec::new(),
16558            put_idx: None,
16559            trigger_stack: Vec::new(),
16560        }
16561    }
16562
16563    fn event_insert(new_cells: &[(u16, Value)]) -> WriteEvent {
16564        WriteEvent {
16565            table: "test".into(),
16566            kind: TriggerEvent::Insert,
16567            new: Some(TriggerRowImage {
16568                columns: new_cells.iter().cloned().collect(),
16569            }),
16570            old: None,
16571            changed_columns: Vec::new(),
16572            op_indices: Vec::new(),
16573            put_idx: None,
16574            trigger_stack: Vec::new(),
16575        }
16576    }
16577
16578    #[test]
16579    fn value_order_int64_vs_float64() {
16580        assert_eq!(
16581            value_order(&Value::Int64(5), &Value::Float64(5.0)),
16582            Some(std::cmp::Ordering::Equal)
16583        );
16584        assert_eq!(
16585            value_order(&Value::Int64(5), &Value::Float64(3.0)),
16586            Some(std::cmp::Ordering::Greater)
16587        );
16588        assert_eq!(
16589            value_order(&Value::Int64(2), &Value::Float64(3.0)),
16590            Some(std::cmp::Ordering::Less)
16591        );
16592    }
16593
16594    #[test]
16595    fn value_order_null_returns_none() {
16596        assert_eq!(value_order(&Value::Int64(5), &Value::Null), None);
16597        assert_eq!(value_order(&Value::Null, &Value::Int64(5)), None);
16598        assert_eq!(value_order(&Value::Null, &Value::Null), None);
16599    }
16600
16601    #[test]
16602    fn value_order_cross_group_returns_none() {
16603        assert_eq!(
16604            value_order(&Value::Int64(5), &Value::Bytes(b"x".to_vec())),
16605            None
16606        );
16607        assert_eq!(value_order(&Value::Bool(true), &Value::Int64(1)), None);
16608        assert_eq!(
16609            value_order(
16610                &Value::Embedding(vec![1.0, 2.0]),
16611                &Value::Embedding(vec![1.0, 2.0])
16612            ),
16613            None
16614        );
16615    }
16616
16617    #[test]
16618    fn eval_trigger_expr_ranges_and_booleans() {
16619        let expr = TriggerExpr::And {
16620            left: Box::new(TriggerExpr::Gt {
16621                left: TriggerValue::NewColumn(1),
16622                right: TriggerValue::Literal(Value::Int64(0)),
16623            }),
16624            right: Box::new(TriggerExpr::Lte {
16625                left: TriggerValue::NewColumn(1),
16626                right: TriggerValue::Literal(Value::Int64(100)),
16627            }),
16628        };
16629        assert!(eval_trigger_expr(&expr, &event_insert(&[(1, Value::Int64(50))])).unwrap());
16630        assert!(!eval_trigger_expr(&expr, &event_insert(&[(1, Value::Int64(200))])).unwrap());
16631        assert!(!eval_trigger_expr(&expr, &event_insert(&[(1, Value::Null)])).unwrap());
16632
16633        let or_expr = TriggerExpr::Or {
16634            left: Box::new(TriggerExpr::Lt {
16635                left: TriggerValue::NewColumn(1),
16636                right: TriggerValue::Literal(Value::Int64(0)),
16637            }),
16638            right: Box::new(TriggerExpr::Not(Box::new(TriggerExpr::IsNull(
16639                TriggerValue::OldColumn(2),
16640            )))),
16641        };
16642        assert!(eval_trigger_expr(
16643            &or_expr,
16644            &event_with(&[(1, Value::Int64(5))], &[(2, Value::Int64(99))])
16645        )
16646        .unwrap());
16647        assert!(!eval_trigger_expr(
16648            &or_expr,
16649            &event_with(&[(1, Value::Int64(5))], &[(2, Value::Null)])
16650        )
16651        .unwrap());
16652
16653        assert!(eval_trigger_expr(
16654            &TriggerExpr::Value(TriggerValue::Literal(Value::Bool(true))),
16655            &event_insert(&[])
16656        )
16657        .unwrap());
16658        assert!(!eval_trigger_expr(
16659            &TriggerExpr::Value(TriggerValue::Literal(Value::Bool(false))),
16660            &event_insert(&[])
16661        )
16662        .unwrap());
16663        assert!(!eval_trigger_expr(
16664            &TriggerExpr::Value(TriggerValue::Literal(Value::Null)),
16665            &event_insert(&[])
16666        )
16667        .unwrap());
16668    }
16669}